Version: v0.2
Date: 2026-08-24
Status: Drafted; pre-flighted at engine 143fd6b and halted on a defect in v0.1's own gate staging, corrected here. Markdown primary — the consumer is Claude Code.
Changes from v0.1: v0.1's gate boundary was broken and would have silently disabled the human-authority gate everywhere between Gate 1 and Gate 2 — §8. The staging is replaced with coexistence: the new gate is built alongside the old one under a different name, and the old one is deleted only after every call site has moved (§3). The is_human_actor_kind rename is dropped — pre-flight showed nothing needs a kind-only authority check, and keeping one invites someone to call the cheap one (§3a). The gate raises rather than returns, which gives §3c its citable verdict naturally. Pre-flight results are folded in at §7, including a fixture-volume warning that changes what Gate 2 looks like. v0.1 stands as sibling.
Implements: Operator ruling broad, 2026-08-24.
Runtime direction screen: No conflict — this tightens an existing gate and widens nothing. No new act becomes possible; some acts that would have been permitted are refused. Three enumerated allowances are preserved exactly, and all three are already CR-scrutinised data.
Cites: standing-notes/loomworks-standing-note-agent-accountability-v0_1 (STANDING); standing-notes/loomworks-standing-note-two-records-and-what-crosses-between-them-v0_1 (STANDING) §5; standing-notes/loomworks-scoping-an-actor-that-resolves-to-no-one-v0_1; CR-2026-226 ruling R1; DR-15; O2, O6, O10.
The check deciding whether a human may commit, retract, discard or unfreeze asks whether the actor says it is a person. It never asks whether that person exists.
Six of seven call sites are safe anyway, because the actor there is built by the server from real authentication first. The seventh — unfreezing a frozen agent identity — has no protection, and a fabricated actor walks through. A real row already shows what that looks like: a platform-wide identity frozen by a person who does not exist.
Two sanctioned actors resolve to nobody on purpose, and a naive fix would refuse both. One is the substrate's own machinery, admitted by an explicit allowlist. The other is a documented sentinel for a legacy path where a human genuinely acted and the system cannot name which human — refusing it would revoke commit authority on records real people made, which the code's own comment warns about in those words. Both keep passing, by enumeration.
The first draft got the staging wrong in a way worth stating. It changed the check from a plain function to one that must be awaited, and scheduled the call-site updates for a later gate. Between the two, every call site would have been calling the new function without awaiting it — and an un-awaited call is always truthy, so the gate would have permitted everything, including machine actors. This version builds the new gate alongside the old one instead, and removes the old one only once nothing calls it.
One warning for the second half. Over 150 places in the test suite construct a human actor with a made-up id, including fixtures used almost everywhere. Those tests will fail, and that is the change working. The fix is to give the fixtures real backing rows — never to weaken the gate.
has_human_commit_authority(actor) tests actor.kind and nothing else, so ActorRef(kind="person", id=uuid4()) returns True.
Six of seven call sites are safe by position, not by their own check — api/deps.py:1255, engagement/creation.py:908 and :962, engagement/assertions.py:284, :515, :601. Each receives an actor built server-side from cookie or bearer authentication.
agents/grantability.py:228 — unfreeze_agent_identity — has nothing in front of it. No HTTP surface; the caller constructs the ActorRef and hands it straight to the check. It is the only widening act in the system.
The name is the trap. It sounds like an authorization check; the body is a type check. Fourth instance in this arc of a contract whose name is broader than its scan.
All three are deliberate. Refusing any is a regression.
2a. SANCTIONED_SYSTEM_WRITERS — two enumerated system-kind sentinels, the bootstrap writer and the form submitter, with fixed ids that resolve to no principal by construction. R1 made this allowlist-as-data so that adding to it is an authority grant with CR-level scrutiny. Enumeration is their resolution — named individually in reviewed data is a stronger check than a table lookup, not a weaker one.
2b. LEGACY_UNRESOLVED_ACTOR_ID — a contributor-kind sentinel for the pre-Phase-25 bearer-token path, where created_by_person_id is NULL and no identity resolves. Its own comment states the stakes:
> agent / companion sit in NON_HUMAN_ACTOR_KINDS and would REVOKE COMMIT AUTHORITY on records a human actually made — a live behavioural consequence, not a cosmetic one.
A naive resolution check does exactly that by a different route. It is documented as the only sanctioned way to write an actor the engine cannot name, with a removal condition that is unmet.
v0.1 proposed renaming it to is_human_actor_kind and keeping it as a pure predicate. Dropped.
Pre-flight found nothing that needs a kind-only check for an authority decision — all seven sites want the gate. Keeping a cheap lookalike beside the real one invites a future caller to reach for it, which is how a gate acquires a bypass. If a pure kind test is genuinely wanted later, it can return with a caller.
has_human_commit_authority is deleted at the end of Gate 2, not renamed.
async def assert_human_commit_authority(
actor: ActorRef, *, db: AsyncSession
) -> None:
Raises rather than returns. Call sites become await assert_human_commit_authority(actor, db=db) instead of if not X: raise, which removes the un-awaited-truthiness hazard by construction — an un-awaited coroutine that returns nothing has no truthiness to misread, and the missing await shows up as a RuntimeWarning and a failing test rather than a silently open gate.
Logic, in order:
SANCTIONED_SYSTEM_WRITERS, or LEGACY_UNRESOLVED_ACTOR_ID → pass, with no lookup.person → principals; contributor → contributors, by primary key. Resolve in the table the kind names — the kind records which authentication path was taken, and that distinction is the audit signal ActorRef exists to carry.
This actor is a machine and this actor names no one are different facts and O6 wants the verdict to say which. unfreeze_agent_identity already raises AgentCannotWidenAuthorityError for the first; the second gets its own message, and its own class if the call sites need to distinguish them.
During Gate 1 both functions exist. The old sync one still guards all seven call sites, unchanged. The new async one is fully built and tested and called by nothing.
Gate 2 moves all seven, then deletes the old one.
The codebase is coherent at every point. After Gate 1 the gate is exactly as it was and the replacement is proven. After Gate 2 there is one authority function and no orphan. Nothing is ever half-converted — which is what v0.1 got wrong.
A database read on six paths that currently do none. At api/deps.py:1255 it is a second read of an actor the auth layer just resolved — genuinely redundant, and the price of the check meaning what it says rather than being safe by position.
If that read proves material, the honest remedy is for the auth layer to pass a resolved actor type the gate can trust, not to skip the check. Do not add caching inside the gate in this CR.
agent_lifecycle_events by freeze_agent_identity, which takes any actor by design. Freezing stays open. That is ruled and untouched (DR-15).freeze_agent_identity a reason parameter.rendering, and does not unfreeze it.SANCTIONED_SYSTEM_WRITERS members with no database read — asserted, not assumed.LEGACY_UNRESOLVED_ACTOR_ID with no database read. A named regression test, citing the comment about revoking authority on records a human made.ActorRef(kind="person", id=uuid4()) — the case that motivated this CR.person) and a real contributor row (contributor).unfreeze_agent_identity refuses an unresolvable actor with a verdict distinguishing names no one from is a machine.has_human_commit_authority does not exist, and a source-contract test asserts no authority decision is made without the gate. Pin against the symbol, not a string.
7a. §8.1 clean. All seven call sites are inside async def with db: AsyncSession in scope.
7b. §8.2 clean — and the worry was unfounded for a specific reason. contributors is engagement-scoped as a table, but every ActorRef(kind="contributor", id=...) construction uses contributor.id, the row's own primary key — never a person id reused across engagements. So resolution is an unambiguous PK lookup. The ambiguity would arise only if ActorRef.id carried an identity shared across rows, and at HEAD it never does.
Worth recording as a property that could change: if a future change makes ActorRef.id carry a person-level identity for contributor-kind actors, this resolution becomes ambiguous. The check would then be asking a question with no single answer.
7c. §8.3 — larger than expected, and it shapes Gate 2. Over 150 test constructions of human actors with fabricated ids, including conftest.py fixtures used pervasively.
This is not a tidy-up. It is most of the suite's fixture layer. Expect a very large failure count on Gate 2's first run.
The fix is to give the fixtures real backing rows — a principal row for person-kind fixtures, a contributor row for contributor-kind ones. Never to weaken the gate, never to add a test-only bypass, and never to enumerate test ids as allowances. The three enumerated lanes are for production sentinels; adding test fixtures to them would make the allowlist meaningless.
If the fixture work turns out to be larger than the gate work — likely — that is a reason to split Gate 2, not to shortcut it. Report at the boundary before starting.
Preserved rather than smoothed.
v0.1's Gate 1 was "§3a, §3b, §3c… no call site moves", where §3a renamed the existing function and §3b gave its name to a new async one.
Between Gate 1 and Gate 2, all seven call sites would have been calling the async gate without awaiting it. A coroutine object is truthy, so if not has_human_commit_authority(actor): raise becomes if not <coroutine> — always false. The refusal never fires, for any actor of any kind, including agent. The human-authority gate would have been silently open everywhere, and the negative tests would have passed for the wrong reason.
The error, named generally: a gate boundary must be a coherent state, not a checkpoint in the middle of a broken one. v0.1 split a change so that the first half changed a symbol's type and the second half updated its callers — which is not two gates, it is one change with a halt inside it.
Why coexistence is the right shape: the new thing is built and proven while the old thing still works, and the old thing is removed only when nothing needs it. The intermediate state is not merely safe — it is unremarkable, which is the test a gate boundary should meet.
Found at pre-flight, not by reading the draft — the fourth time in this arc that attempting the work caught what reviewing it did not.
DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-234 — The Human-Authority Check Resolves the Actor — v0.2 — 2026-08-24