Based on main; independent of #140/#141 (neither touches modules/gitea).
The route
gitea_api (tools/index.ts) sent any method to any path with the tools' token, minted for the forge's site administrator mesh-admin with write:repository and write:admin (token.ts). An agent could drop a branch's protection or its required checks, set mesh/merge-gate to success through /statuses, write to main through /contents, make itself a collaborator or site admin, delete and recreate a repository the mesh builds from, or merge its own pull request past the checks. gitea_branch_protection_set could also weaken a rule directly.
The fix (guard.ts, pure, tested without a forge)
gitea_api reads anything but /admin and /users/*/tokens; writes only what an author writes: issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a file on a branch that is neither the default branch nor one a protection rule (or glob) names. Everything else is refused before it is sent. Paths with dot segments or encoded slashes/dots are refused.
gitea_merge_pull_request reads the base's protection and the head's statuses and merges only when every required status is a success (mergeableUnder, the same judge gitea_commit_statuses uses); sends force_merge: false and head_commit_id, so the forge refuses if the head moved after the check; refuses manually-merged. The operator-approved merge stays: after the operator's yes, the agent's call merges a pull request whose checks passed.
gitea_branch_protection_set only strengthens: dropping a required status, allowing direct push, or allowing an admin override is refused.
gitea_delete_repo refuses a repository any protection rule guards.
Every tool's owner/repo must be a name, never another path.
The token is minted without write:admin. Nothing in the module's code used it (the provisioner settles users with the admin login over basic auth, GiteaAdmin); it was there for hand calls through gitea_api (hq 229). Its scopes are now kept beside it, and a token kept with another list is minted again at the next start — the forge drops the old one by name, so the scope does not outlive this.
What breaks
Settling the forge's users or teams by hand through gitea_api (hq 229's builder-as-site-admin was done that way) — now the operator's, at the forge as themselves, or a dedicated tool later.
Writing a file straight to main through gitea_api — use a branch and a pull request.
Deleting a protected repository through a tool.
Not done: a non-admin account for the tools
The token still belongs to mesh-admin, a site administrator, so scopes alone do not stop protection edits (they sit under repository) — the tools' guards do. Moving the tools to a non-admin user is feasible but larger: that user needs write access to every repository it merges and sets statuses on (a team in novox), the note append already runs in the forge's container, and creating a repository in an organisation needs membership. Proposed as a follow-up.
Tests (test/guard.test.ts; each fails before, passes after)
Against a fake forge: 15 refused gitea_api calls (admin, protection delete/patch, merge-gate status, merge with force_merge, collaborator, hook, key, repo settings, repo delete, transfer, token mint, org member, .. and %2e%2e traversal) with nothing sent; contents to default/guarded branch refused, on a new branch sent; author writes still sent; protection only strengthened; merge refused on a failed or missing required status, then merged with force_merge: false and head_commit_id once both succeed; protected repo not deleted; traversal through owner/repo refused. token.test.ts: scopes without write:admin, a kept admin-scoped token re-minted and the old one dropped, the scope re-mint on 403 still works.
merge-check.sh and merge-check-typescript.sh (with the toolchain's node_modules) pass on this head, which contains current main: 41 tests, 0 failed.
Based on main; independent of #140/#141 (neither touches `modules/gitea`).
## The route
`gitea_api` (tools/index.ts) sent any method to any path with the tools' token, minted for the forge's site administrator `mesh-admin` with `write:repository` and `write:admin` (token.ts). An agent could drop a branch's protection or its required checks, set `mesh/merge-gate` to success through `/statuses`, write to `main` through `/contents`, make itself a collaborator or site admin, delete and recreate a repository the mesh builds from, or merge its own pull request past the checks. `gitea_branch_protection_set` could also weaken a rule directly.
## The fix (guard.ts, pure, tested without a forge)
- **`gitea_api`** reads anything but `/admin` and `/users/*/tokens`; writes only what an author writes: issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a file on a branch that is neither the default branch nor one a protection rule (or glob) names. Everything else is refused before it is sent. Paths with dot segments or encoded slashes/dots are refused.
- **`gitea_merge_pull_request`** reads the base's protection and the head's statuses and merges only when every required status is a success (`mergeableUnder`, the same judge `gitea_commit_statuses` uses); sends `force_merge: false` and `head_commit_id`, so the forge refuses if the head moved after the check; refuses `manually-merged`. **The operator-approved merge stays**: after the operator's yes, the agent's call merges a pull request whose checks passed.
- **`gitea_branch_protection_set`** only strengthens: dropping a required status, allowing direct push, or allowing an admin override is refused.
- **`gitea_delete_repo`** refuses a repository any protection rule guards.
- Every tool's `owner`/`repo` must be a name, never another path.
- **The token is minted without `write:admin`.** Nothing in the module's code used it (the provisioner settles users with the admin login over basic auth, `GiteaAdmin`); it was there for hand calls through `gitea_api` (hq 229). Its scopes are now kept beside it, and a token kept with another list is minted again at the next start — the forge drops the old one by name, so the scope does not outlive this.
## What breaks
- Settling the forge's users or teams by hand through `gitea_api` (hq 229's builder-as-site-admin was done that way) — now the operator's, at the forge as themselves, or a dedicated tool later.
- Writing a file straight to `main` through `gitea_api` — use a branch and a pull request.
- Deleting a protected repository through a tool.
## Not done: a non-admin account for the tools
The token still belongs to `mesh-admin`, a site administrator, so scopes alone do not stop protection edits (they sit under `repository`) — the tools' guards do. Moving the tools to a non-admin user is feasible but larger: that user needs write access to every repository it merges and sets statuses on (a team in `novox`), the note append already runs in the forge's container, and creating a repository in an organisation needs membership. Proposed as a follow-up.
## Tests (`test/guard.test.ts`; each fails before, passes after)
Against a fake forge: 15 refused `gitea_api` calls (admin, protection delete/patch, merge-gate status, merge with `force_merge`, collaborator, hook, key, repo settings, repo delete, transfer, token mint, org member, `..` and `%2e%2e` traversal) with nothing sent; contents to default/guarded branch refused, on a new branch sent; author writes still sent; protection only strengthened; merge refused on a failed or missing required status, then merged with `force_merge: false` and `head_commit_id` once both succeed; protected repo not deleted; traversal through `owner`/`repo` refused. `token.test.ts`: scopes without `write:admin`, a kept admin-scoped token re-minted and the old one dropped, the scope re-mint on 403 still works.
`merge-check.sh` and `merge-check-typescript.sh` (with the toolchain's node_modules) pass on this head, which contains current main: 41 tests, 0 failed.
gitea_api took any endpoint with a token minted for the forge's
administrator with write:admin: an agent could drop a branch's protection
or its required checks, set the merge check's status, write to main or
merge its own pull request (novox/hq ADR 0266 §7, the review of
2026-10-09). gitea_api now reads anything but the administration and
writes only what an author writes; protection is only strengthened; a
merge needs every required status a success and merges only that head; a
guarded repository is not deleted; and the token is minted without
write:admin, a kept one re-minted at the next start.
Deliverynovox/mesh-catalog@1dca181e03e1 — superseded since 2026-10-09T11:53:57Z
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Machines
gitea on novox: judging — 0 healthy judging(s) so far
Transitions
2026-10-09 11:42 (new) → proposed (announced): novox/mesh-catalog#153's head announced
2026-10-09 11:44 proposed → checked (checked): the verdict names this commit
2026-10-09 11:44 checked → ready (accepted): the gate passed or warned, and the repository's own check did not fail
2026-10-09 11:50 ready → published (merged): on the trunk its modules follow: merged there, and its walk opened — or nothing for a walk to move
2026-10-09 11:50 published → delivering (go): its walk started: let go by this owner in its group's order, by a person, or on the controller's own path
2026-10-09 11:53 delivering → superseded (superseded): a newer delivery to the same trunk took over its walk
The commit's note under refs/notes/mesh-plan keeps every transition: git log --notes=mesh-plan.
<!-- mesh-delivery:view -->
**Delivery** `novox/mesh-catalog@1dca181e03e1` — **superseded** since 2026-10-09T11:53:57Z
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
**Machines**
- gitea on novox: judging — 0 healthy judging(s) so far
**Transitions**
- 2026-10-09 11:42 (new) → proposed (announced): novox/mesh-catalog#153's head announced
- 2026-10-09 11:44 proposed → checked (checked): the verdict names this commit
- 2026-10-09 11:44 checked → ready (accepted): the gate passed or warned, and the repository's own check did not fail
- 2026-10-09 11:50 ready → published (merged): on the trunk its modules follow: merged there, and its walk opened — or nothing for a walk to move
- 2026-10-09 11:50 published → delivering (go): its walk started: let go by this owner in its group's order, by a person, or on the controller's own path
- 2026-10-09 11:53 delivering → superseded (superseded): a newer delivery to the same trunk took over its walk
The commit's note under `refs/notes/mesh-plan` keeps every transition: `git log --notes=mesh-plan`.
mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on g14 as build-1791539340850985492 (builds --log build-1791539340850985492).
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 34219 strings of 604 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.004s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.714s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (22.395004ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.445484ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.2059ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.534473ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.668372ms)
✔ a note that cannot be written is said, never read as written (0.375782ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.466584ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (2.935621ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (46.457227ms)
✔ gitea_api still writes what an author writes (6.418549ms)
✔ gitea_branch_protection_set only strengthens (16.151266ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (24.833113ms)
✔ gitea_delete_repo refuses a repository a protection rule guards (2.441765ms)
✔ a repository owner or name is never another path (0.320137ms)
✔ the judges, without a forge (0.322431ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (41.508001ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.33243ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (9.901487ms)
✔ a lookup the forge refuses is thrown, not read as no module (3.205482ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.410264ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.207273ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (4.080109ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.801448ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.569759ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.821335ms)
✔ a commit status is set on the commit, under the merge check's context (38.990061ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.373718ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (29.996353ms)
✔ second start: reuses the kept token, mints nothing (15.16616ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (18.447406ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (24.639877ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (18.335304ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (16.505297ms)
✔ concurrent first calls share one mint (10.486606ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (11.345472ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.819383ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (17.075066ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.167726ms)
✔ nothing to mint with and no token: the client says what is missing (0.145897ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-EuxjIx/state/token
✔ the tools register once there is a way to a token, and the first call mints it (6.688651ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-3qCtAM/state/token
✔ a pull request can be closed, read and cleaned up through the tools (20.232898ms)
ℹ tests 41
ℹ suites 0
ℹ pass 41
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 339.649728
**Merge check** at `080c113d`
- `mesh/merge-gate`: **PASS** — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
- `mesh/repo-check`: **PASS** — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on g14 as `build-1791539340850985492` (`builds --log build-1791539340850985492`).
```
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 34219 strings of 604 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.004s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.714s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (22.395004ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.445484ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.2059ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.534473ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.668372ms)
✔ a note that cannot be written is said, never read as written (0.375782ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.466584ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (2.935621ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (46.457227ms)
✔ gitea_api still writes what an author writes (6.418549ms)
✔ gitea_branch_protection_set only strengthens (16.151266ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (24.833113ms)
✔ gitea_delete_repo refuses a repository a protection rule guards (2.441765ms)
✔ a repository owner or name is never another path (0.320137ms)
✔ the judges, without a forge (0.322431ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (41.508001ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.33243ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (9.901487ms)
✔ a lookup the forge refuses is thrown, not read as no module (3.205482ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.410264ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.207273ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (4.080109ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.801448ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.569759ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.821335ms)
✔ a commit status is set on the commit, under the merge check's context (38.990061ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.373718ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (29.996353ms)
✔ second start: reuses the kept token, mints nothing (15.16616ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (18.447406ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (24.639877ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (18.335304ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (16.505297ms)
✔ concurrent first calls share one mint (10.486606ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (11.345472ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.819383ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (17.075066ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.167726ms)
✔ nothing to mint with and no token: the client says what is missing (0.145897ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-EuxjIx/state/token
✔ the tools register once there is a way to a token, and the first call mints it (6.688651ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-3qCtAM/state/token
✔ a pull request can be closed, read and cleaned up through the tools (20.232898ms)
ℹ tests 41
ℹ suites 0
ℹ pass 41
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 339.649728
```
Follow-ups of the review of 2026-10-09: the escape hatch sent the raw path it
had judged in normalised form; a pull request's base could be moved through
it; the merge tool would merge into a branch no rule guards; a rule named for
a branch could undercut the glob rule guarding it; and the delete tool took
any unguarded repository. It now takes only a repository of the tools' own
user with no protection and no commit in 30 days, named in full.
gitea_api sends the normalised path it judged (its query kept), never the raw path.
gitea_api refuses PATCH /repos/o/r/pulls/{n} with a base in the body. gitea_update_pull_request never changes a base.
gitea_merge_pull_request merges only into a branch whose rule requires status checks. Before, a pull request whose base was moved to an unguarded branch would merge with "nothing required".
gitea_branch_protection_set: a rule named for a branch that has none of its own is judged against every glob rule covering it, with the forge's defaults for a new rule (pushes refused, admin override allowed). It may not be weaker than any of them.
What I chose for gitea_delete_repo: the forge tools have no terminal, so it deletes only when all of these hold: the repository belongs to the tools' own user (from GET /user), no protection rule guards it, it has no commit in 30 days, and confirm is exactly owner/name. An organisation's or a person's repository is never deleted by a tool. The mesh builds only from those, so 'any repository a module builds from' is covered without the gitea module needing the catalogue.
Four new tests, each failing on the previous head; 44 pass, 0 fail.
Follow-ups of the review, commit ff648ad:
- `gitea_api` sends the normalised path it judged (its query kept), never the raw path.
- `gitea_api` refuses `PATCH /repos/o/r/pulls/{n}` with a `base` in the body. `gitea_update_pull_request` never changes a base.
- `gitea_merge_pull_request` merges only into a branch whose rule requires status checks. Before, a pull request whose base was moved to an unguarded branch would merge with "nothing required".
- `gitea_branch_protection_set`: a rule named for a branch that has none of its own is judged against every glob rule covering it, with the forge's defaults for a new rule (pushes refused, admin override allowed). It may not be weaker than any of them.
- **What I chose for `gitea_delete_repo`:** the forge tools have no terminal, so it deletes only when all of these hold: the repository belongs to the tools' own user (from `GET /user`), no protection rule guards it, it has no commit in 30 days, and `confirm` is exactly `owner/name`. An organisation's or a person's repository is never deleted by a tool. The mesh builds only from those, so 'any repository a module builds from' is covered without the gitea module needing the catalogue.
Four new tests, each failing on the previous head; 44 pass, 0 fail.
mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as build-1791541623901758334 (builds --log build-1791541623901758334).
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 34237 strings of 604 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.005s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.701s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (18.294011ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.432672ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.191308ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.572644ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.326605ms)
✔ a note that cannot be written is said, never read as written (0.361708ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.639659ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (3.905825ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (41.441244ms)
✔ gitea_api still writes what an author writes (7.008024ms)
✔ gitea_branch_protection_set only strengthens (14.159146ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (25.793948ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (22.975321ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (3.960518ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (7.21429ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (12.945509ms)
✔ a repository owner or name is never another path (0.368732ms)
✔ the judges, without a forge (0.295344ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (23.503252ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.343505ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (10.5791ms)
✔ a lookup the forge refuses is thrown, not read as no module (3.008653ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.678074ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.256311ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.135571ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.607359ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.472887ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.643186ms)
✔ a commit status is set on the commit, under the merge check's context (28.169453ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.417824ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (30.255175ms)
✔ second start: reuses the kept token, mints nothing (15.49418ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (17.947942ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (18.606557ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (17.251666ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (15.785014ms)
✔ concurrent first calls share one mint (9.471685ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (12.456652ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.928151ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (19.067031ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.242916ms)
✔ nothing to mint with and no token: the client says what is missing (0.184957ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-sBGppV/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.308633ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-YaK3ZJ/state/token
✔ a pull request can be closed, read and cleaned up through the tools (19.580153ms)
ℹ tests 44
ℹ suites 0
ℹ pass 44
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 304.70973
**Merge check** at `ff648ad3`
- `mesh/merge-gate`: **PASS** — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
- `mesh/repo-check`: **PASS** — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as `build-1791541623901758334` (`builds --log build-1791541623901758334`).
```
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 34237 strings of 604 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.005s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.701s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (18.294011ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.432672ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.191308ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.572644ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.326605ms)
✔ a note that cannot be written is said, never read as written (0.361708ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.639659ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (3.905825ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (41.441244ms)
✔ gitea_api still writes what an author writes (7.008024ms)
✔ gitea_branch_protection_set only strengthens (14.159146ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (25.793948ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (22.975321ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (3.960518ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (7.21429ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (12.945509ms)
✔ a repository owner or name is never another path (0.368732ms)
✔ the judges, without a forge (0.295344ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (23.503252ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.343505ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (10.5791ms)
✔ a lookup the forge refuses is thrown, not read as no module (3.008653ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.678074ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.256311ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.135571ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.607359ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.472887ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.643186ms)
✔ a commit status is set on the commit, under the merge check's context (28.169453ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.417824ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (30.255175ms)
✔ second start: reuses the kept token, mints nothing (15.49418ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (17.947942ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (18.606557ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (17.251666ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (15.785014ms)
✔ concurrent first calls share one mint (9.471685ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (12.456652ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.928151ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (19.067031ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.242916ms)
✔ nothing to mint with and no token: the client says what is missing (0.184957ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-sBGppV/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.308633ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-YaK3ZJ/state/token
✔ a pull request can be closed, read and cleaned up through the tools (19.580153ms)
ℹ tests 44
ℹ suites 0
ℹ pass 44
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 304.70973
```
The confirmation review of 2026-10-09 measured GET /user?sudo=… answer the
operator's own account: the query went to the forge unjudged, and the tools'
token is the site administrator's, so any caller could act as any user.
gitea_api now sends only allow-listed query keys and refuses sudo in any case,
and a review that approves or requests changes is the operator's.
mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as build-1791543453554850947 (builds --log build-1791543453554850947).
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35057 strings of 611 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.006s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.661s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (16.047687ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.551547ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.22871ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.687182ms)
✔ a note line is appended once, as the forge's user, in its own repository (5.60689ms)
✔ a note that cannot be written is said, never read as written (0.299253ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.488699ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (4.000942ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (40.353611ms)
✔ gitea_api still writes what an author writes (7.348504ms)
✔ gitea_branch_protection_set only strengthens (11.998938ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (25.93749ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (23.00704ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.572006ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (6.238396ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (12.863733ms)
✔ a repository owner or name is never another path (0.370076ms)
✔ the judges, without a forge (0.303742ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.539134ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (2.355217ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (21.167584ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.3244ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (8.992354ms)
✔ a lookup the forge refuses is thrown, not read as no module (2.676612ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.699135ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.22346ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.292681ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.587594ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.465425ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.63314ms)
✔ a commit status is set on the commit, under the merge check's context (25.180278ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.332906ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (25.098954ms)
✔ second start: reuses the kept token, mints nothing (12.448602ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (18.009616ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (16.658465ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (14.344176ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (14.723028ms)
✔ concurrent first calls share one mint (9.055493ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (10.281718ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (1.032721ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (17.323046ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.192581ms)
✔ nothing to mint with and no token: the client says what is missing (0.199966ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-VdkrFb/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.256974ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-dn8Gw8/state/token
✔ a pull request can be closed, read and cleaned up through the tools (18.376475ms)
ℹ tests 46
ℹ suites 0
ℹ pass 46
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 279.780171
**Merge check** at `213950e3`
- `mesh/merge-gate`: **PASS** — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
- `mesh/repo-check`: **PASS** — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as `build-1791543453554850947` (`builds --log build-1791543453554850947`).
```
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T08:07:39Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T08:07:39Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35057 strings of 611 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.006s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.661s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (16.047687ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.551547ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.22871ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.687182ms)
✔ a note line is appended once, as the forge's user, in its own repository (5.60689ms)
✔ a note that cannot be written is said, never read as written (0.299253ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.488699ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (4.000942ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (40.353611ms)
✔ gitea_api still writes what an author writes (7.348504ms)
✔ gitea_branch_protection_set only strengthens (11.998938ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (25.93749ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (23.00704ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.572006ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (6.238396ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (12.863733ms)
✔ a repository owner or name is never another path (0.370076ms)
✔ the judges, without a forge (0.303742ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.539134ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (2.355217ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (21.167584ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.3244ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (8.992354ms)
✔ a lookup the forge refuses is thrown, not read as no module (2.676612ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.699135ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.22346ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.292681ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.587594ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.465425ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.63314ms)
✔ a commit status is set on the commit, under the merge check's context (25.180278ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.332906ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (25.098954ms)
✔ second start: reuses the kept token, mints nothing (12.448602ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (18.009616ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (16.658465ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (14.344176ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (14.723028ms)
✔ concurrent first calls share one mint (9.055493ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (10.281718ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (1.032721ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (17.323046ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.192581ms)
✔ nothing to mint with and no token: the client says what is missing (0.199966ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-VdkrFb/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.256974ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-dn8Gw8/state/token
✔ a pull request can be closed, read and cleaned up through the tools (18.376475ms)
ℹ tests 46
ℹ suites 0
ℹ pass 46
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 279.780171
```
Urgent fix from the confirmation review, commit ea772a9; head 213950e after merging main (no conflicts, nothing under modules/gitea changed on main).
No acting as another user.gitea_api now sends only an allow-list of query keys (QUERY_KEYS in guard.ts: limit, page, state, type, q, sort, order, since, before, ref, sha, path, labels, milestones, keyword, stat, verification, files, not, mode, include_desc, archived, private, exclusive, uid, priority_repo_id, created_by, assigned_by, mentioned_by, whitespace, binary). sudo in any case is refused, and so is any other key, token and access_token included. The query is judged and also rebuilt from the allow-list before sending (sentQuery), so what is sent is what was judged.
No Sudo header.gitea_api takes no headers argument, and the client sets only Content-Type and Authorization. There is no header route.
No approvals. A POST /repos/o/r/pulls/N/reviews (or /reviews/{id}) whose event is anything but COMMENT or PENDING (APPROVE, REQUEST_CHANGES, any case) is refused. Approving is the operator's, at the forge.
Tests: gitea_api never acts as another user covers ?sudo=, ?SUDO=, Sudo beside allowed keys, token, access_token, and a sudo comment post. Nothing reaches the fake forge, and an allowed query arrives unchanged. gitea_api never approves a pull request nor requests changes covers APPROVE, approve, REQUEST_CHANGES and APPROVE on a pending review; a COMMENT review is still sent. Both fail on the previous head. 46 pass, 0 fail.
Urgent fix from the confirmation review, commit ea772a9; head 213950e after merging main (no conflicts, nothing under `modules/gitea` changed on main).
- **No acting as another user.** `gitea_api` now sends only an allow-list of query keys (`QUERY_KEYS` in guard.ts: limit, page, state, type, q, sort, order, since, before, ref, sha, path, labels, milestones, keyword, stat, verification, files, not, mode, include_desc, archived, private, exclusive, uid, priority_repo_id, created_by, assigned_by, mentioned_by, whitespace, binary). `sudo` in any case is refused, and so is any other key, `token` and `access_token` included. The query is judged and also rebuilt from the allow-list before sending (`sentQuery`), so what is sent is what was judged.
- **No Sudo header.** `gitea_api` takes no headers argument, and the client sets only `Content-Type` and `Authorization`. There is no header route.
- **No approvals.** A `POST /repos/o/r/pulls/N/reviews` (or `/reviews/{id}`) whose `event` is anything but COMMENT or PENDING (APPROVE, REQUEST_CHANGES, any case) is refused. Approving is the operator's, at the forge.
Tests: `gitea_api never acts as another user` covers `?sudo=`, `?SUDO=`, `Sudo` beside allowed keys, `token`, `access_token`, and a sudo comment post. Nothing reaches the fake forge, and an allowed query arrives unchanged. `gitea_api never approves a pull request nor requests changes` covers APPROVE, approve, REQUEST_CHANGES and APPROVE on a pending review; a COMMENT review is still sent. Both fail on the previous head. 46 pass, 0 fail.
The confirmation review of 2026-10-09 found the dedicated tools building paths
from caller text: a branch named .. became DELETE /repos/o/r/ (the repository),
a file path ../../../../version reached /api/v1/version, and a sha with
?sudo= set a status as any user. Each caller-supplied segment now goes through
one helper refusing empty, . and .. and / ? # %; a sha must be a commit; and
the client judges every path once more as it sends it: no dot or empty
segment, encoded or not, and only the allow-listed query keys.
mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as build-1791545730442225160 (builds --log build-1791545730442225160).
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T11:25:23Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T11:25:23Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35079 strings of 611 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.005s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.682s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (9.904341ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.413719ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.192753ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.58973ms)
✔ a note line is appended once, as the forge's user, in its own repository (5.304364ms)
✔ a note that cannot be written is said, never read as written (0.303872ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.460697ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (4.196519ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (38.915491ms)
✔ gitea_api still writes what an author writes (6.245564ms)
✔ gitea_branch_protection_set only strengthens (11.429282ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (27.263321ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (23.363332ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.579509ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (7.219297ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (14.465917ms)
✔ a repository owner or name is never another path (0.360268ms)
✔ the judges, without a forge (0.471367ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.191474ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (2.942547ms)
✔ no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge (3.616516ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (18.931992ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.315454ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (9.056965ms)
✔ a lookup the forge refuses is thrown, not read as no module (2.751489ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.327735ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.220304ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.052304ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.599729ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.490853ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.654291ms)
✔ a commit status is set on the commit, under the merge check's context (26.888998ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.36141ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (25.884978ms)
✔ second start: reuses the kept token, mints nothing (15.786351ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (15.864828ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (19.570914ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (17.1248ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (15.673008ms)
✔ concurrent first calls share one mint (9.278553ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (11.134585ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.835171ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (18.063417ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.268449ms)
✔ nothing to mint with and no token: the client says what is missing (0.177394ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-AIy3Dx/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.151081ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-j4EG0I/state/token
✔ a pull request can be closed, read and cleaned up through the tools (18.942301ms)
ℹ tests 47
ℹ suites 0
ℹ pass 47
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 292.430615
**Merge check** at `5eab5861`
- `mesh/merge-gate`: **PASS** — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
- `mesh/repo-check`: **PASS** — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on ace as `build-1791545730442225160` (`builds --log build-1791545730442225160`).
```
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T11:25:23Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T11:25:23Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35079 strings of 611 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.005s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.682s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ the newest status of each context is kept, with what it said, when and by whom (9.904341ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.413719ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.192753ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.58973ms)
✔ a note line is appended once, as the forge's user, in its own repository (5.304364ms)
✔ a note that cannot be written is said, never read as written (0.303872ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.460697ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (4.196519ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (38.915491ms)
✔ gitea_api still writes what an author writes (6.245564ms)
✔ gitea_branch_protection_set only strengthens (11.429282ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (27.263321ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (23.363332ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.579509ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (7.219297ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (14.465917ms)
✔ a repository owner or name is never another path (0.360268ms)
✔ the judges, without a forge (0.471367ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.191474ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (2.942547ms)
✔ no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge (3.616516ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (18.931992ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.315454ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (9.056965ms)
✔ a lookup the forge refuses is thrown, not read as no module (2.751489ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (4.327735ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.220304ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (3.052304ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (0.599729ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.490853ms)
✔ no verdict sets warning on a required check; only a person's decision fails (0.654291ms)
✔ a commit status is set on the commit, under the merge check's context (26.888998ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.36141ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (25.884978ms)
✔ second start: reuses the kept token, mints nothing (15.786351ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (15.864828ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (19.570914ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (17.1248ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (15.673008ms)
✔ concurrent first calls share one mint (9.278553ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (11.134585ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.835171ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (18.063417ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (2.268449ms)
✔ nothing to mint with and no token: the client says what is missing (0.177394ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-AIy3Dx/state/token
✔ the tools register once there is a way to a token, and the first call mints it (8.151081ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-j4EG0I/state/token
✔ a pull request can be closed, read and cleaned up through the tools (18.942301ms)
ℹ tests 47
ℹ suites 0
ℹ pass 47
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 292.430615
```
After merging #159 (issue 347): every /repos/<owner>/<name> the client builds
now takes its owner and name through one helper, whether they came from a
caller or from the forge's answer; the open-pulls fixture's shas are commits.
mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
Delivery plan — builds gitea → novox; no bus step
tier 0: gitea
novox: receives gitea
gitea provides npm-package-registry, git: its consumers are sent again after it
gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on novox as build-1791546122974779924 (builds --log build-1791546122974779924).
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T11:35:23Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T11:35:23Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35146 strings of 614 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.006s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.930s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ a pull request opened after the margin is looked at, by its own update time (17.225392ms)
✔ every open pull request is read with what its base requires and what its head has (gitea_open_pulls) (66.063335ms)
✔ a pull request or issue number that is not one is refused before the forge is asked (2.014232ms)
✔ the newest status of each context is kept, with what it said, when and by whom (14.473269ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.448311ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.27057ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.672421ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.944759ms)
✔ a note that cannot be written is said, never read as written (0.454791ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.651641ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (5.215167ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (51.091356ms)
✔ gitea_api still writes what an author writes (8.13464ms)
✔ gitea_branch_protection_set only strengthens (16.443661ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (38.47339ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (25.837233ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.411596ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (6.207418ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (15.213499ms)
✔ a repository owner or name is never another path (0.46106ms)
✔ the judges, without a forge (0.395251ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.072142ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (1.919163ms)
✔ no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge (4.754006ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (24.483591ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.476071ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (16.165421ms)
✔ a lookup the forge refuses is thrown, not read as no module (4.412625ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (7.074529ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.254071ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (12.568176ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (1.659782ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.776701ms)
✔ no verdict sets warning on a required check; only a person's decision fails (1.068321ms)
✔ a commit status is set on the commit, under the merge check's context (38.025939ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.611591ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (37.532248ms)
✔ second start: reuses the kept token, mints nothing (18.525993ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (23.52781ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (24.424911ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (21.844408ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (16.481561ms)
✔ concurrent first calls share one mint (10.135453ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (13.588528ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.894331ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (18.439434ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (3.019914ms)
✔ nothing to mint with and no token: the client says what is missing (0.18342ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-AsjSO5/state/token
✔ the tools register once there is a way to a token, and the first call mints it (7.65703ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-pFPnEL/state/token
✔ a pull request can be closed, read and cleaned up through the tools (19.390365ms)
ℹ tests 50
ℹ suites 0
ℹ pass 50
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 417.429684
**Merge check** at `1dca181e`
- `mesh/merge-gate`: **PASS** — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)
- `mesh/repo-check`: **PASS** — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
**Delivery plan** — builds gitea → novox; no bus step
- tier 0: gitea
- novox: receives gitea
- gitea provides npm-package-registry, git: its consumers are sent again after it
- gitea keeps data (ADR 0233): what it holds is backed up before it moves
Run by the build seat on novox as `build-1791546122974779924` (`builds --log build-1791546122974779924`).
```
--- the gate: gitea
gitea: ok, keeps data (valuable); WARNING: server stay(s) up and say(s) not how it is ready — judged by liveness alone, refused from 2026-11-18 (ADR 0240 rule 8)
long-running resources without health: 1
files asking for a setting without saying whether it is trusted: 0
1 manifest(s) checked. Judged against the seats this binary carries; a claim on one of the mesh's own seats is judged fully at registration, and a seat declared by a module not given here reads as unknown. Identities judged on a 6-character machine name, against the bounds of the providers given here
judging 1 module(s) changed against 4 machine(s), as the snapshot of 2026-10-09T11:35:23Z says they run
composing every machine as the mesh is
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
composing every machine with the change
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
the bus's user list leaves out 5 user(s) the mesh has minted no credential for: controller, node.f35, node.idt, node.ygcyr, node.yvbpsm. Each is a user that cannot connect until one is issued
merge gate: PASS — every machine composes with the change as it did without (4 of 4 compose)
judged by controller development build against the facts of 2026-10-09T11:35:23Z
modules the change moves:
- gitea (changed)
machines:
- a machine (f35): composes
- holds mesh-dns-resolver (idt): composes
- holds git, holds mesh-artifact-store, holds mesh-broker, holds mesh-catalog, holds mesh-delivery, holds mesh-dns-resolver, holds mesh-store, holds mesh-vault, holds npm-package-registry, the control node, the hub (ygcyr): composes
- a machine (yvbpsm): composes
a merge would rebuild 1 module(s) in 1 tier(s): gitea
--- its merge-check.sh (go toolchain)
ok git.novox.be/novox/mesh-catalog/checks/words 0.002s
words: 12 retired words, none in the 35146 strings of 614 files
ok git.novox.be/novox/mesh-catalog/checks/paths 0.006s
paths: no module names another's state on its machine (3 known, listed to go)
long-running resources without health: 70, as kept
testing modules/gitea
ok gitea/cmd/npm-registry 1.930s
--- its merge-check-typescript.sh (typescript toolchain)
type-checking modules/gitea (TypeScript)
testing modules/gitea (TypeScript)
✔ a pull request opened after the margin is looked at, by its own update time (17.225392ms)
✔ every open pull request is read with what its base requires and what its head has (gitea_open_pulls) (66.063335ms)
✔ a pull request or issue number that is not one is refused before the forge is asked (2.014232ms)
✔ the newest status of each context is kept, with what it said, when and by whom (14.473269ms)
✔ a commit merges only when every required status is a success; a warning blocks (0.448311ms)
✔ no rule requires nothing; a rule that cannot be read says nothing, never yes (0.27057ms)
✔ a commit is contained in another when the compare finds nothing on it the other lacks (0.672421ms)
✔ a note line is appended once, as the forge's user, in its own repository (6.944759ms)
✔ a note that cannot be written is said, never read as written (0.454791ms)
✔ one view per pull request, found by its marker; only the mesh's statuses (0.651641ms)
✔ gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing (5.215167ms)
✔ gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own (51.091356ms)
✔ gitea_api still writes what an author writes (8.13464ms)
✔ gitea_branch_protection_set only strengthens (16.443661ms)
✔ gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head (38.47339ms)
✔ gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full (25.837233ms)
✔ gitea_api sends the path it judged, and refuses moving a pull request's base (4.411596ms)
✔ gitea_merge_pull_request merges only into a branch whose protection requires the checks (6.207418ms)
✔ a rule named for a branch is not made weaker than the glob rule that guards it now (15.213499ms)
✔ a repository owner or name is never another path (0.46106ms)
✔ the judges, without a forge (0.395251ms)
✔ gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused (2.072142ms)
✔ gitea_api never approves a pull request nor requests changes: those are the operator's (1.919163ms)
✔ no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge (4.754006ms)
✔ every page of a pull request's files is read, though the forge caps a page at fifty (24.483591ms)
✔ a pass asks only the repositories that moved since the last look, every one before the first (0.476071ms)
✔ the directories above a merge's files that hold a module at the commit are said, the root never (16.165421ms)
✔ a lookup the forge refuses is thrown, not read as no module (4.412625ms)
✔ the statuses asked for replace the rule's, and an empty list requires none (7.074529ms)
✔ a new rule refuses direct pushes unless asked; an administrator's override only when said (0.254071ms)
✔ each open pull request's new head is announced once, and nothing closed or merged (12.568176ms)
✔ a verdict is the head commit's status; an error is the forge's error, never a success (1.659782ms)
✔ each layer is its own status: the gate with the modules it judged, the repository's own check beside it (0.776701ms)
✔ no verdict sets warning on a required check; only a person's decision fails (1.068321ms)
✔ a commit status is set on the commit, under the merge check's context (38.025939ms)
✔ a change plan is the gate's result: said on the status and, when it builds something, as a comment (0.611591ms)
✔ first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only (37.532248ms)
✔ second start: reuses the kept token, mints nothing (18.525993ms)
✔ the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through (23.52781ms)
✔ a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one (24.424911ms)
✔ a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through (21.844408ms)
✔ the kept file is gone but the forge still holds a token by that name: replaced, not refused (16.481561ms)
✔ concurrent first calls share one mint (10.135453ms)
✔ the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing (13.588528ms)
✔ one process shares one source per kept file — the watcher and the tools never renew against each other (0.894331ms)
✔ a second process finds the token the first renewed, and reuses it instead of minting over it (18.439434ms)
✔ a configured token wins, and is reported rather than minted over when the forge rejects it (3.019914ms)
✔ nothing to mint with and no token: the client says what is missing (0.18342ms)
[gitea] no tools — no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_USER, MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-AsjSO5/state/token
✔ the tools register once there is a way to a token, and the first call mints it (7.65703ms)
[gitea] no token kept — minting one
[gitea] the forge already holds a token named "mesh-tools" — replacing it
[gitea] minted a token for "mesh-admin" (write:repository, write:issue, read:user), kept at /tmp/gitea-pFPnEL/state/token
✔ a pull request can be closed, read and cleaned up through the tools (19.390365ms)
ℹ tests 50
ℹ suites 0
ℹ pass 50
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 417.429684
```
Path fix from the confirmation review: commit 5eab586, then main merged in with #159 (merge 46fe6ff), then 1dca181.
One helper per caller segment (pathSegment / pathSegments in guard.ts). It refuses an empty segment, ., .., and any of / ? # % \ or control characters, before encoding.
pathSegments judges each part on its own, so a branch like feat/x or a file path still works.
Used for: deleteBranch, setBranchProtection (PATCH), getFile (per segment), moduleDirsAt/holdsFile, combinedStatus, and every /repos/<owner>/<name> through repoPath. That last one covers gitea_open_pulls (#159), whose names come from the forge's own answer.
gitea_branch_protection_set also judges the rule's branch name, so a rule named .. is never created.
Commits checked.setCommitStatus, commitStatuses and compare take only a commit (isCommit).
Checked again at send.GiteaClient.send runs checkSentPath on every request: no empty or dot segment, encoded or not, no fragment, and only the allow-listed query keys. It covers any path built elsewhere too.
Every interpolated path in the client was grepped.
What is left: numbers (index goes through #159's indexOf, comment ids are numbers), URLSearchParams built from fixed keys, and the GiteaAdmin class.
GiteaAdmin builds paths with encodeURIComponent (or team ids) from the provisioner's manifest settings. It has no tool, so no caller reaches it.
Merge resolution. I kept #159's indexOf and this PR's mergePullRequest(…, headCommit). Three test fixtures used 6-character shas (abc123, aaa111, bbb222); a commit is 7 to 40 characters, so they are now 7.
Test: no tool's caller text becomes another path or another user. These 13 cases are refused, with only a read of the repository's own rules reaching the fake forge:
gitea_delete_branch with .., feat/../.., x?sudo= and %2e%2e
gitea_get_file with ../../../../version and README.md?sudo=
gitea_commit_status with abc1234?sudo= and ../../../admin/users
gitea_commit_statuses with .. and main?sudo=
gitea_branch_protection_set with .. and main?sudo=
gitea_contains with ..
A branch feat/x and a file in a directory still go through. On the previous head the test fails at the first case: gitea_delete_branch("..") reached the forge. 50 pass, 0 fail.
Path fix from the confirmation review: commit 5eab586, then main merged in with #159 (merge 46fe6ff), then 1dca181.
- **One helper per caller segment** (`pathSegment` / `pathSegments` in guard.ts). It refuses an empty segment, `.`, `..`, and any of `/ ? # % \` or control characters, before encoding.
- `pathSegments` judges each part on its own, so a branch like `feat/x` or a file path still works.
- Used for: `deleteBranch`, `setBranchProtection` (PATCH), `getFile` (per segment), `moduleDirsAt`/`holdsFile`, `combinedStatus`, and every `/repos/<owner>/<name>` through `repoPath`. That last one covers `gitea_open_pulls` (#159), whose names come from the forge's own answer.
- `gitea_branch_protection_set` also judges the rule's branch name, so a rule named `..` is never created.
- **Commits checked.** `setCommitStatus`, `commitStatuses` and `compare` take only a commit (`isCommit`).
- **Checked again at send.** `GiteaClient.send` runs `checkSentPath` on every request: no empty or dot segment, encoded or not, no fragment, and only the allow-listed query keys. It covers any path built elsewhere too.
- **Every interpolated path in the client was grepped.**
- What is left: numbers (`index` goes through #159's `indexOf`, comment ids are numbers), `URLSearchParams` built from fixed keys, and the GiteaAdmin class.
- GiteaAdmin builds paths with `encodeURIComponent` (or team ids) from the provisioner's manifest settings. It has no tool, so no caller reaches it.
- **Merge resolution.** I kept #159's `indexOf` and this PR's `mergePullRequest(…, headCommit)`. Three test fixtures used 6-character shas (`abc123`, `aaa111`, `bbb222`); a commit is 7 to 40 characters, so they are now 7.
Test: `no tool's caller text becomes another path or another user`. These 13 cases are refused, with only a read of the repository's own rules reaching the fake forge:
- `gitea_delete_branch` with `..`, `feat/../..`, `x?sudo=` and `%2e%2e`
- `gitea_get_file` with `../../../../version` and `README.md?sudo=`
- `gitea_commit_status` with `abc1234?sudo=` and `../../../admin/users`
- `gitea_commit_statuses` with `..` and `main?sudo=`
- `gitea_branch_protection_set` with `..` and `main?sudo=`
- `gitea_contains` with `..`
A branch `feat/x` and a file in a directory still go through. On the previous head the test fails at the first case: `gitea_delete_branch("..")` reached the forge. 50 pass, 0 fail.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Based on main; independent of #140/#141 (neither touches
modules/gitea).The route
gitea_api(tools/index.ts) sent any method to any path with the tools' token, minted for the forge's site administratormesh-adminwithwrite:repositoryandwrite:admin(token.ts). An agent could drop a branch's protection or its required checks, setmesh/merge-gateto success through/statuses, write tomainthrough/contents, make itself a collaborator or site admin, delete and recreate a repository the mesh builds from, or merge its own pull request past the checks.gitea_branch_protection_setcould also weaken a rule directly.The fix (guard.ts, pure, tested without a forge)
gitea_apireads anything but/adminand/users/*/tokens; writes only what an author writes: issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a file on a branch that is neither the default branch nor one a protection rule (or glob) names. Everything else is refused before it is sent. Paths with dot segments or encoded slashes/dots are refused.gitea_merge_pull_requestreads the base's protection and the head's statuses and merges only when every required status is a success (mergeableUnder, the same judgegitea_commit_statusesuses); sendsforce_merge: falseandhead_commit_id, so the forge refuses if the head moved after the check; refusesmanually-merged. The operator-approved merge stays: after the operator's yes, the agent's call merges a pull request whose checks passed.gitea_branch_protection_setonly strengthens: dropping a required status, allowing direct push, or allowing an admin override is refused.gitea_delete_reporefuses a repository any protection rule guards.owner/repomust be a name, never another path.write:admin. Nothing in the module's code used it (the provisioner settles users with the admin login over basic auth,GiteaAdmin); it was there for hand calls throughgitea_api(hq 229). Its scopes are now kept beside it, and a token kept with another list is minted again at the next start — the forge drops the old one by name, so the scope does not outlive this.What breaks
gitea_api(hq 229's builder-as-site-admin was done that way) — now the operator's, at the forge as themselves, or a dedicated tool later.mainthroughgitea_api— use a branch and a pull request.Not done: a non-admin account for the tools
The token still belongs to
mesh-admin, a site administrator, so scopes alone do not stop protection edits (they sit underrepository) — the tools' guards do. Moving the tools to a non-admin user is feasible but larger: that user needs write access to every repository it merges and sets statuses on (a team innovox), the note append already runs in the forge's container, and creating a repository in an organisation needs membership. Proposed as a follow-up.Tests (
test/guard.test.ts; each fails before, passes after)Against a fake forge: 15 refused
gitea_apicalls (admin, protection delete/patch, merge-gate status, merge withforce_merge, collaborator, hook, key, repo settings, repo delete, transfer, token mint, org member,..and%2e%2etraversal) with nothing sent; contents to default/guarded branch refused, on a new branch sent; author writes still sent; protection only strengthened; merge refused on a failed or missing required status, then merged withforce_merge: falseandhead_commit_idonce both succeed; protected repo not deleted; traversal throughowner/reporefused.token.test.ts: scopes withoutwrite:admin, a kept admin-scoped token re-minted and the old one dropped, the scope re-mint on 403 still works.merge-check.shandmerge-check-typescript.sh(with the toolchain's node_modules) pass on this head, which contains current main: 41 tests, 0 failed.Delivery
novox/mesh-catalog@1dca181e03e1— superseded since 2026-10-09T11:53:57ZDelivery plan — builds gitea → novox; no bus step
Machines
Transitions
The commit's note under
refs/notes/mesh-plankeeps every transition:git log --notes=mesh-plan.Merge check at
080c113dmesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)Delivery plan — builds gitea → novox; no bus step
Run by the build seat on g14 as
build-1791539340850985492(builds --log build-1791539340850985492).Follow-ups of the review, commit
ff648ad:gitea_apisends the normalised path it judged (its query kept), never the raw path.gitea_apirefusesPATCH /repos/o/r/pulls/{n}with abasein the body.gitea_update_pull_requestnever changes a base.gitea_merge_pull_requestmerges only into a branch whose rule requires status checks. Before, a pull request whose base was moved to an unguarded branch would merge with "nothing required".gitea_branch_protection_set: a rule named for a branch that has none of its own is judged against every glob rule covering it, with the forge's defaults for a new rule (pushes refused, admin override allowed). It may not be weaker than any of them.gitea_delete_repo: the forge tools have no terminal, so it deletes only when all of these hold: the repository belongs to the tools' own user (fromGET /user), no protection rule guards it, it has no commit in 30 days, andconfirmis exactlyowner/name. An organisation's or a person's repository is never deleted by a tool. The mesh builds only from those, so 'any repository a module builds from' is covered without the gitea module needing the catalogue.Four new tests, each failing on the previous head; 44 pass, 0 fail.
Merge check at
ff648ad3mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)Delivery plan — builds gitea → novox; no bus step
Run by the build seat on ace as
build-1791541623901758334(builds --log build-1791541623901758334).Merge check at
213950e3mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)Delivery plan — builds gitea → novox; no bus step
Run by the build seat on ace as
build-1791543453554850947(builds --log build-1791543453554850947).Urgent fix from the confirmation review, commit ea772a9; head
213950eafter merging main (no conflicts, nothing undermodules/giteachanged on main).gitea_apinow sends only an allow-list of query keys (QUERY_KEYSin guard.ts: limit, page, state, type, q, sort, order, since, before, ref, sha, path, labels, milestones, keyword, stat, verification, files, not, mode, include_desc, archived, private, exclusive, uid, priority_repo_id, created_by, assigned_by, mentioned_by, whitespace, binary).sudoin any case is refused, and so is any other key,tokenandaccess_tokenincluded. The query is judged and also rebuilt from the allow-list before sending (sentQuery), so what is sent is what was judged.gitea_apitakes no headers argument, and the client sets onlyContent-TypeandAuthorization. There is no header route.POST /repos/o/r/pulls/N/reviews(or/reviews/{id}) whoseeventis anything but COMMENT or PENDING (APPROVE, REQUEST_CHANGES, any case) is refused. Approving is the operator's, at the forge.Tests:
gitea_api never acts as another usercovers?sudo=,?SUDO=,Sudobeside allowed keys,token,access_token, and a sudo comment post. Nothing reaches the fake forge, and an allowed query arrives unchanged.gitea_api never approves a pull request nor requests changescovers APPROVE, approve, REQUEST_CHANGES and APPROVE on a pending review; a COMMENT review is still sent. Both fail on the previous head. 46 pass, 0 fail.Merge check at
5eab5861mesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)Delivery plan — builds gitea → novox; no bus step
Run by the build seat on ace as
build-1791545730442225160(builds --log build-1791545730442225160).Merge check at
1dca181emesh/merge-gate: PASS — every machine composes with the change as it did without (4 of 4 compose) (modules: gitea)mesh/repo-check: PASS — its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)Delivery plan — builds gitea → novox; no bus step
Run by the build seat on novox as
build-1791546122974779924(builds --log build-1791546122974779924).Path fix from the confirmation review: commit
5eab586, then main merged in with #159 (merge46fe6ff), then1dca181.pathSegment/pathSegmentsin guard.ts). It refuses an empty segment,.,.., and any of/ ? # % \or control characters, before encoding.pathSegmentsjudges each part on its own, so a branch likefeat/xor a file path still works.deleteBranch,setBranchProtection(PATCH),getFile(per segment),moduleDirsAt/holdsFile,combinedStatus, and every/repos/<owner>/<name>throughrepoPath. That last one coversgitea_open_pulls(#159), whose names come from the forge's own answer.gitea_branch_protection_setalso judges the rule's branch name, so a rule named..is never created.setCommitStatus,commitStatusesandcomparetake only a commit (isCommit).GiteaClient.sendrunscheckSentPathon every request: no empty or dot segment, encoded or not, no fragment, and only the allow-listed query keys. It covers any path built elsewhere too.indexgoes through #159'sindexOf, comment ids are numbers),URLSearchParamsbuilt from fixed keys, and the GiteaAdmin class.encodeURIComponent(or team ids) from the provisioner's manifest settings. It has no tool, so no caller reaches it.indexOfand this PR'smergePullRequest(…, headCommit). Three test fixtures used 6-character shas (abc123,aaa111,bbb222); a commit is 7 to 40 characters, so they are now 7.Test:
no tool's caller text becomes another path or another user. These 13 cases are refused, with only a read of the repository's own rules reaching the fake forge:gitea_delete_branchwith..,feat/../..,x?sudo=and%2e%2egitea_get_filewith../../../../versionandREADME.md?sudo=gitea_commit_statuswithabc1234?sudo=and../../../admin/usersgitea_commit_statuseswith..andmain?sudo=gitea_branch_protection_setwith..andmain?sudo=gitea_containswith..A branch
feat/xand a file in a directory still go through. On the previous head the test fails at the first case:gitea_delete_branch("..")reached the forge. 50 pass, 0 fail.