# Defensive patterns (/docs/defensive-patterns)



These rules came out of production incidents, not a style guide.
Each one names a failure mode that shipped somewhere, cost real
debugging time, and turned out to be a *class* — the same shape
recurs wherever the underlying habit does. This page states each
rule, the why, and — where `@pleach/core` ships an API that
embodies it — the concrete surface. Where a rule is convention
rather than mechanism, the section says so.

<SourceMeta
  pkg="{ name: &#x22;@pleach/core&#x22;, href: &#x22;https://www.npmjs.com/package/@pleach/core&#x22; }"
  source="[
  { label: &#x22;src/graph/CompiledGraph.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/graph/CompiledGraph.ts&#x22; },
  { label: &#x22;src/plugins/PluginValidator.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/plugins/PluginValidator.ts&#x22; },
  { label: &#x22;src/async/AsyncTaskManager.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/async/AsyncTaskManager.ts&#x22; },
  { label: &#x22;src/runtime/teardownQuiescence.ts&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/src/runtime/teardownQuiescence.ts&#x22; },
]"
/>

| Rule                                                                                            | Backed by                                                                                 |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| [Report orthogonal outcomes independently](#report-orthogonal-outcomes-independently)           | `GraphExitMeta.aborted` / `GraphExitMeta.abortedByUser`                                   |
| [Explicit resolution over hidden defaults](#explicit-resolution-over-hidden-defaults)           | Convention — shape validators exist; fallback semantics don't have a gate                 |
| [Branded opaque ids across boundaries](#branded-opaque-ids-across-boundaries)                   | The `SafetyPolicyId` / `PromptContributionId` brand idiom                                 |
| [Misconfiguration fails loud at load time](#misconfiguration-fails-loud-at-load-time)           | `PluginValidationError` at registration; `assertShredCapableDdl` for degradable features  |
| [Dispose must reach quiescence](#dispose-must-reach-quiescence)                                 | `AsyncTaskManager.seal()` → `cancelAllPending()` → `awaitQuiescence()` inside `destroy()` |
| [Contain callback exceptions in the dispatcher](#contain-callback-exceptions-in-the-dispatcher) | Per-plugin catch in every `PluginManager` collection fan-out                              |

## Report orthogonal outcomes independently [#report-orthogonal-outcomes-independently]

When one field can be set for several reasons, a decision that
means *one* of those reasons must not gate on the bare field.
Carry each verdict as its own field and make readers narrow on
purpose.

The founding instance is abort handling. A turn can abort because
the user pressed Stop, because a timeout fired, because every
provider in the family chain was exhausted, or because the runtime
is shutting down. A single `aborted` bit collapses all four — and
a UI that relabels queued work as "superseded" whenever it sees
`aborted` will do it on a timeout the user never asked for.

`GraphExitMeta` carries the split explicitly: `aborted` is the
telemetry bit, true on *any* abort reason; `abortedByUser` is the
intent verdict, true only when the user stopped the turn. Both are
stamped side by side at the same site, and `abortedByUser` is
never derived from `aborted`. Consumers narrow via
`Pick<GraphExitMeta, "aborted" | "abortedByUser">` and use the
exported interface, never a local structural cast.

```typescript
// One bit, many causes — the reader can't recover which one.
if (exit.aborted) markSuperseded()            // fires on timeouts too

// Orthogonal verdicts — the reader narrows deliberately.
if (exit.abortedByUser) markStoppedByUser()
if (exit.aborted && !exit.abortedByUser) markInfrastructureAbort()
```

The same rule applies to enum members: a member is a flag too.
Reading the union tells you a value exists; only its call sites
tell you what it means. Before gating on a member, read what sets
it. See [Session runtime](/docs/session-runtime) for where these
fields surface on a collected turn.

## Explicit resolution over hidden defaults [#explicit-resolution-over-hidden-defaults]

Defaults get applied once, in a named `resolve*()` step that owns
them — never as `??` / `||` fallbacks scattered through execution
paths. The resolver also decides, explicitly, whether an empty
string, `0`, or `false` counts as absent.

The trap is one character wide. An environment variable set to the
empty string is *not* nullish, so `process.env.URL ?? fallback`
keeps the empty string and the fallback never fires — the failure
then gets misattributed to a missing credential that was present
all along. Flip the operator and `||` treats a legitimate `0` or
`false` as absent. Neither operator is wrong; what's wrong is
choosing per call site, invisibly.

```typescript
// The resolver owns the default and the emptiness policy — once.
const resolveEnv = (...names: string[]) => {
  for (const name of names) {
    const value = process.env[name]?.trim()
    if (value) return { value, source: name }
  }
  return null  // absent is an answer, not a silent default
}
```

The inverse shape hides in writers: a producer that omits a key
for the common value makes every downstream `field === value`
comparison silently exclude the majority case. If a field has a
default, either write it explicitly or compare through a predicate
that admits `undefined`.

Status: this one is convention in `@pleach/core` today. The
config-manifest validators (next rule) check *shape*; no gate
checks fallback semantics.

## Branded opaque ids across boundaries [#branded-opaque-ids-across-boundaries]

An id that crosses a module or wire boundary is an opaque token.
Give it a branded type, construct it through one validated helper,
and never derive a join key by truncating or re-encoding it.
Truncating for display is fine; truncating a value that feeds a
lookup is a collision waiting for traffic. Two concrete hazards:
head-truncating a UUIDv7 deletes its timestamp prefix, and a
prefix of a credential is not a cache key — a shared header prefix
collapses distinct identities into one entry.

The brand idiom ships in core: `SafetyPolicyId`
(`src/safety/types.ts`) and `PromptContributionId`
(`src/prompts/types.ts`) are `string & { readonly __brand: ... }`
types minted through `safetyPolicyId()` / `promptContributionId()`
helpers. The pattern generalizes:

```typescript
type Branded<T, B extends string> = T & { readonly __brand: B }
type OrderId = Branded<string, "OrderId">

const asOrderId = (v: string): OrderId => {
  if (!UUID_RE.test(v)) throw new Error(`not an OrderId shape: ${v}`)
  return v as OrderId
}
```

Adoption is cheaper than it looks, because a branded string is
assignable *to* `string`: every logger, formatter, and display
truncation keeps compiling unchanged. The cost lands only at
construction sites and join boundaries — which is exactly where
you want the compiler watching. Migrate one id type, confirm the
join-bug class stops, then extend. Don't brand everything at once.

## Misconfiguration fails loud at load time [#misconfiguration-fails-loud-at-load-time]

Validate configuration *shape* at boot and refuse to start — or
refuse to enable the feature — with a named error. Failing closed
at request time without load-time validation is the worst of both
worlds: you inherit the outage of fail-closed with none of the
warning. A malformed key that 500s every request individually
looks healthy at deploy and is a full outage an hour later.

Two shipped surfaces embody this:

* **Plugin registration is load-time-loud.** `PluginManager`
  validates every plugin at `register()` — `validatePluginShape`
  plus `smokeValidateFactories` — and throws
  `PluginValidationError` before the plugin can serve a turn. A
  factory that would throw on first use fails at registration
  instead. See [Plugin contract](/docs/plugin-contract).
* **Degradable features refuse by name.** The crypto subsystem's
  `assertShredCapableDdl` refuses to enable payload encryption on
  a schema whose columns cannot honor erasure — because enabling
  it there would mint ciphertext that can never be deleted. A
  feature that can't uphold its own contract on this deployment
  says so at enable time, in those words, rather than discovering
  it during an erasure request.

The distinction worth copying: a *required* precondition throws
and refuses to start; a *degradable* one disables the feature with
a named reason and lets the rest of the runtime boot. Both beat
per-request failure, which tells the operator nothing until
traffic arrives.

## Dispose must reach quiescence [#dispose-must-reach-quiescence]

`destroy()` is not done when it returns — it is done when nothing
the runtime started can still fire. The ordering that guarantees
it: **seal, then cancel, then drain**. Close the registry first,
so a late completion lands in a closed door instead of a recycled
slot; then bulk-cancel pending work; then wait — bounded — for
in-flight settlements.

The async-task half of this ships on `AsyncTaskManager` and runs
inside `SessionRuntime.destroy()`:

* `seal()` closes the registry. A sealed manager rejects new
  registrations with an error-status receipt and silences the
  terminal fan-out, so anything that settles late reaches nobody.
  Sealing is idempotent and permanent — a sealed manager stays
  sealed.
* `cancelAllPending()` sweeps every pending task through its
  executor's cancel path.
* `awaitQuiescence(timeoutMs)` waits for in-flight background runs
  to settle and returns a receipt: `quiescent` is the verdict;
  `pendingRuns` and `pendingTasks` are the evidence when it's
  `false`. A non-quiescent receipt is not an error — an executor's
  `execute` takes no abort signal, so an in-flight run can only be
  outwaited or discarded, never force-terminated.

The wait is bounded by `destroyQuiescenceTimeoutMs` on
`SessionRuntimeConfig` (default 5 s), and the whole sequence is
fail-soft: teardown never throws and never hangs. A wedged
executor is outwaited up to the bound and abandoned — its eventual
settlement is already silenced by the seal. That's why the seal
comes first: it converts "we couldn't stop it" from a correctness
bug into a bounded resource leak.

See [Async tasks](/docs/async-tasks) for the manager itself and
[Session lifecycle](/docs/session-lifecycle) for where `destroy()`
sits.

## Contain callback exceptions in the dispatcher [#contain-callback-exceptions-in-the-dispatcher]

Anywhere the runtime fans out into third-party code — plugin
hooks, observers, consumers — each invocation is wrapped so one
throwing plugin cannot starve its siblings or take down the
runtime. And the containment *policy* on a throw is chosen per
surface, on purpose, because "it threw" is not one outcome.

The shipped fan-outs show three deliberate policies:

| Surface                                                                                        | Policy on throw           | Why                                                                                                                   |
| ---------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Plugin collection hooks (`contributeStreamFilters`, `contributeStreamObservers`, and siblings) | Warn and skip that plugin | A broken plugin costs its own contribution, never a sibling's                                                         |
| Capability trust — a *peer plugin's* trust resolver                                            | Abstain and continue      | A broken peer must not become a veto over other plugins                                                               |
| Capability trust — the *host's* own policy                                                     | Denial                    | The embedder's code failing open would admit what it meant to check; on the host side, refusing is the safe direction |

The last two are the same event — a trust callback threw — with
opposite handling, documented at the call sites. That is the first
rule applied to error handling: who threw determines what the
throw means, so the dispatcher must not collapse them into one
policy.

One boundary is deliberately stricter. Per-chunk stream observers
are contracted to never throw; when one does, the seam contains
the exception but stops the stream with a named reason rather than
skipping and continuing over a half-observed stream. Containment
there protects the process, not the contract-breaking observer.
See [Stream observers](/docs/plugins/stream-observers).

When you write your own fan-out — a plugin bundle dispatching to
sub-plugins, a host broadcasting to consumers — copy the shape:
per-callee try/catch, an explicit policy per surface, and a log
line naming which callee failed.
