← stitchkitDocumentation

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/catch nor 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.