Defensive patterns
Six design rules the runtime holds itself to — orthogonal outcome reporting, explicit default resolution, branded ids, load-time config failure, teardown quiescence, and contained plugin dispatch — and the shipped API behind each.
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.
| Rule | Backed by |
|---|---|
| Report orthogonal outcomes independently | GraphExitMeta.aborted / GraphExitMeta.abortedByUser |
| Explicit resolution over hidden defaults | Convention — shape validators exist; fallback semantics don't have a gate |
| Branded opaque ids across boundaries | The SafetyPolicyId / PromptContributionId brand idiom |
| Misconfiguration fails loud at load time | PluginValidationError at registration; assertShredCapableDdl for degradable features |
| Dispose must reach quiescence | AsyncTaskManager.seal() → cancelAllPending() → awaitQuiescence() inside destroy() |
| Contain callback exceptions in the dispatcher | Per-plugin catch in every PluginManager collection fan-out |
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.
// 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 for where these fields surface on a collected turn.
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.
// 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
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:
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
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.
PluginManagervalidates every plugin atregister()—validatePluginShapeplussmokeValidateFactories— and throwsPluginValidationErrorbefore the plugin can serve a turn. A factory that would throw on first use fails at registration instead. See Plugin contract. - Degradable features refuse by name. The crypto subsystem's
assertShredCapableDdlrefuses 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
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:quiescentis the verdict;pendingRunsandpendingTasksare the evidence when it'sfalse. A non-quiescent receipt is not an error — an executor'sexecutetakes 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 for the manager itself and
Session lifecycle for where destroy()
sits.
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.
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.
Language-agnostic contract
Which shapes are the contract — the wire shapes, checkpoint envelope, audit row, sync vectors, event log — versus implementation details. What an independent client must implement.
Graph
The declarative topology that drives a turn — Annotation channel schema, StateGraph builder, the Send envelope, compile() runner, and the four-stage lattice gate that keeps every node attributable.