[T-202] Hire only staff; collect the one hire-born outsource shell - #495
Merged
Conversation
POST /api/members now refuses every kind but staff, and the message names the birth path each refused kind actually has: a warden comes from onboarding its machine (POST /api/machines), an outsource worker is minted by the scheduler when a task is handed out. The previous revision stated the same scoping in a comment — a comment is an assumption, not a check, and the assumption was falsified within a day (owner 2026-09-13, rc-3989498e0c8f). Staff is untouched, including the 2026-09-12 "a staff member requires a role_key" refusal, and the privilege gate still answers 403 before the kind gate is reached. Three conformance fixtures hired non-staff through this door; each now uses the real birth path and verifies the same thing it did before: - conftest.warden_agent → onboards a machine - test_tasks.test_reassign_guards → fresh_machine() - test_sse topic table → dispatches a task to 外包 and waits for the scheduler to mint the worker Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
One UPDATE with a four-clause WHERE: the row only moves when it is outsource-kind, still active on the roster, and bound to no task. All three are checked rather than stated in a comment — which is the lesson of this ticket. A station that never had the row runs this and is unchanged (an UPDATE matching nothing is a successful no-op), so every fresh install and every CI database is untouched. Not a general "remove an outsource worker" feature: owner explicitly excluded that on rc-3989498e0c8f. The test seeds all three inputs the plan names side by side in ONE database — the target, a task-bound worker, and a staff member — and asserts after one run that only the target moved. With the target alone in the fixture, a migration that collects all three and one that collects the right one are indistinguishable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
A mutant found the hole: deleting the linked_task_id clause from 00106 left the whole file green. The migration's WHERE is keyed by a literal id first, so a task-bound worker seeded as a NEIGHBOUR can never be collected no matter what the other clauses say — that fixture has zero power over the very clauses it looks like it is testing. Each disqualifying attribute now sits on the target id itself, one case per clause, plus a case for re-running against an already-collected row (the roster_status clause is what stops released_ts being re-stamped — that timestamp records when a worker actually stopped). With these in place all three clause-deletion mutants go red, each in exactly the test that names it. The neighbour fixture is kept: it still proves no collateral damage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
The hire route's own documentation still described the world before the kind gate: it explained the admin gate on `kind`, and its bullet list ended "Wardens carry no role by design and are unaffected" — a sentence that now reads as an invitation to a call that gets a 422. An agent that only reads tools/list would have had no way to learn where a warden or an outsource worker actually comes from. Spec-first: the change is made in spec/openapi.json (summary, the bullet list, x-mcp.description and its legacy descriptor, which the generator requires to agree), then spec/mcp-catalog.json and ocapi_gen.go are regenerated — neither is hand-edited, and both drift gates pass. The routes.go summary is updated in the same commit so the two faces say the same thing. Also states the gate ORDER, because it is observable: a non-admin who names a kind still gets the 403, not the kind refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
There are FOUR generated artifacts downstream of spec/openapi.json, not two: frontend/src/api/generated/schema.ts carries the route descriptions as comments, and `make drift-schema-ts` fails on it. I had only run drift-mcp-catalog and drift-ocapi, both of which passed — two green gates said nothing about the third. Regenerated with `npm run gen:api`; nothing hand-edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
[why] The `kind != KindStaff` gate this package adds is a new predicate in a request-visible handler, so the two standing governance scans both reported it as unaccounted and go-checks went red on TestAuthzOutsideTheRouteTableIsEnumerated and TestIdentityGatesAreEachOnTheRecord. Neither guard is wrong; they are asking for the registration the rule says a new gate must carry. [how] Register, do not widen the scanners. * nonCallerKindPredicates (NOT authzOutsideRouteTable): `kind` here is the kind of the member being HIRED, read off the request body — no principal is on either side, an owner asking for kind=outsource is refused exactly like anyone else. That is the mis-fire branch the guard's own message names, and it is where its sibling `kind == KindStaff` already sits. * identityGateLedger with the reason and the ruling: the 2026-08-26 rule asks for a conversation before a new 正職/外包 branch, and that conversation is owner 2026-09-13 rc-3989498e0c8f, which is later than the rule. The entry says why this is a birth-path rule rather than a difference in how the two kinds are treated once a row exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why] Closing the hire door took away the way this probe used to conjure a kind='outsource' row, and the replacement it got — dispatch a task and wait for the scheduler — made a topic probe depend on suite-wide state. The Phase 2 admission is gated by the GLOBAL cap task.outsource_max_parallel (default 3) counted over every live worker on the server, and by the time test_sse.py runs that cap is normally already spent by other files. Measured on a full run: max_parallel=3, three workers assigned to three unrelated not_started tasks, so the dispatch was never admitted and the probe had no subject. 1458 passed, 1 failed. [how] Stop minting. A worker ROW is what the write face needs, not a NEWLY MINTED one, and writing to a row that already exists consumes no concurrency slot at all. _an_outsource_worker prefers an existing row and falls back to the dispatch birth path only when the roster is empty — the one state in which there is both nothing to borrow and certainly a free slot. The trigger is still a real field edit (an effort the row is not on), and the row is put back on the effort it arrived with before the test ends, so a borrowed subject is left as found. The assertion is unchanged in strength: no subject is still a loud red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urvive [why] Nothing guarded `id = 'm-51110698e801'`. Measured: delete that clause from migration 00106 and all 1043 tests stayed green — the two neighbours the fixture already had disqualify themselves on the OTHER clauses (one is task-bound, one is staff), so the WHERE passes over them whether or not the id is in it. Without that clause the migration is exactly the general "remove an outsource worker" feature the owner refused (rc-3989498e0c8f), applied to the whole roster in one shot — so the one line standing between this ticket and that is the one line no test could see. [how] Seed a fourth row that satisfies EVERY other clause and differs only in its id: alive, kind='outsource', bound to no task. It must come out untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why] Two gaps found by an independent review of this package.
1. A surviving mutant. Rewriting the migration's `id = 'm-51110698e801'`
to `id LIKE 'm-%'` — still "keyed by id" on the face of it — left all
four TestMigration00106* tests PASSING. The neighbour that was meant
to measure that clause, ow-idle0000001, lives in a DIFFERENT id
namespace: the scheduler mints real workers as `ow-`, while the target
is an `m-` row the hire door malformed into existence. So the fixture
caught "the id clause deleted outright" and was blind to every prefix-
or pattern-shaped rewrite — which is what a wrong id clause actually
looks like. Its comment ("differs from the target ONLY in its id") was
literally true and still could not support the conclusion drawn from
it: the difference was in the prefix.
Seed a second neighbour, m-51110698e802 — same kind, same active
status, bound to no task, differing in the LAST CHARACTER of the id
and nothing else — so any WHERE that is not an exact equality on the
literal id sweeps it up and this test says so.
2. A comment that is false on the failure path. test_every_closed_topic_
emits borrows an existing outsource worker row, edits its effort to
trigger the topic, and restores it on the last line of the function —
while the comment claims the borrowed row is "left exactly as found".
Any failure between the trigger and that line leaves the row parked on
the probe effort. Measured blast radius today is zero (nothing reads a
worker's effort: the other conformance files address a non-existent
worker id, and the list checks pin the key set rather than values),
but the claim is what the next reader will rely on.
[how] Migration test: seed m-51110698e802 alongside the existing three
neighbours, assert it comes out untouched, and rewrite the ④ comment to
name the namespace difference instead of hiding it. No assertion was
weakened and the migration itself is unchanged.
Conformance: wrap the body of test_every_closed_topic_emits in
try/finally with the restore in the finally — the cleanup convention
already used four times in this same file (e.g. the warden-command test's
deactivate). Pure re-indentation plus the try/finally; `git diff -w`
shows no other change.
Re-seeded the mutant after the fix: `id LIKE 'm-%'` now fails
TestMigration00106CollectsOnlyTheUnboundOutsourceShell, and the other
three 00106 tests stay green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # conformance/test_sse.py # server/ocserverd/authz_surface_behavior_test.go # server/ocserverd/lifecycle_identity_behavior_test.go
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.
POST /api/membersrefuses every kind but staff, and the refusal names the birth path each refused kind actually has (warden →POST /api/machines; outsource → minted by the scheduler when a task is handed out). The previous revision stated that scoping in a comment; a comment is an assumption, not a check, and the assumption was falsified within a day.Migration 00106 collects the one row that came through the old door: one UPDATE whose WHERE requires outsource-kind + still active + bound to no task, so a station that never had the row is unchanged. Not a general "remove an outsource worker" feature — owner excluded that explicitly on rc-3989498e0c8f.
Three conformance fixtures that hired non-staff through this door now use the real birth path and verify the same things they did before.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF