Version: v0.3
Date: 2026-08-24
Status: Gate 1 executed and committed; 2a complete and green; 2b measured at zero remaining failures. Markdown primary — the consumer is Claude Code.
Changes from v0.2: §3b step 3 was wrong and would have refused authenticated humans in production — replaced with either-space resolution (§3b, ruled 2026-08-24). §7b's clean finding is corrected: it was syntactically true and semantically false (§7b). Two independent structural reasons the old rule could not work are recorded at §3b-i. 2a's results are folded in at §7d, including two hazards the conversion surfaced. 2b is now only the call-site move and the deletion. v0.1 and v0.2 stand as siblings.
Changes from v0.1 (carried forward): 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.principals row or a contributors row. Ruled 2026-08-24. Kind remains the audit signal recording which authentication path was taken; it is no longer the lookup key.
v0.2 said person → principals, contributor → contributors. Two independent structural facts refute it, and either alone is sufficient.
The cookie path puts person ids in contributor-kind refs. _resolve_cookie_contributor (api/deps.py:960–1000) returns a synthesized, never-persisted Contributor(id=person.id, …). /instantiate then builds ActorRef(kind="contributor", id=<a person id>). That id has a principals row and, by design, no contributors row. The old rule refuses a genuinely authenticated human on the production browser commit path.
And a contributors row cannot exist yet at commit time anyway. contributors is FK-scoped to an engagement, and on the bearer-token seed-amendment path the engagement does not exist until commit_engagement — which is the very call that needs the actor to resolve. The row is inserted immediately afterwards. So contributor-kind can never resolve in contributors at the moment a commit gate asks.
Together: the rule was not merely wrong for one path. It asked a question that has no answer at the moment it is asked.
What the check now answers: is this a real human — not is this a real human of exactly this kind. The stricter question has no consistent answer at HEAD, because the engine itself puts person ids in contributor-kind refs. The motivating case is untouched: the actor that froze rendering matched neither table.
Cost, stated: two reads instead of one on the miss path, and the check no longer verifies that kind and id-space agree.
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 was reported clean and was not. Corrected.
What v0.2 said: every ActorRef(kind="contributor", id=...) construction uses contributor.id, the row's own primary key, so resolution is an unambiguous PK lookup.
Syntactically true and semantically false. On the cookie path that Contributor is a view object, not a row, and its id is a person id. The construction sites all read id=contributor.id; what differs is what contributor is.
§8.2's halt condition arrived through a different door than the one it named. Not resolves for which engagement — resolves in which table. Contributor-kind ids inhabit two id-spaces depending on the authentication path taken.
The general form, and it is this arc's fourth instance: a check whose scan is narrower than its claim. Here the scan was syntactic — the shape of the call — and the claim was semantic — what the value means. The three prior instances were tests; this one was a pre-flight. Reading construction sites tells you the shape of what is passed, not what it refers to.
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.
7d. 2a complete — 227 → 130 → 0.
| Stage | Failures |
|---|---|
| Call sites moved, no fixture work | 227 across 47 files |
| After conftest.py's human_actor_ref alone | 130 |
| After all 27 sites | 0 |
So 2b is only the call-site move and the deletion. No per-test work remains.
Twelve of the 97 fixed by the first step were not fixture work at all. The step-up and Phase 25 clusters cleared because the either-space ruling stopped refusing real humans. That is independent confirmation of the ruling — those tests authenticate genuine humans through genuine cookies, and if the new rule were wrong in the other direction they would still fail.
Two hazards the conversion surfaced, both worth keeping:
Fabricated ids were giving tests distinctness for free. uuid4() per actor meant every actor was implicitly distinct. Backing them with real rows collapsed some onto one identity, and test_open_cadence_amendment_seed_scope_rejects_non_founder failed loudly — founder and intruder became the same person, so the non-founder branch stopped firing. A second instance was found by scanning rather than by waiting for it. A test that depends on two actors being different should say so; several depended on it silently, and a fixture layer that fabricates identities hides that dependency.
Attribution by first-refusal undercounts. The instrumentation recorded the first refusal per test, so a file constructing more than one fabricated actor was attributed to whichever refused first. test_phase8_work_update_firing.py had four inline actors masked behind the shared fixture; they surfaced only once that fixture became truthful. The measurement was sound and its resolution was per-test, not per-actor — worth knowing before trusting a similar count.
Three sites genuinely mean the bearer-token path and still take principals backing — test_seed_amendment_http.py, test_seed_amendment_ceremony_http.py, test_seed_supersession_drift.py. Not a compromise: §3b-i's second fact makes a contributors row impossible at that moment, and principals-backing mirrors production exactly.
Preserved rather than smoothed. Two now: v0.1's gate staging, and v0.2's resolution rule (§3b-i, §7b).
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.3 — 2026-08-24