@bongos/core 1.21.98 → 1.21.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.98",
6
- "core_contract": "1.21.98",
7
- "source_commit": "e830432e8980019fb401fc5aa8adf72bcca01811",
5
+ "core_version": "1.21.99",
6
+ "core_contract": "1.21.99",
7
+ "source_commit": "262e039f7737aee04e24db9559e593544fab5532",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T11:44:35.365Z",
9
+ "built_at": "2026-10-09T12:05:36.533Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 590,
12
+ "docs_redacted": 591,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3017,
14
+ "functional_verbatim": 3019,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3636,
20
- "tree_sha256": "10aaec2a3eabaf5dd793204d82ecbfcb2fbc61dfde61a60d2a3d318c3f9503c5",
19
+ "file_count": 3639,
20
+ "tree_sha256": "6627907aa4a62c0516525dd3273bf188497dd8914388d4fcd5435d4563149bbf",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2309,10 +2309,15 @@
2309
2309
  "mode": "0000644",
2310
2310
  "sha256": "d4963a203bce58f5d5f422bab582e022bf2d2b33eaa854c0ee80b6b76ccff1ee"
2311
2311
  },
2312
+ {
2313
+ "path": "docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md",
2314
+ "mode": "0000644",
2315
+ "sha256": "c23bc85e0e4e925911e7f7c3b2914b8e8dea8db725bd7c8d7840f4dade1f02a6"
2316
+ },
2312
2317
  {
2313
2318
  "path": "docs/adr/README.md",
2314
2319
  "mode": "0000644",
2315
- "sha256": "3a58f45b80d4311b5ae5458bc7d564301978b0cf3ba5c72c6244bc1ef6356e89"
2320
+ "sha256": "a010bc0f1d8a26126c5b86c24a4a0e548e038c651909e2b27d5fbdc7cbdd6f94"
2316
2321
  },
2317
2322
  {
2318
2323
  "path": "docs/api-reference.md",
@@ -2862,7 +2867,7 @@
2862
2867
  {
2863
2868
  "path": "docs/module-api-changelog.md",
2864
2869
  "mode": "0000644",
2865
- "sha256": "7d7a017bb5c1bb3a0af17cd04a2b747a9df2a2fdb26879cb431096eb6d4cf9c0"
2870
+ "sha256": "d43b9ee9d1bae22277b8e9b9d425aafef3d1c2c5298814fd6a1a319e632917b7"
2866
2871
  },
2867
2872
  {
2868
2873
  "path": "docs/modules-contract.md",
@@ -10122,12 +10127,12 @@
10122
10127
  {
10123
10128
  "path": "package-lock.json",
10124
10129
  "mode": "0000644",
10125
- "sha256": "8430c9bf8e4cfbf413316f9b99f85ddfb2dca688374f65373c3959d3f5d67d1a"
10130
+ "sha256": "743e74b9cb354d11b19bb40b81f9640932ac68730487816569ee5796875392a4"
10126
10131
  },
10127
10132
  {
10128
10133
  "path": "package.json",
10129
10134
  "mode": "0000644",
10130
- "sha256": "ff69b26fc946bd74d06693048dd4cb7495107d8ee6fef45b45d5c678bce417a5"
10135
+ "sha256": "e74953a3c03fb015d7f54229580414d430cff7159d5361dca7754eb28d852310"
10131
10136
  },
10132
10137
  {
10133
10138
  "path": "public-docs/index.html",
@@ -10147,7 +10152,7 @@
10147
10152
  {
10148
10153
  "path": "release-notes.json",
10149
10154
  "mode": "0000644",
10150
- "sha256": "feedbd41f5e5808a38ee9478e01a5060197e78ea317797cee594931e24aeb8bc"
10155
+ "sha256": "76a1303dec3ba5b72f9a7eb03de1896d6dafc040c8c9260af6c7b8fe2a6e58cf"
10151
10156
  },
10152
10157
  {
10153
10158
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10829,6 +10834,11 @@
10829
10834
  "mode": "0000644",
10830
10835
  "sha256": "320841b1e4df014966ea9b6b70645ecb7915b412f94b8fa0d9835dae5b9446c5"
10831
10836
  },
10837
+ {
10838
+ "path": "scripts/gds/government-walk.js",
10839
+ "mode": "0000644",
10840
+ "sha256": "f7a6091ce0621fdfdf69f667e44e52aaf96f6a1222f52f9e5730ff003e162383"
10841
+ },
10832
10842
  {
10833
10843
  "path": "scripts/gds/grade-correlation-audit.js",
10834
10844
  "mode": "0000644",
@@ -12417,7 +12427,7 @@
12417
12427
  {
12418
12428
  "path": "src/module-api.js",
12419
12429
  "mode": "0000644",
12420
- "sha256": "7a9984b85013e6be20e59eb846c78da89ea7f62b650aa3f3c7bf9d3fda539e53"
12430
+ "sha256": "fe5b5772fd9a33f1aa10b93689e32179c6c81d9dbf9a3a33ee4af2383a3bdb02"
12421
12431
  },
12422
12432
  {
12423
12433
  "path": "src/module-loader/catalog.js",
@@ -14554,6 +14564,11 @@
14554
14564
  "mode": "0000644",
14555
14565
  "sha256": "444f0638dccebc42e5a371820ed565c665d4373c198e1581389ab222bf267c5e"
14556
14566
  },
14567
+ {
14568
+ "path": "tests/government_walk.mjs",
14569
+ "mode": "0000644",
14570
+ "sha256": "35d39431886fb2faad9508dd8ca481987760432397464b1b702aef0451de2270"
14571
+ },
14557
14572
  {
14558
14573
  "path": "tests/governor_docket.mjs",
14559
14574
  "mode": "0000644",
@@ -0,0 +1,64 @@
1
+ # ADR 0371 — Auto-pickup applies a submitted tweak on the runner, and the artist still approves
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-10-09
5
+ - **Task:** [task 1004330](https://cloudbongos.com/builders#/task/1004330) (BV2.TW19, goal 1000095, BONGOS-V2)
6
+ - **Builds on:** [ADR 0341](0341-the-page-is-the-unit-of-tweak-mode.md) (Tweak Mode; D7 nothing ships without the artist's approval), [ADR 0307](0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md) (Autobongos: one supervisor, one headless worker per task, a DB fence), [ADR 0369](0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md) (a live knob in `project_settings` behind one resolver)
7
+ - **Decided with:** the owner, 2026-09-27 ("queue now, auto later") and 2026-10-08 (write the design now, without waiting for the live artist proof, task 1004329)
8
+
9
+ ## Context
10
+
11
+ In Tweak Mode an artist rewrites a whole page in the studio and presses submit. The round then waits, at `ready` with no claim, until a builder or agent session runs `/tweak` (`modules/copy-desk/skills/tweak/SKILL.md`). That session claims the round, applies the words with `copy-apply.js --batch`, takes before and after renders, commits and runs `ship.js`. A passed grade is then held for the artist (`page-tweak-hold.js`), who approves it in the Approval queue or sends it back.
12
+
13
+ So an artist with nobody at a terminal waits indefinitely. The task asks for an agent that picks up a submitted page on its own, and names four questions: the trigger, who pays, the loop guard, and the rank it runs at.
14
+
15
+ Two machines in the core could host it:
16
+
17
+ - **The agents module** (`modules/agents/`). It fires on events, from the web server, through `claude -p --tools Read`. That spawn lives in grading (`modules/grading/grader-subagent.js`) and is hardcoded read-only, which is why its agents "may only report". Applying a tweak needs file writes, git, a browser for the page reader and renders, and `ship.js`. Widening the grader's spawn is an edit to a protected path, and the web server process has no worktree to write in.
18
+ - **Autobongos** (ADR 0307, `scripts/gds/autobongos-run.js`). A supervisor on a builder's machine claims a task, starts one headless worker in a worktree, and reads the outcome from the ledger. It already writes code, ships through the normal grade, and is bounded by a DB fence (kill switch, goal allowlist, protected paths, a grader-outage stop), a wall clock and a grade cap.
19
+
20
+ ## Decision
21
+
22
+ **D1 — Auto-pickup is a lane of the Autobongos runner, not an agent.** The runner gains a *tweak lane*. Before it picks ordinary work, it asks for the oldest submitted round and, if the lane is allowed, hands that one page to a worker that runs `/tweak` for it. Everything a person's `/tweak` session does still happens, in the same order, through the same scripts. The worker gains no new power: it runs as the runner's builder, through the claim, grade and land paths every session uses. The agents module stays read-only.
23
+
24
+ **D2 — The trigger is the runner's own poll, not a new event.** Submit emits nothing today, and nothing needs it to: the runner already wakes on its cadence and reads the ledger. The lane reads the same derived state the studio shows (`open_round.state = 'submitted'` from `modules/copy-desk/page-status.js`), oldest first, so the queue order an artist sees is the order it is worked. A round becomes eligible the moment it is submitted; how soon it is picked up is the runner's cadence, which is stated on the studio rather than promised.
25
+
26
+ **D3 — It is off until the project turns it on.** A new project setting, `tweak.autopickup` (`off` | `on`, default `off`), in `project_settings` behind one resolver, as ADR 0369 does for `ship.mode`. Any builder can read it, `project.settings.manage` writes it. The lane also needs everything an Autobongos run already needs: the fence's kill switch open, a grader that is up, and a runner some Metic+ builder has chosen to run. The setting replaces only the **goal allowlist** for this lane, because a tweak round lives in whatever goal holds the version's page work and an artist cannot be expected to know which goal to allowlist. It does not replace any other fence rule.
27
+
28
+ **D4 — It runs at the rank of the builder whose runner it is, and that must be Metic or higher.** There is no new service principal. `/tweak` is Metic+ already (`copy-apply.js` writes a `page-tweak-applied` block, which needs `task.edit`), so a runner below Metic gets the same refusal a person would, and the lane reports it once and stops trying. A bot identity was rejected: `builders.system_role` principals are trusted for identity and never for authorization ([ADR 0033](0033-discord-bot-service-principal.md)), and a new one would be a second way to hold `task.edit` that no person holds.
29
+
30
+ **D5 — Who pays.** The worker's model time is the runner owner's own capacity, exactly as for any Autobongos task: the gauge keeps the owner's reserve and the wall clock (`DEFAULT_WORKER_TIMEOUT_MS`) bounds one worker. The grade is charged to the project, as every grade is. Because a tweak costs the project a grade without a person choosing to spend it, the lane has its own ceiling: **`tweak.autopickup_monthly_cap`**, a count of rounds the lane may pick up per calendar month (default **20**), counted from the ledger, never from a counter that can drift. The lane claims with an `auto_pickup` mark that the server records on the claim row, and **the server enforces the cap at claim time**: the count and the claim insert run in one transaction under a project-wide advisory lock (`page-tweak/autopickup`), so two runners racing for the last slot cannot both get it. At the cap the claim is refused, the lane stops and the studio says so; rounds wait for a person, as today. The mark is a cost brake on a runner the owner chose to run, not a security boundary: a worker that claimed without it would be claiming as its builder, which that builder may already do by hand. **Why 20:** a grade costs the project a few dollars (the `/tweak` skill's own cost line), so 20 rounds is a bounded, legible monthly spend at about the level of one active artist; the owner can raise it. The cost-plus session reward (ADR 0054) is unchanged: it goes to the builder whose session shipped, as for any worktree claim, and the artist's page credit is untouched (ADR 0341 D10).
31
+
32
+ **D6 — The loop guard is per round, and the artist is the gate that matters.**
33
+ 1. **Nothing lands without the artist.** The held pass (`isHeldForArtist`) is unchanged, so the worst an unattended worker can do is offer the artist a bad render to send back. This is why auto-pickup is safe to have at all.
34
+ 2. **Two automatic applies per round, then a person.** A sent-back round returns to `ready` with the artist's note, and the lane may take it again, because the note is the artist's instruction. After the lane has applied a round twice (counted from that round's own claims by the runner), it leaves the round for a person and the studio shows "waiting for a person". The server refuses a third `auto_pickup` claim on the same round, with the same refusal shape as the cap. **Why two:** one apply plus one try at the artist's note; a round sent back twice is a disagreement a person should read, not a third render to approve.
35
+ 3. **A failed apply is not retried until the round changes.** `copy-apply.js` exit 2 (nothing applied) or a refused claim releases the round with a note, and the lane skips it until the artist edits or resubmits it (its `updated_at` moves). The runner's existing set-aside and grade cap (one grade plus one re-grade) still apply on top.
36
+
37
+ **D7 — The fence's other rules are kept, on the page's files.** A round's touches are the page's files from the page inventory. The [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md) protected-path matcher and the host-access check run over those exactly as `decideTask` runs them over a task's touches. A page whose files hit a protected path (the shared shell is the likely one) is left for a person, and the studio says why. The lane never edits a file outside the page's own list; a rewrite that needs one is already its own engineer task (`page-tweak-unplaced`).
38
+
39
+ **D8 — The worker is headless, so `/tweak` gains a non-interactive mode.** Today the skill can stop and ask. The worker cannot. `/tweak --page <page-id> --unattended` takes one named round, never asks, and on any halt (claim refused, nothing applied, ship-check stale, grade unavailable) releases the round with a plain note and exits. It never runs `--skip-grade`, never edits a protected path, and never ships more than the one round it was given.
40
+
41
+ **Those rules are not left to the worker.** The skill states them; the server holds them, so a worker that ignored its instructions gets refused rather than trusted:
42
+ - **No land without the artist.** A page-tweak round reaches `confirmed` only through the artist's approval (`approveHeldTweak`) or the owner's override request, never through a ship path that skips or bypasses the grade. Task 1004678 pins this with a test over every confirm path, and closes any path that does not hold it.
43
+ - **No file outside the page.** At publish, the server reads the merged change's files itself (`ship-files.js`, not the client's report) and refuses an `auto_pickup` round whose diff touches any file outside that page's inventory list, or a protected path.
44
+ - **One round per claim** is the claim model already: a worktree holds one active claim.
45
+
46
+ **D9 — The artist can see it happened.** The Approval queue already names who applied a round ("with help from", ADR 0341 / task 1004432). An automatic apply is marked "applied automatically" there and in the studio's queue line, and the project settings page carries the switch and the month's count against the cap.
47
+
48
+ ## Rejected
49
+
50
+ - **A write-capable agent in `modules/agents/`.** It would need a capability-parametrized spawn in grading (a protected edit) and a worktree, a browser and git inside the web server process. Every guard Autobongos already has would be rebuilt there.
51
+ - **A `page.submitted` event.** Useful only to a host that sleeps between events. The runner polls the ledger, which cannot miss a submit that happened while it was down; an event can.
52
+ - **Landing without the artist when the grade passes.** Contradicts ADR 0341 D7, and it is the artist's approval that makes an unattended apply acceptable.
53
+ - **A per-round dollar cap.** The grade cost is set by the grader, not by the lane. A count of rounds is something the owner can reason about and the ledger can count exactly.
54
+
55
+ ## Consequences
56
+
57
+ - Built by five tasks in goal 1000095:
58
+ - [task 1004677](https://cloudbongos.com/builders#/task/1004677) (TW19a): the setting, the claim mark and the server-enforced cap and per-round limit (D3, D5, D6; needs a migration);
59
+ - [task 1004678](https://cloudbongos.com/builders#/task/1004678) (TW19b): `/tweak --page <id> --unattended` and the server guards under it, after TW19a (D8);
60
+ - [task 1004679](https://cloudbongos.com/builders#/task/1004679) (TW19c): the runner's tweak lane, after the two above (D1, D2, D4, D6, D7);
61
+ - [task 1004680](https://cloudbongos.com/builders#/task/1004680) (TW19d): the studio and settings surfaces (D9);
62
+ - [task 1004681](https://cloudbongos.com/builders#/task/1004681) (TW19e): the live proof, which also waits for the artist proof ([task 1004329](https://cloudbongos.com/builders#/task/1004329)) because it needs a real artist to approve the result.
63
+ - Nothing changes until a project turns `tweak.autopickup` on and a Metic+ builder runs the runner. A project that never does keeps today's queue exactly.
64
+ - An instance with no runner has no auto-pickup. That is deliberate: the core does not run unattended workers in its own server process.
@@ -489,3 +489,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
489
489
  | 0368 | [**Red and blue teams practise on a range, never on live** (goal 1000094 — *Working area 10, Security*; proposed). **D2:** the range is its own small server, rebuilt from the candidate the owner marks "stage for attack". **D3:** it shares no identity hub, secret or data with live. **D5:** red is AI plus Metic+, with one side at a time and a 30-day switch. **D6:** attacks run on subscriptions, never the metered API. **D7:** 3 runs on the newest model with 0 high+ breaks, advisory until an outside tenant exists. **D8:** points only, no credits yet. **D9:** three live signals to `#security-alerts`.](0368-red-and-blue-teams-practice-on-a-range-never-on-live.md) | security / red-team / range |
490
490
  | 0369 | [**Ship mode decides who starts a ship, not how it lands** ([task 1004628](https://cloudbongos.com/builders#/task/1004628), goal 1000106). **D1:** a `ship.mode` project setting, `auto` (the AI ships and lands a finished task with no commands) or `manual` (a person approves each ship), in `project_settings` with no migration. **D2:** Auto is the default, and the resolver reports whether anyone has chosen. **D3:** it is separate from deploy mode (`config/deploy.json`), which decides how a land happens. **D4:** any builder can read it (the AI must know), and `project.settings.manage` writes it, from the settings page or `bongos ship-mode`.](0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md) | lifecycle / shipping / project settings |
491
491
  | 0370 | [**A repo we create for a new project is private** ([task 1004645](https://cloudbongos.com/builders#/task/1004645), goal 1000106). Supersedes ADR 0155 Decision 3: `createUserRepo` sends `private: true`, still not a parameter, and the wizard says the new repo is private. Safe because ADR 0176 already gives a private repo its own read-only deploy key before the first pull; the owner can make it public from the manage page.](0370-a-repo-we-create-is-private.md) | onboarding / security / github |
492
+ | 0371 | [**Auto-pickup applies a submitted tweak on the runner, and the artist still approves** ([task 1004330](https://cloudbongos.com/builders#/task/1004330), goal 1000095, BV2.TW19). **D1:** a tweak lane of the Autobongos runner (ADR 0307), not a write-capable agent: the agents spawn is read-only by construction. **D2:** the trigger is the runner's ledger poll of the oldest `submitted` round, no new event. **D3:** off until a `tweak.autopickup` project setting turns it on; it replaces only the fence's goal allowlist. **D4:** runs as the runner's Metic+ builder, no service principal. **D5:** model time is the runner owner's; grades are the project's, under a monthly round cap (default 20) counted from the ledger. **D6:** the held pass still waits for the artist; two automatic applies per round, then a person; a failed apply waits for the round to change. **D7:** protected-path and host checks run on the page's files. **D8:** `/tweak --unattended`. **D9:** "applied automatically" is shown. Build tasks 1004677–1004681.](0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md) | copy-desk / autonomy / tweak mode |
@@ -3033,5 +3033,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3033
3033
  landed since 1.21.96 with no explicit bump. run 37920956545. (task 1002620)
3034
3034
  1.21.98 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3035
3035
  landed since 1.21.97 with no explicit bump. run 37925528587. (task 1002620)
3036
+ 1.21.99 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3037
+ landed since 1.21.98 with no explicit bump. run 37927704012. (task 1002620)
3036
3038
  ---------------------------------------------------------------------------
3037
3039
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.98",
3
+ "version": "1.21.99",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.98",
9
+ "version": "1.21.99",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.98",
3
+ "version": "1.21.99",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9422,5 +9422,15 @@
9422
9422
  "id": "1004592",
9423
9423
  "text": "When someone restores a cut task or takes a task back off the board, it now stays put until a person promotes it. Before, the automatic promoter put it straight back on the claimable list within about five minutes."
9424
9424
  }
9425
+ ],
9426
+ "1.21.99": [
9427
+ {
9428
+ "id": "1004330",
9429
+ "text": "The plan for applying an artist's submitted page automatically, with nobody at a terminal, is written and broken into five build tasks. It is off until a project turns it on, it has a monthly limit, and the artist still approv"
9430
+ },
9431
+ {
9432
+ "id": "1004658",
9433
+ "text": "There is now one walk anyone can re-run that tests a project's whole government from start to finish, and a table showing the result. It founds a test project with a chosen government and checks that this government, not the f"
9434
+ }
9425
9435
  ]
9426
9436
  }
@@ -0,0 +1,842 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/government-walk.js — THE GOVERNMENT WALK: one repeatable end-to-end
5
+ // pass over a throwaway test project's government, producing a pass/fail table
6
+ // (task 1004658, goal 1000111, criterion gov-proven-and-kept-proven).
7
+ //
8
+ // WHAT IT WALKS, in four sections:
9
+ // 1. Genesis — start founding, decide with a chosen government, close all five
10
+ // stages, come alive; the constitution reads as the choice.
11
+ // 2. Every available form (Monarchy / BDFL, Timocratic Republic, Direct Democracy,
12
+ // Foundation with a council rank) plus the two pass rules no form uses
13
+ // (majority, unanimous): switch to it through a charter amendment, then confirm
14
+ // who can vote and what passes.
15
+ // 3. Every building: set it to decide by vote (a decision-rules amendment), try to
16
+ // act alone on each of its bound routes (refused), restore it (reachable again).
17
+ // 4. Every matter action: open the matter, pass it, confirm the effect happened.
18
+ //
19
+ // HOW REAL IT IS. Every request is real HTTP through the real routers (government,
20
+ // onboarding/genesis, and every building's own router), with the real
21
+ // requirePermission gate resolving the real government port, and every decision is
22
+ // the real code: the board, membership, the tally, the close, the charter apply
23
+ // (written to a throwaway instance folder), the decision-rules apply, act-alone, and
24
+ // each module's matter act. What is NOT real is the storage under them: there is no
25
+ // Postgres on the machine this was built on, so the government module's db layer is
26
+ // an in-memory fake with the same row shapes and the same guards (one open sitting
27
+ // per subject, one vote per member, single-winner close, the decision-rules write
28
+ // only from a passed decision-rules amendment), and the few writes a passed matter
29
+ // makes into other modules are recorded in memory instead of written to a database
30
+ // (or, for a gate approval, sent to GitHub). The table says which mode ran.
31
+ //
32
+ // Usage:
33
+ // node scripts/gds/government-walk.js run it and print the table
34
+ // node scripts/gds/government-walk.js --write also write docs/governance/government-walk.md
35
+ // node scripts/gds/government-walk.js --check exit 1 if the committed table differs
36
+ // tests/government_walk.mjs runs --check, so a change that flips any row reds CI.
37
+
38
+ const fs = require('fs');
39
+ const os = require('os');
40
+ const path = require('path');
41
+
42
+ const ROOT = path.resolve(__dirname, '..', '..');
43
+ const DOC_PATH = path.join(ROOT, 'docs', 'governance', 'government-walk.md');
44
+
45
+ // Bugs this walk found, filed as tasks under goal 1000111. A FAIL row names its task
46
+ // so the table reads as a worklist; a row that starts passing again just passes.
47
+ const KNOWN_BUGS = Object.freeze({});
48
+
49
+ // ── the throwaway project ───────────────────────────────────────────────────
50
+
51
+ // The test project's people. 42 founds it. 47 left the project; it only ever
52
+ // authors the probe ideas, so no probe is decided by its own author.
53
+ const BUILDERS = Object.freeze([
54
+ { id: '42', rank: 'archon', github_login: 'ada', display_name: 'Ada (founder)', status: 'active' },
55
+ { id: '43', rank: 'metic', github_login: 'ben', display_name: 'Ben', status: 'active' },
56
+ { id: '44', rank: 'metic', github_login: 'cy', display_name: 'Cy', status: 'active' },
57
+ { id: '45', rank: 'thetes', github_login: 'dee', display_name: 'Dee', status: 'active' },
58
+ { id: '46', rank: 'xenos', github_login: 'eve', display_name: 'Eve', status: 'active' },
59
+ { id: '47', rank: 'xenos', github_login: 'fay', display_name: 'Fay (left)', status: 'left' },
60
+ ]);
61
+ const ACTIVE_IDS = BUILDERS.filter((b) => b.status === 'active').map((b) => b.id);
62
+ const FOUNDER = '42';
63
+ const COUNCIL = ['43', '44'];
64
+
65
+ // Each bound route as a request that, as today, stops in its handler's own first
66
+ // check (a bad id or body) before any database write — the same requests as
67
+ // tests/government_act_alone_buildings.mjs, with People's two carrying a real
68
+ // custom rank and builder of this project.
69
+ const BAD = 'x';
70
+ const REQUESTS = Object.freeze({
71
+ 'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
72
+ 'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
73
+ 'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
74
+ 'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
75
+ 'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
76
+ 'POST /security/reports/:id/fix': ['POST', `/security/reports/${BAD}/fix`, {}],
77
+ 'POST /government/assignments': ['POST', '/government/assignments', { builder_id: 46, rank_key: 'tester' }],
78
+ 'DELETE /government/assignments/:builderId/:rankKey': ['DELETE', '/government/assignments/46/tester'],
79
+ 'PATCH /access-requests/:id': ['PATCH', `/access-requests/${BAD}`, { status: 'invited' }],
80
+ 'POST /access-requests/invite': ['POST', '/access-requests/invite', {}],
81
+ 'POST /goals': ['POST', '/goals', {}],
82
+ 'POST /goals/reorder': ['POST', '/goals/reorder', {}],
83
+ 'POST /work-categories': ['POST', '/work-categories', {}],
84
+ 'PUT /versions/focus': ['PUT', '/versions/focus', { version_id: ' ' }],
85
+ 'POST /autonomy/fence': ['POST', '/autonomy/fence', { enabled: 'yes' }],
86
+ 'POST /autonomy/fence/goals': ['POST', '/autonomy/fence/goals', { goal_id: 0 }],
87
+ 'DELETE /autonomy/fence/goals/:goalId': ['DELETE', `/autonomy/fence/goals/${BAD}`],
88
+ 'POST /autonomy/fence/priority': ['POST', '/autonomy/fence/priority', {}],
89
+ 'POST /autonomy/builders/:builderId/pause': ['POST', `/autonomy/builders/${BAD}/pause`, { paused: false }],
90
+ 'POST /done-when/:criterionId/satisfy': ['POST', `/done-when/${BAD}/satisfy`, {}],
91
+ 'POST /done-when/:criterionId/unsatisfy': ['POST', `/done-when/${BAD}/unsatisfy`, {}],
92
+ 'POST /gate-approvals/:pr/approve': ['POST', `/gate-approvals/${BAD}/approve`, {}],
93
+ 'POST /override-requests/:id/decide': ['POST', `/override-requests/${BAD}/decide`, { decision: 'approve' }],
94
+ 'POST /provisioning/instances/:id/core-upgrade': ['POST', `/provisioning/instances/${BAD}/core-upgrade`, { to: '1.2.3' }],
95
+ 'POST /provisioning/instances/:id/update-channel': ['POST', `/provisioning/instances/${BAD}/update-channel`, { channel: 'nightly' }],
96
+ 'POST /render-deploy/act': ['POST', '/render-deploy/act', { action: 'launch' }],
97
+ 'POST /npm-release/release': ['POST', '/npm-release/release', { version: 'v-next' }],
98
+ 'PATCH /builders/:id/budget': ['PATCH', `/builders/${BAD}/budget`, { monthly_budget_usd: 5 }],
99
+ });
100
+
101
+ // Every matter action, as the matter the walk opens and passes: [area, title, action,
102
+ // the effect in words]. runWalk observes each effect (its HAPPENED table).
103
+ const MATTER_PLAN = Object.freeze([
104
+ ['people', 'Give Eve the tester rank', { type: 'assign_rank', builder_id: 46, rank_key: 'tester' }, 'Eve holds tester'],
105
+ ['people', 'Take the tester rank from Eve', { type: 'unassign_rank', builder_id: 46, rank_key: 'tester' }, 'Eve no longer holds tester'],
106
+ ['automation', 'Switch unattended building on', { type: 'switch_on' }, 'fence on'],
107
+ ['automation', 'Switch unattended building off', { type: 'switch_off' }, 'fence off'],
108
+ ['automation', 'Allow goal 77 to build unattended', { type: 'allow_goal', goal_id: 77 }, 'goal 77 allowed'],
109
+ ['release', 'Approve override request 5', { type: 'approve_override', request_id: 5 }, 'request 5 approved'],
110
+ ['release', 'Clear the gate on PR 1500', { type: 'approve_gate', pr: 1500 }, 'PR 1500 cleared'],
111
+ ['review', 'Sign off criterion 9', { type: 'satisfy_criterion', criterion_id: 9 }, 'criterion 9 satisfied'],
112
+ ['treasury', 'Dee gets $50 a month', { type: 'set_budget', builder_id: 45, monthly_usd: 50 }, 'Dee\'s limit is $50'],
113
+ ]);
114
+
115
+ // Every router the walk drives, mounted as the server mounts them.
116
+ const ROUTERS = Object.freeze([
117
+ 'modules/government/routes/government.js',
118
+ 'modules/onboarding/routes/onboarding.js',
119
+ 'modules/autonomy/routes/autonomy.js',
120
+ // no HTTP surface; it contributes the Release and Review matter acts, as at boot
121
+ 'modules/lifecycle/routes/lifecycle.js',
122
+ 'modules/lifecycle/routes/done-when.js',
123
+ 'modules/lifecycle/routes/gate-approvals.js',
124
+ 'modules/lifecycle/routes/tasks.js',
125
+ 'modules/lifecycle/routes/goals.js',
126
+ 'modules/lifecycle/routes/versions.js',
127
+ 'modules/lifecycle/routes/project-settings.js',
128
+ 'modules/render-deploy/routes/act.js',
129
+ 'modules/npm-release/routes/release.js',
130
+ 'modules/provisioning/routes/core-upgrade.js',
131
+ 'src/bongos/routes/builders.js',
132
+ 'modules/security/routes/reports.js',
133
+ ]);
134
+
135
+ // ── the in-memory government store ──────────────────────────────────────────
136
+ //
137
+ // The government module's db.js, with the same row shapes and the same guards the
138
+ // SQL carries. Any db function the walk did not expect throws, so a code path that
139
+ // reaches storage this fake does not model shows up as a FAIL rather than a quiet
140
+ // undefined.
141
+ function makeStore(catalog) {
142
+ const s = {
143
+ seq: 100,
144
+ clock: 0,
145
+ builders: new Map(BUILDERS.map((b) => [b.id, { ...b }])),
146
+ customRanks: new Map(),
147
+ assignments: [], // { builder_id, rank_key, assigned_by }
148
+ items: [],
149
+ votes: [],
150
+ amendments: new Map(),
151
+ matters: new Map(),
152
+ rules: new Map(),
153
+ ideas: new Map(),
154
+ tasks: new Map(),
155
+ karma: [],
156
+ unexpected: [],
157
+ };
158
+ const next = () => String(++s.seq);
159
+ const tick = () => `T${String(++s.clock).padStart(4, '0')}`;
160
+ const heldRanks = (id) => {
161
+ const b = s.builders.get(String(id));
162
+ if (!b) return [];
163
+ return [b.rank, ...s.assignments.filter((a) => a.builder_id === String(id)).map((a) => a.rank_key)];
164
+ };
165
+ const isLive = (b) => b && b.status === 'active';
166
+ const copy = (x) => (x == null ? x : JSON.parse(JSON.stringify(x)));
167
+ const withExpiry = (i) => ({ ...copy(i), expired: false, remaining_seconds: null });
168
+ const voteRow = (v) => {
169
+ const b = s.builders.get(v.voter_id) || {};
170
+ return { ...copy(v), github_login: b.github_login || null, display_name: b.display_name || null };
171
+ };
172
+
173
+ const fns = {
174
+ async getPermissionKeysForBuilder(id) {
175
+ const keys = new Set();
176
+ for (const r of heldRanks(id)) for (const k of catalog.RANK_SEED[r] || []) keys.add(k);
177
+ return [...keys];
178
+ },
179
+ async getRankKeysForBuilder(id) { return heldRanks(id).sort(); },
180
+ async getRankByKey(k) {
181
+ if (catalog.RANK_SEED[k]) return { rank_key: k, label: k, is_system: true, seeded_rank: k };
182
+ return copy(s.customRanks.get(k)) || null;
183
+ },
184
+ async createRank({ rankKey, label, description, createdBy }) {
185
+ if (catalog.RANK_SEED[rankKey] || s.customRanks.has(rankKey)) throw Object.assign(new Error('dup'), { code: '23505' });
186
+ const row = { rank_key: rankKey, label, description: description ?? null, is_system: false, seeded_rank: null, created_by: String(createdBy) };
187
+ s.customRanks.set(rankKey, row);
188
+ return copy(row);
189
+ },
190
+ async assignRank({ builderId, rankKey, assignedBy }) {
191
+ const id = String(builderId);
192
+ if (!s.builders.has(id)) throw Object.assign(new Error('fk'), { code: '23503' });
193
+ if (s.assignments.some((a) => a.builder_id === id && a.rank_key === rankKey)) return false;
194
+ s.assignments.push({ builder_id: id, rank_key: rankKey, assigned_by: assignedBy == null ? null : String(assignedBy) });
195
+ return true;
196
+ },
197
+ async unassignRank({ builderId, rankKey }) {
198
+ const before = s.assignments.length;
199
+ s.assignments = s.assignments.filter((a) => !(a.builder_id === String(builderId) && a.rank_key === rankKey));
200
+ return s.assignments.length < before;
201
+ },
202
+ async listAssignments() { return copy(s.assignments); },
203
+ async getActiveBuildersWithRank(rankKeys) {
204
+ return [...s.builders.values()]
205
+ .filter((b) => isLive(b) && heldRanks(b.id).some((r) => rankKeys.includes(r)))
206
+ .sort((a, b) => Number(a.id) - Number(b.id))
207
+ .map((b) => ({ builder_id: b.id, github_login: b.github_login, display_name: b.display_name }));
208
+ },
209
+ async builderHoldsRankActive(id, rankKeys) {
210
+ const b = s.builders.get(String(id));
211
+ return isLive(b) && heldRanks(id).some((r) => rankKeys.includes(r));
212
+ },
213
+ async countActiveBuilders() { return [...s.builders.values()].filter(isLive).length; },
214
+ async openBoardItem({ subjectType, subjectId, openedBy = null, constitution, windowMinutes = null }) {
215
+ if (s.items.some((i) => i.subject_type === subjectType && i.subject_id === String(subjectId) && !i.closed_at)) return null;
216
+ const row = {
217
+ id: next(), subject_type: subjectType, subject_id: String(subjectId),
218
+ opened_by: openedBy == null ? null : String(openedBy), opened_at: tick(),
219
+ closes_at: windowMinutes == null ? null : `in ${windowMinutes} minutes`,
220
+ closed_at: null, outcome: null, constitution: copy(constitution || {}),
221
+ };
222
+ s.items.push(row);
223
+ return copy(row);
224
+ },
225
+ async getOpenBoardItem(type, subjectId) {
226
+ return copy(s.items.find((i) => i.subject_type === type && i.subject_id === String(subjectId) && !i.closed_at)) || null;
227
+ },
228
+ async getBoardItemForVote(id) {
229
+ const i = s.items.find((x) => x.id === String(id));
230
+ return i ? withExpiry(i) : null;
231
+ },
232
+ async getBoardItemAuthor(item) {
233
+ if (!item) return null;
234
+ if (item.subject_type === 'full_idea') {
235
+ const idea = s.ideas.get(String(item.subject_id));
236
+ return idea ? idea.captured_by : null;
237
+ }
238
+ return item.opened_by ?? null;
239
+ },
240
+ async insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null }) {
241
+ if (s.votes.some((v) => v.item_id === String(itemId) && v.voter_id === String(voterId))) return null;
242
+ const row = { id: next(), item_id: String(itemId), voter_id: String(voterId), direction, reason_md: reasonMd, section_key: sectionKey, cast_at: tick() };
243
+ s.votes.push(row);
244
+ return copy(row);
245
+ },
246
+ async getBoardVote(itemId, voterId) {
247
+ return copy(s.votes.find((v) => v.item_id === String(itemId) && v.voter_id === String(voterId))) || null;
248
+ },
249
+ async insertVoteKarma({ voterId, itemId, delta }) { s.karma.push({ builder_id: String(voterId), reason: `vote:${itemId}`, delta }); },
250
+ async getVotesForItem(id) { return s.votes.filter((v) => v.item_id === String(id)).map(copy); },
251
+ async getVotesForItems(ids) { const want = ids.map(String); return s.votes.filter((v) => want.includes(v.item_id)).map(voteRow); },
252
+ async closeBoardItem({ itemId, outcome, afterClose = null }) {
253
+ const i = s.items.find((x) => x.id === String(itemId) && !x.closed_at);
254
+ if (!i) return null;
255
+ // A transaction: the close and whatever rides it land together or not at all.
256
+ const rulesBefore = new Map(s.rules);
257
+ i.closed_at = tick();
258
+ i.outcome = outcome;
259
+ if (typeof afterClose === 'function') {
260
+ try {
261
+ await afterClose({ query: async () => { throw new Error('walk store: no raw SQL inside a close'); } }, copy(i));
262
+ } catch (err) {
263
+ i.closed_at = null;
264
+ i.outcome = null;
265
+ s.rules = rulesBefore;
266
+ throw err;
267
+ }
268
+ }
269
+ return copy(i);
270
+ },
271
+ async createAmendment({ proposed, rationaleMd = null, proposedBy, kind = 'constitution' }) {
272
+ const row = { id: next(), proposed: copy(proposed), rationale_md: rationaleMd, proposed_by: String(proposedBy), kind };
273
+ s.amendments.set(row.id, row);
274
+ return copy(row);
275
+ },
276
+ async getAmendment(id) { return copy(s.amendments.get(String(id))) || null; },
277
+ async listAmendmentHistory(limit = 50) {
278
+ return s.items
279
+ .filter((i) => i.subject_type === 'constitutional_amendment' && i.closed_at)
280
+ .sort((a, b) => (a.closed_at < b.closed_at ? 1 : -1))
281
+ .slice(0, limit)
282
+ .map((i) => {
283
+ const a = s.amendments.get(i.subject_id) || {};
284
+ return {
285
+ item_id: i.id, amendment_id: i.subject_id, proposed: copy(a.proposed), rationale_md: a.rationale_md,
286
+ proposed_by: a.proposed_by, opened_at: i.opened_at, closed_at: i.closed_at, outcome: i.outcome,
287
+ decided_under: copy(i.constitution), kind: a.kind || 'constitution',
288
+ };
289
+ });
290
+ },
291
+ async listDecisionRules() { return [...s.rules.values()].map(copy); },
292
+ // The SQL's guard: written only by a PASSED decision-rules amendment item.
293
+ async applyDecisionRules(_client, { itemId, rules }) {
294
+ const i = s.items.find((x) => x.id === String(itemId));
295
+ const a = i && s.amendments.get(i.subject_id);
296
+ const allowed = i && a && i.subject_type === 'constitutional_amendment' && i.outcome === 'passed' && a.kind === 'decision_rules';
297
+ if (!allowed) throw new Error(`decision rules not written (item ${itemId} is not a passed decision-rules amendment)`);
298
+ for (const r of rules) {
299
+ s.rules.set(r.area, { area: r.area, decided_by: r.decided_by, rule: r.rule, who: r.who, set_by_item_id: String(itemId), updated_at: tick() });
300
+ }
301
+ return rules.length;
302
+ },
303
+ async createMatter({ area, title, detailMd = null, action = null, proposedBy }) {
304
+ const row = { id: next(), area, title, detail_md: detailMd, action: copy(action), proposed_by: String(proposedBy) };
305
+ s.matters.set(row.id, row);
306
+ return copy(row);
307
+ },
308
+ async getMatter(id) { return copy(s.matters.get(String(id))) || null; },
309
+ async getMatterSubjects(ids) { return ids.map((id) => s.matters.get(String(id))).filter(Boolean).map(copy); },
310
+ async getTaskStatuses(ids) {
311
+ return ids.map(String).filter((id) => s.tasks.has(id)).map((id) => ({ id, status: s.tasks.get(id).status }));
312
+ },
313
+ async listLatestGenesisStageItems() {
314
+ const latest = new Map();
315
+ for (const i of s.items.filter((x) => x.subject_type === 'genesis_stage')) latest.set(i.subject_id, i);
316
+ return [...latest.values()].map(withExpiry);
317
+ },
318
+ async insertAuthorRatifyKarma({ authorId, itemId, delta }) {
319
+ const reason = `board.item.ratified:${itemId}`;
320
+ if (s.karma.some((k) => k.reason === reason)) return false;
321
+ s.karma.push({ builder_id: String(authorId), reason, delta });
322
+ return true;
323
+ },
324
+ async getIdeaCompleteness(id) { const idea = s.ideas.get(String(id)); return idea ? idea.completeness : null; },
325
+ };
326
+ return { s, fns };
327
+ }
328
+
329
+ function installStore(govDb, fns, unexpected) {
330
+ for (const k of Object.keys(govDb)) {
331
+ if (typeof govDb[k] !== 'function') continue;
332
+ govDb[k] = fns[k] || (async () => {
333
+ unexpected.push(k);
334
+ throw new Error(`walk store: unexpected db call ${k}`);
335
+ });
336
+ }
337
+ }
338
+
339
+ // ── the walk ────────────────────────────────────────────────────────────────
340
+
341
+ async function runWalk() {
342
+ // The constitution is host content: point the instance root at a throwaway folder
343
+ // BEFORE anything reads it, so a ratified charter is written there (the real
344
+ // applyBoardAmendment) and never into this checkout.
345
+ const instanceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'government-walk-'));
346
+ process.env.BONGOS_INSTANCE_ROOT = instanceRoot;
347
+ delete process.env.DATABASE_URL; // the walk never touches a real database
348
+ // The modules' info lines (a fence switched, a budget set) would bury the table.
349
+ if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn';
350
+
351
+ const req = (rel) => require(path.join(ROOT, rel));
352
+ const api = req('src/module-api.js');
353
+ const seams = req('src/module-seams.js');
354
+ const authMod = req('src/bongos/auth.js');
355
+ const projectSettings = req('src/bongos/project-settings.js');
356
+ const { attachFail } = req('src/bongos/middleware/error-envelope.js');
357
+ const catalog = req('modules/government/catalog.js');
358
+ const govDb = req('modules/government/db.js');
359
+ const config = req('modules/government/config.js');
360
+ const board = req('modules/government/board.js');
361
+ const charter = req('modules/government/charter.js');
362
+ const membership = req('modules/government/board-membership.js');
363
+ const dr = req('modules/government/decision-rules.js');
364
+ const genesis = req('modules/onboarding/genesis-home.js');
365
+ const autoDb = req('modules/autonomy/db.js');
366
+ const lifeDb = req('modules/lifecycle/db.js');
367
+ const doneWhen = req('modules/lifecycle/done-when.js');
368
+ const doneWhenRoutes = req('modules/lifecycle/routes/done-when.js');
369
+ const gateRoutes = req('modules/lifecycle/routes/gate-approvals.js');
370
+ const express = require('express');
371
+ const http = require('http');
372
+
373
+ const { s: store, fns } = makeStore(catalog);
374
+ installStore(govDb, fns, store.unexpected);
375
+
376
+ // Project settings (founding mode + the plan) live in memory.
377
+ const settings = new Map();
378
+ projectSettings.getSetting = async (k) => (settings.has(k) ? { key: k, value: settings.get(k) } : null);
379
+ projectSettings.setSetting = async (k, v) => { settings.set(k, String(v)); return { key: k, value: String(v) }; };
380
+
381
+ // What passed matters write into other modules, recorded.
382
+ const effects = { fence: null, allowedGoals: [], overrides: [], gates: [], criteria: new Map([['9', false]]), budgets: new Map() };
383
+ autoDb.setFenceEnabled = async ({ enabled }) => { effects.fence = enabled; return { enabled }; };
384
+ autoDb.allowGoal = async ({ goalId }) => { effects.allowedGoals.push(String(goalId)); return { goal_id: goalId }; };
385
+ lifeDb.decideOverrideRequest = async ({ requestId, decision }) => { effects.overrides.push([String(requestId), decision]); return {}; };
386
+ gateRoutes.approveGatePr = async ({ number }) => { effects.gates.push(String(number)); return { ok: true, body: {} }; };
387
+ doneWhen.getCriterion = async (id) => (effects.criteria.has(String(id)) ? { id, satisfied: effects.criteria.get(String(id)) } : null);
388
+ doneWhenRoutes.satisfyByOverride = async ({ criterionId }) => { effects.criteria.set(String(criterionId), true); return { updated: {}, goalResult: {} }; };
389
+
390
+ // Ports the routers would otherwise reach a database for: the genesis seeder and
391
+ // the Treasury write. Registered first, so the routers' own registrations step aside.
392
+ const STAGE_TASK_IDS = { scope: '9001', strategize: '9002', charter: '9003', legalize: '9004', legislation: '9005' };
393
+ seams.registerProvider('lifecycle.kickoffSeed', {
394
+ seedGenesis: async () => {
395
+ for (const [stage, id] of Object.entries(STAGE_TASK_IDS)) store.tasks.set(id, { id, stage, title: `${stage} (walk)`, status: 'ready' });
396
+ return { seeded: 5 };
397
+ },
398
+ genesisTasks: async () => {
399
+ const out = {};
400
+ for (const t of store.tasks.values()) (out[t.stage] = out[t.stage] || []).push({ id: t.id, title: t.title, status: t.status });
401
+ return out;
402
+ },
403
+ });
404
+ seams.registerProvider('reward', {
405
+ setBuilderMonthlyBudget: async (id, usd) => {
406
+ if (!store.builders.has(String(id))) return null;
407
+ effects.budgets.set(String(id), usd);
408
+ return { id, monthly_budget_usd: usd };
409
+ },
410
+ });
411
+
412
+ // The session: whoever the request says it is, from this project's people.
413
+ const asHeader = 'x-walk-builder';
414
+ const fakeBuilder = (r, res, nextFn) => {
415
+ const b = store.builders.get(String(r.headers[asHeader] || ''));
416
+ if (!b || b.status !== 'active') return res.status(401).json({ error: { code: 'not_signed_in' } });
417
+ r.builder = { id: Number(b.id), rank: b.rank, github_login: b.github_login, display_name: b.display_name };
418
+ return nextFn();
419
+ };
420
+ api.requireBuilder = fakeBuilder;
421
+ authMod.requireBuilder = fakeBuilder;
422
+
423
+ const app = express();
424
+ app.use(express.json());
425
+ app.use(attachFail);
426
+ for (const rel of ROUTERS) app.use(req(rel)());
427
+ const server = http.createServer(app);
428
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
429
+ const BASE = `http://127.0.0.1:${server.address().port}`;
430
+
431
+ async function call(as, method, p, body) {
432
+ const res = await fetch(`${BASE}${p}`, {
433
+ method,
434
+ headers: { [asHeader]: as, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
435
+ body: body === undefined ? undefined : JSON.stringify(body),
436
+ });
437
+ let json = null;
438
+ try { json = await res.json(); } catch (_) { /* no body */ }
439
+ return { status: res.status, json };
440
+ }
441
+ const code = (r) => (r.json && r.json.error && r.json.error.code) || null;
442
+ const vote = (as, itemId, direction = 'yes') => call(as, 'POST', `/government/board/items/${itemId}/votes`,
443
+ direction === 'yes' ? { direction } : { direction, reason_md: 'Not yet: the walk objects.', section_key: 'purpose' });
444
+ const itemOf = (id) => store.items.find((i) => i.id === String(id)) || null;
445
+ const constitution = async () => (await call(FOUNDER, 'GET', '/government/constitution')).json;
446
+ const live = () => config.loadGovernmentConfig().board;
447
+ const stateOf = (id) => { const i = itemOf(id); return !i ? 'missing' : i.closed_at ? i.outcome : 'open'; };
448
+
449
+ // ── the table ──
450
+ const rows = [];
451
+ let current = '';
452
+ const section = (name) => { current = name; };
453
+ async function check(name, expected, fn) {
454
+ let got;
455
+ let pass = false;
456
+ try {
457
+ const out = await fn();
458
+ got = out.got;
459
+ pass = out.pass === true;
460
+ } catch (err) {
461
+ got = `threw: ${err && err.message}`;
462
+ }
463
+ const n = rows.filter((r) => r.section === current).length + 1;
464
+ rows.push({ section: current, key: `${current.split(' ')[0]}${n}`, name, expected, got: String(got), pass });
465
+ return pass;
466
+ }
467
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
468
+
469
+ // Every voter in `voters` votes; returns the state after each vote.
470
+ async function voteAll(itemId, voters, direction = 'yes') {
471
+ const after = [];
472
+ for (const v of voters) {
473
+ const r = await vote(v, itemId, direction);
474
+ after.push(r.status === 201 ? stateOf(itemId) : `${r.status} ${code(r)}`);
475
+ if (stateOf(itemId) !== 'open') break;
476
+ }
477
+ return after;
478
+ }
479
+
480
+ // ── setup: the council and the rank People's acts give ──
481
+ await call(FOUNDER, 'POST', '/government/ranks', { rank_key: 'council', label: 'Council' });
482
+ await call(FOUNDER, 'POST', '/government/ranks', { rank_key: 'tester', label: 'Tester' });
483
+ for (const id of COUNCIL) await call(FOUNDER, 'POST', '/government/assignments', { builder_id: Number(id), rank_key: 'council' });
484
+
485
+ // ══ 1. Genesis ══
486
+ section('G Genesis');
487
+ const CHOSEN = 'timocratic-republic';
488
+ await check('The test project is born a Monarchy (the state every genesis starts from)', 'reads as monarchy', async () => {
489
+ const f = charter.readCharter(live()).form;
490
+ return { got: `reads as ${f}`, pass: f === 'monarchy' };
491
+ });
492
+ await check('Founding starts', '200, stage decide', async () => {
493
+ const r = await call(FOUNDER, 'POST', '/founding/start');
494
+ const stage = r.json && r.json.mode && r.json.mode.stage;
495
+ return { got: `${r.status}, stage ${stage}`, pass: r.status === 200 && stage === 'decide' };
496
+ });
497
+ let charterItem = null;
498
+ await check(`Deciding with ${CHOSEN} saves the plan and files the pick as a charter amendment`, 'plan saved, charter sitting, one vote decides', async () => {
499
+ const r = await call(FOUNDER, 'POST', '/founding/plan', { government: CHOSEN });
500
+ const c = (r.json && r.json.charter) || {};
501
+ charterItem = c.item_id || null;
502
+ return { got: `${r.status}, charter ${c.state}, one vote decides ${c.one_vote_decides}`, pass: r.status === 200 && c.state === 'sitting' && c.one_vote_decides === true };
503
+ });
504
+ await check('Saving the plan moved the project to its first stage', 'stage scope', async () => {
505
+ const g = await genesis.readGenesis();
506
+ return { got: `stage ${g && g.stage}`, pass: !!g && g.stage === 'scope' };
507
+ });
508
+ await check('The founder\'s one yes ratifies the pick under the Monarchy it is born with', 'passed', async () => {
509
+ const r = await vote(FOUNDER, charterItem);
510
+ return { got: `${r.status} ${r.json && r.json.outcome}`, pass: r.status === 201 && r.json.outcome === 'passed' };
511
+ });
512
+ await check('The constitution now reads as the government chosen at decide', `reads as ${CHOSEN}, no divergence`, async () => {
513
+ const c = await constitution();
514
+ const f = c && c.charter && c.charter.reads_as && c.charter.reads_as.form;
515
+ return { got: `reads as ${f}, divergence ${c && c.divergence ? 'yes' : 'none'}`, pass: f === CHOSEN && !c.divergence };
516
+ });
517
+ await check('A stage with unfinished tasks cannot be put to the board', '409 stage_tasks_unfinished', async () => {
518
+ const r = await call(FOUNDER, 'POST', '/founding/stages/scope/close');
519
+ return { got: `${r.status} ${code(r)}`, pass: r.status === 409 && code(r) === 'stage_tasks_unfinished' };
520
+ });
521
+ const timocrats = ['42', '43', '44'];
522
+ for (const stage of genesis.PROPER) {
523
+ store.tasks.get(STAGE_TASK_IDS[stage]).status = 'shipped';
524
+ let itemId = null;
525
+ await check(`${stage}: its finished stage is put to the board`, 'a sitting under the chosen government', async () => {
526
+ const r = await call(FOUNDER, 'POST', `/founding/stages/${stage}/close`);
527
+ itemId = r.json && r.json.item_id;
528
+ const snap = itemId && itemOf(itemId) ? itemOf(itemId).constitution : {};
529
+ const reads = charter.readCharter(snap).form;
530
+ return { got: `${r.status}, sitting under ${reads}`, pass: r.status === 200 && !!itemId && reads === CHOSEN };
531
+ });
532
+ await check(`${stage}: the founder alone cannot close it; every trusted member's yes does`, 'open, open, passed', async () => {
533
+ const after = await voteAll(itemId, timocrats);
534
+ return { got: after.join(', '), pass: same(after, ['open', 'open', 'passed']) };
535
+ });
536
+ const nextStage = genesis.PROPER[genesis.PROPER.indexOf(stage) + 1];
537
+ await check(`${stage}: a pass moves the project on`, nextStage ? `stage ${nextStage}` : 'come-alive door opens', async () => {
538
+ const g = await genesis.readGenesis();
539
+ if (nextStage) return { got: `stage ${g && g.stage}`, pass: !!g && g.stage === nextStage };
540
+ return { got: `can come alive ${g && g.can_come_alive}`, pass: !!g && g.can_come_alive === true };
541
+ });
542
+ }
543
+ await check('The project comes alive', '200, founding over', async () => {
544
+ const r = await call(FOUNDER, 'POST', '/founding/alive');
545
+ const g = await genesis.readGenesis();
546
+ return { got: `${r.status}, founding ${g ? 'still on' : 'over'}`, pass: r.status === 200 && g === null };
547
+ });
548
+ await check('Alive, it is still governed by the choice', `reads as ${CHOSEN}`, async () => {
549
+ const f = charter.readCharter(live()).form;
550
+ return { got: `reads as ${f}`, pass: f === CHOSEN };
551
+ });
552
+
553
+ // ══ 2. Every form ══
554
+ section('F Forms');
555
+ let ideaSeq = 500;
556
+ // A probe: a Full Idea (by someone who left, so no author rule bends the count)
557
+ // put to the board in force, voted on to show who may vote and what passes.
558
+ async function probe() {
559
+ const id = String(++ideaSeq);
560
+ store.ideas.set(id, { captured_by: '47', completeness: 80 });
561
+ const out = await board.openWindowForFullIdea({ ideaId: id, completenessScore: 80 });
562
+ return out.item.id;
563
+ }
564
+ const FORMS = [
565
+ // key, how to switch, who is seated, the yes-path states, the objection path (voter order + states)
566
+ { key: 'timocratic-republic', label: 'Timocratic Republic (in force from genesis)', body: null,
567
+ seated: ['42', '43', '44'], outsider: '45', yes: ['open', 'open', 'passed'], no: { voters: ['43', '42', '44'], dir: ['no', 'yes', 'yes'], states: ['open', 'open', 'returned'] },
568
+ rule: 'consent: a yes does not end it; full turnout passes; one objection returns it' },
569
+ { key: 'direct-democracy', label: 'Direct Democracy', body: { form: 'direct-democracy' },
570
+ seated: ACTIVE_IDS, outsider: null, yes: ['open', 'open', 'open', 'open', 'passed'], no: { voters: ['46', '42', '43', '44', '45'], dir: ['no', 'yes', 'yes', 'yes', 'yes'], states: ['open', 'open', 'open', 'open', 'returned'] },
571
+ rule: 'consent over every builder' },
572
+ { key: 'foundation', label: 'Foundation (council rank)', body: { form: 'foundation', council_rank: 'council' }, selfRemoval: true,
573
+ seated: COUNCIL, outsider: '42', yes: ['open', 'passed'], no: { voters: ['44', '43'], dir: ['no', 'yes'], states: ['open', 'returned'] },
574
+ rule: 'consent over the council only' },
575
+ { key: 'custom', label: 'Every builder, majority (no named form)', body: { franchise: 'everyone', pass_rule: 'majority' }, passRule: 'majority',
576
+ seated: ACTIVE_IDS, outsider: null, yes: ['open', 'open', 'passed'], no: { voters: ['42', '43', '44'], dir: ['no', 'no', 'no'], states: ['open', 'open', 'returned'] },
577
+ rule: 'majority: 3 of 5 yes carries; 3 of 5 no makes it unreachable' },
578
+ { key: 'custom', label: 'Every builder, unanimous (no named form)', body: { pass_rule: 'unanimous' }, passRule: 'unanimous',
579
+ seated: ACTIVE_IDS, outsider: null, yes: ['open', 'open', 'open', 'open', 'passed'], no: { voters: ['43'], dir: ['no'], states: ['returned'] },
580
+ rule: 'unanimous: one yes does not end it; full turnout passes; one objection returns it at once' },
581
+ { key: 'monarchy', label: 'Monarchy / BDFL', body: { form: 'monarchy' },
582
+ seated: ['42'], outsider: '43', yes: ['passed'], no: { voters: ['42'], dir: ['no'], states: ['returned'] },
583
+ rule: 'first yes: the founder\'s one vote decides' },
584
+ ];
585
+ let seatedNow = ['42', '43', '44']; // who sits under the form in force
586
+ for (const form of FORMS) {
587
+ const tag = form.label;
588
+ if (form.body) {
589
+ let itemId = null;
590
+ await check(`${tag}: the switch is filed as a charter amendment`, form.selfRemoval ? '409 proposer_would_lose_their_seat, then 201 once acknowledged' : '201', async () => {
591
+ let r = await call(FOUNDER, 'POST', '/government/board/charter', form.body);
592
+ let got = `${r.status}`;
593
+ if (form.selfRemoval) {
594
+ const first = `${r.status} ${code(r)}`;
595
+ r = await call(FOUNDER, 'POST', '/government/board/charter', { ...form.body, acknowledge_self_removal: true });
596
+ got = `${first}, then ${r.status}`;
597
+ itemId = r.json && r.json.item && r.json.item.id;
598
+ return { got, pass: first === '409 proposer_would_lose_their_seat' && r.status === 201 && !!itemId };
599
+ }
600
+ itemId = r.json && r.json.item && r.json.item.id;
601
+ return { got, pass: r.status === 201 && !!itemId };
602
+ });
603
+ await check(`${tag}: the government in force decides the switch`, 'passed once every seated member has said yes', async () => {
604
+ const after = await voteAll(itemId, seatedNow);
605
+ return { got: after.join(', '), pass: after[after.length - 1] === 'passed' };
606
+ });
607
+ }
608
+ await check(`${tag}: the constitution reads as it`, form.key === 'custom' ? `reads as Custom, pass rule ${form.passRule}` : `reads as ${form.key}`, async () => {
609
+ const c = await constitution();
610
+ const ra = (c && c.charter && c.charter.reads_as) || {};
611
+ const ok = ra.form === form.key && (!form.passRule || ra.pass_rule === form.passRule) && !c.divergence;
612
+ return { got: `reads as ${ra.form === 'custom' ? 'Custom' : ra.form}${form.passRule ? `, pass rule ${ra.pass_rule}` : ''}${c.divergence ? ', DIVERGES' : ''}`, pass: ok };
613
+ });
614
+ await check(`${tag}: who is seated`, `builders ${form.seated.join(', ')}`, async () => {
615
+ const members = (await membership.resolveBoardMembers(live())).map((m) => String(m.builder_id));
616
+ return { got: `builders ${members.join(', ') || 'none'}`, pass: same(members, form.seated) };
617
+ });
618
+ if (form.outsider) {
619
+ await check(`${tag}: someone not seated cannot vote`, '403 not_board_member', async () => {
620
+ const id = await probe();
621
+ const r = await vote(form.outsider, id);
622
+ return { got: `${r.status} ${code(r)}`, pass: r.status === 403 && code(r) === 'not_board_member' };
623
+ });
624
+ }
625
+ await check(`${tag}: what passes (${form.rule})`, form.yes.join(', '), async () => {
626
+ const id = await probe();
627
+ const after = await voteAll(id, form.seated);
628
+ return { got: after.join(', '), pass: same(after, form.yes) };
629
+ });
630
+ await check(`${tag}: what returns`, form.no.states.join(', '), async () => {
631
+ const id = await probe();
632
+ const after = [];
633
+ for (let k = 0; k < form.no.voters.length; k += 1) {
634
+ const r = await vote(form.no.voters[k], id, form.no.dir[k]);
635
+ after.push(r.status === 201 ? stateOf(id) : `${r.status} ${code(r)}`);
636
+ if (stateOf(id) !== 'open') break;
637
+ }
638
+ return { got: after.join(', '), pass: same(after, form.no.states) };
639
+ });
640
+ seatedNow = form.seated;
641
+ }
642
+
643
+ // ══ 3. Every building ══
644
+ section('B Buildings');
645
+ // Who decides at a building is changed by an amendment, decided here under the
646
+ // Monarchy the walk restored: the founder's one yes.
647
+ async function setRules(rules) {
648
+ const r = await call(FOUNDER, 'POST', '/government/board/decision-rules', { rules });
649
+ const itemId = r.json && r.json.item && r.json.item.id;
650
+ if (r.status !== 201 || !itemId) return { ok: false, why: `${r.status} ${code(r)}` };
651
+ const v = await vote(FOUNDER, itemId);
652
+ return { ok: v.status === 201 && v.json.outcome === 'passed', why: `vote ${v.status} ${v.json && v.json.outcome}` };
653
+ }
654
+ const rowOf = async (area) => {
655
+ const c = await constitution();
656
+ return ((c.decision_rules && c.decision_rules.areas) || []).find((a) => a.area === area) || {};
657
+ };
658
+ for (const area of dr.AREAS.filter((a) => a.acts.length)) {
659
+ await check(`${area.name}: set to decide by vote (council, majority) by a passed amendment`, 'sitting, majority vote', async () => {
660
+ const out = await setRules([{ area: area.key, decided_by: 'rank:council', rule: 'majority' }]);
661
+ const row = await rowOf(area.key);
662
+ return { got: `${out.ok ? 'passed' : out.why}; ${row.sitting ? 'sitting' : 'as today'}, ${row.rule_words}`, pass: out.ok && row.sitting === true && row.rule === 'majority' };
663
+ });
664
+ for (const act of area.acts) {
665
+ await check(`${area.name}: acting alone on ${act} is refused`, `409 decided_by_sitting (${area.key})`, async () => {
666
+ const [method, p, body] = REQUESTS[act];
667
+ const r = await call(FOUNDER, method, p, body);
668
+ const d = (r.json && r.json.error && (r.json.error.details || r.json.error)) || {};
669
+ return { got: `${r.status} ${code(r)} (${d.area})`, pass: r.status === 409 && code(r) === 'decided_by_sitting' && d.area === area.key };
670
+ });
671
+ }
672
+ await check(`${area.name}: restored to as today, every one of its ${area.acts.length} routes reaches its own handler again`, 'none refused', async () => {
673
+ const out = await setRules([{ area: area.key, decided_by: 'gate' }]);
674
+ const bad = [];
675
+ for (const act of area.acts) {
676
+ const [method, p, body] = REQUESTS[act];
677
+ const r = await call(FOUNDER, method, p, body);
678
+ const reached = act.includes('/government/assignments') ? r.status < 300 : (r.status >= 400 && r.status < 500 && r.status !== 403 && code(r) !== 'decided_by_sitting');
679
+ if (!reached) bad.push(`${act} ${r.status} ${code(r)}`);
680
+ }
681
+ return { got: `${out.ok ? 'restored' : out.why}; ${bad.length ? bad.join('; ') : 'none refused'}`, pass: out.ok && bad.length === 0 };
682
+ });
683
+ }
684
+
685
+ // ══ 4. Every matter action ══
686
+ section('M Matters');
687
+ const SITTINGS = {
688
+ people: 'consent', automation: 'majority', release: 'unanimous', review: 'signoff', treasury: 'first',
689
+ };
690
+ await check('Each acting building is put to the council by one passed amendment', Object.entries(SITTINGS).map(([a, r]) => `${a} ${r}`).join(', '), async () => {
691
+ const out = await setRules(Object.entries(SITTINGS).map(([area, rule]) => ({ area, decided_by: 'rank:council', rule })));
692
+ const got = [];
693
+ for (const area of Object.keys(SITTINGS)) got.push(`${area} ${(await rowOf(area)).rule}`);
694
+ return { got: `${out.ok ? '' : `${out.why}; `}${got.join(', ')}`, pass: out.ok && same(got, Object.entries(SITTINGS).map(([a, r]) => `${a} ${r}`)) };
695
+ });
696
+ // Open a matter as the founder (not on the council), pass it with council votes.
697
+ async function passMatter(area, title, action, voters = COUNCIL, direction = 'yes') {
698
+ const r = await call(FOUNDER, 'POST', '/government/board/matters', { area, title, action });
699
+ const itemId = r.json && r.json.item && r.json.item.id;
700
+ if (r.status !== 201 || !itemId) return { outcome: `not opened: ${r.status} ${code(r)}` };
701
+ const after = await voteAll(itemId, voters, direction);
702
+ return { outcome: stateOf(itemId), after };
703
+ }
704
+ const holds = (id, rank) => store.assignments.some((a) => a.builder_id === id && a.rank_key === rank);
705
+ // Did the act happen? One observation per matter action in MATTER_PLAN.
706
+ const HAPPENED = {
707
+ 'people.assign_rank': () => holds('46', 'tester'),
708
+ 'people.unassign_rank': () => !holds('46', 'tester'),
709
+ 'automation.switch_on': () => effects.fence === true,
710
+ 'automation.switch_off': () => effects.fence === false,
711
+ 'automation.allow_goal': () => effects.allowedGoals.includes('77'),
712
+ 'release.approve_override': () => effects.overrides.some(([id, d]) => id === '5' && d === 'approve'),
713
+ 'release.approve_gate': () => effects.gates.includes('1500'),
714
+ 'review.satisfy_criterion': () => effects.criteria.get('9') === true,
715
+ 'treasury.set_budget': () => effects.budgets.get('45') === 50,
716
+ };
717
+ const MATTERS = MATTER_PLAN.map(([area, title, action, effect]) => [area, title, action, effect, HAPPENED[`${area}.${action.type}`]]);
718
+ // How each building's sitting closes as Ben then Cy say yes, under its rule: the
719
+ // two-member consent, majority and unanimous sittings need both; first yes and
720
+ // sign-off (by someone who did not propose it) close on the first.
721
+ const CLOSES = { people: 'open, passed', automation: 'open, passed', release: 'open, passed', review: 'passed', treasury: 'passed' };
722
+ await check('Someone the building\'s row does not seat cannot vote on its matter', '403 not_board_member', async () => {
723
+ const r = await call(FOUNDER, 'POST', '/government/board/matters', { area: 'automation', title: 'Who may vote here?' });
724
+ const itemId = r.json && r.json.item && r.json.item.id;
725
+ const v = await vote(FOUNDER, itemId);
726
+ await voteAll(itemId, COUNCIL, 'no'); // close the probe so it carries out nothing
727
+ return { got: `${r.status}, then ${v.status} ${code(v)}`, pass: r.status === 201 && v.status === 403 && code(v) === 'not_board_member' };
728
+ });
729
+ for (const [area, title, action, effect, happened] of MATTERS) {
730
+ await check(`${area} ${action.type}: "${title}" passes under ${SITTINGS[area]} and is carried out`, `${CLOSES[area]}; ${effect}`, async () => {
731
+ const out = await passMatter(area, title, action);
732
+ const done = happened();
733
+ const steps = out.after ? out.after.join(', ') : out.outcome;
734
+ return { got: `${steps}; ${done ? effect : `NOT ${effect}`}`, pass: out.outcome === 'passed' && steps === CLOSES[area] && done };
735
+ });
736
+ }
737
+ await check('A returned matter carries out nothing', 'returned; fence unchanged', async () => {
738
+ const before = effects.fence;
739
+ const out = await passMatter('automation', 'Switch it on again', { type: 'switch_on' }, COUNCIL, 'no');
740
+ return { got: `${out.outcome}; fence ${effects.fence === before ? 'unchanged' : 'CHANGED'}`, pass: out.outcome === 'returned' && effects.fence === before };
741
+ });
742
+ await check('Every building restored to as today', 'all as today', async () => {
743
+ const out = await setRules(Object.keys(SITTINGS).map((area) => ({ area, decided_by: 'gate' })));
744
+ const c = await constitution();
745
+ const sitting = c.decision_rules.areas.filter((a) => a.sitting).map((a) => a.area);
746
+ return { got: `${out.ok ? '' : `${out.why}; `}${sitting.length ? `still sitting: ${sitting.join(', ')}` : 'all as today'}`, pass: out.ok && sitting.length === 0 };
747
+ });
748
+
749
+ // Any storage the fake did not model is a finding about the walk itself.
750
+ section('S Store');
751
+ await check('No code path reached storage the walk does not model', 'none', async () => {
752
+ const u = [...new Set(store.unexpected)];
753
+ return { got: u.length ? u.join(', ') : 'none', pass: u.length === 0 };
754
+ });
755
+
756
+ await new Promise((r) => server.close(r));
757
+ try { fs.rmSync(instanceRoot, { recursive: true, force: true }); } catch (_) { /* best effort */ }
758
+ return { mode: 'in-memory', rows };
759
+ }
760
+
761
+ // ── the table, as Markdown ──────────────────────────────────────────────────
762
+
763
+ const SECTION_TITLES = {
764
+ G: '1. Genesis: founded, decided, five stages closed, alive',
765
+ F: '2. Every form: who can vote and what passes',
766
+ B: '3. Every building: decide by vote refuses acting alone',
767
+ M: '4. Every matter action: passed and carried out',
768
+ S: 'The walk itself',
769
+ };
770
+ const cell = (v) => String(v).replace(/\|/g, '\\|').replace(/\n/g, ' ');
771
+
772
+ function renderTable({ mode, rows }) {
773
+ const out = [];
774
+ out.push('<!-- GENERATED by scripts/gds/government-walk.js - do not hand-edit. Regenerate: node scripts/gds/government-walk.js --write -->');
775
+ out.push('');
776
+ out.push('# The government walk');
777
+ out.push('');
778
+ out.push('One repeatable walk over a throwaway test project\'s government (task 1004658, goal 1000111). Re-run it with `node scripts/gds/government-walk.js`; `tests/government_walk.mjs` re-runs it in CI and fails if any row below changes, so this table is kept true, not just written once.');
779
+ out.push('');
780
+ out.push(`**Mode: ${mode}.** Every request is real HTTP through the real routers, and every decision is the real code: the permission gate, board membership, the vote, the tally, the close, the charter apply (written to a throwaway folder), the decision-rules apply, the act-alone gate on every building, and each module's matter act. The storage underneath is an in-memory stand-in for the government tables with the same guards (no Postgres was available where this was built), and the writes a passed matter makes into other modules (the automation fence, override requests, criteria, the GitHub gate approval, budgets) are recorded in memory. A real-database run of this walk is not built yet.`);
781
+ out.push('');
782
+ out.push('The test project: Ada founds it (Archon); Ben and Cy are trusted builders (Metic) and sit on a Council rank; Dee (Thetes) and Eve (Xenos) are newer builders; Fay has left and only authors the probe ideas, so no probe is decided by its own author.');
783
+ out.push('');
784
+ const keys = Object.keys(SECTION_TITLES).filter((k) => rows.some((r) => r.section.startsWith(k)));
785
+ out.push('| Section | Pass | Fail |');
786
+ out.push('|---|---|---|');
787
+ for (const k of keys) {
788
+ const rs = rows.filter((r) => r.section.startsWith(k));
789
+ out.push(`| ${SECTION_TITLES[k]} | ${rs.filter((r) => r.pass).length} | ${rs.filter((r) => !r.pass).length} |`);
790
+ }
791
+ out.push(`| **All** | **${rows.filter((r) => r.pass).length}** | **${rows.filter((r) => !r.pass).length}** |`);
792
+ for (const k of keys) {
793
+ out.push('');
794
+ out.push(`## ${SECTION_TITLES[k]}`);
795
+ out.push('');
796
+ out.push('| # | Check | Expected | Got | Result |');
797
+ out.push('|---|---|---|---|---|');
798
+ for (const r of rows.filter((x) => x.section.startsWith(k))) {
799
+ const bug = KNOWN_BUGS[r.key];
800
+ const result = r.pass ? 'PASS' : `**FAIL**${bug ? ` (${bug})` : ''}`;
801
+ out.push(`| ${r.key} | ${cell(r.name)} | ${cell(r.expected)} | ${cell(r.got)} | ${result} |`);
802
+ }
803
+ }
804
+ out.push('');
805
+ return out.join('\n');
806
+ }
807
+
808
+ async function main(argv) {
809
+ // Logs from the modules go to stderr so the table on stdout stays readable.
810
+ const realLog = console.log;
811
+ console.log = (...a) => console.error(...a);
812
+ const result = await runWalk();
813
+ const md = renderTable(result);
814
+ console.log = realLog;
815
+ const fails = result.rows.filter((r) => !r.pass);
816
+ if (argv.includes('--check')) {
817
+ const committed = fs.existsSync(DOC_PATH) ? fs.readFileSync(DOC_PATH, 'utf8').replace(/\r\n/g, '\n') : '';
818
+ if (committed !== md) {
819
+ process.stdout.write(md);
820
+ process.stdout.write(`\ngovernment-walk: the committed table (${path.relative(ROOT, DOC_PATH)}) differs from this run. A row changed: find it above, then regenerate with node scripts/gds/government-walk.js --write (and file any new FAIL as a bug under goal 1000111).\n`);
821
+ return 1;
822
+ }
823
+ process.stdout.write(`government-walk: ${result.rows.length - fails.length} pass, ${fails.length} fail - matches the committed table.\n`);
824
+ return 0;
825
+ }
826
+ process.stdout.write(md);
827
+ if (argv.includes('--write')) {
828
+ fs.writeFileSync(DOC_PATH, md);
829
+ process.stdout.write(`\nwrote ${path.relative(ROOT, DOC_PATH)}\n`);
830
+ }
831
+ process.stdout.write(`\ngovernment-walk: ${result.rows.length - fails.length} pass, ${fails.length} fail (${result.mode}).\n`);
832
+ return 0;
833
+ }
834
+
835
+ if (require.main === module) {
836
+ main(process.argv.slice(2)).then((exitCode) => process.exit(exitCode), (err) => {
837
+ console.error(err);
838
+ process.exit(2);
839
+ });
840
+ }
841
+
842
+ module.exports = { runWalk, renderTable, REQUESTS, ROUTERS, MATTER_PLAN, KNOWN_BUGS };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.98'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.99'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,56 @@
1
+ // tests/government_walk.mjs — the government walk stays true (task 1004658, goal
2
+ // 1000111, criterion gov-proven-and-kept-proven).
3
+ //
4
+ // scripts/gds/government-walk.js walks a throwaway project's whole government —
5
+ // genesis, every form, every building's act-alone gate, every matter action — and
6
+ // writes the pass/fail table to docs/governance/government-walk.md. This runs the
7
+ // walk again in its own process (it points the instance root at a temp folder and
8
+ // swaps the government db layer for its in-memory store, so it must not share a
9
+ // module graph with other tests) and fails if ANY row differs from the committed
10
+ // table: a change that breaks a decision flips a row from PASS to FAIL here, and a
11
+ // fix to a filed bug flips one back, which asks for the table to be regenerated.
12
+ //
13
+ // Also pins the walk's own coverage, so it cannot quietly walk less: every bound
14
+ // route of every building has a request in the walk, and every router a bound route
15
+ // lives on is mounted.
16
+ //
17
+ // Run: node --test tests/government_walk.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { test } from 'node:test';
21
+ import { spawnSync } from 'node:child_process';
22
+ import path from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'government-walk.js');
29
+ const walk = require(SCRIPT);
30
+ const dr = require(path.join(ROOT, 'modules', 'government', 'decision-rules.js'));
31
+ const decisionBoard = require(path.join(ROOT, 'modules', 'government', 'decision-board.js'));
32
+
33
+ test('the walk covers every bound route of every building', () => {
34
+ const bound = dr.AREAS.flatMap((a) => a.acts).sort();
35
+ assert.deepEqual(Object.keys(walk.REQUESTS).sort(), bound, 'a route added to decision-rules AREAS[].acts needs a request in the walk');
36
+ });
37
+
38
+ test('the walk passes a matter for every action a passed matter can carry out', () => {
39
+ const planned = walk.MATTER_PLAN.map(([area, , action]) => `${area}.${action.type}`).sort();
40
+ const actions = Object.entries(decisionBoard.MATTER_ACTIONS)
41
+ .flatMap(([area, acts]) => Object.keys(acts).map((type) => `${area}.${type}`)).sort();
42
+ assert.ok(actions.length > 0, 'the matter-action table is not empty');
43
+ assert.deepEqual(planned, actions, 'a new matter action needs a matter in the walk');
44
+ // and every planned action is one the server would accept as written
45
+ for (const [area, , action] of walk.MATTER_PLAN) {
46
+ assert.equal(decisionBoard.validateMatterAction(area, action).ok, true, `${area} ${action.type} validates`);
47
+ }
48
+ });
49
+
50
+ test('the walk re-runs and matches the committed pass/fail table', () => {
51
+ const env = { ...process.env };
52
+ delete env.DATABASE_URL;
53
+ const r = spawnSync(process.execPath, [SCRIPT, '--check'], { cwd: ROOT, env, encoding: 'utf8', timeout: 240000 });
54
+ assert.equal(r.status, 0, `government-walk --check failed (exit ${r.status}):\n${r.stdout}\n${r.stderr}`);
55
+ assert.match(r.stdout, /matches the committed table/);
56
+ });