Version: v0.1
Date: 2026-08-24
Status: Drafted, not pre-flighted. Markdown primary — the consumer is Claude Code.
Implements: Operator ruling broad, 2026-08-24 — has_human_commit_authority should mean what its name says at every call site, rather than being safe by position at six of seven.
Occasion: standing-notes/loomworks-scoping-an-actor-that-resolves-to-no-one-v0_1 §3, §7.
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. Two enumerated allowances are preserved exactly, and both 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; CR-2026-226 ruling R1; DR-15; O2, O6, O10.
The check that decides whether a human may commit, retract, discard or unfreeze asks one question: does this actor say it is a person? It never asks whether that person exists.
Six of the seven places it is used are safe anyway, because the actor there is built by the server from real authentication before the check runs. The seventh — unfreezing a frozen agent identity — has no such protection, and a fabricated actor walks through it. A real row in the database already shows what that looks like: a platform-wide identity frozen by a person who does not exist.
The fix is to make the check resolve the actor. Two things make that less simple than it sounds, and both are deliberate features of the existing design.
The engine already has two sanctioned actors that resolve to nobody. 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 say which human. A naive resolution check would refuse both — and the second one would revoke commit authority on records real people made, which the code's own comment warns about in those words.
So the shape is: the check keeps its enumerated lanes exactly as they are, and adds resolution for everyone else. Enumeration is the resolution for the two sanctioned cases.
The second complication is mechanical. The check is a pure function today. Resolution needs a database, so the gate becomes async. Rather than make a widely-used predicate async, this splits it: the honest kind-test keeps a name that says it tests kinds, and a new async gate does kind plus resolution. All seven call sites move to the gate.
has_human_commit_authority(actor) tests actor.kind and nothing else:
if actor.kind not in NON_HUMAN_ACTOR_KINDS:
return True
return actor.kind == "system" and actor.id in SANCTIONED_SYSTEM_WRITERS
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 and :601 all receive an actor constructed server-side from cookie or bearer authentication. An HTTP caller cannot inject an id without forging the auth layer, which is a different problem.
agents/grantability.py:228 — unfreeze_agent_identity — has nothing in front of it. No HTTP surface, so whoever calls it constructs the ActorRef themselves and hands it straight to the check. It is the only widening act in the system and the thin check is the only thing guarding it.
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 — after the discard fence, the unsessioned baseline, and the FORAY census.
Both are deliberate. Refusing either is a regression, not a tightening.
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. CR-2026-226 ruling R1 made this an allowlist-as-data precisely so that adding to it is an authority grant with CR-level scrutiny.
Enumeration is their resolution. They are named individually, in reviewed data, which is a stronger check than a table lookup.
2b. LEGACY_UNRESOLVED_ACTOR_ID — the landmine. A contributor-kind sentinel for the pre-Phase-25 bearer-token candidate path, where engagements.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 what that comment warns against, by a different route: the sentinel is contributor-kind, it resolves to nothing, and a resolution requirement would refuse it. Records a human genuinely made would lose commit authority.
It is documented as the only sanctioned way to write an actor the engine cannot name, with a stated removal condition — when no engagement row has a NULL created_by_person_id.
So it becomes a third enumerated allowance, on the same footing as the two system writers: named, documented, carrying its own removal condition.
3a. Split the predicate from the gate.
has_human_commit_authority is pure and synchronous. Resolution needs a session. Do not make the predicate async — rename it to say what it does and keep it pure:
def is_human_actor_kind(actor: ActorRef) -> bool:
Same body, honest name. It answers is this a human-shaped kind, or an enumerated machine allowance — a real question, worth having, just not authorization.
3b. Add the gate.
async def has_human_commit_authority(
actor: ActorRef, *, db: AsyncSession
) -> bool:
Keep the name on the gate, because the name describes what a gate does and the six call sites already read correctly with it. The kind-test gets the new name because it is the thing that was misnamed.
Logic, in order:
False. Unchanged from today.SANCTIONED_SYSTEM_WRITERS, or LEGACY_UNRESOLVED_ACTOR_ID → True, without a lookup. Enumeration is the resolution.person → principals; contributor → contributors. Resolve in the table the kind names, because the kind records which authentication path was taken and that distinction is the audit signal ActorRef exists to carry.False.
3c. Refusals stay citable. unfreeze_agent_identity raises AgentCannotWidenAuthorityError on a non-human actor. An unresolvable actor should raise the same class with its own message — this actor names no one is a different fact from this actor is a machine, and O6 wants the verdict to say which.
3d. Move all seven call sites, each awaiting the gate and passing its session. All seven are already inside async functions holding one.
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 work, and the price of the check meaning what it says rather than being safe by position.
If that read proves material on the commit path, the honest remedy is for the auth layer to pass a resolved actor type that the gate can trust, not to skip the check. Do not add a caching layer inside the gate in this CR.
agent_lifecycle_events by freeze_agent_identity, which takes any actor by design (DR-15, tightening never waits). Freezing stays open. That is ruled, and this CR does not touch it.freeze_agent_identity a reason parameter — separate, and the finding/freeze/alert shape may supersede it.rendering.is_human_actor_kind is pure, synchronous, and behaviourally identical to today's has_human_commit_authority.True for both SANCTIONED_SYSTEM_WRITERS members with no database read — asserted, not assumed.True for LEGACY_UNRESOLVED_ACTOR_ID with no database read. A test names this explicitly as a regression guard, citing the comment about revoking authority on records a human made.False for ActorRef(kind="person", id=uuid4()) — the case that motivated this CR.True for a real principal (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.is_human_actor_kind directly for an authority decision. Per the literal-pinning note, pin against the symbol, not a string.
Explicit path staging; no git add -A; halt before push at each. No migration — if one appears necessary, halt.
Confirm before executing:
async functions with a session in scope. Any that is not changes the shape and warrants a halt.contributor-kind actor resolves in, and whether contributors rows are engagement-scoped in a way that makes resolves ambiguous — a contributor may exist for one engagement and not another. If resolution is engagement-dependent, halt: that is a design question about what the check means, not an implementation detail.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-234 — The Human-Authority Check Resolves the Actor — v0.1 — 2026-08-24