# Extending Pleach (/docs/extending)



The audit gates assume you'll extend the substrate. Every page that
calls a gate "structural" is also saying: when you add a node, a
writer, or a scrubber for your own use case, this gate is the spec
your addition has to satisfy. This page is the map from extension
point to that spec.

It pairs each thing you can add with three facts: the interface you
implement, the call that registers it, and the named
[audit gate](/docs/audit-gates) that turns red when the addition is
wrong. Treat the gate as the checklist — it names the file and line
to fix in its failure output.

<Callout type="info" title="Two extension surfaces — know which one you're on">
  **Host extension** — you add a plugin, a prompt block, a tool, or a
  runtime strategy from *your* repo against the published `@pleach/*`
  surface. You edit no substrate source. The gates already ran upstream
  to keep that surface honest; the ones below fire in *your* CI only if
  you mirror them (see [Mirroring gates](#mirror-the-gates-in-your-own-repo)).

  **Upstream contribution** — you add a node to the canonical builder,
  a member to `EventLogInput`, or a sibling `@pleach/*` SKU. You edit
  substrate source, so the gates run against your PR directly. The
  [contributing](/docs/contributing) flow covers this path.

  Most consumer work is host extension. The gate column below tells you
  which surface each gate belongs to.
</Callout>

## The extension map [#the-extension-map]

| You're adding                              | Interface                                                                          | Register via                                                                                         | Gate that fails if wrong                                                                                                                                           | Check locally                                    |
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| A **graph node**                           | `StateGraphNodeMetadata` + `NodeFn` ([nodes](/docs/nodes))                         | `graph.addNode(name, fn, meta)`, or a plugin's `extraGraphNodes()`                                   | `audit:graph-stages` (declares a `stageId`, edges stay in the lattice), `audit:edge-inventory-completeness`                                                        | `npm run ci:graphnoderef`                        |
| A **state channel**                        | `Channel<T>` ([channels](/docs/channels))                                          | a node's `subscribes` / `writes` metadata — there is **no** `contributeChannels` hook                | reached through a node, so `audit:graph-stages` covers it; reducer commutativity is a [determinism contract](/docs/determinism), not gate-checked                  | `npm run audit:graph-stages`                     |
| An **event type / writer**                 | `PluginEventDefinition` ([event log](/docs/event-log))                             | `contributeEventTypes()`                                                                             | `audit:c8-union-member-has-producer` (has a producer), `audit:c8-event-type-allowlist-coverage` (has a scrubber entry)                                             | `npm run audit:c8-event-type-allowlist-coverage` |
| A **scrubber** (the "clearer")             | `Scrubber` ([scrubbers](/docs/scrubbers))                                          | `contributeScrubbers()`                                                                              | `audit:c8-event-type-allowlist-coverage` — your new event type needs a gate, even a pass-through one                                                               | `npm run audit:c8-event-type-allowlist-coverage` |
| A **plugin hook** (sibling SKU / upstream) | a `contribute*` hook on `HarnessPlugin` ([plugin contract](/docs/plugin-contract)) | the hook itself, returning the contribution                                                          | `audit:plugin-contract-completeness` (paired collector + consumer), `audit:plugin-hook-category-assigned` (resolves to a [namespace](/docs/plugins/namespaces))    | `npm run audit:plugin-contract-completeness`     |
| A **system-prompt block**                  | `PromptContribution` ([prompts](/docs/prompts))                                    | `contributePrompts()` / `contributeRuntimeAwarePrompts()` — helpers `appendPrompt`, `prependPersona` | no dedicated gate; static blocks fold into the [config-manifest](/docs/config-manifest) fingerprint (`audit:plugin-content-hash-stability` guards its determinism) | `npm run audit:plugin-content-hash-stability`    |
| A **custom audit field**                   | `PluginAuditPayload` ([typed records](/docs/typed-records))                        | `contributeAuditEmitter()` → `pluginPayloads` — **not** a new column                                 | `audit:auditable-call` — the structural column set is locked; a column add fails the shape+version check                                                           | `npm run audit:auditable-call`                   |
| An **audit-ledger adapter**                | `ProviderDecisionLedger` ([audit ledger](/docs/audit-ledger))                      | `setProviderDecisionLedgerFactory(...)`                                                              | `audit:auditable-call` (the row your adapter persists must match the locked shape)                                                                                 | `npm run audit:auditable-call`                   |
| A **storage adapter**                      | `StorageAdapter` ([storage](/docs/storage))                                        | runtime config / `appRegistries`                                                                     | no shape gate — application code is unchanged by the adapter swap                                                                                                  | —                                                |

The shape is the same across rows: a gate fails because the addition
omitted the one fact the ledger needs to stay joinable — a stage, a
producer, a scrubber entry, a version bump. Read the gate name as the
missing fact.

## Nodes: the consumer path vs the builder path [#nodes-the-consumer-path-vs-the-builder-path]

The [new-node checklist](/docs/nodes#new-node-checklist) on the nodes
page describes editing the canonical builder — `NODE_STAGE_MAP` in
`src/graph/topology.ts`. That's the **upstream** path. A host adding a
node from a plugin takes a different one:

```typescript
// A plugin contributes nodes; the builder calls each factory once at compile time.
const retrievalPlugin: HarnessPlugin = {
  name: "tenant-retrieval",
  extraGraphNodes: () => [{
    name: "tenantRetrieval",
    factory: (ctx) => async (state) => ({ retrieved: await ctx.search(state.query) }),
    metadata: { stageId: "tool-loop", acceptsSeam: null, subscribes: ["query"], writes: ["retrieved"] },
  }],
}
```

A plugin-registered node carries its `stageId` in its own `metadata`.
The lattice gate (`audit:graph-stages`) rejects an out-of-stage edge
on the *compiled* graph, but the node never enters the substrate's
static `NODE_STAGE_MAP` — that table covers the canonical builder
only, which is why the node/edge counts stay byte-identical PR-to-PR.
The fact your node must satisfy is the same (a valid stage, valid
edges); the surface it's checked against differs.

## The locked row, and the slot that isn't [#the-locked-row-and-the-slot-that-isnt]

The single most common extension instinct the substrate refuses:
adding a column to the audit row. The structural column set —
`turnId`, `toolName`, `modelId`, `family`, `inputTokens`,
`outputTokens`, `subagentDepth`, `parentTurnId` — is a cross-SKU
contract. No plugin, config field, or adapter alters it. A column add
fails `audit:auditable-call` by design: changing the shape without
bumping `AuditRecordVersion` is the silent-breaking-change the gate
exists to catch.

The sanctioned path for "I need to record *my* thing on the row" is
`pluginPayloads` — a namespaced, versioned slot the locked contract
reserves for exactly this:

```typescript
const extractionQualityPlugin: HarnessPlugin = {
  name: "extraction-quality",
  contributeAuditEmitter: () => ({
    // Emitted alongside the locked columns; your plugin owns the wire shape.
    record: (call) => ({
      pluginId: "extraction-quality",
      subKind: "score",
      data: { confidence: scoreFor(call), schemaVersion: 1 },
    }),
  }),
}
```

Your payload rides one row per call, joinable by `turnId` like every
other field, without touching the structural columns every consumer
reads. The version lives in your `data`, not in the substrate's
`AuditRecordVersion` — the gate stays green because the contract
didn't move. See [typed records](/docs/typed-records) for the five
first-party payload kinds this slot generalizes.

## Mirror the gates in your own repo [#mirror-the-gates-in-your-own-repo]

Host extension means the upstream gates protected the surface you
consume — not *your* invariants. A per-tenant key that must never
reach logs, a domain tool that must have a matching detector, a
feature flag past its sunset: those are yours to gate. The pattern is
the one the substrate uses, documented at
[Audit gates → Mirroring gates in your own repo](/docs/audit-gates#mirroring-gates-in-your-own-repo):
one invariant per script, structured failure naming file and line,
wired under `audit:<name>` in your PR-blocking CI.

The discipline carries: adding a new invariant means adding a new
gate, not a line on a review checklist. The
[`audit:plugin-contract-completeness`](/docs/audit-gates#plugin-contract-gates-plugin-authors-should-know)
script is a small reference shape to copy.

## Agent-driven self-extension [#agent-driven-self-extension]

The gates aren't only a CI wall — they're a feedback loop an agent
can drive. Because every gate names the offending file, line, and the
fact that's missing, an agent extending the runtime can run the loop
unattended:

1. Write the addition — a node, a scrubber, a `pluginPayloads`
   emitter for the tenant's need.
2. Run the matching `npm run audit:*` gate.
3. Read the structured failure: it names the file, the symbol, and
   the missing fact (a stage, a producer, an allowlist entry).
4. Apply the fix the gate points at. Re-run. Green means the addition
   preserved the ledger contract.

The gate output is the correction signal. An agent that can't see why
its node was rejected can't self-correct; one that reads
`missing-stage: tenantRetrieval` can. This is the same property that
makes the substrate "built for and by agents" — the contract is
legible to the thing operating it.

To push self-optimization further, feed the extension contract into
the agent's *own* system prompt. A host that wants its agent to tune
its plugin set, prompt blocks, or tool selection to a tenant's needs
can inject the rules at turn time:

```typescript
const selfTuningPlugin: HarnessPlugin = {
  name: "self-tuning",
  // Per-turn prompt the operating agent reads — the extension contract as context.
  contributeRuntimeAwarePrompts: (ctx) => [{
    id: "self-tuning.extension-guide",
    mode: "append",
    content: extensionContractFor(ctx.tenant), // gates, slots, and the locked columns
  }],
}
```

The agent then proposes additions that already respect the gates,
because the gates are in its context. Static guidance (the locked
column set, the `pluginPayloads` slot, the gate names) belongs in
`contributePrompts` so it folds into the fingerprint; per-tenant
guidance belongs in `contributeRuntimeAwarePrompts` so it stays out
of the cache key. See [prompts](/docs/prompts) and the
[plugin contract](/docs/plugin-contract) for the composition rules.

The boundary holds either way: an agent can author freely inside the
gates, and the gates are what keep its additions from making the
audit ledger unusable. Self-extension is safe precisely because the
contract is enforced, not advised.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Plugin contract" href="/docs/plugin-contract" description="The full HarnessPlugin hook surface every extension point above registers through." />

  <Card title="Audit gates" href="/docs/audit-gates" description="The subsystem-organized catalog of the gates this page pairs to extension points." />

  <Card title="Nodes" href="/docs/nodes" description="The node shape, the new-node checklist, and the plugin-registered node path." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="The clearer contract and the contributeScrubbers hook for regulated identifiers." />

  <Card title="Typed records" href="/docs/typed-records" description="The pluginPayloads slot and the first-party payload kinds it generalizes." />
</Cards>
