# One decision vocabulary, and an unanswered question is an error

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0155-one-decision-vocabulary-and-an-unanswered-question-is-an-error.md

---

# 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.
