feat(bus): copy credential lifecycle and revoke (CB4, #1873) #1954

Merged
jason.woltje merged 4 commits from org-cb4 into refactor 2026-11-12 22:43:00 +00:00
Owner

Credential binding slice CB4 (#1873): copy credential lifecycle and recovery. Design: PR #1923, approved by Jason on 11-11 at 17:55Z with CB0 to CB5 as written. Rulings in force: org.hire is the entry right, binding approvals are owner-only, there is no org.credentials right, and archive discards copy credentials (Q3).

  • packages/bus/src/copy-revoke.mjs (new): one revoke job per copy for every lifecycle event, in this order: close the gate, record the pending op, drain in-flight requests, stop every run and wait for each ack, remove the slot, revoke the token at Gitea, then finish. Only a definite finish reopens the gate, and then only to the tombstone's own refusal code.
  • Durable pending op (copy-bindings.mjs, organization.mjs): new binding state revoking and a revoke record with op id, cause, time, failed steps and per-service remote outcomes. A revoking record never resolves. It answers credential-revoking, or credential-revoke-failed after an uncertain step.
  • Restart and sweep (runtime.mjs): at start, resume finishes each pending op under its own id before anything can use the copy. sweep checks each copy holding a grant at start and every 60 s, for expiry, slot loss, retire, role change, collision and shadowing.
  • Gate triggers (copy-launch.mjs): the CB3 gate reports a permanent loss through onLoss, which starts the job.
  • Remote step: Gitea revoke goes through the organization's managed admin slot. 204 is revoked, 404 is absent. With no admin slot, or one on another server, the outcome is not-managed: the copy is tombstoned and denied, but the token stays valid at Gitea until it expires, and the record never claims it was revoked. Any uncertain answer is failed and keeps the copy fenced for the next pass.
  • Archive and delete (org-service.mjs, CLI org-bus.mjs, Console org-admin.mjs): archive (Q3) and delete revoke each copy's grant through the same step. Receipts list each copy's remote outcome and never claim a revoke that did not happen. Restore brings no copy credential back.
  • Repository guard: the owner's updateOrg and project.remote.set refuse project-repo-bound while a copy binding still grants the project (pending, bound or revoking). This is a new refusal on existing verbs; the code itself already existed (F7).
  • Refusal table: a new org-archived org-unavailable group for copies, with a next step that names no path.

Nothing in roles/ or contracts/.

Tests: copy-revoke.test.mjs (new), copy-revoke-docker.test.mjs (live revoke against scratch Gitea 1.27.1: old token 401 at Gitea), plus additions in copy-launch, repo-remote, CLI org-delete and org-restore-bus, and Console org-admin. Red before: copy-revoke cannot load on BASE; with the module copied in, 0/34. Crash injection at eight points, each followed by a restart. Mutation tables are in the lane report. Full gate on the head: 8325/2. Both failures are the known CHAT-01 jsonschema base reds in packages/conversation, which this branch does not touch. The Lead check ran separately under env -i.

Reviews (fresh Opus delegates): build review changes 8, security review changes 4, all fixed. The security re-check found one P2 (project.remote.set could change the repository under a live binding), fixed in the last commit. The Lead read that fix: 9 source lines, plus a test and a mutant (F11) that it kills.

Deferred:

  • Tests for re-check P3-1 (shadow-loop isolation), P3-2 (runtime wiring) and P3-3 (the ordinary run path).
  • Kill re-send has no backoff.
  • The binding does not record the bound origin when the credential is issued (suggested as defense in depth).
Credential binding slice CB4 (#1873): copy credential lifecycle and recovery. Design: PR #1923, approved by Jason on 11-11 at 17:55Z with CB0 to CB5 as written. Rulings in force: org.hire is the entry right, binding approvals are owner-only, there is no org.credentials right, and archive discards copy credentials (Q3). - `packages/bus/src/copy-revoke.mjs` (new): one revoke job per copy for every lifecycle event, in this order: close the gate, record the pending op, drain in-flight requests, stop every run and wait for each ack, remove the slot, revoke the token at Gitea, then finish. Only a definite finish reopens the gate, and then only to the tombstone's own refusal code. - Durable pending op (`copy-bindings.mjs`, `organization.mjs`): new binding state `revoking` and a `revoke` record with op id, cause, time, `failed` steps and per-service `remote` outcomes. A revoking record never resolves. It answers `credential-revoking`, or `credential-revoke-failed` after an uncertain step. - Restart and sweep (`runtime.mjs`): at start, `resume` finishes each pending op under its own id before anything can use the copy. `sweep` checks each copy holding a grant at start and every 60 s, for expiry, slot loss, retire, role change, collision and shadowing. - Gate triggers (`copy-launch.mjs`): the CB3 gate reports a permanent loss through `onLoss`, which starts the job. - Remote step: Gitea revoke goes through the organization's managed admin slot. 204 is `revoked`, 404 is `absent`. With no admin slot, or one on another server, the outcome is `not-managed`: the copy is tombstoned and denied, but the token stays valid at Gitea until it expires, and the record never claims it was revoked. Any uncertain answer is `failed` and keeps the copy fenced for the next pass. - Archive and delete (`org-service.mjs`, CLI `org-bus.mjs`, Console `org-admin.mjs`): archive (Q3) and delete revoke each copy's grant through the same step. Receipts list each copy's remote outcome and never claim a revoke that did not happen. Restore brings no copy credential back. - Repository guard: the owner's `updateOrg` and `project.remote.set` refuse `project-repo-bound` while a copy binding still grants the project (pending, bound or revoking). This is a new refusal on existing verbs; the code itself already existed (F7). - Refusal table: a new `org-archived org-unavailable` group for copies, with a next step that names no path. Nothing in `roles/` or `contracts/`. Tests: `copy-revoke.test.mjs` (new), `copy-revoke-docker.test.mjs` (live revoke against scratch Gitea 1.27.1: old token 401 at Gitea), plus additions in copy-launch, repo-remote, CLI org-delete and org-restore-bus, and Console org-admin. Red before: copy-revoke cannot load on BASE; with the module copied in, 0/34. Crash injection at eight points, each followed by a restart. Mutation tables are in the lane report. Full gate on the head: 8325/2. Both failures are the known CHAT-01 jsonschema base reds in packages/conversation, which this branch does not touch. The Lead check ran separately under `env -i`. Reviews (fresh Opus delegates): build review changes 8, security review changes 4, all fixed. The security re-check found one P2 (`project.remote.set` could change the repository under a live binding), fixed in the last commit. The Lead read that fix: 9 source lines, plus a test and a mutant (F11) that it kills. Deferred: - Tests for re-check P3-1 (shadow-loop isolation), P3-2 (runtime wiring) and P3-3 (the ordinary run path). - Kill re-send has no backoff. - The binding does not record the bound origin when the credential is issued (suggested as defense in depth).
jason.woltje added 4 commits 2026-11-12 22:42:57 +00:00
Every lifecycle event that ends a copy's authority (unbind, emergency
revoke, slot removal or expiry, role change, fire, collision, shadowing,
archive, delete and recreate) now closes admissions, drains in-flight
uses, stops runs with a confirmed ack, removes the slot and revokes the
Gitea token through the org admin slot. The binding moves through a
durable revoking state, so a restart resumes the same op and never
resurrects authority; an uncertain outcome stays fenced.
Build review (changes 8) and security review (changes 4):
- P1-1: an owner-approved fire no longer writes the revoke before the
  apply (decision-stale). The fire stops the copy's runs, the fire is
  written, then the retire revoke starts for the fired instance.
- P2 (both): archive and delete from the CLI and the Console pass the
  organization's managed Gitea remote step (copyRemoteFor). A remote
  step that was not attempted or failed keeps the record revoking with
  failed remote, so the bus finishes it after restore; no done tombstone
  over a live token.
- Security P2-1: a bound server that cannot be read is a failed remote
  step, not not-managed. updateOrg refuses project-repo-bound when a
  project a copy binding grants loses or changes its remote or git.
- P3-1 (security): a delete whose organization file cannot be read says
  copiesUnreadable, never an empty copy list.
- P3-2 (security): the remote step requires the record's incarnation to
  equal the registry's live one.
- P3-5 (build): the archive's slot discard checks each parent directory
  (no symlink, own uid, private mode).
- P3-6 (build): resume keeps credential-revoke-failed as the gate code
  while a failed op runs again.
- P3-4 (build): a test pins the refused-begin reopen guard.
jason.woltje merged commit 95c2ce1435 into refactor 2026-11-12 22:43:00 +00:00
Sign in to join this conversation.