Decision records
ADR 0039 — Execution-Lifecycle Authorization (ExecutionAuthorizer + EXECUTION_DENIED)
- Status: Accepted (2026-07-11)
- Date: 2026-07-11
- Builds on: the K2
CapabilityAuthorizer(gates effects) and the K12.1PrincipalChainthreaded from the trust boundary (RunOptions.principal). - Part of: the three-direction kernel arc, multi-tenant isolation (Direction C), Phase 3.
Context
Loom gated effects inside a running execution (K2 GrantTable/CapabilityAuthorizer → durable
EFFECT_DENIED), but the execution lifecycle itself — who may start, resume, cancel, or
query an execution — was ungated. Any caller reaching the runtime API could start, resume, cancel,
or read any execution by id. For multi-tenant hosting that is a real gap.
Decision
Add a pluggable ExecutionAuthorizer — authorize({ op, executionId, workflowId, principal }) → { allowed } | { allowed: false, reason } — wired via WorkflowRuntimeDeps.executionAuthorizer.
It mirrors the K2 capability authorizer one level up. start/resume/cancel and the public
loadState (query) consult it; a denial throws ExecutionDeniedError.
Trust-boundary model. The authorizer is consulted only when it is configured and the call
carries a principal — the edge (HTTP/API/A2A server) attaches the authenticated principal, and
principal-less kernel-internal calls (TimerService, recovery, cancel’s internal fold) are
unaffected, byte-identically (G2). Internal folds use a new private foldState; the public
loadState = foldState + a query authorization (folding is pure, so authorizing after is safe and
a denied read never writes).
Durable audit — safely. A denied resume/cancel best-effort records a durable
EXECUTION_DENIED fact (symmetric to EFFECT_DENIED; folds into _executionDenials, pure audit).
Crucially it is appended WITHOUT acquiring a fence, so it can never fence-out (disrupt) a
running execution; on a fencing store with an active fence the append is simply rejected and skipped
— the enforcement is the thrown error, which never depends on the record. Denied start (no
execution yet) and query (read-only) throw without a record. An onExecutionDenied callback
surfaces every denial for telemetry regardless.
New event type EXECUTION_DENIED → EVENT_SCHEMA_VERSION 1.12.0 (added to knownEventTypes() +
the pinned list). No KERNEL_ABI change — this is a boundary check, not a new syscall.
Consequences
- Multi-tenant hosts can enforce per-tenant/per-principal control over the execution lifecycle, with
a tamper-evident (Phase 1 hash-chain-compatible) denial trail for the sensitive
resume/cancelops. - Opt-in, byte-identical when unwired (G2): unset authorizer ⇒ every op permitted; the full existing suite passes unchanged, and an explicit “no authorizer” test asserts start→query→resume is unaffected.
- Honest scope: enforcement is at the trust boundary (principal-bearing calls). Principal-less internal calls bypass by design — the kernel stays generic (P10); the edge authenticates. Durable denial recording is best-effort (skipped on a fencing store with an active fence) to guarantee it never disrupts a running execution; the throw is the invariant, the record is audit.
Verification
c1-lifecycle-authz.test.ts(6): no authorizer ⇒ start/query/resume unaffected (G2); a permitted principal passes start→query→resume; a deniedstartcreates no execution (nothing written); a deniedresumethrows AND records exactly oneEXECUTION_DENIED { op: "resume", actor }while the execution stays resumable by a permitted principal; deniedcancel/querythrow and fire the audit callback; a principal-less call bypasses even a deny-all authorizer.eventSchema.test.tspinsEXECUTION_DENIED(schema 1.12.0);abi.test.tsunchanged (no syscall).@loom/specregistersexecution-authzat L2. Full runtime suite 109/109.
Amendment (2026-10-17) — the verdict comes before the fold
The Decision above says the public loadState is foldState plus a query authorization, “folding is
pure, so authorizing after is safe”. Folding is pure, but it can throw, and the error is
caller-visible: PayloadShreddedError for a crypto-shredded execution, a failing or foreign
checkpoint (SnapshotIdentityMismatchError, a key-unavailable load), an unreadable sequence 0. A
caller the authorizer would refuse (another tenant) therefore learned that an execution exists and
what became of it (an existence-and-status oracle; no payload content). resume and resumeAs
leaked the same way (they load the checkpoint and read and fold the log before asking), and
resumeAs additionally ran the caller’s transformState over the RECORDED INPUT before the
authorizer was asked. send, discardInbox, quarantineInbox, releaseInbox, recordOutcome,
fork and acquireFence already asked first but read sequence 0 unguarded, which raises the same
erasure error when the recorded principal is itself sealed and shredded.
Rule. For every authorizer-gated op the verdict is reached from sequence 0 alone (the
plaintext workflow id and the recorded principal, one point read that ownerForAuthz reuses), before
any checkpoint load, fold, full read or caller-supplied code. A sequence 0 this node cannot decode
BECAUSE OF AN ERASURE (PayloadShreddedError, or a KeyUnavailableError whose redacted view records
the erase intent) is an execution that EXISTS with an UNKNOWN owner: the policy decides exactly as for
any unknown-owner execution, so the denied caller gets the ordinary ExecutionDeniedError, never a
status-revealing error. A stream-level seal refusal is unchanged (ADR 0130 D7: sealedOp words it as
that op’s unknown-owner denial; the seal guard also turns a raw KeyUnavailableError into that
refusal before readAnchor sees it, so the intent test is a defensive second layer). An authorized
caller is unaffected: it proceeds to the same fold and gets the same errors as before (a shredded
execution still raises PayloadShreddedError; cancel still folds the redacted view and works;
acquireFence and fork re-read an unreadable sequence 0 so a caller the policy lets through, e.g.
unknownOwner: "allow", gets the original error instead of proceeding).
Failures that are not erasures. Any other failure to read sequence 0 (a storage fault, a transient
key-service error, a KeyUnavailableError without the erase intent) says nothing about the execution,
so it is NOT an unknown owner: judging it so denied the legitimate owner (a false onExecutionDenied,
e.g. a worker’s acquireFence during a database blip) and reported a missing id as existing. It throws
the new ExecutionStoreUnavailableError (@loom/workflow-runtime): retryable (retryable: true),
carrying only op and executionId, with no cause, workflow id, subject or redacted view, and
IDENTICAL for the owner and a stranger (a fault must not become a status oracle). The real cause goes
to onStorageError. cancel of a live execution and restoreFromState read the owner through the
same anchor read (they have none in hand), so they follow the same rule. (The earlier residual that
ownerForAuthz kept a catch-all “unknown owner” for the lineage walk is superseded by the next
paragraph; one narrower lineage residual remains, recorded there.)
Owner-resolution faults (2026-10-17, second amendment; FU-B). ownerForAuthz ran AFTER sequence
0 had been read fine and swallowed every failure as an “unknown owner”: the principal resolver or
codec throwing, the sequence-0 read it does itself when the caller holds no anchor, or a store
fault on a parent link of the lineage walk. With the default unknownOwner: "deny" that falsely
DENIED the real owner (a non-retryable ExecutionDeniedError, a false onExecutionDenied), and
under unknownOwner: "allow" it ADMITTED a stranger on a transient fault. The same rule as the
anchor read now applies to the whole owner-resolution path: only a PROVEN erasure
(PayloadShreddedError, or a KeyUnavailableError recording the erase intent; one shared
isErasure test, applied ONLY to ownerForAuthz’s own sequence-0 read, never to a throw of the
principal resolver or codec, which is app code: an error that merely names itself
PayloadShreddedError is still a fault and cannot forge an unknown owner) is an unknown owner; a stream-level seal refusal is unchanged; EVERY other
failure is the sanitized, retryable ExecutionStoreUnavailableError (same shape and the same
onStorageError report of the real cause, for the owner and a stranger alike). A fault is never
an allow, for anyone, and nothing is appended or audited. A legitimately unresolvable record (the
resolver returns undefined, a delegation it cannot restore, a shredded or malformed principal
record) is NOT a fault: it still yields the unknown owner and the policy decides, as before. The
system grant never reaches this path. Existence oracle: ownerForAuthz is reached only once
sequence 0 exists (a missing id returns earlier), so a stranger who now sees
ExecutionStoreUnavailableError where they saw a denial learns only that the id exists, which the
denial (versus a missing id’s not-found) already told them. Two qualifications. (1) Bounded by an
outage only for a TRANSIENT fault: a principal resolver that throws DETERMINISTICALLY (a bug, a
revoked credential, a record it cannot handle) makes the signal PERSISTENT, for as long as the
resolver stays broken, and it is a per-execution signal if the resolver fails only for some records.
(2) For an ANONYMOUS caller under requirePrincipal, a missing id is made to look like a denial
(denyMissing), so there the new error IS new information: it distinguishes an existing id (fault)
from a missing one (denial) during the fault. Both are visible only to a caller who already guessed
the id, and are accepted for parity with readAnchor: hiding them would deny the legitimate owner a
retry. The fault may also have been in the recorded lineage rather than the execution’s own record.
Residual (lineage, precise): a parent that is SEALED or unopenable (SealedStreamUnreadableError
on the parent’s sequence 0 during the walk) still surfaces to the CHILD’s own owner as a
denial-shaped SealedStreamUnreadableError (the seal rule above, unchanged), not as the retryable
error, and says something about the parent’s stream state; and an erasure on a parent hop is an
unknown parent owner (the walk withholds parentOwner).
Residual (2026-10-17): a bounded existence oracle during a key outage. While the key service is
down, a stranger asking about an EXISTING encrypted execution id gets ExecutionStoreUnavailableError
(its anchor cannot be decrypted, so no verdict is possible), whereas a missing id answers
not-found/denied. That tells a stranger that an id exists, but only during a real fault, only for ids
they already guess, and never the owner, workflow or contents. Accepted: answering the same for both
would mean denying or hiding a fault the legitimate owner must be able to retry.
Workflow id of an unreadable anchor. The authorizer and onExecutionDenied get the envelope’s
plaintext workflowId column, taken from the redacted view the erasure error carries (else the
error’s own workflowId). For an in-tree store this is always present. A THIRD-PARTY decorator whose
erasure error carries neither yields "": the authorizer then sees an empty workflow id (an
allowWorkflows policy refuses it with workflow "" is not permitted). Such a decorator should
attach redacted (as EncryptingEventStore does). A sealed refusal’s callback now carries the
refusal’s plaintext workflow id too (it was "").
Reason oracle (documented residual, not redesigned). What is indistinguishable is the ERROR the
fold or decrypt path throws. The denial REASON is the policy’s own and still differs by what the
policy knows: an execution whose owner is unknown (an erased principal, a sealed stream, a legacy log
with no principal) is refused with UNKNOWN_OWNER_REASON, an execution with a recorded owner of
another tenant with “the execution belongs to another tenant”. A caller who sees reasons can
therefore tell an unknown-owner execution from an owned one. Closing it would mean the policy
answering one reason for both (losing the operator-facing distinction in onExecutionDenied, which
carries the same text); left to the integrator, pinned by authzBeforeFold.test.ts.
Reads. The verdict costs one indexed point read of sequence 0, handed on to the owner lookup and
to the fold (foldState’s existence anchor, resume’s carried-scanner path), so loadState reads
sequence 0 once where it read twice and the carried resume path reads it once as before. The first
(uncarried) resume and resumeAs read the whole log anyway; they pay ONE added point read, the
inherent price of a verdict that must precede that read. Counts are pinned by a counting-store test.
Orphan checkpoints. An id with no sequence 0 is “missing” to the authorizer. If a checkpoint
a retention sweep left behind then fails to load, loadState, resume and resumeAs read that as not found
(with denyMissing for an anonymous strict caller) instead of surfacing the checkpoint error to a
caller the authorizer is consulted for. The kernel’s system grant is not gated and keeps the error.
Writes. A read denial writes nothing. A denied resume/resumeAs/cancel keeps its best-effort
EXECUTION_DENIED, now placed at the folded tip found AFTER the verdict; if that fold cannot be read
(shredded, sealed, bad checkpoint) the audit fact is skipped rather than surface the error to the
denied caller (the thrown denial is the enforcement). onExecutionDenied fires as before. The fact
is therefore absent for a denied resume/resumeAs of a shredded execution (its strict fold throws)
and for every op whose checkpoint or stream is unreadable; a denied cancel of a shredded execution
keeps it (the redacted fold is readable).
Missing vs denied (unchanged, intended). An execution that does not exist is judged by the
authorizer before “not found” is revealed only for an ANONYMOUS caller under strict
(denyMissing, R3). A caller that presents a principal still tells “not found” from “denied”:
loadState(id, { principal }) returning null is how an edge asks whether an id is free before
starting it, and a policy cannot tell a free id from a forbidden one without breaking that. This is
a documented residual, not changed here. start of an existing id keeps ExecutionAlreadyExistsError
for the same reason.
Verification: authzBeforeFold.test.ts runs EVERY gated op, one test per (world, op), as a denied
principal against a shredded execution (steps and checkpoint sealed), one whose recorded principal is
sealed too, a sealed stream, a key-unavailable checkpoint and a foreign-identity checkpoint, and
demands the shape (error class, message, reason, audit callback including the workflow id) of the
ordinary denial, no transformState call, and the same appended events as the ordinary execution
(the audit-skip exceptions above are stated in the file). The file’s header table says which cells
failed before the fix and which are vacuous. A counting store pins the reads; a faulting store pins
the retryable error (owner = stranger, no detail, no denial callback, nothing written) for every op; a
raw KeyUnavailableError with and without the erase intent, the workflow-id fallback, the orphan
checkpoint and the acquireFence/fork re-read each have a test; an authorized caller still gets
PayloadShreddedError (and a working cancel). No event shape changes: EVENT_SCHEMA and
KERNEL_ABI are unchanged.
Amendment (2026-10-17, c) - erasure is a BRAND, never a name. The kernel classified a failure as a
PROVEN erasure (isErasure, the recordedPrincipal anchor read, the cancel redacted fold) by
err.name === "PayloadShreddedError". Name matching exists because instanceof fails across
duplicated package copies, but it also let any store decorator or third-party store (or anything in its
error path) that threw a plain Error with that name forge an unknown owner, which
unknownOwner: "allow" admits. Erasure is now the realm-safe brand Symbol.for("loom.payloadShredded")
(a non-enumerable own property, so name, message and serialized shape are unchanged), set by
@loom/plugin-crypto’s PayloadShreddedError constructor and by the kernel’s own synthetic errors
(built from a PAYLOAD_SHREDDED tombstone), and read by ONE predicate, isPayloadShredded in
@loom/event-runtime (beside KeyUnavailableError, so no consumer imports the crypto plugin). The
name alone is never enough; the symbol registry makes the brand survive duplicated copies. Consumers
moved: WorkflowRuntime (3 sites), @loom/orchestration pinParkedChildren, @loom/compliance
EraseCoordinator, @loom/plugin-content GC, the CLI lifecycle. A store that throws its own erasure
error adopts brandPayloadShredded(err); until then its error is a fault (retryable, nobody admitted).
A serialized error (JSON, A2A) has lost the brand by design and no boundary re-trusts it by name; where
a boundary needs the fact it reads the log’s own PAYLOAD_SHREDDED tombstone. Verification:
shreddedBrand.test.ts (each claim has a test that fails with the name-only match restored).
Source: docs/adr/0039-execution-lifecycle-authz.md in the loom repository.