@bongos/core 1.19.995 → 1.19.997

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.995",
6
- "core_contract": "1.19.995",
7
- "source_commit": "5289c5d78570e086cd6f6d7ee4c154883c7adb78",
5
+ "core_version": "1.19.997",
6
+ "core_contract": "1.19.997",
7
+ "source_commit": "e3e7048278119785fc3a9a9ca14edefba9ecae54",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T23:25:13.365Z",
9
+ "built_at": "2026-09-24T00:11:45.316Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 519,
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": 2950,
20
- "tree_sha256": "ca075a6b55f5c4d672bea732dad07148b251ceceabcea4a3823702e708fc28c7",
19
+ "file_count": 2952,
20
+ "tree_sha256": "ce61953a922e8482ee9341329d06ba7a71aa4367e0449c4d9a9990de9ff82e23",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.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": "42c93e21972b2c4cf9e69574fbd72957a80d03ce51f5a86e6d28579981de11df"
2720
+ "sha256": "1b16fcc54d5a5c01d44d93ff307eef44ddec9a993bd81db0d41db4b71f4e5654"
2721
2721
  },
2722
2722
  {
2723
2723
  "path": "docs/modules-contract.md",
@@ -2924,6 +2924,11 @@
2924
2924
  "mode": "0000644",
2925
2925
  "sha256": "f6d2bb1da53f03f54080b8e0aec9f63390d3343c32917b64225ac47ae4ba7b42"
2926
2926
  },
2927
+ {
2928
+ "path": "docs/recipes/render-pilot.md",
2929
+ "mode": "0000644",
2930
+ "sha256": "7b5f391285931a5f5e1bd31683702d534e9876d55ce5a0794e7d8eeec52fc2f3"
2931
+ },
2927
2932
  {
2928
2933
  "path": "docs/recipes/reranking-a-goals-board.md",
2929
2934
  "mode": "0000644",
@@ -6082,12 +6087,12 @@
6082
6087
  {
6083
6088
  "path": "modules/lifecycle/routes/goal-route-authz.js",
6084
6089
  "mode": "0000644",
6085
- "sha256": "3b4fb0168b29fb0df1260c23c5b14d96576f00f587dd5c188fe17a712e3dffaa"
6090
+ "sha256": "89d282e6c5110ae698484ddab698a72f27c6fe509fc97ff628326fe42e79483d"
6086
6091
  },
6087
6092
  {
6088
6093
  "path": "modules/lifecycle/routes/goals.js",
6089
6094
  "mode": "0000644",
6090
- "sha256": "91039df9ca263ad34bda8e230b8e52f2099e6114a2a9eb4bae2b6b21210994c8"
6095
+ "sha256": "8bd3b013fa96d062f15386e53f43dcdb918baf619c2beb5cf5333ef7052475f3"
6091
6096
  },
6092
6097
  {
6093
6098
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -8182,12 +8187,12 @@
8182
8187
  {
8183
8188
  "path": "package-lock.json",
8184
8189
  "mode": "0000644",
8185
- "sha256": "1b91fb64b2d18219b4433b0ef6589de4488e9d7d78db9a1af45268a1f2a51944"
8190
+ "sha256": "93a81b5f6bc16e21a3ad6abd52490ccfbefe0f2d11af8dfd6273f03dd0c91ca0"
8186
8191
  },
8187
8192
  {
8188
8193
  "path": "package.json",
8189
8194
  "mode": "0000644",
8190
- "sha256": "6b994df4ca85e51d032ee640a4aa4b6dfb57d82581a840eb5de5f05a0b831ee3"
8195
+ "sha256": "fe2540636dec8118f88d3c037c991fb3f9f784a72edec9569ecce1f09ec4ab97"
8191
8196
  },
8192
8197
  {
8193
8198
  "path": "public-docs/index.html",
@@ -9122,7 +9127,7 @@
9122
9127
  {
9123
9128
  "path": "scripts/gds/prove-strict-versioning.js",
9124
9129
  "mode": "0000644",
9125
- "sha256": "b0e2c14720e8258eba4d4ef7f69cc2bcbb26c3be312deb891383dce7a1dbf71e"
9130
+ "sha256": "65bb758becd002ae830a8338ec6ba0bc21ec5ef91ad564c85a2a94b7ab6ffbbc"
9126
9131
  },
9127
9132
  {
9128
9133
  "path": "scripts/gds/provision-byo-host.js",
@@ -10232,7 +10237,7 @@
10232
10237
  {
10233
10238
  "path": "src/module-api.js",
10234
10239
  "mode": "0000644",
10235
- "sha256": "34e569846f695b7f3b69061619a97ab92cda5a4262a67f6c016f31edaf984714"
10240
+ "sha256": "aa4d373556efe35c070f90b0ff308b302fbb9887f06cd3655d0fb9df8b157faa"
10236
10241
  },
10237
10242
  {
10238
10243
  "path": "src/module-loader/catalog.js",
@@ -11657,7 +11662,7 @@
11657
11662
  {
11658
11663
  "path": "tests/goal_archive_disposition.mjs",
11659
11664
  "mode": "0000644",
11660
- "sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
11665
+ "sha256": "de5b561ffe78ead5f6a1ad08066aaa2672627f441f8602fd46e1fdac453512e0"
11661
11666
  },
11662
11667
  {
11663
11668
  "path": "tests/goal_archive_hall.mjs",
@@ -11764,6 +11769,11 @@
11764
11769
  "mode": "0000644",
11765
11770
  "sha256": "c93422d76ff7f0107190c6de1954f110e92a18913fabbcdea2b4f67693d538e6"
11766
11771
  },
11772
+ {
11773
+ "path": "tests/goal_owner_manager_powers.mjs",
11774
+ "mode": "0000644",
11775
+ "sha256": "9853fe32906b842e6c78dd024e33bee5c00649ed2de2f6b593d736d0155e7832"
11776
+ },
11767
11777
  {
11768
11778
  "path": "tests/goal_routes.mjs",
11769
11779
  "mode": "0000644",
@@ -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": [
@@ -2464,5 +2464,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2464
2464
  landed since 1.19.993 with no explicit bump. run 35929622479. (task 1002620)
2465
2465
  1.19.995 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2466
2466
  landed since 1.19.994 with no explicit bump. run 35933457545. (task 1002620)
2467
+ 1.19.996 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
2467
2471
  ---------------------------------------------------------------------------
2468
2472
  ```
@@ -0,0 +1,218 @@
1
+ # Recipe — the Render pilot, on a throwaway `mercury` clone
2
+
3
+ The executable half of [task 1004142](https://cloudbongos.com/builders#/task/1004142): stand one Bongos instance up on Render, end to end, and answer the questions [task 1004183](https://cloudbongos.com/builders#/task/1004183) (the standup leg) will be written against. Governed by [ADR 0327](../adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md); platform choice from [ADR 0323](../adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md).
4
+
5
+ **Who runs this.** Whoever holds the Render key — under ADR 0327 that is the *project owner*, and for the pilot it is the platform owner. An agent session cannot run it unless someone has put a key in front of it, and a key must never be pasted into a chat.
6
+
7
+ **The API shape below was read from Render's own reference on 2026-09-23** (`api-docs.render.com`), not inferred. Where this recipe and a live response disagree, the response wins and this file is the bug.
8
+
9
+ ---
10
+
11
+ ## 0. Before you start — three things worth knowing
12
+
13
+ **The pilot costs money, and the free tier cannot prove it.** Done-when clause 4 is "its database is backed up by Render rather than by our timer". Render's **free Postgres has no backups of any kind** — no logical backups, no PITR. Only paid databases are continually backed up (3 days of recovery window on Hobby, 7 on Pro). So the pilot must run a **paid** Postgres or clause 4 is unprovable. Budget `0.1c-256mb` ($6/mo) + `0.5c-512mb` web ($7/mo), prorated for the hours the pilot is up, and tear it down when finished.
14
+
15
+ **The key is account-wide, not workspace-wide.** `GET /owners` returns every workspace the key's *user* can reach, including other people's teams. This is the finding that produced ADR 0327, and step 1 exists to confirm it on live responses before anything is created.
16
+
17
+ **Do not touch live `mercury`.** Row 31 is a real owner's project, serving from our box today. Clone its *repo* to a scratch repo under an account we control, and stand the clone up under a scratch slug. Nothing in this recipe writes to row 31, to `willHermesLines/mercury_bongos`, or to that owner's Render account.
18
+
19
+ Set up once:
20
+
21
+ ```bash
22
+ export RENDER_TOKEN=... # in your shell, not in a file, not in chat
23
+ export R="curl -sS -H \"Authorization: Bearer $RENDER_TOKEN\" -H 'Content-Type: application/json'"
24
+ ```
25
+
26
+ Base URL is `https://api.render.com/v1`; auth is `Authorization: Bearer <key>`.
27
+
28
+ ---
29
+
30
+ ## 1. The `ownerId` guard — confirm the finding BEFORE creating anything
31
+
32
+ This is the pilot's most important output, because every call the standup leg later writes depends on it.
33
+
34
+ ```bash
35
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" https://api.render.com/v1/owners | jq
36
+ ```
37
+
38
+ Each element is `{ owner: { id, name, email, type }, cursor }`, where `type` is `user` or `team`.
39
+
40
+ **Record:** how many workspaces come back, which are not ours, and their ids. Then pick the Bongos workspace's `id` and pin it:
41
+
42
+ ```bash
43
+ export RENDER_OWNER_ID=...
44
+ ```
45
+
46
+ **Then prove the hazard rather than assuming it.** List a resource *without* `ownerId` and check whether another workspace's resources appear:
47
+
48
+ ```bash
49
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" 'https://api.render.com/v1/services?limit=100' \
50
+ | jq '[.[].service | {id, name, ownerId}] | group_by(.ownerId) | map({ownerId: .[0].ownerId, n: length})'
51
+ ```
52
+
53
+ If more than one `ownerId` comes back from a key that was only ever meant for one workspace, the finding is confirmed and the guard is mandatory. **Record the actual output either way** — a negative result is just as useful and would narrow ADR 0327's §"required guard".
54
+
55
+ **The rule the standup leg must implement, whatever this shows:**
56
+
57
+ - Send an explicit `ownerId` on every call. It is *required* on `POST /services` and `POST /postgres`, and it is *optional* on the list calls — which is exactly where the hazard lives.
58
+ - Assert `ownerId` on the **response**, not the request. A request you got right tells you nothing about a response that came from elsewhere.
59
+ - Treat missing or mismatched as a hard error. Never default it, and never derive the workspace by taking the first element of `/owners`.
60
+
61
+ ---
62
+
63
+ ## 2. Clone the repo
64
+
65
+ `mercury`'s instance row carries `target_ref=willHermesLines/mercury_bongos` and `onboard_mode=adopt`. Clone it to a scratch repo you own — this is the pilot's subject and it is disposable.
66
+
67
+ ```bash
68
+ git clone https://github.com/willHermesLines/mercury_bongos /tmp/mercury-clone
69
+ cd /tmp/mercury-clone && git remote remove origin
70
+ gh repo create <you>/render-pilot --private --source=. --push
71
+ ```
72
+
73
+ **Record:** whether the repo is public or private, and under which account. That feeds the open question in step 5.
74
+
75
+ ---
76
+
77
+ ## 3. Create the Postgres
78
+
79
+ ```bash
80
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
81
+ https://api.render.com/v1/postgres -d '{
82
+ "name": "render-pilot-db",
83
+ "ownerId": "'"$RENDER_OWNER_ID"'",
84
+ "plan": "basic_256mb",
85
+ "region": "virginia",
86
+ "version": "16"
87
+ }' | jq
88
+ ```
89
+
90
+ `plan`, `ownerId`, `name` and `version` are all required. `databaseName` and `databaseUser` are generated if omitted. **`plan` must not be `free`** — see §0. Region `virginia` is the closest US region to the current droplet (`PROVISION_REGION=nyc3`).
91
+
92
+ Then read the connection string:
93
+
94
+ ```bash
95
+ curl -sS -H "Authorization: Bearer $RENDER_TOKEN" \
96
+ https://api.render.com/v1/postgres/<postgresId>/connection-info | jq
97
+ ```
98
+
99
+ Returns `password`, `internalConnectionString`, `externalConnectionString`, `psqlCommand`, and optionally the pooled variants. **Assert the `ownerId` on the create response before going further.**
100
+
101
+ ---
102
+
103
+ ## 4. Create the web service
104
+
105
+ Bongos reads `DATABASE_URL` first and falls back to the `PG*` family (`src/bongos/pool.js:56`), so Render's connection string wires in directly — **none of the `PGHOST`/`PGDATABASE`/`PGUSER`/`PGPASSWORD` lines `webEnvBody` writes on the box are needed here.** That is the single biggest simplification Render buys, and it is worth stating in the standup leg's comments.
106
+
107
+ The env prefix is **`BONGOS`** (`config/branding.neutral.json` → `envPrefix`). The variables below are the Render translation of `webEnvBody` in `scripts/gds/provision-units.js:192` — read that function, not this list, if the two ever disagree.
108
+
109
+ ```bash
110
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
111
+ https://api.render.com/v1/services -d '{
112
+ "type": "web_service",
113
+ "name": "render-pilot",
114
+ "ownerId": "'"$RENDER_OWNER_ID"'",
115
+ "repo": "https://github.com/<you>/render-pilot",
116
+ "branch": "main",
117
+ "autoDeploy": "yes",
118
+ "serviceDetails": {
119
+ "runtime": "node",
120
+ "plan": "0.5c-512mb",
121
+ "region": "virginia",
122
+ "healthCheckPath": "/healthz",
123
+ "envSpecificDetails": {
124
+ "buildCommand": "npm ci",
125
+ "startCommand": "npm start"
126
+ }
127
+ },
128
+ "envVars": [
129
+ { "key": "DATABASE_URL", "value": "<internalConnectionString>" },
130
+ { "key": "BONGOS_GITHUB_CLIENT_ID", "value": "" },
131
+ { "key": "BONGOS_GITHUB_CLIENT_SECRET", "value": "" },
132
+ { "key": "BONGOS_FIRST_ADMIN", "value": "<your-github-login>" },
133
+ { "key": "BONGOS_PUBLIC_ORIGIN", "value": "https://render-pilot.onrender.com" },
134
+ { "key": "BONGOS_BUILDERS_ORIGIN", "value": "https://render-pilot.onrender.com" },
135
+ { "key": "BONGOS_STATUS_ORIGIN", "value": "https://render-pilot.onrender.com" },
136
+ { "key": "BONGOS_OAUTH_ORIGIN", "value": "https://render-pilot.onrender.com" }
137
+ ]
138
+ }' | jq
139
+ ```
140
+
141
+ Notes that matter:
142
+
143
+ - **`PORT` is not set by us.** Render injects it; `webEnvBody` writes it because the box allocates ports itself. One more line the standup leg drops.
144
+ - **The four origin overrides are load-bearing, not cosmetic.** Without them `src/branding.js` stays on neutral localhost and the hall never loads (task 1972). They must be the Render hostname, and they must all four be the same host — a single-host instance has no separate `builders.`/`status.` subdomain.
145
+ - **`BONGOS_FIRST_ADMIN` is what seats you as Archon** on first sign-in. Omit it and the instance has no owner and you can never claim it (the 2026-07-07 `demo` regression).
146
+ - **OAuth creds start blank** and are filled after the app exists — see step 5.
147
+ - `PGPOOL_MAX` is read bare and still applies. On `basic_256mb` the connection ceiling is low; if the app struggles to connect, set it small (the box uses `PROVISION_INSTANCE_PGPOOL_MAX`).
148
+ - Do **not** send a `secretFiles` entry for the GitHub App PEM until you know whether the App path is in scope for the pilot; blank OAuth is enough to prove boot.
149
+
150
+ ---
151
+
152
+ ## 5. Sign-in, and the open question the pilot exists to answer
153
+
154
+ Create a GitHub OAuth app (or App) whose callback is the Render hostname, then set `BONGOS_GITHUB_CLIENT_ID` / `_SECRET` via `PATCH /services/{id}/env-vars/{key}` and redeploy.
155
+
156
+ **The question ADR 0327 left for this pilot:** can Render's GitHub App reach a repo under a **personal** account rather than an org, and what does that mean for a project whose repo we do not own? Record:
157
+
158
+ - Which GitHub account/org the pilot repo sits under, and whether Render's GitHub App installed onto it without friction.
159
+ - Whether a *private* repo under someone else's personal account is reachable at all.
160
+ - What the owner has to click, since under ADR 0327 the repo and the Render account are both theirs.
161
+
162
+ This is the answer that decides whether `cloud-host` standup is one API sequence or a sequence plus a human step — and therefore how much friction `cloud-host` actually removes over `byo-host`.
163
+
164
+ ---
165
+
166
+ ## 6. Migrations
167
+
168
+ Run `migrate.sh` against the Render database. Prefer a Render **job** or the service's own shell over your laptop, so the run happens from inside Render's network on the `internalConnectionString`; the `externalConnectionString` works from outside but is not what production will use.
169
+
170
+ Then confirm no schema lag:
171
+
172
+ ```bash
173
+ curl -sS https://render-pilot.onrender.com/version | jq
174
+ ```
175
+
176
+ ---
177
+
178
+ ## 7. Custom domain (optional for the pilot, required for the model)
179
+
180
+ ADR 0323 keeps **Cloudflare DNS as the front door** and drops the Caddy site block, because Render terminates TLS. Prove the chain end to end at least once:
181
+
182
+ ```bash
183
+ curl -sS -X POST -H "Authorization: Bearer $RENDER_TOKEN" -H 'Content-Type: application/json' \
184
+ https://api.render.com/v1/services/<serviceId>/custom-domains -d '{"name": "render-pilot.cloudbongos.com"}' | jq
185
+ ```
186
+
187
+ Then point the Cloudflare record at Render and confirm the response header is **no longer `via: 1.1 Caddy`** — that header is precisely how we established the live `mercury` instance is still on our box, and its absence is the cleanest proof the front door moved.
188
+
189
+ ---
190
+
191
+ ## 8. Capture the evidence — the five done-when clauses
192
+
193
+ Record each in the session log, with the actual output, not a claim that it worked:
194
+
195
+ | # | Clause | Evidence |
196
+ |---|---|---|
197
+ | 1 | Boots | `GET /healthz` → 200, and the header does **not** say `via: 1.1 Caddy` |
198
+ | 2 | Signs in | the OAuth round trip completes and seats you as Archon |
199
+ | 3 | Migrations run | `migrate.sh` completes; `/version` reports no schema lag |
200
+ | 4 | **Render backs up the DB** | a backup exists in Render's dashboard/API that we did not create — this is the clause that retires `provision-units.js`'s backup service + timer, and it needs a **paid** database |
201
+ | 5 | GitHub App reach | written answer to §5 |
202
+
203
+ Plus the three the standup leg is written from: the **exact call sequence** above with real request/response shapes, the **`ownerId` guard's live behaviour** from §1, and whether the **default plan sizes** held or mercury's larger choice was right.
204
+
205
+ ---
206
+
207
+ ## 9. Tear the clone down
208
+
209
+ The pilot is disposable and it is costing money. Delete the web service, delete the Postgres, delete the scratch repo, and remove the Cloudflare record if you made one.
210
+
211
+ **Note the asymmetry deliberately:** you are deleting resources in *your own* account, by hand. ADR 0327 decision 3 says the platform must never hold the ability to destroy infrastructure it does not own — `provision-teardown.js` retargets to *forgetting*, not to a Render delete call ([task 1004183](https://cloudbongos.com/builders#/task/1004183)). The fact that this step is manual is the design working, not a gap in it.
212
+
213
+ ---
214
+
215
+ ## Sources
216
+
217
+ - [Render API reference](https://api-docs.render.com/reference/introduction) — endpoints, required fields, plan and region enums, read 2026-09-23
218
+ - [Render Postgres Recovery and Backups](https://render.com/docs/postgresql-backups) — free plans have no backups; paid plans get PITR with 3-day (Hobby) / 7-day (Pro) windows
@@ -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.995",
3
+ "version": "1.19.997",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.995",
9
+ "version": "1.19.997",
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.995",
3
+ "version": "1.19.997",
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.
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.995'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.997'; // 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');
@@ -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();