Skip to content

feat: a human-only gate that actually requires a human act (SYD-281) - #243

Open
seanperkins wants to merge 16 commits into
mainfrom
feat/syd-281-human-act-integrity
Open

feat: a human-only gate that actually requires a human act (SYD-281)#243
seanperkins wants to merge 16 commits into
mainfrom
feat/syd-281-human-act-integrity

Conversation

@seanperkins

@seanperkins seanperkins commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 in docs/reviews/2026-08-27-syd281-panel.md).

The problem

resolveSupervisedPrincipal resolves a supervised session to a Principal whose actor is the bound human. Correct for attribution. Wrong for authorization, and wrong for identity — three questions were being answered by one field:

  1. Who authorizes this? — must be a human acting, not a human accountable.
  2. Whose credential is this? — the agent's, in a supervised session.
  3. Whose name goes on the row? — the assignee, lease holder, declarer.

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 (branded HumanActor or null), actingAgent (whose claim/lease governs), effectiveActor (what gets written to row-identity columns; fails closed). Human-only services take HumanActor; adapters mint it; the six private requireHuman helpers 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_link wrote confirmedBy: 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, and provesLanded accepted it
SYD-298 its real site is confirmPrLink, which gated on === "agent" and so waved through service actors — the GitHub and deliver poller tokens, which already speak REST
issues.ts inverse gates triage exit and agent provenance (two named CLAUDE.md invariants), the auto label (a self-escalation into unattended dispatch), the transition allow-list, claim gates, self-assign, and SYD-210 lease validation
needsInput issues.ts:664 fires on the presence of a status key, not a change — so a supervised agent cleared its own escalation with a no-op status patch
remove_dependency "dependency removal is human-only" (CLAUDE.md)
Session/worker collision mint-supervised-session sean claude/dev bound a session to the dispatch worker's actor; harmless before, but once the agent holds the claim it collapses assertClaimable. 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-admin path. ensureSupervisedClaimCutover releases migrated claims to todo/unassigned, which is exactly what selectDispatchable picks up.

2. After deploying, set the gate action:

supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"]

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 fails attention.ts's IS NOT NULL before the recency disjunction is reached — so done_without_merged_pr will 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 affirming done.

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 is 1813963.

Two decisions worth a reviewer's attention, both departures from the spec:

  • buildMcpServer does not take a Principal. The spec budgeted ~20 threading sites for this. Once the accessors took (actor, ctx) rather than a Principal, viaAgent had exactly one consumer, so the parameter was dropped instead. Smaller and one source of truth.
  • The {}-as-Attribution lint rule is not enforced. ESLint cannot distinguish it from a LeaseChannel or an options bag without type information; the broad selector produced 71 mostly-legitimate hits. NO_SESSION exists and is used, but conventionally. The as HumanActor cast 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 confirmPrLink moving to population A here, and by the service-token refusal test in tests/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 deliveredsimplifier and cartographer failed 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-44 was in the spec's population B and never converted. 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. Demonstrated with a PoC.
  • comments.ts:32 was misfiled in population C as "signalling". worker-select.ts consumes every agent_question event as a signal to dispatch a headless answerer, so a supervised agent posting @agent … could summon a worker as though a person had asked.
  • heartbeatClaim was given an attr parameter that the MCP call site never passed, and the comment defending that as 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.

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 auto label, done reopen, reassign, the dispatch-actor claim collapse, and confirmPrLink at REST.

Known residuals — not fixed here, filed separately

  1. ctx still defaults on 9 of the 14 Attribution carriers. The spec said "required, no default"; it was applied to 5. Removing it from updateIssue/createIssue/claimIssue/addComment/requestHumanInput touches ~776 call sites, 763 of them tests — a diff that would dwarf the security fix. Production threading is complete and verified (MCP passes attribution, REST passes NO_SESSION explicitly), so this is a latent fail-open for a future caller, not a live one.

  2. Supervised identity is per (human, engine), not per session. Two sessions for the same human and engine share one actor, so one can takeover the 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.

  3. The Architect dissented on shape, arguing (actor, attr) should have been one edge-resolved Principal and that threading a split signature is the expensive-to-reverse choice. Recorded as an unresolved objection rather than addressed.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant