29 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 557 | pi-permission-system: serving is resolution — rebuild processInbox on evaluate() + the serving session's Authorizer |
Serving is resolution — rebuild processInbox on evaluate() + the serving session's Authorizer
Release Recommendation
Release: ship independently
Phase 9 Step 3 is tagged Release: independent in the architecture roadmap, and the roadmap's release-batches note says Steps 3 and 4 each cut a release on landing.
This ships as a feat: (behavior change: parent allow/deny rules now govern children's escalations), so it cuts a release on its own; nothing to defer or coordinate.
Problem Statement
The serving node (ForwardedRequestServer.processSingleForwardedRequest) answers forwarded escalations with bespoke logic: its own yolo check — the last one outside the composed ruleset — and no evaluate(), so a parent allow or deny rule cannot govern a child's escalation.
Every forwarded request that is not yolo-approved goes straight to a dialog, and the server owns its own permissions:ui_prompt emission and UI invocation in parallel to the Authorizer spine Steps 1–2 built.
Phase 9's resolved direction 1 says serving an escalation from below is identical to resolving an action locally: run evaluate() against the serving node's recorded authority, then escalate to the serving session's Authorizer on ask.
One consequence the issue's phrasing did not spell out (uncovered at the Decide gate, now recorded in the roadmap): the serving path today emits the non-degraded forwarded permissions:ui_prompt — original source, surface/value projection, populated forwarding context — per the #292 contract hardening documented in docs/cross-extension-api.md ("Forwarded prompts are not degraded").
Rerouting the prompt through LocalUserAuthorizer naively would re-degrade the broadcast (forwarding: null) with a fully green suite.
This plan threads the forwarded provenance through the escalated ask's details so the invariant survives the reroute.
Goals
- A forwarded request carrying
(surface, value)resolves against the serving node's composed base ruleset:allow(including yolo-rewritten) auto-approves,denyauto-denies,ask— or missing fields — escalates through theAskEscalatorseam to the serving session's selectedAuthorizer. - Behavior change (ships as
feat:): parentallow/denyrules now govern children's escalations; yolo inheritance falls out of the yolo-rewritten ruleset with zero yolo checks outside the composed ruleset (isYoloModeEnabledand theConfigReaderdep leave the server). - The escalated ask carries its forwarded provenance (requester agent/session, original
source/surface/value) as data onPromptPermissionDetails;LocalUserAuthorizerrenders it — the non-degraded forwardedpermissions:ui_promptbroadcast and the"Permission Required (Subagent)"dialog title — leaving exactly onepermissions:ui_promptemit site. - One-hop canary: a loud warning when a request arrives from a requester whose registered parent is not the serving session (multi-hop or misrouted; the depth-2 invariant is external — pi-subagents' recursion guard).
processSingleForwardedRequestdrops below 60 lines (currently 117).- The design decisions are recorded in
docs/decisions/0005-serving-authorizer-provenance.md, with post-ship validation tracked in #565.
Non-Goals
- Grant-scope selection on forwarded approvals — Phase 9 Step 4 (#558); the dialog is escalated without a
sessionLabeland whatever decision state comes back rides the response file exactly as today. - Moving
forwarding-manager.ts/permission-forwarding.ts/subagent-registry.tsintosrc/authority/— Phase 9 Step 5 (#559). - The
ModelTriageAuthorizer(#472) — deferred with its own decision record. - Multi-hop escalation — admitted, not shipped (resolved direction 2); this step adds only the canary.
- Cross-session agent-name semantics: serving evaluates with
agentNameundefined (base ruleset); revisiting that once principal identity lands is part of #565. - Redefining the
permissions:ui_promptevent shape — the cross-extension contract is byte-compatible before and after.
Background
Relevant modules (all in packages/pi-permission-system/):
src/authority/forwarded-request-server.ts—ForwardedRequestServer(InboxProcessor): drains the inbox, and per request runs the bespoke serve flow (yolo check →buildForwardedUiPromptemission → dialog → response write). Deps today:forwardingDir,logger,events?,requestPermissionDecisionFromUi,config: ConfigReader.src/authority/authorizer-selection.ts—AuthorizerSelection: selects theAuthorizeronce per activation; implements the single-methodAskEscalatorseam (escalate(details)) thatGateRunneralready uses, delegating throughPermissionPrompter(review-log bracketing:permission_request.waiting→authorize→approved/denied).src/authority/local-user-authorizer.ts—LocalUserAuthorizer.authorize(details):buildDirectUiPrompt(details)→emitUiPromptEvent→ dialog with title"Permission Required".src/authority/permission-prompter.ts—PromptPermissionDetails(the ask's data:requestId,source,agentName,message, optional display fields).src/permission-ui-prompt.ts— single source for thepermissions:ui_promptcontract shape:buildDirectUiPrompt(DirectPromptInput)(derivessurface/value,forwarding: null) andbuildForwardedUiPrompt(ForwardedPromptInput)(explicitsurface/value, populatedforwarding) — the #292 fidelity split.src/permission-forwarding.ts—ForwardedPermissionRequestcarries optionalsource/surface/valuedisplay fields (version-skew tolerant: an older child may omit them; the reader defaultssourceto"tool_call").src/permissions-service.ts—LocalPermissionsService.checkPermission(surface, value, agentName)=buildAccessIntentForSurface(...)+resolver.resolve(intent); the resolution semantics serving must reuse.src/input-normalizer.ts—buildAccessIntentForSurface(surface, value, pathNormalizer, agentName).src/subagent-registry.ts—SubagentSessionRegistry.get(sessionId)→{ parentSessionId? }; entries exist only for in-process (pi-subagents) children.src/forwarding-manager.ts—ForwardingManager.start(ctx)already gates polling onctx.hasUI && !isSubagent, so the server's internalif (!ctx.hasUI) returnis redundant defense: a UI-less session never polls.src/index.ts— composition root; today constructsrequestServerbeforeprompter,authorizerSelection, andresolver, andsession(which owns the cwd-boundPathNormalizer) after all of them, withForwardingManager(subagentDetection, requestServer)asessionconstructor arg.
Constraints from AGENTS.md / the package skill: docs/architecture/architecture.md names these symbols in narrative prose and the module tree; the roadmap step gets its ✅ in the implementation doc-update commit.
The Phase 9 roadmap (amended in 21472cf9 during this planning session) now records the #292 fidelity invariant on Step 3 and the provenance-as-data sentence in resolved direction 1 — this plan implements exactly that amended step.
Design Overview
Decision: provenance is data on the ask, not a second emission path
A forwarded ask is a different question than a local ask ("may my child do Y"), so its provenance — requester agent/session, original display projection — belongs on the ask's data (PromptPermissionDetails), and the one emit site (LocalUserAuthorizer) renders it.
This was checked against the smell taxonomy at the Decide gate:
- Not tramp data: every hop reads or relays it (
ForwardedRequestServerbuilds it,LocalUserAuthorizerrenders it, a futureParentAuthorizerhop would forward it — multi-hop-ready with no per-hop special-casing). - Not a control flag:
LocalUserAuthorizerdoes not branch its logic on it; it renders it (event payload + dialog title). Absent means a local ask. - It is the live-authority echo of the principal identity the access-intent direction already requires ("the intent must carry principal identity so a forwarded request is evaluable on the serving node").
Rejected alternatives: server-side event emission with a dialog-only authorizer call (splits the emit+dialog pairing Step 1 gave LocalUserAuthorizer, needs an emit-suppressed authorize variant — a genuine control flag — and keeps two emit sites); a per-request decorator authorizer (authorizers are selected once per session; a per-request decorator is the same data flow with object ceremony).
Type changes
// src/authority/permission-prompter.ts
/** Provenance of a forwarded ask: who is really asking, one hop below. */
export interface ForwardedAskProvenance {
requesterAgentName: string | null;
requesterSessionId: string | null;
}
export interface PromptPermissionDetails {
// ... existing fields unchanged ...
/** Explicit display projection overrides (forwarded asks carry the child's originals). */
surface?: string | null;
value?: string | null;
/** Present iff this ask was forwarded from a subagent. */
forwarding?: ForwardedAskProvenance;
}
src/permission-ui-prompt.ts folds the two builders into one — the contract shape keeps a single source:
// buildUiPrompt replaces buildDirectUiPrompt + buildForwardedUiPrompt.
export interface UiPromptInput {
// DirectPromptInput's fields, plus:
surface?: string | null; // explicit override; falls back to directSurface()
value?: string | null; // explicit override; falls back to directValue()
forwarding?: ForwardedPromptContext | null;
}
export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
return {
requestId: input.requestId,
source: input.source,
surface: input.surface !== undefined ? input.surface : directSurface(input),
value: input.value !== undefined ? input.value : directValue(input),
agentName: input.agentName,
message: input.message,
forwarding: input.forwarding ?? null,
};
}
PromptPermissionDetails satisfies UiPromptInput structurally (as it satisfies DirectPromptInput today), so LocalUserAuthorizer still passes details straight through — no field-by-field copying:
// LocalUserAuthorizer.authorize — renders provenance, no logic branch beyond presentation:
const uiPrompt = buildUiPrompt(details);
emitUiPromptEvent(this.deps.events, uiPrompt);
const title = details.forwarding ? "Permission Required (Subagent)" : "Permission Required";
return this.deps.requestPermissionDecisionFromUi(this.deps.ui, title, details.message, ...);
The emitted event is byte-identical to today's for both direct asks (forwarding: null, derived projection) and forwarded asks (populated forwarding, original source/surface/value, agentName = requester agent).
The server: resolve, then escalate
ForwardedRequestServer sheds events, requestPermissionDecisionFromUi, and config; it gains two narrow roles (ISP — exactly the members it reads) and the registry for the canary:
/** Recorded-authority view for serving: answer one (surface, value) query on the base ruleset. */
export interface ServingPolicy {
check(surface: string, value: string | null): PermissionCheckResult;
}
export interface ForwardedRequestServerDeps {
forwardingDir: string;
logger: DebugReviewLogger;
policy: ServingPolicy;
escalator: AskEscalator;
/** In-process registry, read by the one-hop canary. */
registry?: SubagentSessionRegistry;
}
Per-request flow (replacing the yolo/dialog body of processSingleForwardedRequest):
// 1. target-session check (unchanged), then the canary:
const info = this.registry?.get(request.requesterSessionId);
if (info?.parentSessionId && info.parentSessionId !== currentSessionId) {
logPermissionForwardingWarning(this.logger, `one-hop invariant violated: ...`);
} // warn loudly, keep serving — resolution is still well-defined
// 2. recorded authority first; ask (or unevaluable) escalates:
const state = hasDisplayFields(request)
? this.policy.check(request.surface, request.value).state
: "ask";
let decision: PermissionPromptDecision;
if (state === "allow") {
decision = { approved: true, state: "approved" }; // review: forwarded_permission.auto_approved
} else if (state === "deny") {
decision = { approved: false, state: "denied" }; // review: forwarded_permission.auto_denied
} else {
// review: forwarded_permission.prompted, then escalate (try/catch preserved: failure denies)
decision = await this.escalator.escalate(buildForwardedAskDetails(request));
}
// 3. response write + request-file cleanup (unchanged)
buildForwardedAskDetails(request) (module-private) maps the request onto PromptPermissionDetails: requestId: request.id, source: request.source ?? "tool_call" (version-skew default preserved), agentName: request.requesterAgentName || null, message: formatForwardedPermissionPrompt(request), surface/value overrides, and forwarding: { requesterAgentName, requesterSessionId }.
Notes:
- Base ruleset (
agentNameundefined) — Decide-gate resolution: the child applied its own per-agent overrides before forwarding; the requester's agent name is display-only on the serving node. Revisited post-ship in #565. - Missing
(surface, value)floors toask, even under yolo. An older child's request without display fields cannot be evaluated, so it escalates to a prompt where the old code auto-approved under yolo. Accepted version-skew consequence (fail-safe direction); the fields have been carried since #292. - Single-surface re-resolution is best-effort.
One
(surface, value)pair cannot reproduce a child decision that layered multiple checks (e.g.external_directoryoverpath); an imperfect resolution lands onask→ prompt, never a silent grant. Known simplification, recorded in ADR-0005 and validated post-ship in #565. - The
if (!ctx.hasUI) returnguard is removed.ForwardingManager.startalready refuses to poll without UI, so the guard is unreachable defense; removing it is behavior-neutral and completes resolved direction 1's "thehasUIguards dissolve into selection."processInboxkeeps itsForwarderContextparameter forgetSessionId(ctx)but no longer touchesctx.hasUI/ctx.ui.
Review-log and event deltas (deliberate, observable)
forwarded_permission.auto_approvednow fires for any policyallow(yolo-rewritten or plain) — same entry name, wider trigger.- New
forwarded_permission.auto_deniedentry for a policydeny(new observable outcome; previously impossible). - An escalated forwarded ask now also produces the
PermissionPrompterbracketing (permission_request.waiting/approved/denied) around the existingforwarded_permission.prompted/approved/deniedentries — the uniform-escalation shape #556 chose; the serving lifecycle entries record provenance, the ask-path entries record the escalation. - No
permissions:ui_promptis emitted when policy decides (allow/deny) — previously every non-yolo request emitted one. Consumers see prompts only when a human is actually about to be asked (this is the notify-now contract's intent); one sentence added todocs/cross-extension-api.md. - No
permissions:decisionevent changes — forwarded serving never emitted one and still does not (the child's gate emits its own).
Composition root rewiring
index.ts construction order becomes: prompter → authorizerSelection → resolver (moved up; deps permissionManager + sessionRules exist early) → servingPolicy → requestServer → ForwardingManager → session.
The ServingPolicy is a three-line adapter over the same primitives LocalPermissionsService.checkPermission composes:
const servingPolicy: ServingPolicy = {
check: (surface, value) =>
resolver.resolve(
buildAccessIntentForSurface(surface, value ?? undefined, session.getPathNormalizer(), undefined),
),
};
The deferred session.getPathNormalizer() read is safe: inbox polling starts at session_start, after session is assigned — the same deferred-binding precedent as the logger's notify sink (documented with the same style of comment).
Module-Level Changes
src/authority/permission-prompter.ts— addForwardedAskProvenance; addsurface?/value?/forwarding?toPromptPermissionDetails.PermissionPrompter.writeReviewEntryis unchanged (the serving-sideforwarded_permission.*entries already record provenance; keeping the ask-entry shape stable avoids review-log churn for direct asks).src/permission-ui-prompt.ts— replacebuildDirectUiPrompt+buildForwardedUiPrompt+DirectPromptInput+ForwardedPromptInputwithbuildUiPrompt+UiPromptInput(explicit-override-or-derive semantics above); module doc comment updated. Grep confirmation: the onlysrc/consumers arelocal-user-authorizer.ts(buildDirectUiPrompt) andforwarded-request-server.ts(buildForwardedUiPrompt);permission-forwarding.tsmentionsbuildForwardedUiPromptonly in theForwardedPromptDisplaydoc comment (reworded); docs references are point-in-time plans/retros (not edited).src/authority/local-user-authorizer.ts—buildUiPrompt(details); title keyed ondetails.forwarding; doc comment notes it is the singlepermissions:ui_promptemit site.src/authority/forwarded-request-server.ts— deps swap (policy+escalator+registry?in;events/requestPermissionDecisionFromUi/configout); per-request resolve-then-escalate flow; one-hop canary;buildForwardedAskDetailshelper; remove thectx.hasUIguard, theisYoloModeEnabledimport, and thebuildForwardedUiPrompt/emitUiPromptEventemission;formatForwardedPermissionPromptstays (message construction).src/permission-forwarding.ts— reword theForwardedPromptDisplaydoc comment (parent reconstructs the event via the ask's details /buildUiPrompt, notbuildForwardedUiPrompt).src/index.ts— construction reorder +servingPolicyadapter + newrequestServerDeps;isYoloModeEnabledimport stays (still used by the yolo command wiring at line 78).docs/cross-extension-api.md— one sentence in thepermissions:ui_promptsection: a forwarded request the parent's recorded policy decides (allow/deny) emits no prompt event; the event fires only when the human is about to be asked. The existing "Forwarded prompts are not degraded" paragraph stays true verbatim.docs/decisions/0005-serving-authorizer-provenance.md— new ADR: provenance-as-data decision, rejected alternatives, base-agent-scope, missing-fields/version-skew floor, single-surface fidelity limitation, post-ship validation pointer to #565 (next free number after 0004).docs/architecture/architecture.md— module tree:forwarded-request-server.tsentry (serving = resolve + escalate; deps),local-user-authorizer.tsentry (single emit site, provenance rendering),permission-ui-prompt.tsentry (buildUiPrompt),index.ts/composition notes if the tree names construction order; target-model narrative line ~497 ("serving is not yet rebuilt" → landed); roadmap Step 3✅heading + MermaidS3node; metrics-table rows stay (target table convention, Phase 9 incomplete)..pi/skills/package-pi-permission-system/SKILL.md— grep confirmed: no forwarded-serving, yolo-check, or ui-prompt-builder mentions; no edit expected (re-verify at implementation time).
Expected fallow deltas: processSingleForwardedRequest leaves the largest-functions list (117 → < 60 lines via the dialog/emission body moving out); no new dead code (buildForwardedUiPrompt/ForwardedPromptInput are removed in the same step as their last consumer).
Test Impact Analysis
New unit tests enabled:
test/authority/forwarded-request-server.test.ts— the server is now testable without UI/dialog stubs: policy-stuballow→ approved response written, escalator never called,forwarded_permission.auto_approvedlogged;deny→ denied response,auto_denied;ask→ escalated with details carryingforwarding/surface/value/source-default; missing display fields → escalates without a policy call; escalator rejection → denied response (try/catch preserved); canary warning fires on a mismatchedparentSessionIdand stays silent for an unregistered (external file-based) requester.test/authority/local-user-authorizer.test.ts— the #292 invariant pin: forwarded-provenance details emit a populatedforwarding, the originalsource/surface/value, and the"(Subagent)"title; direct details emitforwarding: nullwith derived projection and the plain title (byte-compatible with today's assertions).test/permission-ui-prompt.test.ts—buildUiPromptoverride-vs-derive semantics, includingsurface: nullexplicit override vssurface: undefinedfallback.
Redundant / migrated tests:
test/authority/forwarded-request-server.test.ts— yolo-mode cases (mockingisYoloModeEnabled) become policy-stuballowcases; the ui-prompt-emission assertions move to theLocalUserAuthorizersuite; dialog-invocation assertions become escalator-call assertions; the!ctx.hasUIearly-return case is deleted (guard removed;ForwardingManagerowns the gate, already pinned intest/forwarding-manager.test.ts).test/permission-ui-prompt.test.ts—buildForwardedUiPromptcases (version-skewsourcedefault, null-field handling) are re-expressed againstbuildUiPrompt+ the server'sbuildForwardedAskDetailsmapping; none are dropped silently.
Tests that must stay (exercise preserved layers):
- Inbox mechanics in the server suite: target-session mismatch cleanup, unreadable-request deletion, responses-dir defensive recreation (#398), response-write failure path, empty-location cleanup.
test/authority/permission-prompter.test.ts— bracketing order and marker handling, untouched.test/forwarding-manager.test.ts— the polling gate (hasUI && !isSubagent), now the sole owner of that predicate.
Invariants at Risk
- #292 forwarded-prompt fidelity (
docs/cross-extension-api.md; roadmap Step 3 invariant line) — previously pinned only via the server suite's emission assertions, which this change deletes. Re-pinned by the newLocalUserAuthorizerforwarded-details test plus the server test asserting escalated details carry the original display fields — together they compose to the same end-to-end guarantee. - #555
LocalUserAuthorizeremits the event before the dialog;Denying/ParentAuthorizernever emit — pinned by the existing authorizer suites; the rendering change keeps emit-then-dialog order. - #556 uniform escalation bracketing (
waitingbefore authorize, marker-drivenconfirmation_unavailable) — untouched code; forwarded asks now flow through it, extending rather than altering the invariant. - #398 responses-dir defensive recreation — the
processInboxdrain loop structure is preserved; the existing test stays green. - Escalation requires an activated selection —
AskEscalator.escalaterejects when noAuthorizerhas been selected (selected === null), and the server'stry/catchmaps a rejection to a denied response. A poll that ran beforesession.activatewould therefore silently deny an approvable forwarded request. Satisfied today:PermissionSession.activate(ctx)callsauthorizerSelection.activate(ctx), and inbox polling only begins atsession_start(viasession.activate), so selection is always bound before the first drain. Pinned by the server suite's escalation cases (which activate before polling) plus the existingpermission-session.test.tsactivate-order coverage — a reorder that broke it would fail those, not just surface at runtime. - #526/#527 yolo-as-ruleset (deny-preserving rewrite,
origin: "yolo") — serving now relies on it: under yolo, a forwarded request matching an explicitdenyis now denied where the old bespoke check approved it. This is the intended alignment with documented yolo semantics ("suppresses prompts but preserves hard denies"), called out in thefeat:commit body.
TDD Order
- Provenance rendering (additive; direct behavior unchanged).
Red→green:
permission-ui-prompt.test.tsdrivesbuildUiPrompt(override-or-derive, forwarding passthrough) withbuildDirectUiPromptkept temporarily as a one-line alias so nothing else breaks;local-user-authorizer.test.tsdrives theforwarding-keyed title + non-degraded event;PromptPermissionDetailsgains the three fields. Production still never sets them — unreachable until step 2 (lift-and-shift). Commit:refactor(pi-permission-system): render forwarded provenance through LocalUserAuthorizer. - Rebuild the server on policy + escalator (atomic: deps swap fans out to the constructor, index.ts, and the server suite at the type level).
Red→green in
forwarded-request-server.test.tsper Test Impact Analysis: swapForwardedRequestServerDeps, implement resolve-then-escalate +buildForwardedAskDetails+ canary, drop thehasUIguard and yolo/emission/dialog code; rewireindex.ts(construction reorder +servingPolicyadapter). RemovebuildForwardedUiPrompt/ForwardedPromptInputand the transitionalbuildDirectUiPromptalias in the same commit (their last consumers die here; export removal folds consumer updates in — including re-pointinglocal-user-authorizer.tsatbuildUiPromptif the alias was used); reword theForwardedPromptDisplaydoc comment. Runpnpm run checkand the full package suite immediately after. Commit:feat(pi-permission-system): serve forwarded permissions by resolution and Authorizer escalation (#557). - Decision record.
Write
docs/decisions/0005-serving-authorizer-provenance.md(decision, alternatives, accepted limitations, #565 pointer). Commit:docs(pi-permission-system): record serving-provenance decision (ADR-0005). - Docs + roadmap completion.
architecture.md(tree entries, narrative line ~497, Step 3✅+ MermaidS3),cross-extension-api.mdsentence, SKILL.md re-verify (expected no-op). Commit:docs(pi-permission-system): mark Phase 9 Step 3 complete.
Risks and Mitigations
- Silent contract regression (the reason this plan exists). The #292 fidelity invariant is now written into the roadmap step, pinned by two composing tests (authorizer emission + server details mapping), and the pre-completion reviewer checks cross-step invariants against documented outcomes.
- Yolo behavior shift for forwarded denies and legacy requests.
Explicit
denynow wins under yolo (documented yolo semantics; previously the bespoke check approved everything), and field-less legacy requests prompt instead of yolo-approving. Both fail toward safety; both are named in thefeat:commit body and ADR-0005 so the release notes carry them. - Deferred
session.getPathNormalizer()read in theservingPolicyadapter. A pre-session_startcall would throw on the unassignedsession; mitigated by the polling lifecycle (starts atsession_start) and the existing composition-root precedent (loggernotifysink), with the same explanatory comment; the server suite injects its own policy stub so tests cannot mask a wiring mistake — verify once manually in a live parent/child session before ship. - Review-log shape changes for forwarded asks (added
permission_request.*bracketing, newauto_denied). Deliberate (#556's uniformity decision extended); recorded in the Design Overview and the ADR; no known consumer parses the review log programmatically. - Double review-entry volume could obscure the serving lifecycle.
Accepted: the
forwarded_permission.*entries keep the serving lifecycle greppable on their own; revisit only if real log reading proves noisy (#565 check 3 will surface it). - Inbox drain still serializes on the human response.
Routing the
askthrough theAskEscalatorseam does not change the drain's concurrency: an escalated forwarded requestawaits the human dialog inside the drain, exactly as the old directrequestPermissionDecisionFromUicall did, andForwardingManager'sprocessinglock already forbids overlapping drains. Not a new concurrency change — the UI surface and the one-dialog-at-a-time serialization are identical before and after; a reviewer should read the escalator swap as behavior-neutral here.
Open Questions
None blocking — the three design forks (fidelity-preserving threading vs. re-degrade, base vs. requester agent scope, roadmap amendment scope) were resolved at the Decide gate, and the roadmap amendment landed in 21472cf9.
Post-ship validation of the recorded decisions is tracked in #565.