From 3e66f5acfdecbc5ae77ac058368a0af0af56b0e1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 18:33:29 -0500 Subject: [PATCH] plans: #436b design r4 on dewey-0759: item 1 option (b) per sage-1578 (step 0 refusal unspent only until the next start, cost in D5, X-G11 restart case); item 2 settle 200 unknown id drops memory, deletes nothing, grant-token-unknown, held at start; N7 secretCheck in 3d; duckworth-0067 N1 kind filter, X-G15; section 7 unchanged; no code change --- agents/megavolt/work/436b/DESIGN.md | 62 ++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/agents/megavolt/work/436b/DESIGN.md b/agents/megavolt/work/436b/DESIGN.md index bb06b70d7..b2a6ddffe 100644 --- a/agents/megavolt/work/436b/DESIGN.md +++ b/agents/megavolt/work/436b/DESIGN.md @@ -1,4 +1,4 @@ -# Gitea grant path, design draft r3 (Vikunja #436b, Gitea #1697 follow-up) +# Gitea grant path, design draft r4 (Vikunja #436b, Gitea #1697 follow-up) Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r6 (f9039682e, final, `agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's @@ -7,6 +7,26 @@ DESIGN.md section 7a (e10e03f6b). Mos rulings used here: mos-1130 and mos-1136. This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential. +## r4 changes (dewey-0759 on r3 fa5658941, sage-1578, duckworth-0067) + +Section 7 is unchanged from fa5658941, the text Duckworth approved and Gizmoduck cites. + +- Item 1 (blocks), option (b), Sage's ruling in sage-1578. A step 0 refusal leaves the decision unspent only + until the next start. #436a r6 X3's start reconcile spends every resolved-yes grant decision with no + `action.allowed` as `interrupted`, and this draft no longer claims otherwise. `grants run` works while the + bus that refused is still up; after a restart the chief raises again and Jason approves again. `status` + says so on each open decision (D5, section 4 step 0, section 5, X-G11). (a) would change #436a r6, which is + final. The cost is in D5. +- Item 2 (blocks). A settle that gets 200 with an `id` that is neither token no longer leaves the file in use. + Memory drops the credential (199 `revoke`'s memory step), the file and both recorded tokens stay, nothing + is deleted at Gitea, and the step ends `grant.failed {code: 'grant-token-unknown'}` for Jason. A restart + keeps the credential out of memory (`Credentials` `held`, built in #436b's row like `reload`). Revoke clears + it (section 5, X-G6). +- N7. 3d says the private session-less writer runs `#secretCheck` on every guarded body, as `#event` does + (`broker.mjs:559`). The sentence is in 3d, so section 7 doesn't change. +- duckworth-0067 N1. Every reader of grant evidence filters on `kind = 'action.allowed'` and the grant actor + pair, so a `grant.*` name under another kind is not evidence (section 5, X-G15). + ## r3 changes (dewey-0756 on r2 6db7cc45d, gizmoduck-0081 and -0084, duckworth-0065, mos-1144) - B1 (blocks). Only `#consumeGrant`'s own record carries `decision`. Every other `grant.*` body names the @@ -46,7 +66,9 @@ its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the ex scratch (F10). The separate uid is deferred on cost, and the draft says so. - D5. The admin checks run before `#consumeGrant`, so a predictable refusal leaves Jason's Approve unspent. A human `mosaic grants run ` starts it again (step 0). The raise refuses when the admin slot is past - its rotate-by date. + its rotate-by date. r4 (sage-1578): unspent only until the next start, when #436a r6 X3's reconcile spends + it as `interrupted`. The cost: if a step 0 refusal is followed by a restart before Jason rotates the admin + token or upgrades Gitea, the chief raises again and Jason approves again. - D6. One list of start checks (3a, 3e). The "no organization" claim is now part of the bootstrap promise, and the check is that `GET /user/repos` is empty, which catches org and collaborator repositories (F11, and F12 for a site-admin token). @@ -260,7 +282,9 @@ grant.result { callId, status } written after it - `reason` is the decision id for a grant or its undo, the `grant.revoked` record id for a revoke, the `grant.sweep` record id for a sweep, or `admin:` with the human actor for `set`, `rotate` and `remove`. - No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`, - the runtime's `secretCheck`) runs on both. + the runtime's `secretCheck`) runs on both. The private session-less writer of section 7 runs `#secretCheck` + on every guarded body it takes, `grant.step`, `grant.applied`, `grant.failed`, `grant.revoked`, + `grant.sweep` and the consume record included, as `#event` does (`packages/bus/src/broker.mjs:559`) (N7). - `events` is append-only (`events_no_update`, `packages/bus/src/migrations/3b.sql:131`), so the result is its own record, written in the transaction of the step's next `grant.step` where there is one. A `grant.call` with no `grant.result` means the process died during the call. Reconcile lists each one in its message. @@ -309,7 +333,10 @@ nothing. `#inflight` (#436a X5), then runs C1 to C4 and the admin rotate-by check. A refusal here writes `grant.failed {code, grantDecision}` and leaves the decision unspent (section 7's spend rule). After the fix (a rotation, an upgrade), Jason runs `mosaic grants run ` (human-only, `verifyHuman`), which - starts step 0 again, claim included. While a start or another `grants run` holds the claim it refuses + starts step 0 again, claim included. That holds only while the bus that refused is up: at the next start, + #436a r6 X3's reconcile spends every resolved-yes grant decision with no `action.allowed` as + `interrupted`, a step 0 refusal included, and the chief raises again (D5, sage-1578). `status` lists each + such open decision with `spentAtNextStart: true`. While a start or another `grants run` holds the claim it refuses `grant-busy` before any call, as #436a X2's `host-busy` does. It refuses `decision-consumed` for a spent decision. No other path starts it. The tag is #436a X2 step 2's, over business, agent and `"gitea"`. 1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the @@ -372,15 +399,23 @@ two-scope seat token; op `token-whoami`). The answer is an `id`, never a value. - 401: the file's token no longer exists at Gitea (F13 b4). The previous token can't be in the file, so the agent holds nothing usable. Delete both by id, unregister, delete the record, unlink the file, and end `grant.failed {code: 'grant-lost'}`. A new decision is needed. No file at all is the same case. -- Anything else is `settle-pending`, and nothing is deleted: 200 with an `id` that is neither token, 403, - 404, 5xx, a timeout, Gitea unreachable, or C1 to C4 failing first. The last `grant.step` stays `commit`, the +- 200, `id` is neither token: the file holds a live token this grant didn't mint and step 5 never checked. + Memory drops it (199 `revoke`'s memory step) and the agent gets `credential-unavailable`. The file, the + record and both recorded tokens stay, and nothing is deleted at Gitea. The step ends `grant.failed {code: + 'grant-token-unknown', grantDecision, tokenId}`, where `tokenId` is the id Gitea gave, and Jason gets a + message. `status` shows `tokenUnknown`. At each start, before `Credentials` loads, the reconcile passes such + `(agent, "gitea")` pairs as `held` (a `Credentials` option built in #436b's row, like `reload`), so memory + never loads that file. A revoke (section 6) clears it: it unlinks the file and deletes the recorded token. + The unknown token is Jason's to look at. +- Anything else is `settle-pending`, and nothing is deleted: 403, 404, 5xx, a timeout, Gitea unreachable, or + C1 to C4 failing first. The last `grant.step` stays `commit`, the `grant.call` without a `grant.result` stays listed, and `status` shows `settlePending`. The file stays as it is, and the agent can use it: it holds one of the two tokens, and both passed step 5. The next start and `mosaic grants sweep gitea` settle again. Only a 401 deletes. | Fails at | State left | Cleanup | |---|---|---| -| 0 | nothing, the decision unspent | none. `grants run` retries | +| 0 | nothing, the decision unspent until the next start | none. `grants run` retries while this bus is up; after a restart the reconcile spends it (`interrupted`) | | 1 | nothing | none | | 2, before the create | nothing | none | | 2, after the create, before the record | a user with the nonce | none here. Restart adopts it by the nonce (below) | @@ -402,6 +437,11 @@ the remote part runs after the bus is up, with C1 to C4. | `commit`, first grant | unlink the file if present, delete the record if present | delete the new token | | `commit`, rotation | none | settle | | `commit`, rotation, settle-pending | none; memory loads the file as usual | none. The step stays open, and the next start or `grants sweep` settles again | +| `grant.failed {code: 'grant-token-unknown'}` with no `grant.revoked` after it | pass `(agent, "gitea")` as `held`; memory doesn't load the file | none. Jason's message repeats until a revoke | + +Every reader of grant evidence (this reconcile, `status`, `openGrantDecisions`'s callers, `grants sweep`) +reads a `grant.*` record only where `kind = 'action.allowed'` and `actor_role IS NULL AND actor_run IS +'grant'`, so a `grant.*` name under another kind or actor is never evidence (duckworth-0067 N1). Then `grant.failed {code: 'interrupted'}` (or `grant.applied` if a rotation settled forward) and a message to Jason. A `grant.call` with no `grant.result` is listed in that message. After `interrupted`, the chief raises @@ -530,15 +570,16 @@ or any other live service. No token value is printed. | X-G3 | on scratch 1.27.1, end to end: user made with the nonce and its `id` recorded, token minted with the two scopes, forks made, step 5 passes, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (gone from its list) | | X-G4 | an existing user with no record, a recorded name whose `id` differs (deleted and remade, and a case variant of the name), a site-admin user and a disabled user each refuse before any token call | | X-G5 | each failure row of section 5 leaves the stated state, rotation rows included. A failed delete leaves `serviceLeft` | -| X-G6 | a restart after `user` adopts a user whose `full_name` ends with ` [mosaic ]` (16 lowercase hex), whose lowercased login equals `gitea.user` lowercased and who isn't an admin; does nothing on 404; and reports `gitea-user-orphan` for a different `full_name`, a different login or an admin. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one, ends `grant-lost` only on a 401, and stays `settle-pending` with nothing deleted on 403, 404, 500, a timeout, an unreachable fake and an unknown `id` | +| X-G6 | a restart after `user` adopts a user whose `full_name` ends with ` [mosaic ]` (16 lowercase hex), whose lowercased login equals `gitea.user` lowercased and who isn't an admin; does nothing on 404; and reports `gitea-user-orphan` for a different `full_name`, a different login or an admin. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one, ends `grant-lost` only on a 401, and stays `settle-pending` with nothing deleted on 403, 404, 500, a timeout and an unreachable fake. On 200 with an unknown `id` it drops the credential from memory, deletes nothing at Gitea, keeps the file, ends `grant-token-unknown`, and a second restart still doesn't load the file; a revoke then clears it | | X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call. An admin rotate voids open grant decisions and `status` lists them | | X-G8 | every admin-token and seat-token call writes `grant.call` before it and `grant.result` after it, paired by `callId`. A crash between them is listed by reconcile. No record body holds a token | | X-G9 | admin rotate: C1 to C4, the successor named by time, checked, written, the old token deleted. Two rotations in one second's name refuse cleanly, not half done. Past rotate-by, the raise and the executor refuse | | X-G10 | seat-token rotation: the new token is live before the old one is deleted | -| X-G11 | a step 0 refusal (rotation due, too old) and a step 1 refusal leave `#spent` false, and `grants run` by the human gets past `#consumeGrant`. `grants run` from an agent, the chief or the tester refuses. A `grants run` while a start holds the claim refuses `grant-busy` before any Gitea call. No `grant.*` body other than the consume record has a `decision` key | +| X-G11 | a step 0 refusal (rotation due, too old) and a step 1 refusal leave `#spent` false, and `grants run` by the human gets past `#consumeGrant`. `grants run` from an agent, the chief or the tester refuses. A `grants run` while a start holds the claim refuses `grant-busy` before any Gitea call. No `grant.*` body other than the consume record has a `decision` key. A step 0 refusal, then a restart, then `grants run`: the reconcile has spent the decision as `interrupted` and `grants run` refuses `decision-consumed`; before the restart `status` shows `spentAtNextStart` | | X-G12 | step 5 refuses `gitea-reach-wider` when the user was made collaborator on another repository, when it joined an org team, and when it owns a fork of an upstream that has left `publish`, and deletes the token. It passes with an old fork of a `publish` project outside `target.projects`, which the rendered block named. A fake returning 51 forks over two pages is read in full; a total that disagrees refuses `gitea-reach-unknown` | | X-G13 | `recordEvent` refuses an `action.allowed` with `body.action` `grant.call` (and each `grant.*`) and one with `body.operation` `grant.gitea`. A direct store insert aborts on the trigger for `grant.step` and `grant.host.enrolled` from a session, and for `grant.step` under the human's NULL pair. `host.sudo` from an agent session still writes | | X-G14 | fire: every token the user has is deleted, the user gets `prohibit_login`, and a failure of either lands in `serviceLeft` | +| X-G15 | a `grant.step {step: 'commit'}` stored under `action.refused`, and one under the human's NULL pair, are ignored by the restart reconcile: no settle runs and no Gitea call is made | Mutants, a first list: drop the version floor; compare the version as a string; accept another admin scope; skip `gitea-user-foreign`; compare the user by name instead of `id`; skip the admin-user refusal; adopt a user @@ -551,7 +592,8 @@ in `recordEvent`; drop the trigger; widen the trigger to `host.*` (breaks `host. tokens on a settle 403 or 5xx; compare step 5 against `target.projects` instead of `publish`; read one page of `/user/repos`; let `grants run` skip the claim; send the admin token in a `token` header on the mint route; revoke the remote token before the local steps; skip the fork `parent` check; skip step 5's reach check; delete only the -recorded token on fire; let `grants run` accept an agent. +recorded token on fire; let `grants run` accept an agent; keep the file in use on a settle 200 with an +unknown `id`; load a `held` pair at start; drop the `kind` filter from the restart reconcile. ## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)