Skip to content

Approvals

How an act that this Node will not perform on one person’s word gets decided: what an approval is, who may decide it, what happens when two people act at once, and what is deliberately absent.

Implemented by apps/node/worker/src/approvals.ts with migrations/0020_approvals.sql, 0021_hold_lift.sql, 0022_approval_expiry.sql and 0026_send_breakers.sql, on top of the policy object in src/policy.ts (0019_policy.sql) and the eligible-set query in src/deciders.ts; the dispatch-time recheck an approved send gets is src/outbound/recheck.ts. The sequence “evaluate policy, then plan the approval a policy demanded” is src/governed.ts. Decision record: #61, with #60 for the policy outcomes a send’s approval hangs off, #64 for the legal-hold lift, #62 for the recheck, the effect envelope and expiry, and #160 for the gate.

The gate, and what it deliberately leaves to its caller

Section titled “The gate, and what it deliberately leaves to its caller”

stagePolicy in src/governed.ts is the sequence an act passes through to be governed: evaluate, then, only if a version required approval, requiredStages, decidersOf, rostersOf and planApproval, folded in that order. It returns a decision plus the approval’s rows for the caller’s transaction, and writes nothing itself. That is planApproval’s reason carried up one level: the approval and the act it gates are one act, so a gate that wrote its own rows would make a half-gated act representable.

It exists because #160 found requiredStages had exactly one caller. Policy was not a plane acts pass through, it was a step inside a send’s sealing. Correct while sends were the only governed act, and a correspondence to maintain by hand the moment there was a second one. The lift removes a future duplication rather than adding an abstraction now, and src/outbound/manifest.ts calls it where those five calls used to be inline.

Three things stay with the caller, and each is a decision rather than an omission:

  • The conditions. evaluate takes SendFacts, and policy_versionswhen_* columns are send-shaped. A second governed act either fits them or #60’s policy object needs new conditions, which is a decision about what a policy may say, not something to pre-build.
  • The breakers. The domain pause, the rate gate and #50’s Butler release fold in after the gate, in the total order sealManifest documents. They are properties of a send: a label has no recipient domain to pause.
  • The subject’s row. 0021 settled that every subject is a row in a table of its own. The gate takes (subject_kind, subject_id) and does not offer to store an act for you.

subjectKind is typed ApprovalSubjectKind, not string, and that is the point of the type rather than a nicety. Governing a new act requires declaring it in APPROVAL_SUBJECT_KINDS beside the five that exist. Widening it would let an act be governed without ever being classified, which is the generic subject 0021 refused.

An approval decides on a subject: (subject_kind, subject_id), unique over the pair.

KindThe subject isCompletion doesStages come fromApprovers
send_manifesta send_manifests rowreleases the send to heldthe fold over every matching require_approval policy versionapproval.decide on the mailbox
hold_lifta hold_lifts row, one request to lift one legal holdapplies the lift: lifted_at, lifted_reason, lift_id[2], which is #64’s decision and not a policy’sapproval.decide on the held mailbox
supervised_reada supervised_grants rowsets granted_at and owes §7’s notice[2] (#63)approval.decide on the mailbox being read
ediscovery_exportan exports rowmakes the run permissible[2] (#65)approval.decide on the mailbox being copied
domain_pausea domain_pauses rowsets placed_at; stops every send from a domain[2] (#66)org.admin on the organization

It shipped manifest-shaped (manifest_id TEXT NOT NULL, UNIQUE (manifest_id)), and the lift was the second caller, which found that on its first day. The two alternatives lose for the same reason in two directions: a nullable hold_id beside manifest_id starts a column per subject kind and makes which subject a question nothing validates, and a separate hold_lift_approvals table duplicates the fold, the eligible set and the conditional completion, and all three of #61’s defects were in that last one, so a second copy is a second place for them.

A lift’s subject is the request, not the hold, and that is what keeps the index a full UNIQUE rather than a partial one over pending rows. Asking again has to have a representation: a send re-seals and mints a new manifest id, a refused lift mints a new hold_lifts row. Had the subject been the hold id, one denial would have made that hold unliftable for ever, #64’s operational trap arriving through the schema.

Two columns carry across every kind, checked rather than assumed:

  • scope_id was mailbox_id until #66, and the rename is the point rather than tidying. It always meant the object whose relation-holders are eligible to decide: the mailbox a message is from, the held mailbox, the mailbox being read or copied. A domain pause has no mailbox, so it carries the organization instead, and a column named mailbox_id holding an organization id is the overclaiming name AGENTS.md calls a landmine: the join to mailboxes returns nothing, and a join that returns nothing is the one nobody notices. Which relation on which object is not a column. It is SCOPE_OF, a total map keyed on the subject-kind union, so a sixth kind is a compile error until it says where its approvers come from.
  • actor_user_id was author_user_id. It always meant the person whose act this approval gates, and therefore the one person who may never decide it: the author of the send, the requester of the lift. A lift has no author, and a name that overclaims by one word is how a reader is handed a landmine.

One caller-visible consequence: the refusal for deciding your own is E_APPROVER_IS_ACTOR, renamed from E_APPROVER_IS_AUTHOR, and its what is per subject kind: “you composed this send” against “you requested this hold lift”. A Record keyed on the kind means a new subject is a compile error rather than a sentence about the wrong act.

A subject kind with no mailbox at all (§18 names domain and routing changes) was left open by 0021 as a real question, on exactly these terms: “that kind either names a mailbox or brings a second source for its eligible set, and that is its ticket’s work.” #66’s domain pause is that kind, and it brought the second source: adminsOf in src/deciders.ts, the org.admin holders on the organization, resolved through teams and de-duplicated on the person. A nullable scope_id is still refused, for 0021’s reason: eligibility would become a question nothing validates.

subject_kind carries no CHECK constraint, and that is stated rather than implied: SQLite cannot add one with ALTER TABLE, and no trigger can exist in this tree because the Node applies migrations by splitting on semicolons. The constraint is APPROVAL_SUBJECT_KINDS in src/approvals.ts plus a closed-world test that requires one writer for the table and every subject-kind literal in src/ to be a declared one.

A policy version whose outcome is require_approval carries stages: an ordinal and a count of distinct decisions. That one structure expresses all three review shapes §18 asks for.

§18 asks forWritten asMeans
parallel[2]one stage, two distinct people, no order between them
sequential[1, 1]two stages, one person each, in order
dual controleitherwhichever of the two the organization means
separation of duty[{count: 1, team: finance}, {count: 1, team: legal}]the same chain, each stage narrowed to a team (#73)

A stage is a count and an optional team, and those are the only two things it is. 2 and {count: 2} are the same stage and normalise to one stored form; team: null is the absence of a constraint and is what every stage written before migration 0032 means, so nothing was backfilled.

Since #93 the API refuses a stage field it does not recognise, rather than dropping it: {count: 1, teem: finance} accepted quietly is not a stage with less detail, it is separation of duty replaced by any single approver, in a rule whose author believed they had written the opposite. The refusal is E_POLICY_STAGE_FIELD_UNKNOWN and it names the three fields that exist. teamId is accepted as a spelling of team because the route always accepted both.

One qualification, because the sentence above was unqualified and the behaviour is not. A stage is a union, a bare number or an object, and an unknown key is reported only from a branch that failed on nothing else. So {count: 1, teem: finance} is refused by name, and {count: "one", teem: finance} is refused for the count instead: the object branch failed on a type as well, and a branch that never described the value should not be reporting which fields it lacks. The caller fixes count, resubmits, and then hears about teem. Two round trips rather than one, and no version of it accepts the stage quietly, which is the property that matters. The same one-position-at-a-time limit is why a body with unknown keys in two places names the first.

The order is on the stages, not on the people. That is what makes an order expressible at all: a set defined by a relation has no natural sequence, and naming people in a policy would widen authority. Each stage’s membership stays derived from relations; only the stages are ordered.

A require_approval version with no stage rows means one stage of count 1: one decision by somebody other than the author, which is the least the words can mean, and which is also what every version published before migration 0020 means. Writing [1] explicitly normalises to the same thing, so one rule has exactly one stored form.

eligible(stage) = approval.decide holders on the approval's mailbox
∩ members of stage.team_id, if one is named (#73)
− the actor: the send's author, or the lift's requester
− everybody who has already decided in this approval

The team term is an intersection, and that is load-bearing rather than incidental. It can only ever remove people, so Layer 5’s may-narrow-never-widen rule survives whole: naming a team makes a stage harder to fill and can never make somebody eligible who holds no relation. A team id naming nothing resolves to the empty set, the restrictive answer for the unclassified input, so a stage naming a team that has ceased to exist refuses rather than passes.

approval.decide is a relation on the mailbox (src/access.ts), grantable by an administrator like any other. It is not implied by org.admin and not implied by send.propose: the first would make every administrator an approver, and the second would make every author an approver of their own mailbox.

Distinctness is measured on user_id, not on tuples. readableSubjects authorizes a principal as [userId, ...teamIds], so a relation can be held through a team, which means the holder set is a set of tuples while a decider is a person. One person in two teams that both hold approval.decide would satisfy a count of 2 if the count were taken at the tuple layer. Two things stop it:

  • decidersByMailbox (src/deciders.ts) resolves team-held tuples to their members, requires a row in users (a tuple’s subject may be a team), and de-duplicates on the person;
  • apd_one_per_person, a UNIQUE index on (approval_id, decider_user_id), which is the half that holds when two decisions race.

The query is the message; the index is the guarantee.

Checked twice, and the second check is the point

Section titled “Checked twice, and the second check is the point”
WhenWhat it knowsWhat it does
Publication (publishPolicy)who holds approval.decide today; whether a named team exists; not who will write the messagerefuses the publish, naming the mailbox, the stage, the team and how many short
Evaluation (sealManifest)the author too, today’s grants, and today’s membershipseals the send withheld with approval_unsatisfiable
The decision (decideApproval)who this person is, right nowrefuses E_APPROVER_NOT_IN_TEAM if they have left the stage’s team

Publication verifies that a named team exists, which is the check that was impossible before there was a teams row to look for: all the old world could ask was whether a team currently has members, and a misspelled id and a real team on a quiet week need opposite answers. E_NO_SUCH_TEAM and E_APPROVAL_UNSATISFIABLE are therefore two refusals rather than one.

A stage freezes the team’s id and deliberately not its members. Membership is authority and §7 makes authority live, so somebody who leaves a team stops being able to decide on their next request rather than on the next send, which is why the decision is a third check rather than a courtesy.

Publication-only was rejected. Revoking approval.decide would then make a live policy unsatisfiable silently, and gated sends would collect in awaiting with nothing having failed, the shape of a stale_when that named the right condition and which nothing checked.

Publication of a policy with no mailbox condition is checked against every mailbox in the organization and refused if any of them is short, because such a policy gates sends from all of them.

A team that is emptied reaches exactly the same answer as a revoked relation, and that is the point rather than a coincidence: removing the last member of a team a live policy names is permitted (refusing it would put a policy in charge of who may leave a team), and the next send is withheld with approval_unsatisfiable naming the stage, the team and the shortfall. Reversible: put somebody back and the next send is gated again.

What is still not covered, stated rather than implied: a send already awaiting when the last approver loses the relation, or the last member leaves the stage’s team, is not re-checked. Nothing sweeps awaiting. It is never dispatched, so #62’s dispatch-time recheck cannot see it, and the drain that exists is the author cancelling their own send. The one live case that is closed is a withdrawal that leaves too few eligible people, because that path already holds the eligible set. Closing the revoke case needs a pass over awaiting sends, which is the shape #63’s notification cron already has.

seal, outcome = require_approval
├── stages satisfiable ──► manifest awaiting / policy_approval_required
│ approval pending
│ │
│ ├── every stage satisfied ──► approval approved
│ │ manifest held
│ │ │
│ │ └── dispatch rechecks it (#62)
│ │ ├── all six pass ► handed_over
│ │ └── any fails ──► withheld
│ │ + the reason
│ │
│ ├── one denial ────────────► approval denied
│ │ manifest withheld / approval_denied
│ │
│ └── withdrawal leaves too
│ few eligible people ───► approval unsatisfiable
│ │ manifest withheld / approval_unsatisfiable
│ │
│ └── the author cancels ────► approval cancelled
│ manifest cancelled
└── stages unsatisfiable ─► manifest withheld / approval_unsatisfiable
no approval row at all

Cancelling is the drain awaiting has, so cancelSend settles the request in the same transaction as the manifest. Two reasons, and neither is tidiness: apr_pending is an approver’s queue, and a request whose send no longer exists is dead work nobody can clear; and the decision path’s conditional UPDATE keys on approvals.state, so leaving it pending let an approval of a cancelled send close the request, move nothing, and report the send as released.

withheld and awaiting keep #62’s convention: gates are awaiting plus a reason, refusals are withheld plus a reason. The reason tokens are minted in code and the words live in src/client/delivery.client.js, which is the one module a test can evaluate as the exact bytes a browser is served.

An approved send goes back to held, with state_reason cleared: the gate is gone, so it is an ordinary send waiting out whatever remains of its hold window. The record that it was gated and approved is in policy_outcome, in the approvals row, and in the trail, not in a stale reason on a released row.

The recheck before hand-over, and why only approved sends get it

Section titled “The recheck before hand-over, and why only approved sends get it”

Implemented by apps/node/worker/src/outbound/recheck.ts, called from dispatchOne beside the authority re-read that has been there since ADR 39. Decision record: #62.

§18 requires that immediately before execution a Node rechecks approval validity and revocation, current actor authority, approver eligibility, policy, and every bound object hash. An approved send gets all of it. An unapproved send gets the authority re-read and nothing else.

SubjectCostReason token when it fails
current actor authority (both paths, ADR 39)0 extraauthority_lost
the approval is approved, nobody withdrew, somebody’s approval stands2approval_revoked
the deadline has not passed0, same rowapproval_expired
every approver still holds approval.decide, and is not the author1approver_ineligible
max(current policy) > max(bound policy)1–3policy_stricter
both stored bodies still hash to what the manifest recorded4evidence_changed
the transport’s capability1 on a Node that can sendrecorded, not a gate

The two paths differ deliberately, and a future reader must not unify them. The recheck is a measured 8 subrequests (9 with the shipped adapter) against a 16-subrequest dispatch, so making it universal is a 50% increase in what every send costs to buy a guarantee nobody asked for. docs/receipts/dispatch-recheck-cost.md carries the figures, and the tripwire is on the unapproved path: a bound of 20 against a measured 16, which a unified path would blow through. Deciding which path a send is on costs nothing. It is policy_outcome = 'require_approval' on a row dispatchOne had already read.

The checks run cheapest-first, so a refusal costs 6 rather than 24 and never touches R2 or the vault. The consequence, stated because it is observable: when two things are wrong at once the earlier reason is recorded. That is the first answer rather than the worst one, on purpose. Reporting a hash mismatch on a send whose approval had already lapsed would raise a corruption alarm about a message nobody was going to send.

evidence_changed is the one reason that also raises. Every other reason is the system working: authority withdrawn, policy tightened, a deadline passed, and the person who wrote the message reads their own outbox row. A hash mismatch means the archive differs from its own record, corruption or tampering, so it writes an operational log entry (send.evidence_changed, carrying the blob key and both hashes) and doctor reports it as send_evidence_changed, which is degraded. An unreadable or missing object is the same reason with a different detail: it is the same claim about the same object, and §24’s worst failure.

Two of the three body hashes, and that is structural. submitted_sha256 is written during dispatch, immediately before the transport is asked, so at recheck time it does not exist. The submitted bytes are derived from the normalized body, so verifying the input verifies what the output is built from.

approval_revoked is the one reason no path in this Node produces: withdrawApproval refuses a settled request, which is exactly what makes an approved send safe to dispatch. It is checked anyway, because the point of re-reading is not to trust what the manifest’s state implies, and it is the layer that holds if that ever stops being true. test/outbound-recheck.test.ts asserts the refusal as part of producing the state, so the distinction is in the test rather than in a comment.

§18 makes every approval bind a canonical effect envelope, and the recheck is performed against it rather than producing it as a by-product. It is built from the manifest row plus the approval, and recorded in the send.withheld audit entry when a check refuses: 1,372 bytes against the 2,048-byte detail cap, measured, because an over-cap detail is replaced wholesale and would take the reason with it.

Bound: the manifest id as target resource, expected version and idempotency key. The manifest is the revision and ADR 9’s effect key is already that id, so no second identifier was invented that would have to be kept equal to it; From, To, Cc, Bcc and subject; both body hashes; the author as actor; the mailbox; the policy outcome and version set as bound at the seal; the approval with its state, deadline, standing approvers and withdrawals; the emitted header set; and the adapter’s capability.

The header set is fixed and enumerable: From, To, Cc, Subject, Message-ID, Date, MIME-Version, Content-Type, plus In-Reply-To and References on a reply, with To and Cc present only when they have recipients. Bcc is absent, which is what Bcc means. It is derived from the same columns renderRfc822 derives it from, and the test renders real bytes and reads the names back out of them rather than trusting the list.

Two members are recorded rather than checked: the header set, because a manifest is immutable so what it implies cannot have moved; and the capability, because the transport’s own refusal is already the gate on it and a seventh withholding reason is not something #62 decided.

Absent, each with its reason, carried on the envelope and recorded on every refusal: rendered HTML (only a typed body exists; packages/contract’s optional bodyHtml is a contract-versus-implementation gap), attachment hashes and filenames (no attachment representation in the outbound path at all), template and prompt versions (neither object exists), Butler version (Layer 4), delegator (no delegation mechanism), DLP results (no DLP), and submitted_sha256 (for the structural reason above).

approvals.expires_at, written at request time from approval.send_expiry_seconds: four days, sized rather than measured, with the trade-off in docs/receipts/dispatch-recheck-cost.md: long enough that an approver working across a weekend plus a public holiday is not defeated, short enough that an approval is not a standing permission.

Three properties are decisions rather than accidents.

It is a constant, not a per-policy field. The policy object has no expiry column, and adding one would invent a governance dimension no ticket has decided, #60’s own governing failure, a condition backed by no interface. The named refinement if somebody asks for it: a nullable column on policy_versions, folded by minimum over the matching versions rather than by maximum, because narrowing runs one way and the shorter deadline is the stricter rule. The constant becomes the default.

Nothing sweeps it. A deadline passing is not an event; it is a fact the recheck reads. So an approver can still decide a lapsed request and their decision lands. The send returns to held, and the recheck then withholds it with approval_expired. One enforcement point rather than two, which is the same argument #62 makes for the reason vocabulary; a second would need its own release act and its own state. What that costs is a decision taken on a request that will not send, so expires_at travels on GET /api/approvals and on every ApprovalRow: the deadline is visible to the person being asked, before they answer.

A hold_lift approval has no deadline, and EXPIRES_AFTER_SECONDS is a total map over the subject kinds so a third kind has to decide rather than inherit. Nothing rechecks a lift, so a deadline on one would be a limit no code compares, the mirror image of a bound field nothing populates.

NULL means no deadline is recorded, for one of exactly two reasons: the request predates migration 0022, or its kind has none. Neither is treated as expired. A migration inventing a deadline for a decision somebody already took would be a false statement about the past, and the pre-0022 population is bounded by the hold window and shrinks to nothing.

Withdrawal and denial are asymmetric, deliberately

Section titled “Withdrawal and denial are asymmetric, deliberately”

An approver may withdraw their own approval while the request is incomplete. A denial is terminal.

Without withdrawal, an approver who learns something has one remedy: persuading a colleague to deny. That records somebody else’s judgement as the reason a message was stopped, in a trail whose entire value is that it does not do that.

A denial needs no counterpart. Re-sealing mints a new manifest and a fresh approval, which is the invalidation mechanism Layer 5 already rests on, so “I changed my mind” is served by the author composing again.

Withdrawal is terminal for the withdrawer: they cannot decide again (apd_one_per_person). So the eligible set only ever shrinks within one approval, and no amount of withdraw-and-approve oscillation lets one person fill two slots. The withdrawn row is kept, with withdrawn_at set: “I approved this and then took it back” is a fact an investigation asks about, and deleting the row would answer it with silence.

Both are settled the way every other conflict in this Node is settled: a conditional UPDATE, where the conflict is the signal (#9).

Two people casting what each read as the final approval. Every statement in a decision shares one predicate: the approval is still pending. So the loser writes nothing at all and is refused with E_APPROVAL_SETTLED; exactly one decision releases the send.

A withdrawal racing the final approval. The completion transition is “every stage satisfied AND nothing withdrawn”, evaluated inside the database at the moment of the write. changes = 0 on it does not by itself mean somebody withdrew. Every non-final approval leaves it 0, legitimately. The signal is “this decision should have closed the last stage and did not”, and that means a withdrawal, because a competing finalisation is refused by the shared predicate rather than recorded. The decision is kept, the send stays awaiting, and the caller is told which conflict happened.

Two withdrawals landing together. A withdrawal has to know what it leaves behind, whether enough eligible people remain to finish the stages, and that shortfall is computed from decisions read a moment earlier. So its predicate pins the decision counts as well as the request being open: the number standing and the number in total, because a new approval and a withdrawal arriving together would leave the first unchanged. Any concurrent change to approval_decisions therefore makes the withdrawal write nothing and answer E_WITHDRAW_RACED, which the caller resolves by reading and withdrawing again. Without it, two withdrawals each reading a satisfiable request would leave an unsatisfiable one reading as pending, the exact state this design closes.

The three statements that close an unsatisfiable request run after the withdrawal has moved that count, so they carry a different gate: this call’s own withdrawal landed, keyed on its withdrawn_at. Ungated they were unconditional, and a withdrawal that lost to a completing approval rewrote the recipients of a released send to withheld.

A withdrawal racing the approval that completes a lift is the one place the answer differs, and it differs deliberately. That decision carries two audit entries, approval.decided and hold.lifted, and auditedBatchMany gates a batch rather than an entry, so under the ordinary pending predicate a lost race would insert a hold.lifted entry for a lift that did not happen: a false statement in the one place that is supposed to be checkable. So that decision carries a stronger predicate (the approval is pending, this decision closes every stage, and the hold is not already lifted), and a lost race records nothing and answers E_HOLD_LIFT_RACED. A send keeps its decision because it still counts toward its stage whatever else happened; the lift’s completing decision and the lift itself are one act that must either both be true or both be absent.

The interleaving inside the product cannot be constructed from one isolate, so the refusal is exercised through the other door into the same state: a hold lifted outside the product, which is the boundary the hold mechanism has anyway (wrangler d1 execute, the dashboard). test/legal-hold.test.ts drives it and asserts that nothing was recorded: no hold.lifted, no second approval.decided, no decision row, and a chain still contiguous. Stated because a refusal nothing reaches is a refusal nobody has read.

Two administrators asking for the same lift at once. Every statement of a request carries the hold exists, is not lifted, and has no open lift other than this one, so one request lands and the other is refused with E_HOLD_LIFT_PENDING. There is no read beforehand that could disagree with it. The clause “other than this one” is load-bearing rather than defensive: without it the batch invalidated its own predicate. The approvals row goes in as pending, so the stage inserts that followed were silently skipped and the first approver met an approval with an empty stage set.

Four actions, all in the same transaction as the rows they describe (auditedBatch, auditedBatchMany):

ActionSubjectSays
approval.requestedthe approvalwhy it was asked (a policy, or an administrator’s stated reason for a lift) with the stages and how many people were eligible
approval.decidedthe approvalwho approved or denied, at which stage; a denial records outcome: refused
approval.withdrawnthe approvalwho took their own approval back, and whether that left the request unsatisfiable
hold.liftedthe holdthe lift took effect: the reason it was asked for, who asked, and both approvers by name

The first three cover every subject kind, which is the return on generalising the table: a lift is requested, decided and withdrawn by the same three acts. Only its effect earned a fourth, and its subject is the hold rather than the approval, so hold.placed and hold.lifted line up for a reader filtering one hold. There is deliberately no hold.lift_requested: approval.requested already records that act, in the same transaction as the request row, and a second entry would make “who asked to lift this hold” answerable from two places that can disagree.

hold.lifted names both approvers because dual control is only evidence if the trail says who the two were. The eligible set is live and cannot be reconstructed from the tuples as they stand later.

approval.requested rides in the same transaction as the seal, alongside send.sealed, through auditedBatchMany: two entries chained to each other, consecutive sequence numbers, one batch(). So a gated send that exists without a request to decide is not unlikely, it is unrepresentable. Its subject is the approval rather than the manifest, because it records that people are being asked, which is not something send.sealed can say without becoming an entry about two things.

Measured, not counted: docs/receipts/approval-decision-cost.md.

OperationSubrequests
eligibility check on one mailbox1
any decision on a send: approve, final approve, deny6
any withdrawal6
seal where policy demanded no approval (a hold, or nothing matching)12, bounded with no headroom
seal gated by an approval14
seal gated by a team-scoped approval15
requesting a legal-hold lift5
approving a lift, stage still open6
the approval that applies a lift7

The first two rows read 11 and 13 in this document until 3 September 2026, when they were corrected to the receipt’s figures. The receipt had recorded the drift on 21 August and this table was not updated with it, which is a document quoting numbers a reader has no way to know are a fortnight stale.

The 12 has no headroom on purpose. It bounds the seal on the path where policy demanded no approval, and that path is supposed to pay nothing for the approval mechanism. Until #160 that property was asserted only as the difference between the first two rows, so a read added above the gate’s require_approval branch lifted both figures and no assertion moved. approval-decision-cost.md carries the correction.

And the dispatch, measured in docs/receipts/dispatch-recheck-cost.md:

OperationSubrequests
dispatching an unapproved send, hand-over included16
dispatching an approved send, every check passing24, or 25 with the shipped adapter
an approved send refused at the first check6

The approval path adds two operations to a seal, and only there: a seal that no policy gated, or that a hold gated, pays nothing for this mechanism. The approvals row, its stage rows and the second audit entry are free, because they ride in the batch() the seal was already making. expires_at is free on every read of an approval, for the same reason: a column added to a SELECT already being issued costs nothing, which is what that receipt’s “the approvals tables gain a column a decision has to read” clause exists to have checked.

The recheck’s 8 is spent in the dispatch invocation, not in a Butler step. #62 predicted it would land on mail.send.propose and it does not, because dispatch runs from the sweeper’s alarm with its own subrequest budget. Both halves of that prediction were wrong and the receipt says so at length; the decision it was drawn for stands on the measurement instead.

A lift costs one operation more than a send’s decision, and exactly one: the request row, whose reason the hold.lifted entry has to name. Everything else, the second audit entry and the UPDATE holds itself, is free for the same reason, because it rides in the batch the decision was already making. That is what makes “the lift and its record are one act” a property of the transaction rather than a claim.

  • Two teams at one ordinal. “A member of Finance and a member of Legal” is a conjunction one stage cannot carry, and folding two live rules by picking either team would silently drop half of a rule somebody wrote. Publication refuses that pair when the two rules could provably both gate one send (E_POLICY_STAGE_TEAM_CONFLICT), and the fold raises if it ever meets one anyway. What this costs: an organization wanting both has to put them on different stages, which is an order they may not have meant. A richer stage shape is a governance dimension no ticket has decided.
  • A doctor finding for a live policy naming an empty team. legal_hold_unliftable is the analogous check one table over. This one is absent for the same reason the awaiting sweep above is: it is a pass over live policy versions crossed with team rosters, which is cron-shaped rather than request-shaped, and a second mechanism invented here would be the thing to undo later. The two checks that do exist, publication and evaluation, are the two this mechanism has always had.
  • Notification. Every act here is something a person is waiting on, and there is no notification mechanism in this product. #63 owns the harder version (§7 requires a notice the investigator cannot switch off) and has already chosen the shape: the obligation is a row, an existing cron delivers it.
  • The approval evidence snapshot (§18, §21): the minimum-necessary snapshot of the proposed effect and the excerpts an approver may see. approval.decide is not a read relation, so an approver holding nothing else on the mailbox can decide without being able to open the bytes. That is §21’s rule about approval not granting ambient access, and it also means this build does not yet give an approver what §18 says they must see. Named here rather than closed by granting a read as a shortcut, which §21 explicitly forbids.
  • A sweep for lapsed approvals. Expiry is built (see the section above) and it is enforced at the dispatch rather than by a pass over pending requests. So a lapsed request stays in an approver’s queue with its deadline shown, and deciding it is honest work whose send is then withheld. What is absent is the cron that would resolve it without anybody looking, which is the same shape #63’s notification obligation wants and belongs with it.
  • Per-policy expiry. A constant with a receipt rather than a policy condition, for the reason the section above gives, with the fold named for whoever asks.
  • A release act for a policy_hold. #60 gave it to any send.propose holder and nobody has built it. An approval-gated send now has its release; a hold-gated one still drains only by its author cancelling.
GET /api/approvals what is waiting on you: subject, stages, which stage is open, the reason,
and the deadline, because nothing sweeps it, so it has to be visible
POST /api/approvals/:id/decide { "decision": "approve" | "deny" }; no default, deliberately
POST /api/approvals/:id/withdraw take back your own approval while the request is incomplete
POST /api/holds/:id/lift { "reason": "..." }; org.admin asks, these three endpoints decide
POST /api/teams { "name": "..." }; org.admin. See docs/teams.md
GET /api/teams every team with its size, the number a team-scoped stage turns on

Scoped to the mailboxes the caller holds approval.decide on, and excluding approvals of their own acts: a queue that lists work nobody can do is a queue people learn to ignore. There is deliberately no UI (the shell is Layer 1 to 3’s surface), but the outbox already shows a send’s consequence, because it renders awaiting and withheld with the reason beside them, and doctor reports a pending lift beside the hold it would release.

A lift is decided through the approvals endpoints, not through a second hold endpoint. That is the whole point of the subject: an approver’s queue, a decision, a withdrawal and the trail behind them were never about sends. GET /api/approvals carries the lift’s reason, because somebody asked to re-permit destruction has to see what they are agreeing to before they decide. A trail is where a decision is accounted for afterwards.