diff --git a/00-META/glossary.md b/00-META/glossary.md index 7e382130..424962df 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -53,7 +53,8 @@ is listed under [Homonyms](#homonyms), with the qualified form each domain uses. provision**, and its holder is then the mesh's answer for it when several modules provide it ([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). The set, with who holds each seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). - A seat carries **verbs** — the tools every holder must serve. + A seat carries **verbs** — the tools every holder must serve. A verb added to a held seat is first + promised as optional, then served, then required ([ADR 0246](../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md)). - **operator** — the person who runs the mesh, and the one the mesh talks to. *Not:* ~~master~~ (hq) - **person** — any human, as against the mesh acting unattended: *a person's word* releases a held diff --git a/02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md b/02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md new file mode 100644 index 00000000..7f9cad63 --- /dev/null +++ b/02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md @@ -0,0 +1,83 @@ +--- +topic: the mesh +status: accepted +date: 2026-10-07 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md +--- + +# 246. A seat's new verb is promised before it is required + +## Context + +[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) made serving a seat's verbs a +condition of holding it. [Design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §7 says +those verbs change "additively within a version". The controller's claim check enforces two things: a +holder serves every verb the seat promises, and a claim names no verb the seat does not promise. + +A mesh seat is defined in the controller, and its holders live in the catalogue. On 2026-10-07 three +verbs were added this way: `checks` on `mesh-delivery`, `failed` on `node-service-manager`, and +`resolvers` and `links` on `node-uplink`. Each time, neither repository could merge first +([issue 298](../04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md)). The new +controller refused the old holder for not serving the verb. The old controller refused the new holder for +claiming a verb it did not promise. Every catalogue check between the two failed on a module nobody had +touched. The controller was unblocked by marking the added verb optional (mesh-controller #117, kept +optional in a stored seat row by #114). + +## Options + +1. **Drop the check that refuses an unpromised verb.** The catalogue could then land first. But the check + catches a misspelt verb that would otherwise be served to nobody. The controller's change would still + refuse every holder not yet rebuilt, so only one order opens. +2. **A new seat version for every added verb.** §7 already allows a version for a change that would + break a caller. Running two versions side by side to add one verb costs a set of subjects, grants and + a retirement for nothing a caller would notice. +3. **Land both repositories at once.** Nothing joins two checked pull requests into one merge, and the + mesh would have to run both builds at the same moment on every machine. +4. **Promise first, require later.** The controller promises the verb as optional. Then the holder + serves it. Then the controller makes it required. + +## Decision + +Option 4. **A verb added to a seat whose holder lives in another repository lands in three steps, each +in its own pull request:** + +1. **The controller promises it, optional.** A claim that serves it is accepted, and a holder that does + not serve it still holds the seat. The mark comes from the seat as compiled and survives a seat row + read back from the store. +2. **Every holder serves it.** Each holder is checked against a controller that already promises the + verb. +3. **The controller requires it.** The mark is removed, and serving the verb becomes a condition of + holding like every other verb of the seat. + +A verb on a new seat, or on a seat that nothing holds yet, is required from the start. Removing a verb +or changing its arguments in a way that breaks a caller is still a new version (§7). + +## Consequences + +- Adding a verb to a held seat takes two controller changes and one in each holder's repository, where + design 33 implied one change. That is the price of §3 holding at every moment in between. +- Between steps 1 and 3, a caller can be told that a holder does not answer the verb. The caller finds + this out from the holder, not from the seat's protocol. Discovery lists the verb, because it is + promised. +- Nothing yet checks that step 3 happens. A verb left optional is a promise that no future holder has to + keep. Issue 298 is not resolved until a check covers it. + +## How it is checked + +- The controller's claim check refuses a claim that serves an unpromised verb, and a holder that misses a + required verb. It runs at registration, at handover, and in `module check`, which the catalogue's merge + check runs over every manifest with the controller the mesh runs. +- Tests in mesh-controller: + - `TestTheDeliverySeatPromisesChecks` (#117); + - `TestFailedIsAnOptionalVerbOfTheServiceManager`, `TestAnOptionalVerbStaysOptionalInASetReadFromTheStore` + and `TestASeatsVerbGainsTheArgumentsTheBinaryNames` (#114); + - `TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet` and + `TestTheUplinkVerbsReadBackFromTheRowStayOptional` (#116). + +## References + +- [Issue 298](../04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md) +- [Design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §3 and §7 +- mesh-controller #114, #116 and #117; mesh-catalog #105, #107 and #109 diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index bb8baca5..ccdca027 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -208,6 +208,7 @@ python3 00-META/checks/index.py fail if stale - **0237** — [A change is judged against the mesh that runs, before it merges, on the build seat](0237-a-change-is-judged-against-the-mesh-that-runs-before-it-merges-on-the-build-seat.md) - **0238** — [A commit is the build at hand: one commit, one change plan, checked off the trunk and published only on it](0238-a-commit-is-the-build-at-hand-one-commit-one-change-plan-checked-off-the-trunk-and-published-only-on-it.md) - **0239** — [A delivery is owned by the mesh-delivery module and runs from commit to delivered](0239-a-delivery-is-owned-by-the-mesh-delivery-module-and-runs-from-commit-to-delivered.md) +- **0246** — [A seat's new verb is promised before it is required](0246-a-seats-new-verb-is-promised-before-it-is-required.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md b/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md index 5d0603e9..5b6527e9 100644 --- a/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md +++ b/03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md @@ -2,8 +2,9 @@ layer: to-be status: implemented code: [mesh-controller, mesh-tools] -updated: 2026-10-02 +updated: 2026-10-07 decisions: + - 02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md - 02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md - 02-DECISIONS/0179-the-intrusion-seat-serves-its-verbs-and-every-door-declares-its-jail.md - 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md @@ -131,6 +132,24 @@ A seat's tools are an interface and change like one. Additive within a version. break a caller takes the version token the subject already has room for, and the two versions run side by side until nothing is bound to the old one. +*Amended 2026-10-07 ([ADR 0246](../../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md), +[issue 298](../../04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md)):* +**additive takes two steps when the seat and its holder are in different repositories.** That is the +case for every mesh seat whose holder is a catalogue module. §3 makes every promised verb a condition of +holding, and a claim may name no verb the seat does not promise. So a verb added to the seat in the +controller refuses today's holder, and a holder that serves it is refused by today's controller. Neither +change can land first. An added verb therefore lands in three steps, each its own pull request: + +1. **The controller promises the verb as optional.** A holder that serves it is accepted, and one that + does not yet serve it still holds the seat. The mark comes from the seat as compiled, so a seat row + read back from the store keeps it. +2. **Every holder serves it**, checked against a controller that already promises it. +3. **The controller requires it.** The mark is removed, and the verb is a condition of holding like the + rest. + +A verb on a seat that nothing holds yet is required from the start. A change that breaks a caller is +still a new version. + ## How it is checked - **A holder missing a verb cannot take the seat.** One test per condition of holding, as the existing @@ -142,6 +161,16 @@ by side until nothing is bound to the old one. answer equals what the seats declare — if it needed a module up, it would not be a read. - **Two nodes holding one node-scoped seat derive two addresses.** Checked by the same test as the rest of the subject table. +- **An added verb lands in steps, and each step is checked (§7).** The controller's claim check refuses + a claim that serves a verb the seat does not promise, and a holder that misses a required verb. It runs + at registration, at handover, and in `module check`, which the catalogue's merge check runs over every + manifest with the controller the mesh runs. So a step out of order fails the pull request that took + it. That an optional verb stays optional when read back from the store is held by mesh-controller's + `TestAnOptionalVerbStaysOptionalInASetReadFromTheStore` (#114), and that a seat's row gains a verb's + new arguments by `TestASeatsVerbGainsTheArgumentsTheBinaryNames` (#114). Each added verb has its own + test that it is promised and not yet required: `TestTheDeliverySeatPromisesChecks` (#117) and + `TestFailedIsAnOptionalVerbOfTheServiceManager` (#114). **Not yet checked:** that step 3 happens. A + verb left optional binds no future holder ([issue 298](../../04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/01-diagnosis.md)). ## What is built, 2026-09-30 diff --git a/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md b/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md new file mode 100644 index 00000000..64363ce5 --- /dev/null +++ b/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/00-report.md @@ -0,0 +1,68 @@ +--- +status: located +opened: 2026-10-07 +located-in: [mesh-controller internal/catalogue (seats.go, verbs.go), mesh-catalog (the seats' holders)] +fixed-by: mesh-controller PR #117, mesh-controller PR #114 +amended-design: 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md +--- + +# 298. A new seat verb deadlocks across two repositories + +## Symptom + +[Design 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §7 says a seat's verbs are +"additive within a version". §3 says a module may not hold a seat unless it serves every verb the seat +declares. A mesh seat's verbs are defined in the controller, and the modules that hold it live in the +catalogue. Under those two rules, adding a verb cannot land in one step, in either order: + +- **The controller first.** The new controller promises the verb. The holder in the catalogue does not + serve it yet, so the claim check refuses the holder ("claims … but does not serve …, which that + seat's protocol promises"). Every check of the catalogue run against that controller fails on a module + nobody touched. +- **The catalogue first.** The new holder's claim says it serves the verb. The controller the mesh runs + does not promise it, so the same check refuses the claim ("says it serves …, which that seat's + protocol does not promise"). The catalogue's merge check runs every manifest through the controller + the mesh runs, so the pull request cannot pass. + +Each repository's change waits on the other's, and neither can merge first. + +## Evidence + +On 2026-10-07 this happened three times, once for each verb added to a mesh seat that day: + +| Seat | Verb | Controller | Catalogue | +|---|---|---|---| +| `mesh-delivery` | `checks` | mesh-controller #117 | mesh-catalog #109 | +| `node-service-manager` | `failed` (until then the systemd module's own tool) | mesh-controller #114 | mesh-catalog #105 | +| `node-uplink` | `resolvers`, `links` | mesh-controller #116 | mesh-catalog #107 | + +Each pair was unblocked the same way. The controller's `Verb` gained an `Optional` mark (#117): an +optional verb is promised, so a claim that serves it is accepted, but it is not required, so a holder +that does not serve it still holds the seat. `failed` was first written as required and refused today's +holder. #114 made it optional and fixed a second hole. The mark was never stored, so a verb seeded into +a seat row came back required when the row was read. The working set now takes the mark from the +compiled seat. #116 adds the uplink verbs as optional from the start. + +## Why it is a design issue + +Design 33 says what may change (additively) but not how a change crosses the two repositories that +share a seat. §3's condition of holding, enforced as designed, makes "additive" impossible whenever the +seat's definition and its holder are in different repositories. That is the case for every mesh seat +whose holder is a catalogue module. The rule that settles it is now in §7, by +[ADR 0246](../../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md): the +controller first promises the verb as optional, the holder serves it, and the controller requires it. + +## How it is checked + +- The controller's claim check (`internal/catalogue/seats.go`) refuses both an unpromised verb and a + missing required one. It runs at registration, at handover, and in `module check`, which the + catalogue's merge check runs over every manifest. +- Tests in the controller: + - `TestTheDeliverySeatPromisesChecks` (#117); + - `TestFailedIsAnOptionalVerbOfTheServiceManager`, `TestAnOptionalVerbStaysOptionalInASetReadFromTheStore` + and `TestASeatsVerbGainsTheArgumentsTheBinaryNames` (#114); + - `TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet` and + `TestTheUplinkVerbsReadBackFromTheRowStayOptional` (#116, open). + +The trail is in [01-diagnosis.md](01-diagnosis.md). This is a core issue: at resolution it names its +replay, or says in `replay-none:` why none is possible (ADR 0237). diff --git a/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/01-diagnosis.md b/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/01-diagnosis.md new file mode 100644 index 00000000..2ac829d6 --- /dev/null +++ b/04-ISSUES/298-a-new-seat-verb-deadlocks-across-two-repositories/01-diagnosis.md @@ -0,0 +1,46 @@ +# 298 — diagnosis + +## 2026-10-07: the two refusals + +Both refusals are in one function of the controller, the claim check in `internal/catalogue/seats.go`. +After scope and provision, it checks two things about a claim: + +1. `unservedVerbs`: every verb the seat's protocol promises is among what the claimant serves, under + the claim's `serves` or among its own tools. This is design 33 §3 and ADR 0132. +2. `unpromised`: everything the claim's `serves` names is a verb the protocol promises. A verb nobody + promised is served to nobody, so this catches a typo before a caller finds it as a timeout. + +The protocol of a mesh seat is compiled into the controller and seeded into the store. Its holders are +catalogue modules. Check 1 refuses the old holder against the new controller, and check 2 refuses the new +holder against the old controller. The catalogue's merge check runs `module check` over every manifest +with the controller the mesh runs (`MESH_GATE`), so either order fails a check on one side. + +Ruled out: + +- **Dropping check 2.** Without it, a claim could name verbs that nothing promises, and the typo it + catches would come back. It would also only open one order (catalogue first). The controller's change + would still refuse every holder that is not yet rebuilt. +- **One commit in both repositories.** The two repositories merge separately, each through its own + checked pull request (ADR 0238). Nothing joins two pull requests into one merge. +- **A new seat version (§7's second sentence).** A version is for a change that would break a caller, + and running two versions side by side for an added verb costs more than the verb. + +## 2026-10-07: the mark, and the store + +`Verb.Optional` (mesh-controller #117) makes check 1 skip a verb that is marked, while check 2 still +counts it as promised. With the mark, the controller lands first and the old holder still holds the seat. +The holder that serves the verb then lands against a controller that promises it. Once every holder +serves it, removing the mark makes it a condition of holding like any other verb. + +#114 found two holes in the store. First, re-seeding a seat row added a verb but never a new argument to +an existing verb, so the journal window's arguments would never have reached the row the mesh MCP server +reads. Second, the mark has `json:"-"` and is never stored, so a seeded verb came back required and the +deadlock returned on any mesh whose seat rows already existed. The working set now takes the mark from +the compiled seat (`optionalAsCompiled`), and a row's verb gains the arguments the binary names. + +## What is still not checked + +Nothing checks that the mark is ever removed. A verb that stays optional after every holder serves it is +a promise that no future holder has to keep. The design now names the third step. A check that fails +when a holder of the mesh already serves an optional verb, or one that fails after a date, would close +this. Both are left to the controller, and are the reason this issue stays `located`.