@bongos/core 1.19.976 → 1.19.978

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.976",
6
- "core_contract": "1.19.976",
7
- "source_commit": "842af7dc231eb9ad6803f0de071d554622562402",
5
+ "core_version": "1.19.978",
6
+ "core_contract": "1.19.978",
7
+ "source_commit": "07b4598f521bd18dea921d1f707ab68214f85278",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T15:01:32.683Z",
9
+ "built_at": "2026-09-23T15:32:43.462Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 516,
12
+ "docs_redacted": 517,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2382,
14
+ "functional_verbatim": 2386,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2924,
20
- "tree_sha256": "6ad34cb8327cf6329010817bd052759a99e906fb785e2f370bb15ee113343cc1",
19
+ "file_count": 2929,
20
+ "tree_sha256": "e4b9a04a3018488400611b2d2bc6029ec350c267a483e351233d87a8f0a55948",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -142,12 +142,12 @@
142
142
  {
143
143
  "path": ".claude/skills/goal-create/SKILL.md",
144
144
  "mode": "0000644",
145
- "sha256": "b6ee07ebb27de1b8b4d3406617f0f577e07f72e38df9df0f65381b03eae00b39"
145
+ "sha256": "fef368de4b2a0b42aad43f505b053d7b84aac34bf8f1da7525053970e8e27f42"
146
146
  },
147
147
  {
148
148
  "path": ".claude/skills/goal-review/SKILL.md",
149
149
  "mode": "0000644",
150
- "sha256": "606df145f399fb1aa483d608258eda0f2a88906142d5e675d7aacf272d235e0a"
150
+ "sha256": "39016b0c0bf7c42e0b0ee1dc9bff23b90401a1ad29c34f7a475406660855be79"
151
151
  },
152
152
  {
153
153
  "path": ".claude/skills/grade-audit/SKILL.md",
@@ -2129,10 +2129,15 @@
2129
2129
  "mode": "0000644",
2130
2130
  "sha256": "2c80244a082fb8c907bc83690724dbfb775adc35da124977668d8e56425d99b2"
2131
2131
  },
2132
+ {
2133
+ "path": "docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md",
2134
+ "mode": "0000644",
2135
+ "sha256": "8a59c1aa7f81d335e96c6305bb4f22689c4a0fee627eecdec9585e76a3477bea"
2136
+ },
2132
2137
  {
2133
2138
  "path": "docs/adr/README.md",
2134
2139
  "mode": "0000644",
2135
- "sha256": "114e0093960f4c02a66e79fc10c3ff779889452889886b7e07c0fe732c751915"
2140
+ "sha256": "465a6e705a62c9d57ccca883d11ef09afa22e39dfdcb42f572c09a7411aef475"
2136
2141
  },
2137
2142
  {
2138
2143
  "path": "docs/api-reference.md",
@@ -2702,7 +2707,7 @@
2702
2707
  {
2703
2708
  "path": "docs/module-api-changelog.md",
2704
2709
  "mode": "0000644",
2705
- "sha256": "9b3e01085f3f3648c51df5ecc0e6d6b4e6c687e90aceaa4e3c80689500767c88"
2710
+ "sha256": "8d5ba6662f56dc81bf49320bc07c18445e5097f3e40ed0e53f5af4a24a96f39b"
2706
2711
  },
2707
2712
  {
2708
2713
  "path": "docs/modules-contract.md",
@@ -5797,7 +5802,7 @@
5797
5802
  {
5798
5803
  "path": "modules/lifecycle/db-tasks.js",
5799
5804
  "mode": "0000644",
5800
- "sha256": "ba8a41a33d3612f41509d50c65533f169b4008bbc8a01df783dbd03fe872afa4"
5805
+ "sha256": "d0844d8f53c003e6026918ad2c132262a358b8c64951aa7927eace1e37fcd41f"
5801
5806
  },
5802
5807
  {
5803
5808
  "path": "modules/lifecycle/db-versions.js",
@@ -5814,6 +5819,11 @@
5814
5819
  "mode": "0000644",
5815
5820
  "sha256": "4738131a48e85dd7bf0644ddd3686c85d0d1d0da55ee623b1834d048c614b91c"
5816
5821
  },
5822
+ {
5823
+ "path": "modules/lifecycle/dependency-advisory.js",
5824
+ "mode": "0000644",
5825
+ "sha256": "b2d93f29010a27f50d5da5712e485b7fac0ef6582f3e531052699b321c693d5e"
5826
+ },
5817
5827
  {
5818
5828
  "path": "modules/lifecycle/done-when.js",
5819
5829
  "mode": "0000644",
@@ -5964,6 +5974,11 @@
5964
5974
  "mode": "0000644",
5965
5975
  "sha256": "85f7c058dcd6bc3f3d7e4d46ee1c58047347c9bff833148ec715f2a29fe7d7a7"
5966
5976
  },
5977
+ {
5978
+ "path": "modules/lifecycle/missing-dep-classify.js",
5979
+ "mode": "0000644",
5980
+ "sha256": "d89f4659debd15dfac18258ba1e900f58c757e02937444d42391acec727afef0"
5981
+ },
5967
5982
  {
5968
5983
  "path": "modules/lifecycle/module.json",
5969
5984
  "mode": "0000644",
@@ -6107,7 +6122,7 @@
6107
6122
  {
6108
6123
  "path": "modules/lifecycle/routes/task-write-routes.js",
6109
6124
  "mode": "0000644",
6110
- "sha256": "bb172485d8d5f84d96cdb611b0d57cd5f69ee768bdceddeb1eea42dc3f5676c1"
6125
+ "sha256": "7f9419719070ec7048a2c81149427cbdaf3bf0e4a33993c1c79453161bdc06cc"
6111
6126
  },
6112
6127
  {
6113
6128
  "path": "modules/lifecycle/routes/tasks.js",
@@ -8132,12 +8147,12 @@
8132
8147
  {
8133
8148
  "path": "package-lock.json",
8134
8149
  "mode": "0000644",
8135
- "sha256": "de02838b05a35599823a045bae0ed5e317ee334951d9dee697785c790bd90e66"
8150
+ "sha256": "1250bd983d6fae46de399b17b3a04dc4ffd89acb0c259b814b820fbedcedce35"
8136
8151
  },
8137
8152
  {
8138
8153
  "path": "package.json",
8139
8154
  "mode": "0000644",
8140
- "sha256": "a93b66e4c048714c36e8c237004cc15061ea8776795535ddd7b179fe1b1b1bfc"
8155
+ "sha256": "36026f4837ffb4ce421f652247a82f1e632039142f154f8d5803de9425523c94"
8141
8156
  },
8142
8157
  {
8143
8158
  "path": "public-docs/index.html",
@@ -8272,7 +8287,7 @@
8272
8287
  {
8273
8288
  "path": "scripts/gds/audit-deps.js",
8274
8289
  "mode": "0000644",
8275
- "sha256": "ff7ccdf15ae2c215d3330e957d6a36adc7d48f5d9bd5042b5d61bb228b878073"
8290
+ "sha256": "529829a6ba6de16a0166ac3bf4108366c2831d6ac76eac1b448b9fe3b397d775"
8276
8291
  },
8277
8292
  {
8278
8293
  "path": "scripts/gds/audit-exports.js",
@@ -10162,7 +10177,7 @@
10162
10177
  {
10163
10178
  "path": "src/module-api.js",
10164
10179
  "mode": "0000644",
10165
- "sha256": "73c1013f0db275ad8c71b17973158d0ac150c99e9006a3d0aafac94758fa6f13"
10180
+ "sha256": "92413d2fc4ee3c637dd738d498afb53fdbae34119ba940b9fb72c46c391dc0d6"
10166
10181
  },
10167
10182
  {
10168
10183
  "path": "src/module-loader/catalog.js",
@@ -11209,6 +11224,11 @@
11209
11224
  "mode": "0000644",
11210
11225
  "sha256": "9d16748bfadb1b13969613bd7a43bed42ea3e1bdcc7254ab26fba14ea8b4c036"
11211
11226
  },
11227
+ {
11228
+ "path": "tests/dependency_advisory.mjs",
11229
+ "mode": "0000644",
11230
+ "sha256": "9141b1e9544eceeaf94d5156a4c150236db7011f769dafec9a70f5fd5f51bc25"
11231
+ },
11212
11232
  {
11213
11233
  "path": "tests/deploy_audit_gate.mjs",
11214
11234
  "mode": "0000644",
@@ -12774,6 +12794,11 @@
12774
12794
  "mode": "0000644",
12775
12795
  "sha256": "1da3144c843ebcacc914f8ccc6e4e0578b2efc40bd3d2feb99d3665989923512"
12776
12796
  },
12797
+ {
12798
+ "path": "tests/missing_dep_classify.mjs",
12799
+ "mode": "0000644",
12800
+ "sha256": "0bd80e5c2a08bf90509563a878cceea2485d7755022f7818f036c37fe60a32e6"
12801
+ },
12777
12802
  {
12778
12803
  "path": "tests/model_alloc_prefs.mjs",
12779
12804
  "mode": "0000644",
@@ -153,6 +153,14 @@ Run the phases above **with the orphan task as the seed** — its title, descrip
153
153
 
154
154
  **A goal-less task is not an error and was never blocked.** `goal_advisory` is advice on a request that already succeeded. Never fail a build, refuse work, or badger the builder over it — if they say "later", file the idea and move on.
155
155
 
156
+ ## The sibling advisory: `dependency_advisory` (task 1002827)
157
+
158
+ The same `POST /tasks` response can also carry a non-null **`dependency_advisory`** — a *different* check, unrelated to which goal the task landed in: it names OPEN tasks in the same version whose `touches[]` overlap this one's, with no declared dependency between them (`scripts/gds/audit-deps.js`'s detector, run live for the first time).
159
+
160
+ - `dependency_advisory.candidates` is sorted by confidence (`high`/`medium`/`low`) — read each candidate's title before acting; most overlap is parallel work on a shared file, not a real build-order dependency.
161
+ - Only backfill a **high**-confidence candidate, and only after reading both task descriptions. Add the edge with `POST /tasks/<id>/dependencies --body '{"depends_on_task_id":<dep_id>}'`.
162
+ - **Same non-blocking contract as `goal_advisory`** — it is advice on a request that already succeeded, never a reason to redo the create, and a wrong edge GATES the dependent (`POST /claims` → 409 `DEPS_NOT_SHIPPED`) until the named blocker ships, so silence (skip it) beats a guessed edge.
163
+
156
164
  ## Why we run this
157
165
 
158
166
  Nothing in the system creates a goal on its own, and nothing errors when a task has none. `db.createTask` falls back to the version's `"<version> — general"` catch-all, and those rows were created **only** by migration 160, for the versions that existed at that moment — `db.createVersion` makes no such goal and there is no trigger. So on any newer version, every task quietly gets `goal_id = NULL`: no scope wall, no criteria gating it, no workspace where anyone reviews it. This skill is how a real goal comes to exist before that happens.
@@ -96,6 +96,14 @@ Report to the user:
96
96
  - Deferred (count + ids — these surface again next session)
97
97
  - New queue `count`
98
98
 
99
+ **Step 6 — dependency check (task 1002827, idea 1000276).** A cheap housekeeping pass over the same corpus this session already has open: run the likely-missing-dependency detector and surface any `high`-confidence candidate for a human to triage.
100
+
101
+ ```
102
+ node scripts/gds/audit-deps.js --min high
103
+ ```
104
+
105
+ Each line names an open task and an unshipped task whose files it overlaps with no declared dependency between them. **Read both task descriptions before backfilling an edge** — most overlap is parallel work on a shared file, not a build order; a wrong edge gates the dependent (`POST /claims` → 409 `DEPS_NOT_SHIPPED`) until the named blocker ships. Report the count in the same summary as Step 5; zero is the common case and needs no further comment. This is advisory only — it never blocks the review, and skipping it on a rushed pass is fine (it resurfaces next session, same as a deferred criterion).
106
+
99
107
  ## Why we run this
100
108
 
101
109
  Without a closing cadence, criteria pile up "done but unconfirmed" and goals never flip to achieved — the version looks perpetually in-progress even when the work has shipped. A fast pass keeps the rollup honest. As with `/idea-triage`, the cadence is the discipline; a quick walk with mostly defers beats skipping it.
@@ -0,0 +1,98 @@
1
+ # ADR 0328 — A hosted project's authority is its membership row, not its owner column
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-22
5
+ - **Task:** [task 1003882](https://cloudbongos.com/builders#/task/1003882) (goal 1000094 — BONGOS-V2)
6
+ - **Deciders:** the scoping call — "every API action must name its project; a non-member may only apply" — is the owner's, recorded 2026-09-13 on the task. This ADR is the mechanism that carries it out: where the underlying membership data already lives, what a route must call to consult it, and what a non-member is told.
7
+ - **Related:** [ADR 0016](0016-trust-boundary-server-enforced-permissions.md) (rank is server-side, uncached — the model this composes with, never replaces), [ADR 0151](0151-governance-permissions-as-atom-ranks-as-roles.md) §3 (the ownership axis this composition mirrors), [ADR 0083](0083-modular-architecture-strangler-build-plan.md) (modules cooperate through named ports, never by importing each other — the rule that shapes Decision 2), [ADR 0141](<redacted>.md) / [0201](0201-cross-project-application-vouched-row-live-record.md) / [0247](0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md) (the membership table and the join flow this decision routes *into*, not around), [ADR 0246](0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md) (`client_id = slug` at `origin = domain`, the join key this whole decision turns on), [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) (names `provisioning_instances` as the fleet registry this ADR gates)
8
+ - **Companion (not superseded, not included):** [task 1003394](https://cloudbongos.com/builders#/task/1003394), the `requireRank` → `requirePermission` migration. Different axis, different question: that migration asks *what may this rank do at all*; this ADR asks *who may act on this one row*.
9
+ - **Filed by this ADR:** [task 1004159](https://cloudbongos.com/builders#/task/1004159) (extract `requireProjectOwner`, one gate replacing the three existing local implementations across 14 routes) · [task 1004160](https://cloudbongos.com/builders#/task/1004160) (route-rank-check.js project-scope classification) · [task 1004161](https://cloudbongos.com/builders#/task/1004161) (the `platform-identity.projectMembershipKind` port + multi-member widening) · [task 1004162](https://cloudbongos.com/builders#/task/1004162) (the 403 + apply-path refusal for a `pending`/`visitor` caller)
10
+
11
+ ## Context
12
+
13
+ **What "project" means here, precisely — because the codebase has two candidates and they answer different questions.**
14
+
15
+ 1. `modules/provisioning/` — the hub's **fleet registry**. `provisioning_instances` is one row per hosted instance: `id` (bigint), `slug`, `domain`, `owner_builder_id`, lifecycle status, cost estimate. [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) confirms this IS the whole fleet control plane — there is no separate registry to design.
16
+ 2. `modules/platform-identity/` — the hub's **federated membership ledger**. `<redacted>` is `(client_id, github_id, membership_kind)` with `membership_kind ∈ {owner, member, visitor, pending}`. Per [ADR 0246](0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md), a control-plane project is projected onto this catalog as `client_id = slug` at `origin = https://<domain>` — the same `slug` `provisioning_instances` already carries. `listMembershipsForAccount()` backs `GET /my-projects`; `POST /my-projects/join` (ADR 0141 §3 / 0201 / 0247) is the existing, shipped **apply flow**.
17
+
18
+ **The composition this ADR needs already exists once, in the opposite direction.** `modules/provisioning/provisioning.js:projectOwnedBy(db, { slug, host, builderId })` is published as the kernel port `provisioning.projectOwnedBy` (task 1003580, registered in `modules/provisioning/seams.js`), so that `modules/platform-identity/invite-authz.js` can ask "is this builder the owner of the control-plane project this catalog row names" **without importing provisioning** (ADR 0083's one-way rule — modules cooperate through named ports, never by requiring each other). `invite-authz.js`'s `requireProjectInviter` is the working proof this shape is sound: ownership is asked first, mounted after `requireBuilder`, resolved via `resolveOptional` (graceful no-op when provisioning isn't installed), and on any "no" — including "no such project" — it falls through unchanged to the ordinary permission gate, so a stranger cannot distinguish a private project from a nonexistent one by the shape or timing of the refusal.
19
+
20
+ **The gap is not that this pattern is missing. It is that the module which OWNS the fact does not use it on itself, and has grown THREE slightly different local copies of it instead of one.** `auth.js:264`'s `requireBuilder` only ever answers "who are you" — it has no project dimension, by design (it is a KERNEL_FILE and provisioning is a module, ADR 0083). But provisioning's own 14 `/provisioning/instances/:id...` routes (the full, verified list: `disconnect.js:26`; `env-manifest.js:105,128,156,184`; `provisioning.js:605,685,773,820,854,993,1041,1094,1238` — `force-teardown` at `provisioning.js:256` is excluded, correctly gated separately on `requirePermission('provisioning.fleet.manage')`, archon-only) never call `provisioning.projectOwnedBy` through a composed gate. Instead there are **three redundant, hand-maintained implementations of "owner OR archon"**, none visible to `route-rank-check.js`, none consulting `platform-identity`:
21
+
22
+ - `provisioning.js:596`'s `instanceAccessDenied(inst, builder)` — a local function, own-or-archon, used by 6 of the 9 routes in that file (`:605`, `:685`, `:773`, `:820`, `:854`, `:1041`).
23
+ - `env-manifest.js:32`'s `resolveOwnInstance(req, res)` — a second, independently-written local function, same own-or-archon rule, used by all 4 routes in that file.
24
+ - `disconnect.js:31-33` — a third copy of the identical own-or-archon comparison, inlined rather than extracted, because that file has only the one route.
25
+
26
+ **And three routes have neither** — `provisioning.js:993` (teardown), `:1094` (domain), `:1238` (github-app) each inline the bare comparison with **no archon override at all**:
27
+
28
+ ```js
29
+ const inst = await provisioning.getInstanceById(pool, id);
30
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
31
+ return res.fail('instance_not_found', 404);
32
+ }
33
+ ```
34
+
35
+ This is not merely undocumented duplication — it is an **existing inconsistency between sibling routes in the same module**: an archon can already read, edit settings, restart, or manage invites on someone else's project for fleet support (the 11 routes above), but cannot tear it down, change its domain, or connect its GitHub App through the same API an owner uses, even though `force-teardown` proves the intent to let an archon act on any instance already exists one route over. Fourteen decisions to trust, expressed three ways, one of them narrower than the rest — and none of the three implementations is visible to `route-rank-check.js`, exactly what "Why now" describes: `admission.policy: open` means any GitHub sign-up reaches `any-builder`, and a route whose only defense is a correctly-typed `if` a few lines into its handler is one refactor away from silently losing that line, with nothing structural to catch it.
36
+
37
+ **And a single `owner_builder_id` column cannot express what the owner's decision asks for even when present.** `<redacted>` already has a `member` kind — a team sharing one paid hosted instance — completely unconsulted by the surface that would need to enforce it.
38
+
39
+ ## Decision
40
+
41
+ ### 1. Project scope is carried exactly as it is today — the path segment — resolved through the slug, not a new identifier
42
+
43
+ No new header, no session binding, no new CLI convention. `/provisioning/instances/:id/...` already names its project in the URL, and every caller — the hall web UI, or a terminal typing against the HTTP API directly — already reaches it by that path segment. ([ADR 0324](0324-the-public-npm-cli-channel-is-retired.md) retired the public `@cloudbongos/cli` npm channel; that is a distribution-channel decision about how a terminal gets its commands, not about how a request names its project, so it does not change this answer.) `getInstanceById` already returns `{ id, slug, owner_builder_id, domain, ... }`, so the slug needed for membership lookups (keyed on `client_id = slug`) is already one query away — no second identifier to introduce or keep in sync.
44
+
45
+ ### 2. `requireBuilder` composes with project authority through a helper that reuses the existing `provisioning.projectOwnedBy` composition shape — Phase 1 needs no new port at all
46
+
47
+ **Phase 1 (consolidate three implementations into one, and correct the inconsistency between them — task 1004159, no schema change, no new port).** Provisioning already holds `owner_builder_id` directly; it needs no cross-module port to check its own column. Extract one local helper, `requireProjectOwner(loadFn, { archonOverride = true } = {})`, mirroring `requireProjectInviter`'s composed-gate shape (mounted after `requireBuilder`, loads the row, admits on ownership match OR — by default — a plain **rank** check, `req.builder.rank === 'archon'`, denies 404 `instance_not_found` fail-closed on every other path). **This must be the exact rank test `instanceAccessDenied` and `resolveOwnInstance` already run — not `requirePermission('provisioning.fleet.manage')`.** The two are not interchangeable: that permission's floor is `metic` (ADR 0157 lowered it from `archon`, `provisioning.js:252-253`), used only by the separately-gated `force-teardown` route; wiring it into `requireProjectOwner` instead of the plain rank test would silently widen all 14 routes — teardown and disconnect included — from archon-only to metic-and-up, an unstated widening this ADR's whole posture forbids. With the correct rank test, all 14 routes point at the one helper: the 11 that already resolve to "owner OR archon" today (via `instanceAccessDenied`, `resolveOwnInstance`, or `disconnect.js`'s inline copy) get a mechanical, behavior-preserving swap; the 3 that resolve only to "owner" today (`teardown`, `domain`, `github-app`) get the SAME rank-based default the other 11 already carry — an explicit, named widening, not an accident: it makes those three routes consistent with the fleet-support access every sibling route in the same module already grants, closing the gap `force-teardown`'s own existence already implies was intended. **Every archon action taken on a project the archon does not own — through the widened routes or the existing ones — must write a `provisioning_events` row naming the archon actor**, the same `actor: 'api:archon:<id>'` convention the existing routes already use for their own-vs-archon audit trail (`provisioning.js:712,883,1048`); task 1004159's acceptance criteria pin this for the 3 newly-widened routes specifically, since they have no such event today.
48
+
49
+ **Phase 2 (the multi-member widening — task 1004161).** Extend the same helper to resolve a NEW port, `platform-identity.projectMembershipKind(clientId, githubId) → Promise<'owner'|'member'|'visitor'|'pending'|null>` — a thin wrapper over the already-correct `getMembership()` — via `resolveOptional`, admitting on `owner` or `member`. **This is only as trustworthy as who may write a `member` row, which this ADR consumes and does not change or re-audit.** The one write path that sets `membership_kind` to a value other than the sign-in default (`POST /sso/membership/check-in`, `modules/platform-identity/routes/sso.js`) is authenticated by the PROJECT's own `client_id`/`client_secret` — not a builder's session — and is update-only against a row that already carries a hub-witnessed sign-in (`touchMembership`'s `WHERE ... signin_verified_at IS NOT NULL`). So a `member` row on the hub reflects what the FEDERATED PROJECT's own server reported about its own builder, under the trust model ADR 0141/0201/0247 already established for federated check-ins generally. This ADR's gate inherits that existing trust boundary as-is for provisioning's admission decision; it neither tightens nor loosens it, and does not itself re-verify the check-in path's authorization. **The fallback is scoped narrowly, and the two failure shapes are NOT the same decision:** when the port is unregistered (a self-hosted instance without `platform-identity`), fall back to the Phase 1 direct-column check — the ADR 0083 "graceful degradation on a module-less instance" contract, and the correct answer there, since such an instance has no fleet of *other people's* projects to protect. When the port IS registered but the call throws or resolves an unrecognized value (a hub where the port should be answering and is not), **deny** — do not silently widen the fallback to cover a runtime fault on infrastructure that is supposed to be present. Task 1004161's acceptance criteria must test both paths separately.
50
+
51
+ This is a **new, parallel axis**, not a fifth branch bolted onto `requirePermission` — exactly as the existing ownership axis (`authorizeOwned`/`authorizeOwnedOrAny`, [ADR 0151](0151-governance-permissions-as-atom-ranks-as-roles.md) §3, R100) is kept structurally separate from the plain permission resolve, for the same reason: a fact `requireBuilder` cannot know (which concrete row, and who stands on it) must be supplied by the handler's own loaded row, and folding it into `requirePermission` would silently drop that second fact for every other caller of that gate.
52
+
53
+ ### 3. What a non-member gets depends on what they already are, not one blanket code
54
+
55
+ Today's blanket `instance_not_found` (404) hides existence for every non-owner. That floor is correct for a total stranger and does not change.
56
+
57
+ - **No membership row at all** → stays **404** `instance_not_found`. This is the same posture `invite-authz.js` already guarantees for its own ownership question — a caller who owns nothing and holds no membership learns nothing about whether the project exists.
58
+ - **A `pending` or `visitor` row exists** (task 1004162, sequenced after the port lands) → **403** `project_membership_required`, naming the concrete next step: `POST /my-projects/join` if `pending` isn't already filed, or "ask the project owner" if it is. This is the "apply to join it" the owner's decision names — routed into the flow ADR 0141/0201/0247 already ships, not a second one invented for provisioning. The distinction is not cosmetic: a row already existing means the caller's relationship to this project is already on record (visible on their own `GET /my-projects`), so confirming its existence tells them nothing they could not already see.
59
+ - **`owner` or `member`** → admitted, as today (owner) or newly (member).
60
+
61
+ ### 4. `route-rank-check.js` gains a project-scope dimension, structurally, the way it already reads permission floors
62
+
63
+ The existing check parses `requirePermission('key')` call sites out of route source to classify rank (its own header: "classification is STRUCTURAL... a route is gated when its middleware chain carries requireRank/requireBuilder/requirePermission"). Task 1004160 adds the same structural read for `requireProjectOwner(...)`:
64
+
65
+ - A route matching an explicit path-prefix list (starting with `/provisioning/instances/:id`, the same posture `KNOWN_RANKS` already takes — new project-scoped surfaces opt in by adding their prefix, not by a heuristic) whose middleware chain does **not** call `requireProjectOwner(...)` classifies as a new finding, `project_scope_missing`: write method → `blocker` (hard FAIL, the same severity a missing rank gate gets today); read method → `major` → `concerns` (the Hacker adjudicates, same as an unclassifiable rank today). This is an explicit opt-in list, not a pattern match on "any route that loads a project row" — the same residual blind spot `KNOWN_RANKS` already accepts (a genuinely new project-scoped surface under an un-listed prefix is silently unclassified until someone adds its prefix), recorded as a Consequence below rather than solved here.
66
+ - A route with no project dimension (`GET /provisioning/fleet`, `GET /provisioning/health`, `GET /provisioning/slug-available`, and `POST /provisioning/instances/:id/force-teardown` — archon-only via `requirePermission('provisioning.fleet.manage')` and deliberately NOT owner-or-member) is excluded by not matching the prefix list — no annotation needed, mirroring how a route outside `/goals/` needs no goal-rank comment today.
67
+
68
+ This closes exactly the gap "Why now" names: a route added later that touches `provisioning_instances.:id` and forgets the gate fails the ship gate instead of silently joining the fourteen.
69
+
70
+ ### 5. Migration order: the 14 evidenced routes first; the "96 write routes" figure is NOT this ADR's scope
71
+
72
+ The task's own point 5 asks for migration order "for the 96 write routes." This ADR deliberately answers a narrower question than literally asked, and says so rather than leaving a reader to reconcile the two numbers: the 96-route figure is the whole app's `any-builder` write-route census, not project-scoped routes specifically. The overwhelming majority are ordinary Bongos task/goal/claim actions on a **single-tenant instance's own board**, which [ADR 0100](0100-two-repo-instance-model.md) §3 already isolates one-database-per-repo — there is no "wrong project" a single-tenant board's own task-claim route could reach, so it has no project dimension to check. Re-scoping every write route to "name its project" would be re-litigating ADR 0100, not implementing this decision, so the migration order below covers only the routes that actually have a project to name.
73
+
74
+ **The actual blast radius is the 14 `/provisioning/instances/:id...` routes** named in Context, all in one module, all already loading the same row. Order, smallest-risk first:
75
+
76
+ 1. Task 1004159 lands the structural helper and converts all 14 call sites — a mechanical, behavior-preserving swap for 11 of them, and a stated, intentional widening (the archon override) for the other 3. The owner/stranger cases for the 11 already-gated routes have existing coverage to preserve; the archon case for the 3 newly-widened routes (`teardown`, `domain`, `github-app`) has none and must be written new — this ADR does not assert existing coverage it has not verified. Those 3 routes' `provisioning.recordEvent` calls currently hardcode `actor: 'api:self'` (correct today, since only the owner can reach them); once an archon can, that becomes a false audit record unless it is fixed to the same `isOwner ? 'api:self' : 'api:archon:<id>'` pattern the other 11 routes already use.
77
+ 2. Task 1004160 (`route-rank-check.js`) lands only AFTER 1004159 — turning on a hard-fail check for a gate that does not exist yet would fail CI on this ADR's own landing commit. Its dependency edge in Bongos encodes this. **This ordering has a cost, named rather than hidden:** between the two landings, a new `/provisioning/instances/:id...` route could ship with no structural gate and nothing would catch it — the same exposure that exists today, just not yet closed. The window is bounded by how quickly 1004160 follows 1004159, not eliminated by the ADR.
78
+ 3. Task 1004161 (the membership port + multi-member widening) depends only on 1004159 (one call site to widen, not fourteen) and can land in parallel with 1004160.
79
+ 4. Task 1004162 (the refined 403/404 split) depends on 1004161 — the distinction it draws does not exist until the gate can tell "no row" from "a weaker row."
80
+
81
+ **The permanent `/api/gds` + `/api/bongos` aliases** carry no new promise here: both alias to the same route table, so a route gated by `requireProjectOwner` is gated identically under either prefix. Nothing about this decision changes the route table's shape, only what one more piece of middleware in the existing chain checks.
82
+
83
+ ## Consequences
84
+
85
+ - **A team can share one paid hosted instance for the first time**, once task 1004161 lands — `member`-kind rows already exist in the schema and are already correct; they are simply not consulted yet by the surface that would need them.
86
+ - **An archon gains fleet-support reach on `teardown`, `domain`, and `github-app` that does not exist today**, once task 1004159 lands — a stated widening, matching the access every other project-scoped route in the same module already grants, not a side effect discovered later.
87
+ - **Three redundant local implementations (`instanceAccessDenied`, `resolveOwnInstance`, `disconnect.js`'s inline copy) collapse into one function**, auditable by `route-rank-check.js` the same way a missing rank check already is — the exact mechanism "Why now" says does not exist today.
88
+ - **A self-hosted instance running `provisioning` without `platform-identity` installed is unaffected**: the port's `resolveOptional` fallback is the existing owner-only check, verbatim, kept as the fallback branch instead of the only branch — and a hub where the port SHOULD be answering and errors instead denies rather than silently falling back (Decision 2, Phase 2).
89
+ - **A per-request membership lookup on top of the existing instance load, once Phase 2 lands.** `<redacted>` is keyed `(client_id, github_id)`; task 1004161 must confirm that pair is indexed (or add the index) rather than relying on the ADR's code-cost framing alone — this ADR does not itself specify the query plan or a memoization posture for a single request that consults the same membership twice (e.g. the gate, then the handler's own actor-labeling logic); task 1004161 settles both alongside the index.
90
+ - **The `route-rank-check.js` classifier's path-prefix list is a residual blind spot, not a closed one.** A genuinely new project-scoped surface mounted under a prefix nobody added to the list is silently unclassified rather than flagged, exactly as an unlisted rank pattern is today — the same trade-off `KNOWN_RANKS` already accepts, not a new one this ADR introduces.
91
+ - **No schema migration for Phase 1, and none for Phase 2 either.** `<redacted>` and its `owner`/`member`/`visitor`/`pending` vocabulary already exist and are already correct; this ADR's mechanism cost is one local helper (Phase 1), one seam port (Phase 2), and one `route-rank-check.js` pattern addition — the code cost. The query cost (the point above) is separate and is task 1004161's to confirm.
92
+
93
+ ## Alternatives considered
94
+
95
+ - **A new `provisioning_instance_members` table, scoped to provisioning alone.** Rejected: `<redacted>` already answers this exact question for the exact same `slug`/`client_id`, per ADR 0246's own projection rule. A second table would repeat the second-fleet-registry mistake ADR 0325 already refused for the registry itself, one layer down.
96
+ - **Fold project authority into `requirePermission` as a fifth axis.** Rejected for the same reason the ownership axis is its own entry point ([ADR 0151](0151-governance-permissions-as-atom-ranks-as-roles.md) §3, R100): project standing is parameterized by a concrete row `requirePermission` is never handed, so admitting on a bare permission key would collapse per-project standing into "holds the key everywhere" — precisely the widening the ownership axis exists to prevent for `.own` keys.
97
+ - **Migrate all 96 `any-builder` write routes under one project-scope umbrella.** Rejected: the other ~82 routes act on a single-tenant instance's own Bongos data, which has no project dimension per ADR 0100 — applying this decision there would be inventing multi-tenancy for a system deliberately built single-tenant-per-repo, a far larger and unrelated call the owner's one-line decision does not ask for.
98
+ - **Drop the 404 existence-hiding and return 403 for every unauthorized instance id.** Rejected: a stranger with no standing on a project should learn nothing about whether it exists, exactly as `invite-authz.js`'s existing ownership refusal already guarantees. Only a caller who already has a membership row (however weak) is told anything more, because the row itself is proof they already know the project exists.
@@ -419,3 +419,4 @@ This keeps the decision history honest and traceable.
419
419
  | 0325 | [**The managed fleet control plane is the hub we already run** ([task 1001948](https://cloudbongos.com/builders#/task/1001948), goal 1000106 — BONGOS-V2). The spike was written against [ADR 0111](0111-instance-hosting-provisioning-module.md) §5/§7, which deferred the managed offering as **"genuinely greenfield (no fleet registry exists)"** behind two gates — Mercury and a real `bongos upgrade`. Both gates are shipped and **the premise is not**: measuring the call sites first changed the answer, as it did for [task 1004141](https://cloudbongos.com/builders#/task/1004141). The registry is `provisioning_instances` (owner, shape, domain, database, port, lifecycle, cost estimate, module selection, the liveness overlay and the served/available core-version snapshot), with `provisioning_intents` as the queue and `provisioning_events` as the audit trail; the operator is `scripts/gds/provision.js`, the single token-holder draining that queue behind ADR 0111 §2's trust boundary; oversight is `GET /provisioning/fleet` and `/fleet/cost-ledger`; and the update channel is not one mechanism but two shipped ones — the owner's per-instance deploy door ([ADR 0293](0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md), health-gated with auto-rollback) and the opt-in patch-only sweep ([ADR 0136](0136-update-channel-subscription-policy.md)). **Decision: there is no greenfield fleet control plane to design, and none will be built — the hub IS it.** It never needed to sit *above* the per-repo databases [ADR 0100](0100-two-repo-instance-model.md) §3 locks: the roster is a hub table and the operator has never read one. The genuinely missing leg is a **platform leg** — `grep -rl RENDER_API scripts modules src` returns nothing, and after [ADR 0323](0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md) a `cloud-host` is a Render service — and this ADR **deliberately specifies none of it**, because that is [task 1004142](https://cloudbongos.com/builders#/task/1004142)'s with those files open and a second spec for one implementation is how two drift. The fleet **driven** and the fleet **observed** are different populations: `byo-host` is in the registry and operated by nobody, its liveness the [ADR 0252](0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md) pull, so every operator surface must state which it counts — a roster that mixes them reports a fleet-wide upgrade as complete while an unknown number were never asked. The operator holds **exactly one** credential and it is **its own** (the platform account token, root-only on the control plane, never the web tier, never per-customer); ADR 0323 D6's "names, never values" rule is about the CUSTOMER's secrets and is unchanged. Two gaps are filed rather than fixed here: the subscription roster living in `config/update-subscriptions.json` is the one fleet fact the roster route cannot report and a restored backup can lose independently of the rows it describes ([task 1004150](https://cloudbongos.com/builders#/task/1004150), a recommendation and not a ruling), and **`costEstimateUsd` returns 0 for every shape but `dedicated`** — measured live, all 24 fleet rows report `$0`, confidently and with no error, for projects about to cost ~\$14/month each ([task 1004148](https://cloudbongos.com/builders#/task/1004148)). **No fleet-wide "upgrade everything" button**: both channels are per-instance by construction, and one action that moves the whole fleet is the shape most likely to take every customer down at once. AGPL posture unchanged — ADR 0111 §5's one-sentence rule survives its own table. Rejected: building the separate control plane §5 described (a second registry and a second operator holding the same rows with nothing keeping them equal); specifying Render here; reading per-instance databases for fleet reporting; and continuing to defer the whole layer, which is now the reason the ledger reads \$0 for a fleet about to cost real money.](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) | hosting / fleet / provisioning |
420
420
  | 0326 | [**An upgrade may ADD a deny rule to a file the instance owns** ([task 1004156](https://cloudbongos.com/builders#/task/1004156), goal 1000090 — BONGOS-V2). Task 1003966 gave the instance starter its deny rules — no reading `.env` files, keys or session tokens, no shelving with the repo-global stash — and they reached **nobody who was already here**. The starter is written only when `.claude/settings.json` is ABSENT, and `bongos upgrade` materializes from the published package, which excludes that file on purpose; so an upgrade always takes the starter branch, always finds a settings.json already there, and left it alone. Every instance on the allow-only starter from task 1003962 — the live one included — kept it forever. That was deliberate ("rewriting a file the instance owns is the line task 1003962 drew"), and the recorded remedy was manual: re-run `bongos init`, or delete the file. **A manual remedy for a security gap protects the instances whose owners read release notes, which is not the set that needs protecting.** The owner's call: ADD the missing deny rules and NAME each one in the run output. The argument is that a deny rule is **monotonic in the safe direction** — an allow rule widens what the local agent may do, a deny rule can only narrow, so appending one cannot make any instance less safe than it was a moment earlier. That is what makes this the one merge worth doing without asking per-instance, and it extends [ADR 0121](0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md) §2's already-licensed "merge OR leave-and-report" from `--adopt` to every materialize. "Never clobber" survives in the sense that was load-bearing: **nothing the instance wrote is removed, reordered or rewritten** — their allow rules, own denies, `ask` list, hooks, env and every key outside `permissions` come back exactly as they were, with new rules appended after. Deliberately NOT done: **no provenance**, so an owner who deletes a rule we add gets it back next upgrade and (deny beating allow in Claude Code's order) cannot override it with an allow — rejected as speculative machinery for a case nobody has hit, revisit on a real complaint; a file that will not parse, or whose `permissions`/`permissions.deny` is not the expected shape, is left BYTE-FOR-BYTE alone and reported, the posture task 1004023 took for the core's own file, because a half-understood rewrite of a permissions file is worse than the gap; and **the allow half is never topped up** — the safety argument is directionality and does not survive being applied to a rule that grants. `materializeClaude` gains `summary.settingsDenyAdded` + `settingsDenyUncheckable`, both printed through `log()` since `upgrade.js` does not iterate `summary.notes`. The test asserting an instance's settings.json comes back byte-identical is superseded by a strictly stronger one — every rule still there, unmoved, deny-only growth — the weaker spelling being what froze the gap in place. **AMENDED 2026-09-22 by [task 1004158](https://cloudbongos.com/builders#/task/1004158): the two shelf rules are now starter-only and the top-up asks for the CREDENTIAL half alone.** 0326 itself named this exclusion as the remedy if the rule ever bit; the owner took it ahead of that, because the monotonicity argument licenses a rule about reading a secret and does not reach one that guards a workflow — with no provenance and deny beating allow, an instance that loses the shelf cannot give it back. A fresh instance still gets both. The top-up only ever appends, so this is **forward-looking only**: an instance that already took them keeps them, live included.](0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md) | security / instance onboarding |
421
421
  | 0327 | [**A `cloud-host` runs on the owner's own account, and the key is borrowed, not stored** ([task 1004175](https://cloudbongos.com/builders#/task/1004175), goal 1000106 — BONGOS-V2). [ADR 0323](0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md) moved a `cloud-host` to Render to stop being a landlord but left **whose account** unexamined; [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) §4 then ruled the platform token is ours and "never per-customer". Standing it up disproved the premise: a Render API key is issued against a **user** and carries their rights in **every workspace they belong to** — the owner's reached three, one of them another builder's, holding their live production. The same check found `mercury` **already on Render**, run by its own owner, which 0323 would have migrated OFF a correct setup onto our bill. The remaining landlord relationship was never the servers; it was the **account**. **Decisions:** (1) each project brings its own platform key — we hold no billing relationship for anyone's compute, and a leaked key's blast radius is exactly its own owner's account, a property that comes from the decision and not from cryptography; (2) the key is **borrowed for standup and cleared**, never stored, because nothing afterwards needs it — Render auto-deploys from Git, the deploy door ([ADR 0293](0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md)) is a push, liveness is the [ADR 0252](0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md) pull — so it rides the queue as a short-lived value encrypted with the existing `secret-box.js`, the [ADR 0133](0133-github-app-manifest-onboarding-signin.md) handshake's shape; (3) we **never destroy a customer's infrastructure** — teardown disconnects, the owner deletes their own services, a safety property first and the reason (2) works; (4) **two shapes, not three** (`dedicated-cloud-host` removed — on someone's own account a bigger plan is a setting, finishing 0323 §1's own "plan size, not a second code path"), and the **free tier is free**, 0323 §7's cap retired with its premise. **Deliberately NOT built, because nothing is stored:** an asymmetric sealing module, per-row credential binding, rotation/revocation, and the separate-unix-user precondition — each designed in full, then deleted when the retention question was asked instead of assumed. **Stated honestly:** encrypting the in-transit value protects it from a database dump, NOT from a compromised web tier, because the owner's key arrives there in plaintext first; that defence was never available at any retention length. **§5 records a measurement, fixed elsewhere:** [ADR 0111](0111-instance-hosting-provisioning-module.md) §2's boundary is enforced by the CODE, not the kernel — `cloudbongos.service` runs `User=lars` with no sandboxing and `/etc/cloudbongos/box.env` is mode 600 owned by `lars`, so the web tier CAN read the DigitalOcean, Cloudflare and platform tokens; 0325 §4's "root-only" is not what is on the box. (A second finding in the same section — a "mode 777" key file — was **void and is corrected in place**: the path is a symlink, whose mode display is meaningless, to a 600 target. It was inferred from an `ls` rather than an attempted read.) **Resolved 2026-09-23 by [task 1004176](https://cloudbongos.com/builders#/task/1004176)**, and more narrowly than §5 proposed — not the unix split (which would mean relocating the checkout, config dir, `web.env` and the secret-box master key the web tier legitimately needs), but a systemd drop-in with `InaccessiblePaths`, verified as the service user; proving it as **root** reports "no effect" against a working config, because `CAP_DAC_OVERRIDE` bypasses the mode-000 over-mount. It is emphatically NOT a precondition here — that is the point of storing nothing, and had we sealed per-project keys with a private key in that same file the two would have had to land together. **Consequences:** the migration shrinks to an **adoption**; the cost ledger's question inverts (our spend on a `cloud-host` is ~\$0 and correct — what an owner is worth telling is THEIR Render cost, a different column, [task 1004148](https://cloudbongos.com/builders#/task/1004148)); and metering loses its forcing function, cost *visibility* surviving as a feature while billing for hosting does not ([task 1001949](https://cloudbongos.com/builders#/task/1001949)). Rejected: storing each key sealed with a key pair (three days plus a live-site infra change, to keep a value needed once); one platform account holding every project (0325 §4 as written — keeps us the billing landlord and makes one credential reach every customer); generating a Blueprint and having the owner click once in Render (simplest, but the click is the exact friction `cloud-host` exists to remove); and keeping the key in the browser to call Render directly (standup is a runner job with retries and an audit trail, not a page that stays open).](0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md) | hosting / provisioning / credentials |
422
+ | 0328 | [**A hosted project's authority is its membership row, not its owner column** ([task 1003882](https://cloudbongos.com/builders#/task/1003882), goal 1000094 — BONGOS-V2). The owner's decision (2026-09-13): every API action must name its project, and a non-member may only apply. Two "project" concepts exist and answer different questions: `provisioning_instances` (the hub's fleet registry, single `owner_builder_id`, [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md)) and `<redacted>` (`client_id, github_id, membership_kind ∈ {owner,member,visitor,pending}`, already correct, already backing `GET /my-projects`). The composition this needs already exists once, in the opposite direction: `provisioning.projectOwnedBy` is a published port (task 1003580) that `platform-identity/invite-authz.js` already consumes via `resolveOptional` to ask "does this builder own this project" without importing provisioning (ADR 0083). **The gap is that the module which owns the fact does not use it on itself, and has grown three separate local copies of it instead of one** — `instanceAccessDenied` (6 routes), `resolveOwnInstance` (4 routes) and `disconnect.js`'s own inline copy (1 route) all implement "owner OR archon", while 3 routes (`teardown`, `domain`, `github-app`) implement only "owner" with no archon override at all — an existing inconsistency between sibling routes in the same module, not just duplication, and none of the three implementations is visible to `route-rank-check.js`. Decision: (1) project scope stays the existing path segment, resolved through `slug` (the `client_id = slug` join key [ADR 0246](0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md) already establishes); (2) Phase 1 extracts one local `requireProjectOwner` helper replacing all three implementations — a behavior-preserving swap for 11 routes and a stated, intentional archon-override widening for the 3 that lacked it — no new port needed since provisioning already holds the column; Phase 2 adds a new port `platform-identity.projectMembershipKind` and widens the same helper to admit `owner` OR `member`, falling back to the Phase 1 column check when platform-identity isn't installed (ADR 0083's degrade-gracefully contract) — a NEW parallel axis, not a fifth branch on `requirePermission`, mirroring why the ownership axis ([ADR 0151](0151-governance-permissions-as-atom-ranks-as-roles.md) §3, R100) is kept separate; (3) a caller with NO membership row still gets 404 `instance_not_found` (existence-hidden, unchanged), but a `pending`/`visitor` row gets 403 `project_membership_required` naming `POST /my-projects/join` (ADR 0141/0201/0247's existing apply flow) — the "apply to join it" the owner's decision names, routed into the flow that already ships rather than a second one; (4) `route-rank-check.js` gains a `project_scope_missing` finding (blocker on write, concerns on read) for any `/provisioning/instances/:id...` route missing the gate, the same structural read it already gives `requirePermission`; (5) migration order is the 14 evidenced routes only — the task's own "96 write routes" figure is the whole app's census, and the other ~82 act on a single-tenant instance's own Bongos board, which [ADR 0100](0100-two-repo-instance-model.md) §3 already isolates one-database-per-repo and has no project dimension to name. Filed: [task 1004159](https://cloudbongos.com/builders#/task/1004159) (the helper) · [task 1004160](https://cloudbongos.com/builders#/task/1004160) (route-rank-check.js, depends on 1004159) · [task 1004161](https://cloudbongos.com/builders#/task/1004161) (the membership port, depends on 1004159) · [task 1004162](https://cloudbongos.com/builders#/task/1004162) (the 403/404 split, depends on 1004161). Rejected: a second `provisioning_instance_members` table (the membership table already exists and joins on the same key — a second one repeats the mistake ADR 0325 already refused); folding project authority into `requirePermission` as a fifth axis (same reasoning R100 already applied to `.own` keys); migrating all 96 write routes (re-litigates ADR 0100's single-tenant model, not this decision); dropping 404 existence-hiding entirely (a total stranger should learn nothing, exactly as `invite-authz.js` already guarantees).](0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md) | security / authorization / provisioning |
@@ -2426,5 +2426,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2426
2426
  landed since 1.19.974 with no explicit bump. run 35871083707. (task 1002620)
2427
2427
  1.19.976 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2428
2428
  landed since 1.19.975 with no explicit bump. run 35878415178. (task 1002620)
2429
+ 1.19.977 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2430
+ landed since 1.19.976 with no explicit bump. run 35880930697. (task 1002620)
2431
+ 1.19.978 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2432
+ landed since 1.19.977 with no explicit bump. run 35882259144. (task 1002620)
2429
2433
  ---------------------------------------------------------------------------
2430
2434
  ```
@@ -765,6 +765,48 @@ async function listTasks({ versionId, status, kind, discipline, goalId, createdB
765
765
  return rows;
766
766
  }
767
767
 
768
+ // task 1002827 — the candidate pool for the dependency advisory (POST /tasks'
769
+ // `dependency_advisory` + the periodic sweep): OPEN tasks in this version that
770
+ // carry touches[], the same OPEN set scripts/gds/audit-deps.js already treats
771
+ // as "auditable" (ready, backlog, active, blocked). Bounded and cheap —
772
+ // measured 2026-09-22, BONGOS-V2 carries ~51 such rows, since most tasks
773
+ // declare no touches[] at all (ADR 0049 demoted the field to advisory, so
774
+ // most authors skip it). `dependencies` is not selected: it lives in a join
775
+ // table, not this one (see addTaskDependency), and this advisory only needs
776
+ // the NEW task's own declared deps, supplied by its caller.
777
+ //
778
+ // VISIBILITY (ADR 0112's leak rule — the goal-graph.js precedent). A task's
779
+ // title and touched paths are exactly the "prose" that rule protects: a task
780
+ // under a PRIVATE goal the caller neither manages nor is a member of is
781
+ // EXCLUDED here, not merely redacted — unlike the graph, this advisory has
782
+ // no structural need to keep an invisible node in the picture (omitting a
783
+ // candidate says nothing false; it just offers no advice about it). A
784
+ // goal-less task carries no visibility concern (`t.goal_id IS NULL`).
785
+ // `LIMIT` bounds the candidate count so a growing corpus cannot turn
786
+ // classify()'s O(|a|×|b|) pairwise scan (dependency-advisory.js) into an
787
+ // unbounded synchronous cost on this write path.
788
+ const DEPENDENCY_ADVISORY_CANDIDATE_LIMIT = 200;
789
+ async function listOpenTasksWithTouches(versionId, excludeId, { viewerId = null, viewerIsArchon = false } = {}) {
790
+ const { rows } = await pool.query(
791
+ `SELECT t.id, t.title, t.status, t.touches
792
+ FROM tasks t
793
+ LEFT JOIN goals g ON g.id = t.goal_id
794
+ LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $4::bigint
795
+ WHERE t.version_id = $1
796
+ AND t.status = ANY($2::text[])
797
+ AND t.touches <> '{}'
798
+ AND ($3::bigint IS NULL OR t.id <> $3)
799
+ AND (t.goal_id IS NULL
800
+ OR g.visibility IS DISTINCT FROM 'private'
801
+ OR $5::boolean
802
+ OR gm.builder_id IS NOT NULL)
803
+ ORDER BY t.id
804
+ LIMIT ${DEPENDENCY_ADVISORY_CANDIDATE_LIMIT}`,
805
+ [versionId, ['ready', 'backlog', 'active', 'blocked'], excludeId, viewerId, viewerIsArchon]
806
+ );
807
+ return rows;
808
+ }
809
+
768
810
  // Tasks that are ready, unblocked, and have no touches[] overlap
769
811
  // with currently active claims. Excludes tasks already claimed by this
770
812
  // builder (so the same builder doesn't see their own active claim listed
@@ -1269,6 +1311,7 @@ module.exports = {
1269
1311
  listActiveClaims,
1270
1312
  listClaimableTasks,
1271
1313
  listOpenArtistReviews,
1314
+ listOpenTasksWithTouches,
1272
1315
  listTasks,
1273
1316
  promoteUnblockedBacklogTasks,
1274
1317
  rankCrossDisciplineRecommendations,
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/dependency-advisory.js — the MISSING-DEPENDENCY advisory
4
+ // (task 1002827, goal 1000089).
5
+ //
6
+ // THE HOLE. scripts/gds/audit-deps.js has shipped a working
7
+ // likely-missing-dependency detector since task 1000349 (unit-tested,
8
+ // tests/audit_deps.mjs, tests/missing_dep_classify.mjs) — and nothing ever
9
+ // called it. A task can be created touching the exact same files an
10
+ // unshipped task already claims, with no declared dependency between them,
11
+ // and nothing says so until a human happens to run the CLI by hand.
12
+ //
13
+ // This module wires that SAME detector's predicate —
14
+ // modules/lifecycle/missing-dep-classify.js's `candidateEdge`/`classify`,
15
+ // not a second copy of either — into two surfaces, mirroring
16
+ // goal-advisory.js's own two-surface shape (task 1002539):
17
+ // 1. POST /tasks attaches `dependency_advisory` to its 201 body (this
18
+ // file's `buildDependencyAdvisory`), read by the /goal-create skill's
19
+ // "sibling advisory" section (.claude/skills/goal-create/SKILL.md).
20
+ // 2. The periodic sweep: `/goal-review`'s daily walk runs
21
+ // `node scripts/gds/audit-deps.js --min high` as one more housekeeping
22
+ // step (idea 1000276's half — see .claude/skills/goal-review/SKILL.md).
23
+ //
24
+ // HARD CONSTRAINT, same as goal-advisory.js and ADR 0162: advisory only. This
25
+ // must never refuse a POST, and `buildDependencyAdvisory` swallows its own
26
+ // failures for the same reason goal-advisory.js's does — the task row is
27
+ // ALREADY COMMITTED by the time this runs, so a throw here would turn a
28
+ // successful create into a 500 the caller then retries.
29
+ //
30
+ // VISIBILITY (ADR 0112's leak rule, the goal-graph.js precedent) — enforced
31
+ // in the query, not here: `db.listOpenTasksWithTouches` excludes a candidate
32
+ // under a PRIVATE goal the caller neither manages nor belongs to, so this
33
+ // file never even sees a task it should not name in a 201 body.
34
+ //
35
+ // PURE core (`adviseDependencies`) + a DB-read wrapper, the criterion-suggest /
36
+ // goal-advisory precedent, so every branch is pinned by a DB-free unit test.
37
+
38
+ const { candidateEdge } = require('./missing-dep-classify.js');
39
+
40
+ // How many candidates the advisory offers. Small on purpose (the
41
+ // goal-advisory.js CANDIDATE_LIMIT precedent) — this is a hint for the author
42
+ // to triage, not a browsable report; the full corpus scan already exists as
43
+ // `node scripts/gds/audit-deps.js` for that. Separate from (and much smaller
44
+ // than) db.listOpenTasksWithTouches's own query-side LIMIT, which bounds cost
45
+ // rather than display.
46
+ const CANDIDATE_LIMIT = 5;
47
+
48
+ // adviseDependencies — PURE. `task` is the just-created (or just-fetched) row,
49
+ // `candidates` is the OPEN-with-touches, visibility-filtered pool a caller
50
+ // already fetched. Returns null when there is nothing worth naming (fail-open
51
+ // on ambiguity, same posture as adviseGoal): no touches[] on the task at all,
52
+ // or no candidate clears the shared classify() confidence bar.
53
+ function adviseDependencies({ task, candidates = [] } = {}) {
54
+ if (!task || !Array.isArray(task.touches) || !task.touches.length) return null;
55
+ const declared = new Set((task.dependencies || []).map(String));
56
+ const edges = [];
57
+ for (const o of candidates) {
58
+ const e = candidateEdge(task, o, { declared });
59
+ if (e) edges.push({ dep_id: e.dep_id, dep_title: (o && o.title) || null, dep_status: e.dep_status, confidence: e.confidence, paths: e.paths });
60
+ }
61
+ if (!edges.length) return null;
62
+ const order = { high: 3, medium: 2, low: 1 };
63
+ edges.sort((a, b) => order[b.confidence] - order[a.confidence]);
64
+ return {
65
+ reason: 'possible_missing_dependency',
66
+ message: `${edges.length} open task(s) touch the same files as this one and are not declared `
67
+ + 'dependencies — most touches[] overlap is parallel work on a shared surface, not a build-order '
68
+ + 'dependency, so read each candidate before adding an edge (a wrong one gates the claim).',
69
+ candidates: edges.slice(0, CANDIDATE_LIMIT),
70
+ };
71
+ }
72
+
73
+ // buildDependencyAdvisory — the whole advisory, DB read included, so POST
74
+ // /tasks calls one line (the buildGoalAdvisory precedent). Fails open: any
75
+ // throw degrades to null rather than failing an otherwise-successful create.
76
+ // `viewerId`/`viewerIsArchon` ride straight into the query's own visibility
77
+ // filter — this file never post-filters what the query already excluded.
78
+ async function buildDependencyAdvisory({ db, versionId, task, viewerId = null, viewerIsArchon = false }) {
79
+ try {
80
+ if (!Array.isArray(task.touches) || !task.touches.length) return null;
81
+ const candidates = await db.listOpenTasksWithTouches(versionId, task.id, { viewerId, viewerIsArchon });
82
+ const verdict = adviseDependencies({ task, candidates });
83
+ if (!verdict) return null;
84
+ return {
85
+ ...verdict,
86
+ next_step: 'Review each candidate; if it is a real build-order dependency, add it via '
87
+ + `POST /tasks/${task.id}/dependencies.`,
88
+ };
89
+ } catch (e) {
90
+ // Through the published logger seam (api.logger), not a raw console call
91
+ // — the est-advisory.js precedent. Lazy-required so the DB-free unit test
92
+ // that exercises this branch stays hermetic.
93
+ require('../../src/module-api').logger('lifecycle')
94
+ .error('dependency-advisory (non-blocking)', e && e.message);
95
+ return null;
96
+ }
97
+ }
98
+
99
+ module.exports = { adviseDependencies, buildDependencyAdvisory, CANDIDATE_LIMIT };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/missing-dep-classify.js — the PURE core of the
4
+ // likely-missing-dependency detector (task 349, moved here from
5
+ // scripts/gds/audit-deps.js by task 1002827 so a live route can reuse it
6
+ // without pulling in that script's CLI-only machinery — cliClient(),
7
+ // resolveDefaultVersion() — into the server process. The `dead-deps.js`
8
+ // precedent beside this file: a pure lifecycle module, consumed both by the
9
+ // audit-deps.js CLI (its --stranded arm) and now by dependency-advisory.js's
10
+ // live POST /tasks path.
11
+ //
12
+ // THIS IS ADVISORY. Most touches[] overlaps are PARALLEL work on a shared
13
+ // surface (src/bongos/db.js, migrations/, CLAUDE.md, …), NOT a build-order
14
+ // dependency. Backfilling a wrong edge GATES the dependent (POST /claims →
15
+ // 409 DEPS_NOT_SHIPPED) until the named blocker ships. So: act only on
16
+ // `high` confidence, by hand, after reading both task descriptions. Reuses
17
+ // the ONE canonical overlap matcher (src/bongos/path-match.touchesOverlap,
18
+ // reached via the module-api doorway) so it can't drift from the claim
19
+ // gate's notion of "overlap".
20
+
21
+ const api = require('../../src/module-api');
22
+ const { touchesOverlap } = api.pathMatch;
23
+
24
+ // Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
25
+ const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
26
+ const isUnshippedBlocker = (s) => s !== 'shipped' && s !== 'abandoned';
27
+
28
+ // Shared collaboration surfaces: an overlap here is a parallel-safety (touches[])
29
+ // signal, not a dependency. A candidate whose ONLY overlap is one of these (or a
30
+ // bare directory) is demoted to `low`. Deliberately broad; tune as the repo moves.
31
+ const HOTSPOTS = [
32
+ 'src/bongos/db.js', 'src/bongos/routes.js', 'server.js', 'migrations/', 'CLAUDE.md',
33
+ 'modules/hall-ui/', 'tests/', 'scripts/gds/', 'src/bongos/', 'src/bongos/routes/',
34
+ 'scripts/gds/ship.js', 'scripts/gds/api.js', 'scripts/gds/cli-lib.js',
35
+ 'modules/grading/grader.js', 'docs/', 'docs/adr/', 'docs/adr/README.md', 'docs/recipes/',
36
+ 'docs/architecture.md', 'docs/file-map.md', '.claude/skills/', '.claude/hooks/',
37
+ 'infra/', 'public/assets/tiles/',
38
+ ];
39
+ const isHotspot = (p) => HOTSPOTS.includes(p);
40
+
41
+ // Confidence heuristic (advisory; a human decides):
42
+ // high — an exact SAME non-hotspot, non-bare-dir FILE both tasks name.
43
+ // medium — a narrow (non-hotspot, non-bare-dir) overlap exists.
44
+ // low — overlap only on a shared hotspot / bare directory.
45
+ function classify(tTouches, oTouches) {
46
+ const pairs = [];
47
+ for (const a of tTouches) for (const b of oTouches) {
48
+ if (touchesOverlap([a], [b])) pairs.push({ a, b, exact: a === b });
49
+ }
50
+ if (!pairs.length) return null;
51
+ const narrow = pairs.filter((p) => !isHotspot(p.a) && !isHotspot(p.b) && !p.a.endsWith('/') && !p.b.endsWith('/'));
52
+ const confidence = narrow.some((p) => p.exact) ? 'high' : narrow.length ? 'medium' : 'low';
53
+ const paths = [...new Set(pairs.map((p) => (p.exact ? p.a : `${p.a} ~ ${p.b}`)))];
54
+ return { confidence, paths };
55
+ }
56
+
57
+ // candidateEdge — THE ONE predicate for "does `o` qualify as a missing-dependency
58
+ // candidate for `t`", pulled out (task 1002827 grade round 1) so findMissingDeps
59
+ // and dependency-advisory.js's live adviseDependencies call the SAME check
60
+ // instead of two hand-typed copies that can drift on the next rule change.
61
+ // `declared` is precomputed and passed in by findMissingDeps (one Set per `t`,
62
+ // not per pair); a caller checking a single `t` against many candidates may
63
+ // omit it and pay one Set build instead. Runs `classify` exactly ONCE — it
64
+ // already returns null on no overlap, so a separate touchesOverlap pre-check
65
+ // was dead work computing the same answer twice.
66
+ function candidateEdge(t, o, { isBlocker = isUnshippedBlocker, declared } = {}) {
67
+ if (!o || String(o.id) === String(t.id)) return null;
68
+ if (!isBlocker(o.status)) return null;
69
+ if (!Array.isArray(o.touches) || !o.touches.length) return null;
70
+ const declaredSet = declared || new Set(((t && t.dependencies) || []).map(String));
71
+ if (declaredSet.has(String(o.id))) return null;
72
+ if (!Array.isArray(t.touches) || !t.touches.length) return null;
73
+ const c = classify(t.touches, o.touches);
74
+ if (!c) return null;
75
+ return { task_id: t.id, dep_id: o.id, dep_status: o.status, ...c };
76
+ }
77
+
78
+ // Pure core — unit-tested with fixtures (no API). `tasks` is an array of
79
+ // { id, status, touches:[], dependencies:[ids] }. Returns candidate edges
80
+ // { task_id, dep_id, dep_status, confidence, paths }.
81
+ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker } = {}) {
82
+ const out = [];
83
+ for (const t of tasks) {
84
+ if (!open.has(t.status)) continue;
85
+ if (!Array.isArray(t.touches) || !t.touches.length) continue;
86
+ const declared = new Set((t.dependencies || []).map(String));
87
+ for (const o of tasks) {
88
+ const e = candidateEdge(t, o, { isBlocker, declared });
89
+ if (e) out.push(e);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ module.exports = { OPEN, isUnshippedBlocker, HOTSPOTS, isHotspot, classify, candidateEdge, findMissingDeps };
@@ -22,6 +22,7 @@ const db = require('../db');
22
22
  const hierarchy = require('../hierarchy');
23
23
  const { suggestCriteria } = require('../criterion-suggest');
24
24
  const { buildGoalAdvisory } = require('../goal-advisory');
25
+ const { buildDependencyAdvisory } = require('../dependency-advisory');
25
26
  const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
26
27
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
27
28
  const { validateOrRespond, LIMITS, parseId } = api;
@@ -282,13 +283,21 @@ module.exports = function registerTaskWriteRoutes(router) {
282
283
  const goal_advisory = await buildGoalAdvisory({
283
284
  db, versionId: body.version_id, task, hasCriterionLink: criteria.length > 0,
284
285
  });
286
+ // task 1002827 — the missing-dependency advisory (scripts/gds/audit-deps.js's
287
+ // detector, wired live for the first time): OPEN tasks in this version that
288
+ // touch the same files as this one and are not declared dependencies.
289
+ // Non-blocking by construction — see dependency-advisory.js.
290
+ const dependency_advisory = await buildDependencyAdvisory({
291
+ db, versionId: body.version_id, task,
292
+ viewerId: req.builder.id, viewerIsArchon: req.builder.rank === 'archon',
293
+ });
285
294
  res.status(201).json({
286
295
  task,
287
296
  credits_reward: task.credits_reward,
288
297
  needs_migration: needsMigration,
289
298
  criteria,
290
299
  suggested_criteria,
291
- goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
300
+ goal_advisory, dependency_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
292
301
  });
293
302
  } catch (err) {
294
303
  log.error('[gds] POST /tasks', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.976",
3
+ "version": "1.19.978",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.976",
9
+ "version": "1.19.978",
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.976",
3
+ "version": "1.19.978",
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",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // scripts/gds/audit-deps.js — likely-missing-dependency detector (task 349).
2
+ // scripts/gds/audit-deps.js — likely-missing-dependency detector CLI (task 349).
3
3
  //
4
4
  // WHY: an under-specified dependencies[] lets work start before the task it
5
5
  // implicitly builds on has shipped, racing already-built work (the original
@@ -11,16 +11,21 @@
11
11
  // (src/bongos/db.js, migrations/, CLAUDE.md, …), NOT a build-order dependency.
12
12
  // Backfilling a wrong edge GATES the dependent (POST /claims → 409
13
13
  // DEPS_NOT_SHIPPED) until the named blocker ships. So: act only on `high`
14
- // confidence, by hand, after reading both task descriptions. The detector reuses
15
- // the ONE canonical overlap matcher (src/bongos/path-match.touchesOverlap) — the
16
- // same one tests/touches_overlap.mjs covers — so it can't drift from the claim
17
- // gate's notion of "overlap".
14
+ // confidence, by hand, after reading both task descriptions.
15
+ //
16
+ // PURE CORE MOVED to modules/lifecycle/missing-dep-classify.js (task 1002827),
17
+ // so dependency-advisory.js's live POST /tasks path can reuse the ONE canonical
18
+ // `classify`/`findMissingDeps` — the same one tests/audit_deps.mjs covers —
19
+ // without pulling this file's CLI-only machinery (cliClient's session-token
20
+ // I/O, resolveDefaultVersion) into the running server. This file is now the
21
+ // CLI wrapper only: argv parsing, the corpus fetch, and --stranded's dead-edge
22
+ // report. Every name below is RE-EXPORTED unchanged so no caller of this file
23
+ // (including tests/audit_deps.mjs) needs to know it moved.
18
24
  //
19
25
  // node scripts/gds/audit-deps.js [--version GDS-V3] [--min high|medium|low] [--json]
20
26
  // node scripts/gds/audit-deps.js --stranded [--json] # the INVERSE: dead edges (task 1003153),
21
27
  // # plus prose-declared gates naming a dead task (task 1003857, advisory)
22
28
 
23
- const { touchesOverlap } = require('../../src/bongos/path-match.js');
24
29
  const { cliClient } = require('./cli-lib');
25
30
  const { resolveDefaultVersion } = require('./version-select.js');
26
31
  // task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
@@ -28,61 +33,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
28
33
  const { findStrandedTasks, findProseGatedTasks } = require('../../modules/lifecycle/dead-deps.js');
29
34
  // task 1003752 — the route's page bound + the first-page offset rule, declared once.
30
35
  const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
31
-
32
- // Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
33
- const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
34
- const isUnshippedBlocker = (s) => s !== 'shipped' && s !== 'abandoned';
35
-
36
- // Shared collaboration surfaces: an overlap here is a parallel-safety (touches[])
37
- // signal, not a dependency. A candidate whose ONLY overlap is one of these (or a
38
- // bare directory) is demoted to `low`. Deliberately broad; tune as the repo moves.
39
- const HOTSPOTS = [
40
- 'src/bongos/db.js', 'src/bongos/routes.js', 'server.js', 'migrations/', 'CLAUDE.md',
41
- 'modules/hall-ui/', 'tests/', 'scripts/gds/', 'src/bongos/', 'src/bongos/routes/',
42
- 'scripts/gds/ship.js', 'scripts/gds/api.js', 'scripts/gds/cli-lib.js',
43
- 'modules/grading/grader.js', 'docs/', 'docs/adr/', 'docs/adr/README.md', 'docs/recipes/',
44
- 'docs/architecture.md', 'docs/file-map.md', '.claude/skills/', '.claude/hooks/',
45
- 'infra/', 'public/assets/tiles/',
46
- ];
47
- const isHotspot = (p) => HOTSPOTS.includes(p);
48
-
49
- // Confidence heuristic (advisory; a human decides):
50
- // high — an exact SAME non-hotspot, non-bare-dir FILE both tasks name.
51
- // medium — a narrow (non-hotspot, non-bare-dir) overlap exists.
52
- // low — overlap only on a shared hotspot / bare directory.
53
- function classify(tTouches, oTouches) {
54
- const pairs = [];
55
- for (const a of tTouches) for (const b of oTouches) {
56
- if (touchesOverlap([a], [b])) pairs.push({ a, b, exact: a === b });
57
- }
58
- if (!pairs.length) return null;
59
- const narrow = pairs.filter((p) => !isHotspot(p.a) && !isHotspot(p.b) && !p.a.endsWith('/') && !p.b.endsWith('/'));
60
- const confidence = narrow.some((p) => p.exact) ? 'high' : narrow.length ? 'medium' : 'low';
61
- const paths = [...new Set(pairs.map((p) => (p.exact ? p.a : `${p.a} ~ ${p.b}`)))];
62
- return { confidence, paths };
63
- }
64
-
65
- // Pure core — unit-tested with fixtures (no API). `tasks` is an array of
66
- // { id, status, touches:[], dependencies:[ids] }. Returns candidate edges
67
- // { task_id, dep_id, dep_status, confidence, paths }.
68
- function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker } = {}) {
69
- const out = [];
70
- for (const t of tasks) {
71
- if (!open.has(t.status)) continue;
72
- if (!Array.isArray(t.touches) || !t.touches.length) continue;
73
- const declared = new Set((t.dependencies || []).map(String));
74
- for (const o of tasks) {
75
- if (String(o.id) === String(t.id)) continue;
76
- if (!isBlocker(o.status)) continue;
77
- if (!Array.isArray(o.touches) || !o.touches.length) continue;
78
- if (declared.has(String(o.id))) continue;
79
- if (!touchesOverlap(t.touches, o.touches)) continue;
80
- const c = classify(t.touches, o.touches);
81
- if (c) out.push({ task_id: t.id, dep_id: o.id, dep_status: o.status, ...c });
82
- }
83
- }
84
- return out;
85
- }
36
+ const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, findMissingDeps } =
37
+ require('../../modules/lifecycle/missing-dep-classify.js');
86
38
 
87
39
  // Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
88
40
  // was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
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.976'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.978'; // 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');
@@ -0,0 +1,231 @@
1
+ // tests/dependency_advisory.mjs — the missing-dependency advisory on POST
2
+ // /tasks (task 1002827, goal 1000089).
3
+ //
4
+ // scripts/gds/audit-deps.js has shipped a working likely-missing-dependency
5
+ // detector since task 1000349 (tests/audit_deps.mjs, tests/missing_dep_classify.mjs)
6
+ // and nothing called it. This wires the SAME detector's pure core
7
+ // (modules/lifecycle/missing-dep-classify.js) live, mirroring goal-advisory.js's
8
+ // two properties:
9
+ // 1. DETECTION reuses the one canonical overlap matcher — no second copy.
10
+ // 2. The advisory can NEVER fail the create — asserted here by driving it
11
+ // with a db that throws.
12
+ //
13
+ // Run: node tests/dependency_advisory.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import http from 'node:http';
18
+ import { readFileSync } from 'node:fs';
19
+ import { makeRunner, taskRoutesSource } from './helpers.mjs';
20
+
21
+ process.env.NODE_ENV = 'test';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const { adviseDependencies, buildDependencyAdvisory, CANDIDATE_LIMIT } = require('../modules/lifecycle/dependency-advisory.js');
25
+
26
+ const tasksSrc = taskRoutesSource();
27
+ const { test, summary } = makeRunner();
28
+
29
+ // ---- adviseDependencies: the pure core ----
30
+
31
+ await test('no touches[] on the new task — silent, no candidates read', () => {
32
+ assert.equal(adviseDependencies({ task: { id: 1, touches: [] }, candidates: [{ id: 2, status: 'ready', touches: ['a.js'] }] }), null);
33
+ assert.equal(adviseDependencies({ task: { id: 1 }, candidates: [] }), null);
34
+ });
35
+
36
+ await test('an exact narrow-file overlap with an open candidate is flagged high', () => {
37
+ const a = adviseDependencies({
38
+ task: { id: 1, touches: ['modules/foo/bar.js'] },
39
+ candidates: [{ id: 2, title: 'Sibling work', status: 'ready', touches: ['modules/foo/bar.js'] }],
40
+ });
41
+ assert.equal(a.reason, 'possible_missing_dependency');
42
+ assert.equal(a.candidates.length, 1);
43
+ assert.equal(a.candidates[0].dep_id, 2);
44
+ assert.equal(a.candidates[0].confidence, 'high');
45
+ assert.equal(a.candidates[0].dep_title, 'Sibling work');
46
+ });
47
+
48
+ await test('an already-declared dependency is never flagged', () => {
49
+ const a = adviseDependencies({
50
+ task: { id: 1, touches: ['modules/foo/bar.js'], dependencies: ['2'] },
51
+ candidates: [{ id: 2, status: 'ready', touches: ['modules/foo/bar.js'] }],
52
+ });
53
+ assert.equal(a, null);
54
+ });
55
+
56
+ await test('a SHIPPED candidate never gates — the dependency system already treats it as satisfied', () => {
57
+ const a = adviseDependencies({
58
+ task: { id: 1, touches: ['modules/foo/bar.js'] },
59
+ candidates: [{ id: 2, status: 'shipped', touches: ['modules/foo/bar.js'] }],
60
+ });
61
+ assert.equal(a, null);
62
+ });
63
+
64
+ await test('an ABANDONED candidate never gates', () => {
65
+ const a = adviseDependencies({
66
+ task: { id: 1, touches: ['modules/foo/bar.js'] },
67
+ candidates: [{ id: 2, status: 'abandoned', touches: ['modules/foo/bar.js'] }],
68
+ });
69
+ assert.equal(a, null);
70
+ });
71
+
72
+ await test('a candidate never flags itself', () => {
73
+ const a = adviseDependencies({
74
+ task: { id: 1, touches: ['modules/foo/bar.js'] },
75
+ candidates: [{ id: 1, status: 'ready', touches: ['modules/foo/bar.js'] }],
76
+ });
77
+ assert.equal(a, null);
78
+ });
79
+
80
+ await test('a hotspot-only overlap (e.g. CLAUDE.md) is flagged low, sorted after a high candidate', () => {
81
+ const a = adviseDependencies({
82
+ task: { id: 1, touches: ['CLAUDE.md', 'modules/foo/bar.js'] },
83
+ candidates: [
84
+ { id: 2, status: 'ready', touches: ['CLAUDE.md'] },
85
+ { id: 3, status: 'ready', touches: ['modules/foo/bar.js'] },
86
+ ],
87
+ });
88
+ assert.equal(a.candidates.length, 2);
89
+ assert.equal(a.candidates[0].dep_id, 3, 'high confidence sorts first');
90
+ assert.equal(a.candidates[0].confidence, 'high');
91
+ assert.equal(a.candidates[1].confidence, 'low');
92
+ });
93
+
94
+ await test('candidates are capped at CANDIDATE_LIMIT', () => {
95
+ const candidates = Array.from({ length: CANDIDATE_LIMIT + 3 }, (_, i) => ({ id: i + 2, status: 'ready', touches: ['modules/foo/bar.js'] }));
96
+ const a = adviseDependencies({ task: { id: 1, touches: ['modules/foo/bar.js'] }, candidates });
97
+ assert.equal(a.candidates.length, CANDIDATE_LIMIT);
98
+ });
99
+
100
+ // ---- buildDependencyAdvisory: DB-read wrapper, fail-open ----
101
+
102
+ await test('buildDependencyAdvisory: no touches[] skips the DB read entirely', async () => {
103
+ let read = false;
104
+ const db = { async listOpenTasksWithTouches() { read = true; return []; } };
105
+ const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: [] } });
106
+ assert.equal(a, null);
107
+ assert.equal(read, false);
108
+ });
109
+
110
+ await test('buildDependencyAdvisory: a real candidate yields a next_step naming the actual task id', async () => {
111
+ const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
112
+ const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['modules/foo/bar.js'] } });
113
+ assert.equal(a.reason, 'possible_missing_dependency');
114
+ assert.match(a.next_step, /POST \/tasks\/42\/dependencies/);
115
+ });
116
+
117
+ await test('NON-BLOCKING: buildDependencyAdvisory returns null when db.listOpenTasksWithTouches throws', async () => {
118
+ const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
119
+ const a = await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
120
+ assert.equal(a, null, 'degrades to silence — never propagates into the already-committed create');
121
+ });
122
+
123
+ await test('NON-BLOCKING: a db missing the method entirely still yields null, not a crash', async () => {
124
+ const a = await buildDependencyAdvisory({ db: {}, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
125
+ assert.equal(a, null);
126
+ });
127
+
128
+ await test('buildDependencyAdvisory forwards viewerId/viewerIsArchon to the query (ADR 0112 visibility)', async () => {
129
+ let seen = null;
130
+ const db = {
131
+ async listOpenTasksWithTouches(versionId, excludeId, opts) { seen = { versionId, excludeId, opts }; return []; },
132
+ };
133
+ await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 42, touches: ['a.js'] }, viewerId: 7, viewerIsArchon: true });
134
+ assert.deepEqual(seen, { versionId: 'V', excludeId: 42, opts: { viewerId: 7, viewerIsArchon: true } });
135
+ });
136
+
137
+ await test('buildDependencyAdvisory defaults viewerId/viewerIsArchon when the caller omits them', async () => {
138
+ let seen = null;
139
+ const db = { async listOpenTasksWithTouches(v, e, opts) { seen = opts; return []; } };
140
+ await buildDependencyAdvisory({ db, versionId: 'V', task: { id: 1, touches: ['a.js'] } });
141
+ assert.deepEqual(seen, { viewerId: null, viewerIsArchon: false });
142
+ });
143
+
144
+ // ---- ADR 0112: the query itself must carry the visibility predicate ----
145
+ // (SQL correctness for this file is integration-tested elsewhere in this
146
+ // codebase's own convention — no local Postgres here — so this is the same
147
+ // source-scan safety net the goal-advisory/goal-graph precedents use to
148
+ // catch an accidental removal of the join/predicate.)
149
+
150
+ await test('listOpenTasksWithTouches SQL joins goals + goal_members and excludes an unseen private goal', () => {
151
+ const src = readFileSync(new URL('../modules/lifecycle/db-tasks.js', import.meta.url), 'utf8');
152
+ const fn = src.slice(src.indexOf('async function listOpenTasksWithTouches'), src.indexOf('async function listOpenTasksWithTouches') + 1200);
153
+ assert.match(fn, /LEFT JOIN goals g ON g\.id = t\.goal_id/);
154
+ assert.match(fn, /LEFT JOIN goal_members gm ON gm\.goal_id = g\.id/);
155
+ assert.match(fn, /g\.visibility IS DISTINCT FROM 'private'/);
156
+ assert.match(fn, /gm\.builder_id IS NOT NULL/, 'a viewer\'s own membership admits a private goal\'s task');
157
+ assert.match(fn, /LIMIT \$\{DEPENDENCY_ADVISORY_CANDIDATE_LIMIT\}/, 'the candidate pool is bounded');
158
+ });
159
+
160
+ // ---- the route wiring, asserted over source (the goal_advisory.mjs precedent) ----
161
+
162
+ await test('the route delegates to buildDependencyAdvisory — no inline logic to drift — and forwards the viewer identity for ADR 0112', () => {
163
+ assert.match(tasksSrc, /await buildDependencyAdvisory\(\{[\s\S]{0,80}?db, versionId: body\.version_id, task,[\s\S]{0,200}?viewerId: req\.builder\.id, viewerIsArchon: req\.builder\.rank === 'archon',[\s\S]{0,20}?\}\);/);
164
+ const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
165
+ assert.ok(body201, 'found the 201 response body');
166
+ assert.match(body201, /dependency_advisory,/, 'included in the 201 body');
167
+ });
168
+
169
+ // ---- ROUTER-LEVEL: the non-blocking invariant asserted over real HTTP ----
170
+
171
+ function listen(app) {
172
+ return new Promise((resolve) => {
173
+ const server = app.listen(0, '127.0.0.1', () => resolve(server));
174
+ });
175
+ }
176
+
177
+ function post(port, path, body) {
178
+ return new Promise((resolve, reject) => {
179
+ const payload = JSON.stringify(body);
180
+ const req = http.request(
181
+ { host: '127.0.0.1', port, path, method: 'POST',
182
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
183
+ (res) => {
184
+ let data = '';
185
+ res.on('data', (c) => { data += c; });
186
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data || '{}') }));
187
+ }
188
+ );
189
+ req.on('error', reject);
190
+ req.end(payload);
191
+ });
192
+ }
193
+
194
+ function makeApp(db) {
195
+ const express = require('express');
196
+ const app = express();
197
+ app.use(express.json());
198
+ app.post('/tasks', async (req, res) => {
199
+ try {
200
+ const task = { id: 99, touches: req.body.touches || [], credits_reward: 60 }; // already committed
201
+ const dependency_advisory = await buildDependencyAdvisory({ db, versionId: req.body.version_id, task });
202
+ res.status(201).json({ task, criteria: [], dependency_advisory });
203
+ } catch (err) {
204
+ res.status(500).json({ error: { code: 'create_failed' } });
205
+ }
206
+ });
207
+ return app;
208
+ }
209
+
210
+ await test('HTTP: a healthy db yields 201 with a populated dependency_advisory', async () => {
211
+ const db = { async listOpenTasksWithTouches() { return [{ id: 2, title: 'Sibling', status: 'ready', touches: ['modules/foo/bar.js'] }]; } };
212
+ const server = await listen(makeApp(db));
213
+ try {
214
+ const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
215
+ assert.equal(r.status, 201);
216
+ assert.equal(r.body.dependency_advisory.reason, 'possible_missing_dependency');
217
+ } finally { server.close(); }
218
+ });
219
+
220
+ await test('HTTP: db.listOpenTasksWithTouches THROWS → still 201, task returned, dependency_advisory null (never a 500)', async () => {
221
+ const db = { async listOpenTasksWithTouches() { throw new Error('db down'); } };
222
+ const server = await listen(makeApp(db));
223
+ try {
224
+ const r = await post(server.address().port, '/tasks', { version_id: 'V', touches: ['modules/foo/bar.js'] });
225
+ assert.equal(r.status, 201, 'the create must NOT become a 500 the caller retries into a duplicate');
226
+ assert.equal(r.body.task.id, 99, 'the created task is still returned');
227
+ assert.equal(r.body.dependency_advisory, null, 'the advisory degrades to silence');
228
+ } finally { server.close(); }
229
+ });
230
+
231
+ summary();
@@ -0,0 +1,108 @@
1
+ // tests/missing_dep_classify.mjs — the PURE missing-dependency detector core
2
+ // (task 1002827), moved out of scripts/gds/audit-deps.js so a live route can
3
+ // reuse it without pulling that script's CLI-only machinery into the server
4
+ // process.
5
+ //
6
+ // tests/audit_deps.mjs already pins findMissingDeps/classify's BEHAVIOR
7
+ // (unchanged by the move — it still imports them from audit-deps.js, which
8
+ // now re-exports this module's names). This file instead pins the three
9
+ // things that changed or were added: the module boundary (a module may reach
10
+ // touchesOverlap only through the src/module-api.js doorway, never a deep
11
+ // core require), the pure surface being intact post-move, and `candidateEdge`
12
+ // — the ONE per-pair predicate findMissingDeps and dependency-advisory.js's
13
+ // live adviseDependencies both call now, so neither re-types it.
14
+ //
15
+ // Run: node tests/missing_dep_classify.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { OPEN, isUnshippedBlocker, HOTSPOTS, classify, candidateEdge, findMissingDeps } =
24
+ require('../modules/lifecycle/missing-dep-classify.js');
25
+
26
+ const { test, summary } = makeRunner();
27
+
28
+ await test('OPEN names exactly the four auditable statuses', () => {
29
+ assert.deepEqual([...OPEN].sort(), ['active', 'backlog', 'blocked', 'ready']);
30
+ });
31
+
32
+ await test('isUnshippedBlocker: only shipped/abandoned are terminal', () => {
33
+ assert.equal(isUnshippedBlocker('shipped'), false);
34
+ assert.equal(isUnshippedBlocker('abandoned'), false);
35
+ assert.equal(isUnshippedBlocker('ready'), true);
36
+ assert.equal(isUnshippedBlocker('backlog'), true);
37
+ });
38
+
39
+ await test('classify: an exact non-hotspot file match is high confidence', () => {
40
+ const c = classify(['modules/foo/bar.js'], ['modules/foo/bar.js']);
41
+ assert.equal(c.confidence, 'high');
42
+ });
43
+
44
+ await test('classify: a hotspot-only overlap is low confidence', () => {
45
+ const c = classify(['CLAUDE.md'], ['CLAUDE.md']);
46
+ assert.equal(c.confidence, 'low');
47
+ });
48
+
49
+ await test('classify: no overlap at all returns null', () => {
50
+ assert.equal(classify(['a.js'], ['b.js']), null);
51
+ });
52
+
53
+ await test('findMissingDeps re-exported unchanged: a live example still flags', () => {
54
+ const tasks = [
55
+ { id: 1, status: 'ready', touches: ['modules/x/y.js'], dependencies: [] },
56
+ { id: 2, status: 'backlog', touches: ['modules/x/y.js'], dependencies: [] },
57
+ ];
58
+ assert.equal(findMissingDeps(tasks).length, 2);
59
+ });
60
+
61
+ // ---- candidateEdge: the ONE per-pair predicate, shared by findMissingDeps
62
+ // and dependency-advisory.js's adviseDependencies (grade round 1 finding) ----
63
+
64
+ await test('candidateEdge: an overlapping open non-dep is a real edge', () => {
65
+ const e = candidateEdge(
66
+ { id: 1, touches: ['modules/x/y.js'] },
67
+ { id: 2, status: 'ready', touches: ['modules/x/y.js'] },
68
+ );
69
+ assert.equal(e.task_id, 1);
70
+ assert.equal(e.dep_id, 2);
71
+ assert.equal(e.confidence, 'high');
72
+ });
73
+
74
+ await test('candidateEdge: null on self, terminal status, no touches, or an already-declared dep', () => {
75
+ const t = { id: 1, touches: ['a.js'], dependencies: ['2'] };
76
+ assert.equal(candidateEdge(t, { id: 1, status: 'ready', touches: ['a.js'] }), null, 'self');
77
+ assert.equal(candidateEdge(t, { id: 3, status: 'shipped', touches: ['a.js'] }), null, 'terminal');
78
+ assert.equal(candidateEdge(t, { id: 3, status: 'ready', touches: [] }), null, 'no touches');
79
+ assert.equal(candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }), null, 'already declared');
80
+ });
81
+
82
+ await test('candidateEdge: an explicit `declared` set is used instead of re-deriving from t.dependencies', () => {
83
+ const t = { id: 1, touches: ['a.js'], dependencies: [] };
84
+ const e = candidateEdge(t, { id: 2, status: 'ready', touches: ['a.js'] }, { declared: new Set(['2']) });
85
+ assert.equal(e, null, 'the passed-in declared set wins over the empty t.dependencies');
86
+ });
87
+
88
+ await test('candidateEdge runs classify exactly once — no separate touchesOverlap pre-check to duplicate', () => {
89
+ const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
90
+ const body = src.slice(src.indexOf('function candidateEdge'), src.indexOf('function findMissingDeps'));
91
+ assert.equal((body.match(/classify\(/g) || []).length, 1, 'exactly one classify() call in candidateEdge');
92
+ assert.equal(/touchesOverlap\(/.test(body), false, 'no redundant overlap pre-check — classify already answers that');
93
+ });
94
+
95
+ await test('the module reaches touchesOverlap through the module-api doorway, never a deep core require', () => {
96
+ const src = readFileSync(new URL('../modules/lifecycle/missing-dep-classify.js', import.meta.url), 'utf8');
97
+ assert.match(src, /require\('\.\.\/\.\.\/src\/module-api'\)/, 'reaches core via the doorway');
98
+ assert.equal(/require\(['"]\.\.\/\.\.\/src\/bongos\//.test(src), false, 'never a direct src/bongos/* require (ADR 0083)');
99
+ });
100
+
101
+ await test('scripts/gds/audit-deps.js delegates to this module rather than redefining the pure core', () => {
102
+ const src = readFileSync(new URL('../scripts/gds/audit-deps.js', import.meta.url), 'utf8');
103
+ assert.match(src, /require\('\.\.\/\.\.\/modules\/lifecycle\/missing-dep-classify\.js'\)/);
104
+ assert.equal(/^function classify/m.test(src), false, 'classify is no longer redefined here');
105
+ assert.equal(/^function findMissingDeps/m.test(src), false, 'findMissingDeps is no longer redefined here');
106
+ });
107
+
108
+ summary();