plans: row 199 design r3, Mos rulings mos-1027 (forks, git.push.remote as a hard wait, tester status and revoke only) (#1697)

This commit is contained in:
jason.woltje committed 2026-10-20 14:27:49 -05:00
1 parent e806656b1d
commit c2c729ddf0
1 file changed
+37 -19
+37 -19
View File
@@ -7,6 +7,9 @@ No credential value was read to write this. Evidence is code and docs only.
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
drops O2 (D10, A3, R2).
## 0. Terms
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
@@ -115,13 +118,21 @@ commits to that user. Agents without `gitea` keep E7.
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
```
publish {"cap", "op": "publish", "task_ref", "project", "branch"}
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
-> {"ok": true, "result": {"remote", "ref", "commit", "previous"}} or a refusal
```
The host checks, in order, and each refusal goes through `broker.refuse()`:
1. Identity from the cap (E5). The role holds the new action `git.push.remote` (`remote-not-allowed`).
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
ACTIONS and is not in ROUTED (REQ-WAIT-3, `vocabulary.mjs:52-55`, `broker.mjs:42-69`), so it is a hard wait
and classifies `gated` for every role. Every publish therefore needs a decision the human resolved for
this task ref, and the decision names the commit. A decision is spent once (`decision-consumed`), and a
commit other than the task's latest recorded push refuses `ref-moved` (`#latestPush`, `broker.mjs:936`).
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
without a decision, and `decision` is optional for a within-role class only.
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
project's remote, `projects.<project>.remote: "https://..."` (`remote-not-granted`). The remote's scheme is
https and its host and port equal `identity.gitea.url`'s (`remote-host-mismatch`). The default https port
@@ -159,21 +170,23 @@ D9b. **How `publish` reaches the broker and the store.** The git socket runs in
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
`publish` adds three entries there, each wrapped in `refusing()`:
- `checkPublish(cap, args)`: steps 1 to 3. It returns `{remote, user, email, source}` and no token.
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, remote, user,
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
same way `checkLand` holds a landing.
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
step 5. The token never leaves that call.
- `recordPublish(cap, result)`: step 6.
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
The op table gets no entry that returns a token.
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
- **O1. A fork per agent (recommended).** The agent's Gitea user has read on the upstream repo and owns a fork.
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork.
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
upstream as a pull request from the fork, opened by a human or a later API verb.
- **O2. One shared repo.** The agent is a Write collaborator. Upstream's default and release branches get
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
@@ -228,8 +241,7 @@ Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea
`gitea.user`. The email is the agent's `identity.email`.
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
`write:repository` and `read:user`, nothing else.
- A3. Under O1, create the fork as that user. Under O2, add the user as a Write collaborator and add the branch
rules.
- A3. Create the fork of each granted repo as that user (O1).
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
the token pasted into stdin.
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
@@ -248,17 +260,19 @@ Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 chan
## 9. Questions
For Mos (the authority model):
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
- Q-M1. Gitea side: O1 forks (recommended) or O2 one repo with branch rules.
- Q-M2. A new role action `git.push.remote`, added to the coder definition in `roles/` (a reviewed role
change). The agent pushes its own branch to a granted remote, and nothing is automatic.
- Q-M3. Issue, rotate, revoke and delete are human-only. The tester gets `status` and `revoke` on the dev
instance only (duckworth N1). The chief gets none of them.
- Q-M4. The model credential stays per stack instance for 10-27, and per-agent model tokens wait for a proxy
- Q-M1. O1, one fork per agent.
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
grants it to coder.
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
instance's tester gets `status` and `revoke` only.
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
row.
For Jason, through Mos:
Open for Jason. Mos takes these to him one at a time:
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account.
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3).
@@ -281,7 +295,10 @@ All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` be
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. `publish`: happy path lands `refs/heads/<user>/<branch>` on the fake remote, a fork that shares root's
history. Refusals for each step in D9, one test each, including `work-not-yours`. A force-push attempt is
history. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake remote that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. A first publish to an empty remote and to an unrelated remote
@@ -297,12 +314,13 @@ Mutants to kill: drop the cap-derived key in `useFor`, let the request name the
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check.
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. P1 (Gitea rule priority) is unverified. It matters only under O2.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.