0155 — One decision vocabulary, and an unanswered question is an error
Decision
allow / deny (with a reason) / defer is one type, PolicyDecision,
defined in internal/decision.ts and re-exported by both the entrypoints that
need it: stitchkit/live for an event topic declared mode: 'decision', and
stitchkit/application for createDecisionPipeline.
It was previously two — EventDecision in the events declaration and an
identical shape in the pipeline. Two names for one thing is the failure this
repository refuses everywhere else: a reader searching for one finds half the
truth, and the next contributor honestly adds a third.
UndecidedOutcome ('allow' | 'deny') is shared the same way: it is what a
topic’s whenAllDefer and a pipeline’s undecided policy both name.
A deny carries a reason, by schema
deny without a reason is not representable. A refusal whose cause exists only
in the log of whoever refused is a support ticket, and the moment it is needed is
the moment nobody can reproduce it.
Every policy deferring is not an outcome
A pipeline that reaches its end with nobody claiming the question raises
DecisionUndecidedError rather than choosing. Both defaults are wrong in a way
that looks like working software: defaulting to allow turns an incomplete
policy set into an open door, and defaulting to deny turns it into an outage
whose cause reads as a legitimate refusal.
Raising is the only answer that says what actually happened — nobody here can answer this — and it is loud at the moment the policy set is wrong rather than at the moment it matters.
The trace is what ran
DecisionResult.trace lists the policies that were actually invoked, in order,
and stops at the terminal verdict. It is not the declared list annotated with
outcomes.
The distinction is the whole value of the trace: when a request was denied, the question is which policy denied it and what the ones before it said — and a trace that included policies which never ran would answer that question wrongly while looking complete. The first terminal verdict short-circuits; the rest do not run, and do not appear.
A policy that returns a non-decision, throws, or runs past its deadline raises
DecisionPolicyError, naming the policy and carrying the trace so far. A policy
that is broken must not be a policy that is skipped.
All three are one error deliberately: they are indistinguishable downstream — the question is undecided and the policy set is at fault — and splitting them invites a caller to handle one and not the others.
The deadline is policyTimeoutMs, required with no default, for the reason
defineEvents requires listenerTimeoutMs: a default is a number nobody chose,
applied to consumer code the framework has never seen, and the failure it hides
is a policy that never settles hanging every caller of the operation it guards.
This paragraph described behaviour that did not exist when it was written. The pipeline shipped in 0.77.0 with neither a
try/catchnor a deadline: a throw reached the caller raw and a hang was forever. It was written from the event bus, which does both, and nothing checked that the sentence was true of the code beneath it. 0.78.0 made it true. The tests that hold it now name the throw, the hang and the required deadline.
Duplicate ids are refused at construction
Two policies under one id make the trace ambiguous exactly when it is being read
to explain a refusal — the worst moment to discover it. So createDecisionPipeline
refuses them when the pipeline is built, not when a decision is made.