feat: a human-only gate that actually requires a human act (SYD-281) - #243
Open
seanperkins wants to merge 16 commits into
Open
feat: a human-only gate that actually requires a human act (SYD-281)#243seanperkins wants to merge 16 commits into
seanperkins wants to merge 16 commits into
Conversation
Closes the declare_pr_link hole: a supervised declaration was auto-confirmed and stamped with the human's actor id, manufacturing the attestation SYD-280 exists to protect. Also closes SYD-298 at its real site (confirmPrLink, reachable today by the poller service tokens), and the inverse-gate population in issues.ts — triage exit, agent provenance, the auto label, the transition allow-list, claim gates, self-assign, and needsInput clearing were all satisfiable by a supervised agent because it is typed human and is not typed agent.
…(SYD-281) The agent name came straight from argv and getOrCreateActor returns an existing actor, so `mint-supervised-session sean claude/dev` bound a session to the live dispatch worker. Harmless while a supervised claim assigned the human; once the agent holds the claim it is the same actor, so assertClaimable early-returns and claimIssue offers takeover instead of refusing. Namespaced <engine>/supervised/ <human> — engine first, so callerClassification still reads the engine.
Selects on the assigned event's sessionId, not the issues table: claim_leases has no session column and a human assigneeId is byte-identical whether the claim was supervised or made in person, so the broad hammer would release Sean's own work. Invalidates the lease in the same transaction — the lease-cutover precedent does not, and leaving the stale row active means the re-claim mints a second one and the agent holds a token validateLease rejects. Also soft-closes open supervised sessions, whose 12h agent binding the namespacing cannot retroactively fix.
…D-281)
whoami now reports both identities when they differ — the accountable human and
the agent that actually holds the claims, since search_issues(assignee: ...)
would otherwise look for the session's work under a name that holds none of it.
Refusal text that described the pre-SYD-281 model is reworded.
The eslint cast ban on HumanActor is enforced (verified by inserting a cast).
The companion rule banning a bare {} as an Attribution is deliberately NOT
enforced: ESLint cannot distinguish it from a LeaseChannel or an options bag
without type information, and the broad selector produced 71 mostly-legitimate
hits — the shape of a rule that gets switched off. NO_SESSION remains
conventional, which is honest, since it is a legibility control either way.
Found by the panel on the implementation, not the plan: - needs-input.ts:43-44 was in the spec's population B and never converted, so a supervised session could call request_human_input on an issue its own agent holds and skip validateLease entirely — the SYD-210 holder-lease gate, dead. - comments.ts:32 (@agent questions) was filed in population C as "signalling". It is not: worker-select.ts consumes every agent_question event as a signal to dispatch a headless answerer, so a supervised agent could summon a worker as though a person had asked. - heartbeatClaim was given an attr parameter but the MCP call site never passed it. The comment claiming this was unreachable was wrong: x-switchyard-lease is a client-supplied header independent of the bearer, so a sup_ connection can carry one and get heartbeat registered. Keyed to the accountable human it would validate against an agent-minted lease and throw on every beat. Also: five tests built attribution from a hand-made actor rather than the resolved principal, exercising an actor/session pairing production cannot create — the state-constructing shape this story exists to remove, in its own test suite. Each fix ships with a test proven to fail without it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes SYD-281. Design:
docs/superpowers/specs/2026-08-27-human-act-integrity-design.md(rev 5, approved by a seven-seat review panel over four rounds — findings and disposition indocs/reviews/2026-08-27-syd281-panel.md).The problem
resolveSupervisedPrincipalresolves a supervised session to aPrincipalwhoseactoris the bound human. Correct for attribution. Wrong for authorization, and wrong for identity — three questions were being answered by one field:A supervised principal is typed human and is not typed agent, so it passed gates of both shapes —
!== "human"and=== "agent".The fix
Three accessors in
src/services/principal.ts:asHuman(brandedHumanActoror null),actingAgent(whose claim/lease governs),effectiveActor(what gets written to row-identity columns; fails closed). Human-only services takeHumanActor; adapters mint it; the six privaterequireHumanhelpers collapse into one.The identity rule: in a supervised session the acting agent holds the work — assignee, lease holder, declarer. The human stays the accountable root on every event.
What this closes
declare_pr_linkconfirmedBy: isAgent ? null : actor.id, so a supervised agent's declaration was auto-confirmed and stamped with the human's actor id — it did not evade the attestation, it manufactured one, andprovesLandedaccepted itconfirmPrLink, which gated on=== "agent"and so waved throughserviceactors — the GitHub and deliver poller tokens, which already speak RESTissues.tsinverse gatesautolabel (a self-escalation into unattended dispatch), the transition allow-list, claim gates, self-assign, and SYD-210 lease validationneedsInputissues.ts:664fires on the presence of a status key, not a change — so a supervised agent cleared its own escalation with a no-op status patchremove_dependencymint-supervised-session sean claude/devbound a session to the dispatch worker's actor; harmless before, but once the agent holds the claim it collapsesassertClaimable. Agents are now namespaced<engine>/supervised/<human>Operator steps — both required
1. Stop the worker LaunchAgents for this deploy, or run the migration through the SYD-291
switchyard-adminpath.ensureSupervisedClaimCutoverreleases migrated claims totodo/unassigned, which is exactly whatselectDispatchablepicks up.2. After deploying, set the gate action:
This fails closed without it. A supervised removal skips the divert and lands on the human-only gate — refused, not executed. The setting upgrades refusal to a proposable divert. It is UX, not security, so a partially-applied rollout costs a capability rather than opening a hole.
Second-order effect to expect
Killing the supervised auto-confirm writes
confirmedBy: null, which failsattention.ts'sIS NOT NULLbefore the recency disjunction is reached — sodone_without_merged_prwill now fire on supervised work whose link was never separately confirmed. That is the flag working as designed (it is the vouch reminder), but it is new steady state. Convention: confirm the link in the SYD-290 UI before affirmingdone.Reading the diff
Commits are ordered accessors → adapters → live holes → sweep. The wide mechanical part (population A signature changes) is
1fdb454; the security-critical part is1813963.Two decisions worth a reviewer's attention, both departures from the spec:
buildMcpServerdoes not take aPrincipal. The spec budgeted ~20 threading sites for this. Once the accessors took(actor, ctx)rather than aPrincipal,viaAgenthad exactly one consumer, so the parameter was dropped instead. Smaller and one source of truth.{}-as-Attributionlint rule is not enforced. ESLint cannot distinguish it from aLeaseChannelor an options bag without type information; the broad selector produced 71 mostly-legitimate hits.NO_SESSIONexists and is used, but conventionally. Theas HumanActorcast ban is enforced (verified by inserting a cast and watching it fire).Not closed by this PR
SYD-298 stays open until this merges — it is closed by
confirmPrLinkmoving to population A here, and by the service-token refusal test intests/rest/api-service-actor.test.ts.Verification
npm run lint && npm run format:check && npm run typecheck && npm test— 0 lint errors, format clean, typecheck clean, 2129 tests pass (baseline 2083; net +46, and every deleted service-level refusal assertion was replaced by a broader adapter-level one covering both non-human tiers across 20 routes).Changeset review (added after the first push)
A seven-seat panel reviewed the implementation. 7 of 9 seats delivered —
simplifierandcartographerfailed at spawn (the selector handed an unadvertised model id to an openrouter-backed agent; codex died twice), so this was a shrunk panel, not a clean sweep.It found three gates still open, all fixed in
d14771e, each with a test proven to fail without the fix:needs-input.ts:43-44was in the spec's population B and never converted. A supervised session could callrequest_human_inputon an issue its own agent holds and skipvalidateLeaseentirely — the SYD-210 holder-lease gate, dead. Demonstrated with a PoC.comments.ts:32was misfiled in population C as "signalling".worker-select.tsconsumes everyagent_questionevent as a signal to dispatch a headless answerer, so a supervised agent posting@agent …could summon a worker as though a person had asked.heartbeatClaimwas given anattrparameter that the MCP call site never passed, and the comment defending that as unreachable was wrong:x-switchyard-leaseis a client-supplied header independent of the bearer, so asup_connection can carry one and getheartbeatregistered.It also found five tests building attribution from a hand-made actor rather than the resolved principal — an actor/session pairing production cannot create. That is the state-constructing shape this story exists to remove, in its own test suite. Fixed.
Verified closed by probe: triage exit, the
autolabel,donereopen, reassign, the dispatch-actor claim collapse, andconfirmPrLinkat REST.Known residuals — not fixed here, filed separately
ctxstill defaults on 9 of the 14Attributioncarriers. The spec said "required, no default"; it was applied to 5. Removing it fromupdateIssue/createIssue/claimIssue/addComment/requestHumanInputtouches ~776 call sites, 763 of them tests — a diff that would dwarf the security fix. Production threading is complete and verified (MCP passesattribution, REST passesNO_SESSIONexplicitly), so this is a latent fail-open for a future caller, not a live one.Supervised identity is per (human, engine), not per session. Two sessions for the same human and engine share one actor, so one can
takeoverthe other's claim. Pre-existing in character — before this change both shared the human as assignee — and the namespacing was scoped to prevent binding to a dispatch worker, which it does. Per-session identity is a separate design question.The Architect dissented on shape, arguing
(actor, attr)should have been one edge-resolvedPrincipaland that threading a split signature is the expensive-to-reverse choice. Recorded as an unresolved objection rather than addressed.