@bongos/core 1.19.996 → 1.19.998

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.19.996",
6
- "core_contract": "1.19.996",
7
- "source_commit": "5929da0385a4022562ebd43a5a71d81400e45836",
5
+ "core_version": "1.19.998",
6
+ "core_contract": "1.19.998",
7
+ "source_commit": "18eb9f5bf0ce34f16c214da16a3b095c3bffb919",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T23:40:05.075Z",
9
+ "built_at": "2026-09-24T00:50:38.644Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 520,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2405,
14
+ "functional_verbatim": 2406,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2951,
20
- "tree_sha256": "cd26f1916edf3136e53591dc18c37ed45254dd805880bf4b25bc1af1c59baf93",
19
+ "file_count": 2952,
20
+ "tree_sha256": "674cb32d9c86b0bad374632422a694d65919b84b97f553a856b7ba66baa23d81",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -222,7 +222,7 @@
222
222
  {
223
223
  "path": ".claude/skills/read-session-export/SKILL.md",
224
224
  "mode": "0000644",
225
- "sha256": "060811040144266504a206156422b6166b6cf5c7a2cd1f38c52b117ab4b5e0d6"
225
+ "sha256": "594d32ef63be2b45c360210ad004fc14e6dae889af97d47d72aac6a738fe1878"
226
226
  },
227
227
  {
228
228
  "path": ".claude/skills/recall/SKILL.md",
@@ -2132,7 +2132,7 @@
2132
2132
  {
2133
2133
  "path": "docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md",
2134
2134
  "mode": "0000644",
2135
- "sha256": "2c80244a082fb8c907bc83690724dbfb775adc35da124977668d8e56425d99b2"
2135
+ "sha256": "9ebd9e4b3cb71f3ee4c5e06cdad04471ab07988744263fef7cd40a403b296b0b"
2136
2136
  },
2137
2137
  {
2138
2138
  "path": "docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md",
@@ -2157,7 +2157,7 @@
2157
2157
  {
2158
2158
  "path": "docs/api/openapi.json",
2159
2159
  "mode": "0000644",
2160
- "sha256": "8b1f2a53680ae8b68be5d54a82664413fa29186fb580825f5a1b0e3ce3677525"
2160
+ "sha256": "734016d0505821010e06c197ea1987e3eb4e9700d45ad32d982a5b9cd9da5aed"
2161
2161
  },
2162
2162
  {
2163
2163
  "path": "docs/architecture.md",
@@ -2717,7 +2717,7 @@
2717
2717
  {
2718
2718
  "path": "docs/module-api-changelog.md",
2719
2719
  "mode": "0000644",
2720
- "sha256": "950ea34b2bb315e54fc02aa22753b3370ee009ab06727238002aa3a54e78782e"
2720
+ "sha256": "ce30c687aa2730d3e13dc5bb9ea21d180b7272bbd080f8e564818c0f2b43c1cd"
2721
2721
  },
2722
2722
  {
2723
2723
  "path": "docs/modules-contract.md",
@@ -6087,12 +6087,12 @@
6087
6087
  {
6088
6088
  "path": "modules/lifecycle/routes/goal-route-authz.js",
6089
6089
  "mode": "0000644",
6090
- "sha256": "3b4fb0168b29fb0df1260c23c5b14d96576f00f587dd5c188fe17a712e3dffaa"
6090
+ "sha256": "89d282e6c5110ae698484ddab698a72f27c6fe509fc97ff628326fe42e79483d"
6091
6091
  },
6092
6092
  {
6093
6093
  "path": "modules/lifecycle/routes/goals.js",
6094
6094
  "mode": "0000644",
6095
- "sha256": "91039df9ca263ad34bda8e230b8e52f2099e6114a2a9eb4bae2b6b21210994c8"
6095
+ "sha256": "8bd3b013fa96d062f15386e53f43dcdb918baf619c2beb5cf5333ef7052475f3"
6096
6096
  },
6097
6097
  {
6098
6098
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -8187,12 +8187,12 @@
8187
8187
  {
8188
8188
  "path": "package-lock.json",
8189
8189
  "mode": "0000644",
8190
- "sha256": "6d4291f9e503ac170998451d6607d15f831d39cd43aaa2443ada021e10386f13"
8190
+ "sha256": "badffa260a3d5d830fea0ed1e1beb2950d9c03f0c0dd5c05998603953b710050"
8191
8191
  },
8192
8192
  {
8193
8193
  "path": "package.json",
8194
8194
  "mode": "0000644",
8195
- "sha256": "50946900cdbd241bfe7a8ac94d4b50b65a9adb622f2debdd2048354a2ead38e4"
8195
+ "sha256": "82c6009c6ab97ab286b0fca8b3e3cd82d8f832c7abdb988751ad0da0760d078a"
8196
8196
  },
8197
8197
  {
8198
8198
  "path": "public-docs/index.html",
@@ -9127,7 +9127,7 @@
9127
9127
  {
9128
9128
  "path": "scripts/gds/prove-strict-versioning.js",
9129
9129
  "mode": "0000644",
9130
- "sha256": "b0e2c14720e8258eba4d4ef7f69cc2bcbb26c3be312deb891383dce7a1dbf71e"
9130
+ "sha256": "65bb758becd002ae830a8338ec6ba0bc21ec5ef91ad564c85a2a94b7ab6ffbbc"
9131
9131
  },
9132
9132
  {
9133
9133
  "path": "scripts/gds/provision-byo-host.js",
@@ -9147,7 +9147,7 @@
9147
9147
  {
9148
9148
  "path": "scripts/gds/provision-core-upgrade.js",
9149
9149
  "mode": "0000644",
9150
- "sha256": "9d6ef473dcf4b9d8bbaa8c5a796d8d21f761b3732a0279981ff9e60aadf25919"
9150
+ "sha256": "70ee5d90d63c8b7de938bb22c7a2e49154f5e80ce7e4ac78d180148017a09e74"
9151
9151
  },
9152
9152
  {
9153
9153
  "path": "scripts/gds/provision-disconnect.js",
@@ -10237,7 +10237,7 @@
10237
10237
  {
10238
10238
  "path": "src/module-api.js",
10239
10239
  "mode": "0000644",
10240
- "sha256": "899926ec43a4f0087125deada1bf430aa5f34bd1b3f32ab65ff2ffea981431a3"
10240
+ "sha256": "6003f41c9940fe1e5a265d2d1812fd4ee43a67aed8cf9a4a60c5495b3918c388"
10241
10241
  },
10242
10242
  {
10243
10243
  "path": "src/module-loader/catalog.js",
@@ -11152,7 +11152,7 @@
11152
11152
  {
11153
11153
  "path": "tests/core_upgrade_runner.mjs",
11154
11154
  "mode": "0000644",
11155
- "sha256": "47618fa0dd42bfa8f542b6830c3e68b715e655280f9aaf6684085478b257d497"
11155
+ "sha256": "104ff5419638902eb272d15040a8fe69c4e1d14c38c7a977b1c2fd64c3cadd11"
11156
11156
  },
11157
11157
  {
11158
11158
  "path": "tests/cosmos_token_contrast.mjs",
@@ -11662,7 +11662,7 @@
11662
11662
  {
11663
11663
  "path": "tests/goal_archive_disposition.mjs",
11664
11664
  "mode": "0000644",
11665
- "sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
11665
+ "sha256": "de5b561ffe78ead5f6a1ad08066aaa2672627f441f8602fd46e1fdac453512e0"
11666
11666
  },
11667
11667
  {
11668
11668
  "path": "tests/goal_archive_hall.mjs",
@@ -11769,6 +11769,11 @@
11769
11769
  "mode": "0000644",
11770
11770
  "sha256": "c93422d76ff7f0107190c6de1954f110e92a18913fabbcdea2b4f67693d538e6"
11771
11771
  },
11772
+ {
11773
+ "path": "tests/goal_owner_manager_powers.mjs",
11774
+ "mode": "0000644",
11775
+ "sha256": "9853fe32906b842e6c78dd024e33bee5c00649ed2de2f6b593d736d0155e7832"
11776
+ },
11772
11777
  {
11773
11778
  "path": "tests/goal_routes.mjs",
11774
11779
  "mode": "0000644",
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: read-session-export
3
3
  description: >-
4
- Read a Claude Code /export zip — conversation, thinking, and tool calls — to answer questions about a past session. READS an existing export, never creates one. Local and read-only. Triggers: a dropped session-export-*.zip, "read this session", "what happened in this session", "what was I/you thinking here", "summarize this export", "read the latest session export", "/read-session-export".
4
+ Read a Claude Code /export zip — conversation, thinking, tool calls — to answer questions about a past session. READS an existing export, never creates one. Triggers: a dropped session-export-*.zip, "read/summarize this session", "what happened in this session", "what was I/you thinking", "/read-session-export".
5
5
  ---
6
6
 
7
- The user already has the zip — Claude Code's built-in `/export` made it. Your job is to **read it well**: a session export is the same shape every time, so don't go spelunking through it by hand or load the megabytes of debug logs. Run the reader, which prints a compact transcript (prompts + thinking + replies + one-line tool calls) to stdout, then answer whatever the user actually asked.
7
+ The user already has the zip — Claude Code's built-in `/export` made it. Read it with the reader rather than by hand: it prints a compact transcript (prompts + thinking + replies + one-line tool calls) to stdout and skips the megabytes of debug logs.
8
8
 
9
9
  ```bash
10
- node scripts/gds/read-session-export.js <export.zip> # read it (compact transcript → stdout)
10
+ node scripts/gds/read-session-export.js <export.zip> # compact transcript → stdout
11
11
  node scripts/gds/read-session-export.js --latest # newest session-export-*.zip in ~/Downloads
12
12
  ```
13
13
 
14
- Then **read that output and respond to the user's real intent** — summarize the session, pull out the reasoning behind a decision, find where something went wrong, list what was built. Don't just paste the transcript back; that's the raw material, not the answer.
14
+ Then **answer what the user actually asked** — summarize the session, pull out the reasoning behind a decision, find where something went wrong, list what was built. Don't paste the transcript back; that's the raw material, not the answer.
15
15
 
16
16
  ## Pick the right depth (it's fast either way)
17
17
 
@@ -22,38 +22,14 @@ Then **read that output and respond to the user's real intent** — summarize th
22
22
  | Normal "read / summarize this session" | _(no flag)_ — compact transcript, tool results clipped |
23
23
  | "I need the full tool output / exact text" | `--full` (loosens the result clipping) |
24
24
 
25
- For a long session (a `--meta` showing hundreds of tool calls / thousands of lines), start with `--meta`, then `--thoughts` or a targeted read — don't dump the whole thing into context if the user only wants the gist.
25
+ **A long export is a file, not a dump.** Always open with `--meta`; if it reports hundreds of tool calls, redirect to your session's scratchpad directory and read it in slices, rather than pouring thousands of lines into context at once:
26
26
 
27
- ## What you're reading (the constant layout)
28
-
29
- A `/export` zip is identical every time, which is why this is reliable:
30
-
31
- - `metadata.json` — model, effort, title, cwd, branch, timestamps.
32
- - `<cliSessionId>.jsonl` — **the transcript**, one JSON record per line. Assistant records carry `content[]` blocks of type `thinking` (the thoughts), `text` (the reply), and `tool_use`; the matching `tool_result` rides in the next `user` record. The reader stitches these together.
33
- - `<cliSessionId>/subagents/agent-*.jsonl` — subagent transcripts (rendered after the main one).
34
- - `<cliSessionId>/tool-results/*.txt` — outputs too big to inline.
35
- - `logs/*.log` — app debug logs. **Not the conversation** — ignored.
36
-
37
- You rarely need this — the reader handles it — but it's here so you can read a record by hand if you must.
38
-
39
- ## The thinking may be redacted — and what to show instead
40
-
41
- Whether "the thoughts" are actually readable depends on the model that ran the session, not on this tool:
42
-
43
- - **Sonnet sessions** → thinking exports as plaintext; you'll see the real reasoning.
44
- - **Opus sessions** (e.g. `claude-opus-4-8`) → thinking is **redacted**: the export carries only an encrypted signature, not the words. The reader states this once in the header (`💭 0 thoughts (+N redacted)` + a one-line banner) and otherwise keeps the body clean.
45
-
46
- **The substitute for redacted thinking is already in the transcript — not in the logs.** When the thoughts are encrypted, lean on what Claude actually *said and did*: the **text replies** routinely state the reasoning in plain language ("the problem is X, so I'll Y"), and the **tool-call sequence** shows the investigation step by step. The reader surfaces both, so you can reconstruct the "why" of a redacted session from its actions. (The zip's `logs/*.log` are NOT a fallback: they're app-wide, multi-day rolling logs with no per-session correlation and no reasoning — ignore them.)
47
-
48
- So if the user exported an Opus session hoping to re-read the literal reasoning, tell them plainly it's redacted — then answer their question anyway from the text + actions, which usually makes the reasoning clear.
49
-
50
- ## Constraints
27
+ ```bash
28
+ node scripts/gds/read-session-export.js <zip> > <scratchpad>/transcript.md # then sed -n '1,400p', '400,850p', …
29
+ ```
51
30
 
52
- - **Reads, never writes.** Output goes to stdout for you to consume; it creates no files and never modifies the zip.
53
- - **Local + zero-dependency.** Pure Node stdlib (a small `zlib` ZIP reader) — no Bongos session, no network, no install. Works on any builder machine.
54
- - ZIP64 archives (multi-GB) are refused with a clear message; no real session export hits that.
31
+ ## If the thinking is redacted
55
32
 
56
- ## Files this skill touches
33
+ Opus sessions export encrypted reasoning rather than words, and the reader says so in its header. Tell the user plainly — then answer their question anyway from the **text replies**, which routinely state the reasoning outright ("the problem is X, so I'll Y"), and the **tool-call sequence**, which shows the investigation step by step. The zip's `logs/*.log` are not a fallback: app-wide rolling debug logs, no per-session correlation, no reasoning.
57
34
 
58
- - Runs: `node scripts/gds/read-session-export.js` (the whole implementation).
59
- - Reads: the `session-export-*.zip` you point it at. Writes: nothing.
35
+ Reads only — it creates no files and never modifies the zip.
@@ -1,6 +1,6 @@
1
1
  # ADR 0327 — A cloud-host runs on the owner's own account, and the key is borrowed, not stored
2
2
 
3
- - **Status:** accepted
3
+ - **Status:** accepted — **one Context finding retracted 2026-09-23** ([task 1004186](https://cloudbongos.com/builders#/task/1004186)): `mercury` was *not* already on Render, and the first Consequences bullet that rested on it is retracted with it. Decisions 1–5 are unaffected and stand. See the correction in Context.
4
4
  - **Date:** 2026-09-22
5
5
  - **Task:** [task 1004175](https://cloudbongos.com/builders#/task/1004175) (goal 1000106 — working area 1, Project creation)
6
6
  - **Deciders:** Lars (Archon) — decisions 1, 3 and 4 are the owner's. Decision 2 is Claude's design answer to the owner's question "is there a simpler way", and decision 5 records a measurement.
@@ -14,9 +14,18 @@
14
14
 
15
15
  Standing that up exposed what the unexamined assumption costs. A Render API key is issued against a *user* and carries that user's rights in **every workspace they belong to**. The owner's key reached three workspaces, one of them another builder's, holding their live production services. We would have been one bug away from acting on a third party's infrastructure with a credential they never gave us.
16
16
 
17
- The same check found the second thing: **`mercury` was already on Render**, run by its own owner from their own repository. Under ADR 0323 we would have migrated a working project *off* a correct setup and onto our bill.
17
+ The same check found what was recorded here as a second thing: "**`mercury` was already on Render**, run by its own owner from their own repository. Under ADR 0323 we would have migrated a working project *off* a correct setup and onto our bill."
18
18
 
19
- Both facts point the same way. The remaining landlord relationship was never the servers — it was the **account**.
19
+ **Correction, 2026-09-23 ([task 1004186](https://cloudbongos.com/builders#/task/1004186)). That finding is wrong; the sentence above is kept only to show what was claimed.** The Bongos instance is on our own box, and always was. Measured against the live fleet and the live endpoint:
20
+
21
+ - Fleet row 31: `hosting_shape='standalone'`, `domain=mercury.cloudbongos.com`, `db_name=mercury`, `port=3018`, `onboard_mode=adopt`, `target_ref=willHermesLines/mercury_bongos`, last verified up 2026-09-24.
22
+ - `GET https://mercury.cloudbongos.com/healthz` → `200`, carrying the header **`via: 1.1 Caddy`** — our Caddy, our control-plane box, behind Cloudflare. `/version` reports core 1.19.312.
23
+
24
+ **What was actually seen, and why it misled.** `mercury_bongos` is a *hybrid* repository: its owner's own Python warehouse application (`app.py`, plus a `Procfile` reading `web: gunicorn app:app`) with an **adopted** Bongos instance layered onto it (`package.json` depends on `@bongos/core` as `file:vendor/bongos-core-1.19.305.tgz` — the vendoring path). It carries no `render.yaml`. So the live production services in that workspace are most plausibly the owner's *Python app* — a real Render deployment that is simply not the Bongos instance. A workspace listing and an instance row were read as the same object; they are two systems sharing a name and a repository.
25
+
26
+ That last step is the likeliest reading, not a measurement — confirming it needs the owner, or a look inside their workspace, and neither was available. What *is* measured is the half that matters: **the Bongos instance is on our box and has not moved.**
27
+
28
+ **The decision below does not depend on the retracted half.** The first finding — that a Render API key carries its user's rights into every workspace they belong to — is measured, and on its own it is sufficient. The remaining landlord relationship was never the servers; it was the **account**.
20
29
 
21
30
  ## Decision
22
31
 
@@ -86,13 +95,13 @@ So the web tier process **can** read the DigitalOcean, Cloudflare and platform t
86
95
 
87
96
  ## Consequences
88
97
 
89
- - **The migration shrinks to an adoption.** `mercury` is already in the target state; its instance record needs pointing at a deployment its owner runs, not a move. <redacted> is a distinction `scripts/gds/provision-repo.js` already draws for repositories, and the platform leg needs it too. [task 1004142](https://cloudbongos.com/builders#/task/1004142) is re-scoped accordingly.
98
+ - ~~**The migration shrinks to an adoption.** `mercury` is already in the target state; its instance record needs pointing at a deployment its owner runs, not a move.~~ **Retracted 2026-09-23 ([task 1004186](https://cloudbongos.com/builders#/task/1004186)) — see the correction in Context.** The migration does not shrink: `mercury`'s Bongos instance runs on our box and still has to move, like the other three. Because the project is someone else's, that move needs its owner's agreement and their own Render account, which makes it the courtesy call this ADR's closing thought was reaching for. <redacted> remains a real distinction — `scripts/gds/provision-repo.js` already draws it for repositories and the platform leg still needs it — but nothing in the fleet is currently an example of it, so it has to be built from the spec rather than from a live case. The pilot is [task 1004142](https://cloudbongos.com/builders#/task/1004142) and the moves are [task 1004184](https://cloudbongos.com/builders#/task/1004184).
90
99
 
91
100
  - **The fleet cost ledger's question changes, and it is no longer "what do we owe".** [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) §6 filed [task 1004148](https://cloudbongos.com/builders#/task/1004148) because `costEstimateUsd` returns 0 for a project "the platform is about to be billed for". The platform is not going to be billed for it. A ledger reporting our spend should read ~$0 per `cloud-host` and be *correct*; what an owner is worth telling is **their** estimated Render cost, which is a different column with a different meaning. Reporting one as the other is the defect now.
92
101
 
93
102
  - **Metering and charging lose their forcing function.** [task 1001949](https://cloudbongos.com/builders#/task/1001949) was sized against recovering ~$14/project/month. There is nothing to recover. Cost *visibility* — showing an owner what their project costs them — survives as a product feature; billing for hosting does not, because we do not host.
94
103
 
95
- - **`dedicated` becomes dead data as well as dead code.** Its stored value must be migrated, not merely orphaned. That migration runs over live rows and belongs with the rest of the shape rename in [task 1004142](https://cloudbongos.com/builders#/task/1004142).
104
+ - **`dedicated` becomes dead code, and — measured — no data at all.** Its stored value must be dropped rather than merely orphaned. Pointer corrected 2026-09-23: that work split out of task 1004142 into [task 1004182](https://cloudbongos.com/builders#/task/1004182). The same check found the fleet carries **zero** `dedicated` rows, so the "runs over live rows" caution belongs to the 22 `standalone` rows being renamed to `cloud-host`, not to `dedicated`, whose migration is a no-op. Re-verify before writing one.
96
105
 
97
106
  - **Our own token exposure is now the largest unmitigated item in this area.** Decision 5 is filed as its own task. It was not urgent while it protected only tokens we could rotate ourselves; it stays that way, but it should not stay unowned.
98
107
 
@@ -6127,7 +6127,7 @@
6127
6127
  "goals"
6128
6128
  ],
6129
6129
  "summary": "POST /goals/:id/archive",
6130
- "description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.archive` (all required).",
6130
+ "description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.archive` (all required).",
6131
6131
  "x-rank": "metic+archon",
6132
6132
  "x-source": "modules/lifecycle/routes/goals.js",
6133
6133
  "x-permissions": [
@@ -7134,7 +7134,7 @@
7134
7134
  "goals"
7135
7135
  ],
7136
7136
  "summary": "POST /goals/:id/scope",
7137
- "description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply: requireRank('metic','archon') + the caller must be a member (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the POST /goals create gate). A sub-Metic member therefore cannot widen at all — they ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57). The goal must be open. Body: { add: [\"module-key\", ...] }; idempotent (set-union). rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.scope.widen` (all required).",
7137
+ "description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply: the goal.scope.widen permission + the caller must be the goal's owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the POST /goals create gate). A plain member therefore cannot widen at all — they ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57). The goal must be open. Body: { add: [\"module-key\", ...] }; idempotent (set-union). rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.scope.widen` (all required).",
7138
7138
  "x-rank": "metic+archon",
7139
7139
  "x-source": "modules/lifecycle/routes/goals.js",
7140
7140
  "x-permissions": [
@@ -2466,5 +2466,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2466
2466
  landed since 1.19.994 with no explicit bump. run 35933457545. (task 1002620)
2467
2467
  1.19.996 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2468
2468
  landed since 1.19.995 with no explicit bump. run 35934673169. (task 1002620)
2469
+ 1.19.997 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2470
+ landed since 1.19.996 with no explicit bump. run 35937246865. (task 1002620)
2471
+ 1.19.998 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2472
+ landed since 1.19.997 with no explicit bump. run 35940192900. (task 1002620)
2469
2473
  ---------------------------------------------------------------------------
2470
2474
  ```
@@ -14,10 +14,23 @@ const api = require('../../../src/module-api');
14
14
  const db = require('../db');
15
15
  const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
16
16
  const goalScopeCheck = api.goalScopeCheck;
17
- const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
17
+ const { isGoalOwner, isGoalOwnerOrManager, authorizeGoalManagement } = require('../goal-authz');
18
18
  const { versionAcceptsNewWork } = require('../db-tasks.js');
19
19
  const METIC_PLUS = ['metic', 'archon'];
20
20
 
21
+ // isOwnerManagerOrArchon — a goal's OWN authority (task 1001739): owner (created_by),
22
+ // manager ('lead' membership) or Archon. Gates archive / reopen / scope-widen; a plain
23
+ // member holds none of these, and no route deletes a goal. An OWNERLESS goal
24
+ // (created_by NULL — the migration-160 system goals) has no owner or manager to
25
+ // ask, so any member keeps the power they had before; only Archons would otherwise
26
+ // be left able to disposition it.
27
+ async function isOwnerManagerOrArchon(builder, goal) {
28
+ if (builder.rank === 'archon') return true;
29
+ if (goal.created_by == null) return db.isGoalMember({ goalId: goal.id, builderId: builder.id });
30
+ const actorMembership = await db.getGoalMember({ goalId: goal.id, builderId: builder.id });
31
+ return isGoalOwnerOrManager({ goal, actorId: builder.id, actorRank: builder.rank, actorMembership });
32
+ }
33
+
21
34
  async function isMemberOrArchon(builder, goalId) {
22
35
  if (builder.rank === 'archon') return true;
23
36
  return db.isGoalMember({ goalId, builderId: builder.id });
@@ -666,6 +679,7 @@ module.exports = {
666
679
  decorateGoalScope,
667
680
  goalNotOpenRefusal,
668
681
  isMemberOrArchon,
682
+ isOwnerManagerOrArchon,
669
683
  planGoalArchiveDispositions,
670
684
  protectedScopeAdmissionBlock,
671
685
  };
@@ -73,7 +73,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
73
73
  // proceed; a false result means "not a member" → the route should 403. This is the
74
74
  // membership half of the wall; the protected-module rank check is enforced per-route
75
75
  // (scope is never self-granted).
76
- const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
76
+ const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, isOwnerManagerOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
77
77
  // task 1003208: structured logging (pino via the doorway) — was console.*.
78
78
  const log = api.logger('lifecycle');
79
79
  module.exports = function buildGoalsRouter() {
@@ -1099,7 +1099,7 @@ module.exports = function buildGoalsRouter() {
1099
1099
  // POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived
1100
1100
  // goals are re-openable (below). This is the lifecycle disposition the /goal-review
1101
1101
  // skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
1102
- // rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
1102
+ // rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.
1103
1103
  router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
1104
1104
  // ADR 0118 / R12: every write route that reads a body declares it, so an
1105
1105
  // unknown field is REJECTED rather than silently ignored — a typo'd
@@ -1117,8 +1117,8 @@ module.exports = function buildGoalsRouter() {
1117
1117
  try {
1118
1118
  const goal = await db.getGoal(id);
1119
1119
  if (!goal) return res.fail('goal_not_found', 404);
1120
- if (!(await isMemberOrArchon(req.builder, id))) {
1121
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to disposition it.' });
1120
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1121
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may archive it.' });
1122
1122
  }
1123
1123
  if (goal.status === 'archived') {
1124
1124
  return res.fail('already_archived', 409);
@@ -1241,8 +1241,8 @@ module.exports = function buildGoalsRouter() {
1241
1241
  try {
1242
1242
  const goal = await db.getGoal(id);
1243
1243
  if (!goal) return res.fail('goal_not_found', 404);
1244
- if (!(await isMemberOrArchon(req.builder, id))) {
1245
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to reopen it.' });
1244
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1245
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may reopen it.' });
1246
1246
  }
1247
1247
  if (goal.status !== 'archived') {
1248
1248
  return res.fail('not_archived', 409, { status: goal.status });
@@ -1257,12 +1257,12 @@ module.exports = function buildGoalsRouter() {
1257
1257
 
1258
1258
  // POST /goals/:id/scope — "ask to widen": add module(s) to a goal's scope_modules
1259
1259
  // (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated
1260
- // approve-and-apply: requireRank('metic','archon') + the caller must be a member
1261
- // (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
1262
- // POST /goals create gate). A sub-Metic member therefore cannot widen at all — they
1260
+ // approve-and-apply: the goal.scope.widen permission + the caller must be the goal's
1261
+ // owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
1262
+ // POST /goals create gate). A plain member therefore cannot widen at all — they
1263
1263
  // ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57).
1264
1264
  // The goal must be open. Body: { add: ["module-key", ...] }; idempotent (set-union).
1265
- // rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
1265
+ // rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
1266
1266
  router.post('/goals/:id/scope', auth.requireBuilder, auth.requirePermission('goal.scope.widen'), async (req, res) => {
1267
1267
  const id = parseId(req, res, { code: 'bad_goal_id' });
1268
1268
  if (id === null) return;
@@ -1288,8 +1288,8 @@ module.exports = function buildGoalsRouter() {
1288
1288
  if (goal.status !== 'open') {
1289
1289
  return res.fail('goal_not_open', 409, { status: goal.status });
1290
1290
  }
1291
- if (!(await isMemberOrArchon(req.builder, id))) {
1292
- return res.fail('not_a_member', { status: 403, message: 'Join the goal (or be an Archon) to widen its scope.' });
1291
+ if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
1292
+ return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may widen its scope.' });
1293
1293
  }
1294
1294
  const updated = await db.addGoalScopeModules({ goalId: id, modules: add });
1295
1295
  return res.json({ ok: true, goal: updated });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.996",
3
+ "version": "1.19.998",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.996",
9
+ "version": "1.19.998",
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.19.996",
3
+ "version": "1.19.998",
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",
@@ -216,7 +216,7 @@ async function runProbes(ctx) {
216
216
  const r = await probePost(`/goals/${target.id}/archive`, {});
217
217
  const after = (await get(`/goals/${target.id}`)).goal;
218
218
  out.push({ absent: r.status === 404, id: 'P2', rule: `R10 — bare archive of goal ${target.id} (holds ${held.get(String(target.id))})`,
219
- status: r.status, refused: r.status === 409, error: r.why, mutated: after.status !== 'open' });
219
+ status: r.status, refused: r.status === 409 || r.status === 403, error: r.why, mutated: after.status !== 'open' });
220
220
  }
221
221
 
222
222
  // P3 — R12: a version holding open goals refuses a close with no dispositions.
@@ -520,29 +520,141 @@ function parseChangeRecords(stdout) {
520
520
  * is interpolated into a command is checked where it is interpolated — the rule
521
521
  * upgradeInvocation states about the slug.
522
522
  */
523
+ /**
524
+ * Classify a git command that did not succeed. PURE.
525
+ *
526
+ * "COULD NOT LOOK" AND "LOOKED AND DID NOT FIND" ARE DIFFERENT ANSWERS (task 1004212).
527
+ * The first cut collapsed both into "could not find those releases in the core history on
528
+ * this machine", and that sentence sent the investigation at the history for an hour when
529
+ * the history was never the problem — the directory was. A message that names the wrong
530
+ * cause is worse than a vague one, because it is followed.
531
+ */
532
+ function gitFault(r, dir) {
533
+ const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
534
+ if (/dubious ownership|safe\.directory/i.test(said)) {
535
+ return `the core checkout at ${dir} belongs to another account, so this could not read it`;
536
+ }
537
+ if (/not a git repository/i.test(said)) return `${dir} is not a git checkout`;
538
+ const tail = said.trim().split('\n').filter(Boolean).slice(-1)[0] || '';
539
+ return `could not read ${dir}${tail ? ` — ${tail.slice(0, 160)}` : ''}`;
540
+ }
541
+
542
+ /**
543
+ * Where the CORE's own git history lives on this machine, if anywhere (task 1004212).
544
+ *
545
+ * WHAT THE FIRST CUT GOT WRONG, and it was not a small thing. It read `selfInstanceRoot()`
546
+ * on the assumption that the control plane's instance root is a checkout of the core. On
547
+ * the live box it is not: `/version` there reports a commit that does not exist in the core
548
+ * repository at all, because the instance root is a checkout of the INSTANCE's own repo
549
+ * with the core vendored in at node_modules (the task 1004123 shape). So every lookup
550
+ * searched the wrong repository's history.
551
+ *
552
+ * THAT IS A CORRECTNESS BUG, NOT ONLY A MISSING FEATURE. On a box whose instance repo
553
+ * happened to carry `Merge <branch>: <summary>` commits — which any repo built this way
554
+ * does — the page would have shown ANOTHER project's changelog as the core's, with no
555
+ * error at all. Which is why a candidate is not trusted for being a git repo: it has to
556
+ * prove it carries core RELEASE history, and `looksLikeCore` is that proof.
557
+ *
558
+ * NO PATH IS HARDCODED. `audit-authorship.sh` forbids naming the founder's directories,
559
+ * and a guessed path is how this got here. The candidates are: an explicit operator
560
+ * setting, the directory the CLI is executing from, and the instance root — each checked,
561
+ * none assumed.
562
+ */
563
+ function coreCheckoutCandidates(deps) {
564
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
565
+ const env = deps.env || process.env;
566
+ const out = [];
567
+ const add = (dir, why) => { if (dir && !out.some((c) => c.dir === dir)) out.push({ dir, why }); };
568
+ add(String(env.PROVISION_CORE_CHECKOUT || '').trim() || null, 'PROVISION_CORE_CHECKOUT');
569
+ add(REPO_ROOT, 'the directory this core is running from');
570
+ add(selfRoot(), "this project's own root");
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * Read the core's release history, or say precisely why it cannot be. Returns
576
+ * `{ dir }` on success, or `{ error }` — never a directory it has not verified.
577
+ */
578
+ function findCoreCheckout(deps, git) {
579
+ const tried = [];
580
+ for (const { dir, why } of coreCheckoutCandidates(deps)) {
581
+ const probe = git(dir, 'rev-parse --is-shallow-repository');
582
+ if (!probe.ok) { tried.push(`${why}: ${gitFault(probe, dir)}`); continue; }
583
+ if (probe.stdout.trim() === 'true') {
584
+ // A depth-limited clone resolves HEAD perfectly well — which is why /version reports
585
+ // `resolvedVia: git` on a box that cannot answer this — and carries no old releases.
586
+ tried.push(`${why}: ${dir} is a shallow clone, so it carries no release history`);
587
+ continue;
588
+ }
589
+ // THE PROOF THAT THIS IS THE CORE. Any repository answers `rev-parse`; only the core's
590
+ // carries release commits, and accepting a repo without asking is how the wrong
591
+ // project's changelog would have been shown as this one's.
592
+ const marker = git(dir, "log --format=%H --max-count=1 -E --grep='^release: core [0-9]+\\.[0-9]+\\.[0-9]+ '");
593
+ if (!marker.ok) { tried.push(`${why}: ${gitFault(marker, dir)}`); continue; }
594
+ if (!marker.stdout.trim()) { tried.push(`${why}: ${dir} is a git checkout, but not of the core`); continue; }
595
+ return { dir };
596
+ }
597
+ return { error: `no core checkout is reachable on this machine (${tried.join('; ')}). Set PROVISION_CORE_CHECKOUT to one.` };
598
+ }
599
+
600
+ /**
601
+ * What landed between two core releases, as the summaries their ships wrote.
602
+ *
603
+ * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
604
+ * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
605
+ * which on a page whose job is to say what a restart buys is the one wrong impression it
606
+ * must not give, and it is exactly what a missing checkout produces.
607
+ *
608
+ * Every version reaching a shell is re-checked against the strict three-number form
609
+ * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
610
+ * is interpolated into a command is checked where it is interpolated — the rule
611
+ * upgradeInvocation states about the slug.
612
+ */
523
613
  function changesBetween(fromVersion, toVersion, deps) {
524
614
  const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
525
615
  if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
526
- // The CONTROL PLANE's executor and the CONTROL PLANE's checkout, never the previewed
527
- // instance's: under PROVISION_REMOTE_HOST `exec` is the remote box, and a tenant's own
528
- // directory holds no core history at all.
616
+ // The CONTROL PLANE's executor, never the previewed instance's: under
617
+ // PROVISION_REMOTE_HOST `exec` is the remote box, while the core history — and the whole
618
+ // question of what changed in the CORE — belongs to the machine that builds it.
529
619
  const exec = deps.controlExec || deps.exec;
530
- const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
531
- const dir = selfRoot();
620
+ // AS THE ACCOUNT THAT OWNS THE CHECKOUT, for the same reason the dry run beside this one
621
+ // does (task 1004128). `appUser()` is defined as "the unprivileged account that owns this
622
+ // checkout" and throws rather than return root, so a runner executing as root reads the
623
+ // files through the account that may actually read them instead of tripping git's
624
+ // ownership check. Unprivileged runners are unchanged — there is nothing to escalate to.
625
+ let runAs = null;
626
+ if (deps.privileged) {
627
+ try { runAs = (deps.appUser || appUser)(); } catch { runAs = null; }
628
+ }
629
+ const git = (dir, args) => {
630
+ const r = exec(`${runAs ? `sudo -u ${runAs} ` : ''}git -C ${dir} ${args}`, { allowFail: true });
631
+ return {
632
+ ok: !!(r && r.softFailed !== true && r.ok !== false),
633
+ stdout: String((r && r.stdout) || ''),
634
+ stderr: String((r && r.stderr) || ''),
635
+ };
636
+ };
637
+ const found = findCoreCheckout(deps, git);
638
+ if (found.error) return { from: fromVersion, to: toVersion, error: found.error };
639
+ const dir = found.dir;
532
640
  const shaOf = (v) => {
533
- const r = exec(`git -C ${dir} log --format=%H --max-count=1 -F --grep='release: core ${v} '`, { allowFail: true });
534
- const sha = String((r && r.stdout) || '').trim().split('\n')[0] || '';
535
- return /^[0-9a-f]{7,40}$/.test(sha) ? sha : null;
641
+ const r = git(dir, `log --format=%H --max-count=1 -F --grep='release: core ${v} '`);
642
+ if (!r.ok) return { fault: gitFault(r, dir) };
643
+ const sha = r.stdout.trim().split('\n')[0] || '';
644
+ return /^[0-9a-f]{7,40}$/.test(sha) ? { sha } : {};
536
645
  };
537
646
  const from = shaOf(fromVersion);
538
647
  const to = shaOf(toVersion);
539
- if (!from || !to) {
540
- return { from: fromVersion, to: toVersion, error: 'could not find those releases in the core history on this machine' };
541
- }
542
- const r = exec(`git -C ${dir} log --merges --format=%s%x1f%b%x1e ${from}..${to}`, { allowFail: true });
543
- if (!r || r.softFailed === true || r.ok === false) {
544
- return { from: fromVersion, to: toVersion, error: 'could not read the core history on this machine' };
648
+ if (from.fault || to.fault) return { from: fromVersion, to: toVersion, error: from.fault || to.fault };
649
+ // LOOKED, AND DID NOT FIND — a different sentence from "could not look", and it names
650
+ // WHICH version is missing so the reader can tell a not-yet-published target from a
651
+ // checkout that has not been fetched.
652
+ const missing = [!from.sha && fromVersion, !to.sha && toVersion].filter(Boolean);
653
+ if (missing.length) {
654
+ return { from: fromVersion, to: toVersion, error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
545
655
  }
656
+ const r = git(dir, `log --merges --format=%s%x1f%b%x1e ${from.sha}..${to.sha}`);
657
+ if (!r.ok) return { from: fromVersion, to: toVersion, error: gitFault(r, dir) };
546
658
  const all = parseChangeRecords(r.stdout);
547
659
  return { from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP) };
548
660
  }
@@ -668,4 +780,4 @@ async function snapshotAllCoreVersions(deps) {
668
780
  return { seen };
669
781
  }
670
782
 
671
- module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
783
+ module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.996'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.998'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -883,16 +883,36 @@ await t('a paragraph-long summary is bounded — it reaches a DOM', () => {
883
883
 
884
884
  // --- the range, and every way it can fail to resolve ------------------------------
885
885
 
886
- const changeDeps = ({ shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) }, log = LOG, logOk = true } = {}) => {
886
+ // A box that really does hold a core checkout. `repos` maps a directory to what git
887
+ // there answers: `core` (a real core checkout), `notCore` (a git repo of something else),
888
+ // `shallow`, `owned` (git refuses on ownership) or absent (not a git repo).
889
+ const changeDeps = ({
890
+ shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) },
891
+ log = LOG, logOk = true, repos = { '/srv/core': 'core' }, env = {}, privileged = false,
892
+ } = {}) => {
887
893
  const ran = [];
888
894
  return {
889
- ran,
895
+ ran, env, privileged,
890
896
  selfInstanceRoot: () => '/srv/core',
897
+ appUser: () => 'appacct',
891
898
  exec: (cmd) => {
892
899
  ran.push(cmd);
893
- const m = /--grep='release: core ([0-9.]+) '/.exec(cmd);
900
+ const dir = (/git -C (\S+) /.exec(cmd) || [])[1];
901
+ const kind = repos[dir];
902
+ if (!kind) return { ok: false, softFailed: true, stdout: '', stderr: `fatal: not a git repository: '${dir}'` };
903
+ if (kind === 'owned') {
904
+ return { ok: false, softFailed: true, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${dir}'` };
905
+ }
906
+ if (/rev-parse --is-shallow-repository/.test(cmd)) {
907
+ return { ok: true, stdout: `${kind === 'shallow' ? 'true' : 'false'}\n` };
908
+ }
909
+ // The marker probe: does this repo carry core RELEASE commits at all?
910
+ if (/-E --grep='\^release: core/.test(cmd)) {
911
+ return { ok: true, stdout: kind === 'core' ? `${'c'.repeat(40)}\n` : '\n' };
912
+ }
913
+ const m = /-F --grep='release: core ([0-9.]+) '/.exec(cmd);
894
914
  if (m) return { ok: true, stdout: `${shas[m[1]] || ''}\n` };
895
- if (!logOk) return { ok: false, softFailed: true, stdout: '' };
915
+ if (!logOk) return { ok: false, softFailed: true, stdout: '', stderr: 'fatal: bad revision' };
896
916
  return { ok: true, stdout: log };
897
917
  },
898
918
  };
@@ -908,29 +928,85 @@ await t('the range resolves through the release commits and lists what landed be
908
928
  assert.ok(d.ran.some((c) => /--merges/.test(c) && /a{40}\.\.b{40}/.test(c)), 'the log is asked for the resolved range');
909
929
  });
910
930
 
911
- await t('IT READS THE CONTROL PLANE’S OWN CHECKOUT, whichever instance is previewed', () => {
912
- // A tenant's directory is a checkout of the TENANT's repo with the core vendored in
913
- // from npm — no core history in it, and the wrong history if you squint. Under
914
- // PROVISION_REMOTE_HOST `exec` is the remote box, so the executor matters too.
931
+ await t('IT USES THE CONTROL PLANE’S EXECUTOR, whichever instance is previewed', () => {
932
+ // Under PROVISION_REMOTE_HOST `exec` is the remote box, while the core history belongs
933
+ // to the machine that builds the core.
915
934
  const d = changeDeps();
916
935
  d.controlExec = d.exec;
917
936
  d.exec = () => { throw new Error('the instance executor must not be used for core history'); };
937
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
938
+ });
939
+
940
+ // ---- WHICH checkout, and the bug that made this task (task 1004212) ---------------
941
+
942
+ await t('A GIT REPO THAT IS NOT THE CORE IS REFUSED, not read as if it were', () => {
943
+ // THE BUG THIS TASK EXISTS FOR. The first cut trusted selfInstanceRoot() to be a core
944
+ // checkout. On the live box it is the INSTANCE's own repo with the core vendored in at
945
+ // node_modules — its /version commit does not exist in the core repository at all. Any
946
+ // repo built that way carries `Merge <branch>: <summary>` commits, so without this the
947
+ // page would have shown ANOTHER project's changelog as the core's, with no error.
948
+ const d = changeDeps({ repos: { '/srv/core': 'notCore' } });
918
949
  const r = R.changesBetween('1.19.900', '1.19.949', d);
919
- assert.equal(r.entries.length, 2);
920
- for (const c of d.ran) assert.match(c, /git -C \/srv\/core /, 'always the control plane’s own core checkout');
950
+ assert.equal(r.entries, undefined, 'nothing is listed from the wrong repository');
951
+ assert.match(r.error, /not of the core/);
952
+ assert.ok(!d.ran.some((c) => /--merges/.test(c)), 'and its log is never even read');
953
+ });
954
+
955
+ await t('the candidates are tried in order, and an operator setting wins', () => {
956
+ const d = changeDeps({
957
+ repos: { '/opt/corerepo': 'core', '/srv/core': 'core' },
958
+ env: { PROVISION_CORE_CHECKOUT: '/opt/corerepo' },
959
+ });
960
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
961
+ assert.ok(d.ran.every((c) => /git -C \/opt\/corerepo /.test(c)), 'only the named checkout is touched');
962
+ });
963
+
964
+ await t('a candidate that is not a core checkout FALLS THROUGH to one that is', () => {
965
+ const d = changeDeps({
966
+ repos: { '/opt/notcore': 'notCore', '/srv/core': 'core' },
967
+ env: { PROVISION_CORE_CHECKOUT: '/opt/notcore' },
968
+ });
969
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
970
+ assert.ok(d.ran.some((c) => /git -C \/srv\/core /.test(c)), 'the next candidate is reached');
971
+ });
972
+
973
+ await t('NO reachable core checkout names every candidate it tried, and the way out', () => {
974
+ const d = changeDeps({ repos: {} });
975
+ const r = R.changesBetween('1.19.900', '1.19.949', d);
976
+ assert.equal(r.entries, undefined);
977
+ assert.match(r.error, /no core checkout is reachable/);
978
+ assert.match(r.error, /PROVISION_CORE_CHECKOUT/, 'and the setting that fixes it');
979
+ });
980
+
981
+ // ---- "could not look" vs "looked and did not find" -------------------------------
982
+
983
+ await t('AN OWNERSHIP REFUSAL SAYS SO — it is not a missing release', () => {
984
+ // The misdiagnosis this replaces: every one of these used to print "could not find
985
+ // those releases in the core history", which sends the reader at the history when the
986
+ // history was never the problem.
987
+ const d = changeDeps({ repos: { '/srv/core': 'owned' } });
988
+ const r = R.changesBetween('1.19.900', '1.19.949', d);
989
+ assert.match(r.error, /belongs to another account/);
990
+ assert.doesNotMatch(r.error, /not in the core history/);
991
+ });
992
+
993
+ await t('a shallow clone says so — it resolves HEAD fine and carries no releases', () => {
994
+ const d = changeDeps({ repos: { '/srv/core': 'shallow' } });
995
+ assert.match(R.changesBetween('1.19.900', '1.19.949', d).error, /shallow clone/);
921
996
  });
922
997
 
923
- await t('A RANGE THAT CANNOT BE RESOLVED IS AN ERROR, NEVER AN EMPTY LIST', () => {
924
- // An empty list reads as "nothing changed", which over ninety-one releases is the one
925
- // wrong impression this section could give — and it is the shape a missing checkout,
926
- // a shallow clone or an unknown release all take.
927
- const missing = R.changesBetween('1.19.900', '9.9.9', changeDeps());
928
- assert.equal(missing.entries, undefined, 'no list at all');
929
- assert.match(missing.error, /could not find those releases/);
998
+ await t('a release the checkout has not fetched NAMES which one', () => {
999
+ const d = changeDeps();
1000
+ const r = R.changesBetween('1.19.900', '9.9.9', d);
1001
+ assert.equal(r.entries, undefined, 'no list at all');
1002
+ assert.match(r.error, /core 9\.9\.9 is not in the core history on this machine yet/);
1003
+ assert.doesNotMatch(r.error, /could not read/, 'it looked successfully — it just did not find it');
1004
+ });
930
1005
 
931
- const unreadable = R.changesBetween('1.19.900', '1.19.949', changeDeps({ logOk: false }));
932
- assert.equal(unreadable.entries, undefined);
933
- assert.match(unreadable.error, /could not read the core history/);
1006
+ await t('a log that fails after the range resolved is reported as a read failure', () => {
1007
+ const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ logOk: false }));
1008
+ assert.equal(r.entries, undefined);
1009
+ assert.match(r.error, /could not read \/srv\/core/);
934
1010
  });
935
1011
 
936
1012
  await t('a version that is not three numbers never reaches the shell', () => {
@@ -941,6 +1017,33 @@ await t('a version that is not three numbers never reaches the shell', () => {
941
1017
  assert.match(R.changesBetween(null, '1.19.949', changeDeps()).error, /could not work out/, 'an unknown starting point too');
942
1018
  });
943
1019
 
1020
+ // ---- as WHICH account ------------------------------------------------------------
1021
+
1022
+ await t('a privileged runner reads git as the account that owns the checkout', () => {
1023
+ // The same move the dry run beside this one makes (task 1004128). appUser() is defined
1024
+ // as "the unprivileged account that owns this checkout", so a runner executing as root
1025
+ // reads through an account that may actually read the files.
1026
+ const d = changeDeps({ privileged: true });
1027
+ R.changesBetween('1.19.900', '1.19.949', d);
1028
+ assert.ok(d.ran.length > 0);
1029
+ for (const c of d.ran) assert.match(c, /^sudo -u appacct git -C /, `every read is escalated: ${c}`);
1030
+ });
1031
+
1032
+ await t('an UNPRIVILEGED runner escalates nothing — there is nothing to escalate to', () => {
1033
+ const d = changeDeps();
1034
+ R.changesBetween('1.19.900', '1.19.949', d);
1035
+ for (const c of d.ran) assert.doesNotMatch(c, /sudo/);
1036
+ });
1037
+
1038
+ await t('an app user that cannot be resolved does not take the whole read down', () => {
1039
+ // appUser() THROWS when it would return root or an unusable name. That is a reason to
1040
+ // read unescalated and let git say whether it could, not a reason to return no answer.
1041
+ const d = changeDeps({ privileged: true });
1042
+ d.appUser = () => { throw new Error('the app user "root" is root'); };
1043
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
1044
+ for (const c of d.ran) assert.doesNotMatch(c, /sudo/);
1045
+ });
1046
+
944
1047
  await t('a long range is capped, and says how many it did not list', () => {
945
1048
  const many = Array.from({ length: R.CHANGE_CAP + 5 }, (_, i) => REC(`Merge b-${i}: summary number ${i}`)).join('');
946
1049
  const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ log: many }));
@@ -109,12 +109,12 @@ test('the refusal RETURNS the tasks, so the caller can decide without a second f
109
109
  });
110
110
 
111
111
  test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
112
- const auth = ARCHIVE.indexOf('isMemberOrArchon');
112
+ const auth = ARCHIVE.indexOf('isOwnerManagerOrArchon');
113
113
  const already = ARCHIVE.indexOf("'already_archived'");
114
114
  const gate = ARCHIVE.indexOf('openTasksInGoal');
115
115
  const write = ARCHIVE.indexOf('db.setGoalStatus');
116
116
  assert.ok(auth !== -1 && already !== -1 && gate !== -1 && write !== -1, 'all four landmarks present');
117
- assert.ok(auth < gate, 'a non-member must not learn what a goal is holding');
117
+ assert.ok(auth < gate, 'a non-owner must not learn what a goal is holding');
118
118
  assert.ok(already < gate, 're-archiving an archived goal keeps its own answer');
119
119
  assert.ok(gate < write, 'a refusal after the write is not a refusal');
120
120
  });
@@ -0,0 +1,90 @@
1
+ // tests/goal_owner_manager_powers.mjs — archive / reopen / scope-widen belong to a
2
+ // goal's own authority (task 1001739): the owner (created_by), a manager (a 'lead'
3
+ // membership) or an Archon. A plain member — who used to pass — is refused 403
4
+ // not_owner_or_manager. Drives the REAL router with the db layer stubbed.
5
+ //
6
+ // Run: node tests/goal_owner_manager_powers.mjs
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+ const require = createRequire(import.meta.url);
11
+ process.env.NODE_ENV = 'test';
12
+
13
+ const express = require('express');
14
+ const api = require('../src/module-api.js');
15
+ const db = require('../modules/lifecycle/db.js');
16
+
17
+ let WHO = { id: '10', rank: 'metic' };
18
+ api.requireBuilder = (req, _res, next) => { req.builder = WHO; next(); };
19
+ api.requirePermission = () => (req, _res, next) => next();
20
+
21
+ const OWNER = '10', MANAGER = '11', MEMBER = '12';
22
+ const GOALS = {
23
+ 1: { id: 1, status: 'open', title: 'g', version_id: 'V', created_by: OWNER, scope_modules: [] },
24
+ 3: { id: 3, status: 'open', title: 'system', version_id: 'V', created_by: null, scope_modules: [] },
25
+ 2: { id: 2, status: 'archived', title: 'a', version_id: 'V', created_by: OWNER, scope_modules: [] },
26
+ };
27
+ const KINDS = { [MANAGER]: 'lead', [MEMBER]: 'member' };
28
+ db.getGoal = async (id) => GOALS[Number(id)] ?? null;
29
+ db.getGoalMember = async ({ builderId }) => (KINDS[builderId] ? { builder_id: builderId, membership_kind: KINDS[builderId] } : null);
30
+ db.isGoalMember = async ({ builderId }) => !!KINDS[builderId] || builderId === OWNER;
31
+ db.getVersion = async () => null;
32
+ db.openTasksInGoal = async () => ({ total: 0, tasks: [] });
33
+ db.archiveGoalWithDispositions = async ({ goalId }) => ({ goal: { ...GOALS[goalId], status: 'archived' }, applied: [] });
34
+ db.setGoalStatus = async ({ goalId, status }) => ({ ...GOALS[goalId], status });
35
+ db.addGoalScopeModules = async ({ goalId }) => GOALS[goalId];
36
+
37
+ const app = express();
38
+ app.use(express.json());
39
+ app.use((req, res, next) => {
40
+ res.fail = (code, o, d) => { const x = typeof o === 'number' ? { status: o } : (o || {}); return res.status(x.status || 400).json({ error: code, details: x.details ?? d }); };
41
+ next();
42
+ });
43
+ app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
44
+ const server = app.listen(0);
45
+ await new Promise(r => server.once('listening', r));
46
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
47
+ const post = async (p, body) => {
48
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
49
+ return { status: r.status, body: await r.json().catch(() => null) };
50
+ };
51
+ const calls = [
52
+ ['/goals/1/archive', {}],
53
+ ['/goals/2/reopen', {}],
54
+ ['/goals/1/scope', { add: ['hall-ui'] }],
55
+ ];
56
+ let n = 0;
57
+ for (const [who, label, expectOk] of [
58
+ [{ id: OWNER, rank: 'metic' }, 'owner', true],
59
+ [{ id: MANAGER, rank: 'metic' }, 'manager', true],
60
+ [{ id: '99', rank: 'archon' }, 'archon', true],
61
+ [{ id: MEMBER, rank: 'metic' }, 'plain member', false],
62
+ [{ id: '77', rank: 'metic' }, 'non-member', false],
63
+ ]) {
64
+ WHO = who;
65
+ for (const [path, body] of calls) {
66
+ const r = await post(path, body);
67
+ if (expectOk) assert.equal(r.status, 200, `${label} ${path} → ${r.status} ${JSON.stringify(r.body)}`);
68
+ else { assert.equal(r.status, 403, `${label} ${path}`); assert.equal(r.body.error, 'not_owner_or_manager'); }
69
+ n++;
70
+ }
71
+ }
72
+ // An OWNERLESS goal (created_by NULL, the system catch-alls): a member keeps the
73
+ // power, a non-member has none — otherwise only an Archon could ever disposition it.
74
+ WHO = { id: MEMBER, rank: 'metic' };
75
+ assert.equal((await post('/goals/3/archive')).status, 200); n++;
76
+ WHO = { id: '77', rank: 'metic' };
77
+ assert.equal((await post('/goals/3/archive')).status, 403); n++;
78
+
79
+ // POST /goals creation gate: a protected-module scope is Archon-only.
80
+ WHO = { id: OWNER, rank: 'metic' };
81
+ let c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
82
+ assert.equal(c.status, 403); assert.equal(c.body.error, 'archon_required_for_protected_scope'); n++;
83
+ c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['hall-ui'], admit_reason: 'because reasons' });
84
+ assert.equal(c.body.error, 'archon_required_for_admission'); n++;
85
+ WHO = { id: '99', rank: 'archon' };
86
+ c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
87
+ assert.notEqual(c.body && c.body.error, 'archon_required_for_protected_scope'); n++;
88
+
89
+ console.log(`${n} owner/manager/member checks passed against the real router.`);
90
+ server.close();