pleach
Architecture

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.

RuleBacked by
Report orthogonal outcomes independentlyGraphExitMeta.aborted / GraphExitMeta.abortedByUser
Explicit resolution over hidden defaultsConvention — shape validators exist; fallback semantics don't have a gate
Branded opaque ids across boundariesThe SafetyPolicyId / PromptContributionId brand idiom
Misconfiguration fails loud at load timePluginValidationError at registration; assertShredCapableDdl for degradable features
Dispose must reach quiescenceAsyncTaskManager.seal()cancelAllPending()awaitQuiescence() inside destroy()
Contain callback exceptions in the dispatcherPer-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. 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.
  • 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

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 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:

SurfacePolicy on throwWhy
Plugin collection hooks (contributeStreamFilters, contributeStreamObservers, and siblings)Warn and skip that pluginA broken plugin costs its own contribution, never a sibling's
Capability trust — a peer plugin's trust resolverAbstain and continueA broken peer must not become a veto over other plugins
Capability trust — the host's own policyDenialThe 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.

On this page