Skip to content

[T-202] Hire only staff; collect the one hire-born outsource shell - #495

Merged
pkyosx merged 12 commits into
mainfrom
t-202/hire-staff-only
Sep 13, 2026
Merged

[T-202] Hire only staff; collect the one hire-born outsource shell#495
pkyosx merged 12 commits into
mainfrom
t-202/hire-staff-only

Conversation

@pkyosx

@pkyosx pkyosx commented Sep 13, 2026

Copy link
Copy Markdown
Owner

POST /api/members refuses 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

pkyosx and others added 12 commits September 13, 2026 11:28
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
@pkyosx
pkyosx merged commit 37e437c into main Sep 13, 2026
13 checks passed
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