@bongos/core 1.19.913 → 1.19.915

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.913",
6
- "core_contract": "1.19.913",
7
- "source_commit": "e5423d044bfab389c03a2bf60d244f83c015154c",
5
+ "core_version": "1.19.915",
6
+ "core_contract": "1.19.915",
7
+ "source_commit": "34ab0481d787ea0faebe090f349dd6aee3af7411",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T16:36:18.057Z",
9
+ "built_at": "2026-09-20T16:56:03.819Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 504,
12
+ "docs_redacted": 505,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2287,
14
+ "functional_verbatim": 2289,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2817,
20
- "tree_sha256": "f335bd0e9dfe731d6a3b535d10eab167f47b4517a03e5ed410bf48e8d607cc4d",
19
+ "file_count": 2820,
20
+ "tree_sha256": "0aef60e98f88414bfc2aa5d791a0ebb7726681c98a47f3f2f46cf0ebea06bc6d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2069,10 +2069,15 @@
2069
2069
  "mode": "0000644",
2070
2070
  "sha256": "bc9dde45a33790e4fbce47682a5734a1adaf4fdd7f9347bb0128c6cca6fcf4e5"
2071
2071
  },
2072
+ {
2073
+ "path": "docs/adr/0316-the-ignite-floor-is-not-the-trusted-tier.md",
2074
+ "mode": "0000644",
2075
+ "sha256": "d0597a50d8f9a23c8d6c81a595fe6af73640cf574ae32f64730078ac7e19c7a5"
2076
+ },
2072
2077
  {
2073
2078
  "path": "docs/adr/README.md",
2074
2079
  "mode": "0000644",
2075
- "sha256": "a6bf1805ee02ca941a1910d722cfc61e4f0e519aa79c9d102aadfb8bf7d95a5a"
2080
+ "sha256": "005b960dede2131ea0e2f3b81afcb119d7b65ddb6ed81bb246896eff64a69320"
2076
2081
  },
2077
2082
  {
2078
2083
  "path": "docs/api-reference.md",
@@ -2642,7 +2647,7 @@
2642
2647
  {
2643
2648
  "path": "docs/module-api-changelog.md",
2644
2649
  "mode": "0000644",
2645
- "sha256": "16f8d72724c91138e8fb17da0723f2901c66dee89a53e44dbc41dd65eb71b161"
2650
+ "sha256": "1c9dcc49caf6106cf21a207da263c449a6c01d05a1647b11c6a1cd41deeeba79"
2646
2651
  },
2647
2652
  {
2648
2653
  "path": "docs/modules-contract.md",
@@ -5334,6 +5339,11 @@
5334
5339
  "mode": "0000644",
5335
5340
  "sha256": "07e5666362c91f92d34388b4f6f4507cc5387866d317970478e6cae417c19975"
5336
5341
  },
5342
+ {
5343
+ "path": "modules/ideas/config.js",
5344
+ "mode": "0000644",
5345
+ "sha256": "70caf7fe901380679ec4a418056e2688efe4afe26d6d5a6058ff64fa36445109"
5346
+ },
5337
5347
  {
5338
5348
  "path": "modules/ideas/descent.js",
5339
5349
  "mode": "0000644",
@@ -5347,12 +5357,12 @@
5347
5357
  {
5348
5358
  "path": "modules/ideas/inbox.js",
5349
5359
  "mode": "0000644",
5350
- "sha256": "ff6c0346215d1b88309c4c9360cd60c59299b21b45e27cce09c4b748bf3411e1"
5360
+ "sha256": "45e46cbd1b15692e4ba74932986e23c138a5457d96d4d6e72193bb3f690e08af"
5351
5361
  },
5352
5362
  {
5353
5363
  "path": "modules/ideas/landing-preview.js",
5354
5364
  "mode": "0000644",
5355
- "sha256": "912c7007c534a198adce9c83a56e39da8a1475bfe5b09b2a6fe1ff8914d775c9"
5365
+ "sha256": "735571069c131d0ee9f88b0925b5dc50c0583f295220027ab88e791bc940fe3c"
5356
5366
  },
5357
5367
  {
5358
5368
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -5412,7 +5422,7 @@
5412
5422
  {
5413
5423
  "path": "modules/ideas/routing.js",
5414
5424
  "mode": "0000644",
5415
- "sha256": "da653f475d6c91db8e84bff46c797bb68b61e48d561eacf7bfd228a119ac7266"
5425
+ "sha256": "ec26fc6a2481e1f13d1141f6f1248132ed282f73ddba37c525af5ecae7fd1f0e"
5416
5426
  },
5417
5427
  {
5418
5428
  "path": "modules/ideas/skill-friction.js",
@@ -7797,12 +7807,12 @@
7797
7807
  {
7798
7808
  "path": "package-lock.json",
7799
7809
  "mode": "0000644",
7800
- "sha256": "599eeee82f59bc74d6e6e19ebc61bba89421c050ca7444478e13860d6e0276cf"
7810
+ "sha256": "2536cbe6300c83d6010ccf3fdb7a5fe00cdeda0808440da9bb2c2dbc69242f76"
7801
7811
  },
7802
7812
  {
7803
7813
  "path": "package.json",
7804
7814
  "mode": "0000644",
7805
- "sha256": "899fa307dabbae44dd14efe9d121af734c3cdbb9d0027668ff21ba5e2b8e49dd"
7815
+ "sha256": "73a950b0195739ef832e49d57b5e2e9387bab45e5726ceddcaec2411a8a65994"
7806
7816
  },
7807
7817
  {
7808
7818
  "path": "public-docs/index.html",
@@ -9612,7 +9622,7 @@
9612
9622
  {
9613
9623
  "path": "src/bongos/module-scope-map.js",
9614
9624
  "mode": "0000644",
9615
- "sha256": "b82d3257c27689e53b3d491251cc46d474c8f4da944e2e8f73242525df878484"
9625
+ "sha256": "cfcc13ad9d554c4f6f0d8bb4df822a1e2a7f589c2bc3c2829b95b64d00f82cd0"
9616
9626
  },
9617
9627
  {
9618
9628
  "path": "src/bongos/module-submissions.js",
@@ -9802,7 +9812,7 @@
9802
9812
  {
9803
9813
  "path": "src/module-api.js",
9804
9814
  "mode": "0000644",
9805
- "sha256": "5f5e11f8e4ade6fc0642e3051c89a1f3495628cb567c0fa30037b2fd55640170"
9815
+ "sha256": "79f3ff7326a66a71696ead1df0b21d15ca5bb7f5351d0aee03d00ba8207b7b78"
9806
9816
  },
9807
9817
  {
9808
9818
  "path": "src/module-loader/catalog.js",
@@ -11959,6 +11969,11 @@
11959
11969
  "mode": "0000644",
11960
11970
  "sha256": "c7ba20839a7b33df0eb7e8bfc2f463217e7d3a538d888e89774107d55d34556d"
11961
11971
  },
11972
+ {
11973
+ "path": "tests/ideas_config.mjs",
11974
+ "mode": "0000644",
11975
+ "sha256": "35334314ea15cfa5cd262c18def917bea04f569cfa52a0a86b663710de2bd172"
11976
+ },
11962
11977
  {
11963
11978
  "path": "tests/idp_device_exchange.mjs",
11964
11979
  "mode": "0000644",
@@ -12302,7 +12317,7 @@
12302
12317
  {
12303
12318
  "path": "tests/module-scope-map.mjs",
12304
12319
  "mode": "0000644",
12305
- "sha256": "fae747eaeea8e9d657de097a72e5f8d244bd2ece6c7e890ff4c9756787721bc5"
12320
+ "sha256": "4d65821ed847d364a80920b29c82cf1ee8ac06b3a8349845902e58e75cf96c7b"
12306
12321
  },
12307
12322
  {
12308
12323
  "path": "tests/module_api.mjs",
@@ -0,0 +1,98 @@
1
+ # ADR 0316 — The ignite floor is a scheduling dial, not the trusted tier
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-20
5
+ - **Task:** 1004084 (BV2.R05) · goal 1000095 · spec `docs/specs/<redacted>.md` decisions 3 and 16
6
+ - **Supersedes nothing.** Narrows how spec decision 3 is to be read.
7
+
8
+ ## Context
9
+
10
+ BV2 decision 3 makes the **ignite floor** — who may turn a quick idea directly into
11
+ claimable work — a per-instance setting: "a project of one wants every thought
12
+ claimable; a project of fifty wants a nod." Decision 16 adds a second dial, the
13
+ **descent decay rate**, and rules that the two should share one home rather than
14
+ growing separately.
15
+
16
+ Both the spec and the seeded task described the implementation the same way: the
17
+ ignite floor *is* the `METIC_PLUS` constant in `modules/ideas/routing.js`, so this
18
+ is "a setting, not a refactor."
19
+
20
+ Tracing the code before building showed that premise is false. `METIC_PLUS` is
21
+ spelled once but **read for four semantically different gates**:
22
+
23
+ 1. `decideLanding`, the homeless-bug branch — land at `ready` or at `backlog`.
24
+ 2. `decideLanding`, the goal-carrying bug branch — the same split.
25
+ 3. `mayProposeCriterion` (and the 403 in `routes/inbox.js`) — who may file a
26
+ `kind='criterion-proposal'`, a planning-scope write that reshapes a version's
27
+ scope-truth.
28
+ 4. The protected-scope wall in `authorizeRoutedLanding` — who may route work into
29
+ the permission / ship-grade / deploy / migration core.
30
+
31
+ Only (1) and (2) are the ignite floor. Making the *constant* configurable, as
32
+ written, would mean an instance setting its floor to `xenos` — precisely the
33
+ "project of one" case the owner asked for — would **also** grant every Xenos the
34
+ right to file criterion-proposals and to put rows into the protected core's queue.
35
+
36
+ The codebase already said so, in three places, and none of them had been read into
37
+ the spec:
38
+
39
+ - `routing.js` at the constant's definition: "It grants nothing — the server-side
40
+ rank floor and the goal's scope wall are separate, load-bearing gates."
41
+ - The protected-scope branch: "the two are layers, not alternatives."
42
+ - `tests/filing_abuse_matrix.mjs` models the widening as an **attack**, recording
43
+ in its header that "METIC_PLUS widened to include xenos ... 4 of 23 fail", and
44
+ pinning the literal.
45
+
46
+ ## Decision
47
+
48
+ **The ignite floor is a separate dial from the trusted tier, and only the dial is
49
+ configurable.**
50
+
51
+ - A new per-instance config home, `modules/ideas/config.js` +
52
+ `config/ideas.neutral.json`, carries both dials. Precedence mirrors
53
+ `modules/government/config.js`: env override → `config/ideas.json` at the
54
+ **instance** root → `config/ideas.neutral.json` at the **core** root. The
55
+ dual-root split matters here for the reason ADR 0268 records.
56
+ - `igniteFloor` defaults to `metic`, which resolves to exactly the `METIC_PLUS`
57
+ set, so the default is a true no-op. It is threaded into `decideLanding` as a
58
+ ready-made `trustedRanks` Set by the callers, keeping `routing.js` pure and
59
+ free of both config and DB.
60
+ - `descentDecayRate` defaults to `0.5`. R11 is its intended consumer and has not
61
+ landed, so it ships as a contract with no production caller — the same
62
+ deliberate slice boundary `modules/government/config.js` documents for itself,
63
+ and what decision 16 asked for when it said the two dials should share one home.
64
+ - **`METIC_PLUS` stays a fixed constant** for gates (3) and (4). Neither takes a
65
+ floor argument, and `tests/ideas_config.mjs` asserts structurally that
66
+ `authorizeRoutedLanding`'s signature never grows one.
67
+
68
+ ## Consequences
69
+
70
+ - An instance may open its ignite floor all the way to `xenos` without opening any
71
+ authority surface. The worst a wide-open floor can do is put a row at `ready`
72
+ that a human can move back to `backlog`.
73
+ - The `filing_abuse_matrix` pin on the `METIC_PLUS` literal stays green and is not
74
+ to be relaxed. A future change that needs to relax it is almost certainly the
75
+ widening this ADR exists to refuse.
76
+ - `modules/ideas/config.js` derives the rank ladder from `api.LIVE_RANK_LADDER`
77
+ on the doorway. The first cut restated it as a literal, reasoning that the file
78
+ must resolve at load with no port wired; `tests/rank_tier_single_source.mjs`
79
+ rejected that — a ladder array literal in a tracked `src`/`modules`/`scripts`
80
+ file fails CI unless allowlisted — and it was right to. The doorway value is a
81
+ lazy getter over the kernel this module's callers already load, so deriving
82
+ costs nothing and removes the drift risk instead of merely detecting it.
83
+ - Spec decision 3's phrase "spelled in one place, so this is a setting, not a
84
+ refactor" should be read as describing the *intent*, not the mechanism. The
85
+ spec was not edited; this ADR is the correction of record, and the task
86
+ description was amended in-flight to match.
87
+
88
+ ## Alternatives considered
89
+
90
+ - **Make `METIC_PLUS` itself configurable, as written.** Rejected: it is a
91
+ security widening wearing a config file's clothes, and the repo's own tests
92
+ already classify it as an attack.
93
+ - **Split `METIC_PLUS` into four named constants and configure one.** Rejected as
94
+ more churn than the decision needs — the other three gates want the *same* fixed
95
+ tier, and giving them separate names invites someone to configure them later.
96
+ - **Have `routing.js` read the config itself.** Rejected: it would make the
97
+ landing matrix non-pure and file-system-dependent, and would remove the
98
+ callers' ability to be tested at a different floor.
@@ -406,4 +406,5 @@ This keeps the decision history honest and traceable.
406
406
  | 0312 | [**Provenance is a SECOND ideas-owned edge table, written by the seams that already know** ([task 1004080](https://cloudbongos.com/builders#/task/1004080), goal 1000095 — BONGOS-V2). The spike [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: how the descent edges are physically recorded. A descent (`idea → task → rewrite → split → removal`) is four levels deep and crosses a version boundary, and the task hierarchy refuses both — `attachParent` enforces `SELF_PARENT`, `VERSION_MISMATCH`, `PARENT_HAS_PARENT` and `TARGET_HAS_CHILDREN`, all load-bearing for ship rollup and claim safety. The spike was told to CONFIRM `ideas_task_lineage` rather than assume it, and confirmed one half while refuting the other: the table is right as the **idea→task anchor** (bipartite, no depth cap, no version predicate) but CANNOT carry the task→task edge, because a derivation is a property of the task PAIR and [ADR 0236](0236-many-ideas-one-task-the-lineage-key-is-the-pair.md) made co-source ideas both legal and common — a `derived_from_task_id` column would write the same derivation once per co-source idea and let two rows disagree about where a task came from, the same under-keying that silently lost attribution on prod once already. A merge also gives a task SEVERAL parents, so the relation is a **DAG, not a tree**. Decision: a new `ideas_task_descent` (`task_id`, `parent_task_id`, CHECK'd `relation`, PK on the pair, indexed both ways), with NO depth cap and NO version predicate, and **cycles refused at write time** because the payout walks this graph and a cycle is a non-terminating walk rather than a cosmetic error. On who writes it — the question the task actually set, since an edge depending on memory will have holes — **three seams already know and none is telling anyone**: `cascade.js` encodes the originating task id inside `source_ref` as a STRING nothing can join on, `tasks.merged_into_task_id` already records the fold, and `parent_task_id` already records the split. Only `rewrite` and cross-version `merge` stay manual, which is honest — they are editorial acts with no automatic signal, and their absence degrades the picture rather than corrupting it. What it fixes: `sourceIdeaForTask` and `awardIdeaTaskShipCredit` are both one-hop `LIMIT 1` point reads, so a DERIVED task resolves to `no_source_idea` and the origin is not paid at all. Does not decide: the decay rate beyond its default, what counts as removed ([ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md)), or how any of it is drawn.](0312-provenance-is-a-second-ideas-owned-edge-table.md) | ideas / lifecycle |
407
407
  | 0313 | [**A removal is derived from the SERVER's own merge diff and confirmed by a person** ([task 1004081](https://cloudbongos.com/builders#/task/1004081), goal 1000095 — BONGOS-V2). The other half [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: what counts as removed, so that brightness can fall at all. Nothing records it today — `shipped` is terminal (`rot.js` says so outright) and `merged_into_task_id` is CHECK'd to `abandoned` rows, describing only work cut BEFORE it landed. Owner decision 13 set the shape (system proposes, person confirms); this settles the detail, and corrects two premises the task was written on. **`cascade.js` is no longer empty** — it carries a production rule and [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) fixed the contract (append an entry, never touch the runner), so there is a precedent to copy including its anti-recursion guard. **`netNegativeBonusAmount` cannot be the trigger** despite being the named nearest signal: it is an aggregate with NO TARGET (it says deletion happened, never what or whose), it is CLIENT-SUPPLIED via `signals.diff_stats`, and `grader-score.js` already documents the forgery it defends against — and brightness sits beside a payout, so a forgeable target-less counter cannot carry it. **Nothing durable records what any ship touched**: `committed_files` and `diff_stats` are client-supplied at grade time and never persisted, `touches[]` is advisory ([ADR 0049](0049-split-parallel-safety-contract.md)), so a removal cannot be reconstructed from history — only derived as a ship lands. Decision: derive it from the SERVER's own merge diff ([ADR 0055](0055-server-mediated-branch-publish.md) — the server holds the repo and does the merge, so it computes the diff rather than being told one), and attribute the deleted hunks through **`claims.published_head_sha`** (`migrations/171`), NOT by parsing `(task NNNN)` out of commit text — only 7 of `main`'s last 15 commits carry a task id, and a convention that holds most of the time cannot underwrite a payout. An UNATTRIBUTABLE deletion files nothing: a wrong proposal costs attention and dims a real star. A rename, a move and a rewrite-in-place are NOT removals, and a cold body is not a remnant — removal applies only below a task that actually shipped. The proposal is an appended `CASCADES` entry filing a QUESTION; confirmation is Metic+/`task.promote` on the existing awaiting-nod surface; an unanswered one rots on the normal clock. Only a CONFIRMED proposal dims a star. The derivation must be NON-BLOCKING — landing work matters more than dimming a star on time. Brightness can only fall forward from here, so historical sizes need a marker or the number lies.](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) | ideas / lifecycle |
408
408
  | 0314 | [**A module owns a protected path without the whole module becoming protected** ([task 1004070](https://cloudbongos.com/builders#/task/1004070), goal 1000095 — BONGOS-V2). Three modules derive `protected` — and so are Archon-only to scope a goal to — because of ONE file that is not their domain: `onboarding` on `scripts/gds/setup.js`, `autonomy` on `scripts/gds/conductor-dispatch.js`, `copy-desk` on `scripts/gds/copy-inventory.js`. The owner chose *narrow what counts as protected*; the task set the fork and required the choice be justified before any glob moved. **Deleting the glob is out**: `MODULE_GLOBS` is also the territory deciding who may AUTHOR a task on a path, and the shared floor is only `tests/`, so an unowned path is authorable by no goal at all. That left reassigning each CLI to an already-protected module — **measured, not reasoned about**, by applying the edit to the source and re-requiring in a fresh process (`MODULE_SCOPE_MAP` is frozen and built from a COPY of `MODULE_GLOBS` at load, so an in-process mutation proves nothing and passes green). The measurement kills it: `copy-desk` and `autonomy` lose the ability to author on their own generator and their own conductor, and — the row that decides it — `devsecops` as the new owner **cannot author the generator together with the two artifacts it writes**, because `docs/copy-registry.json` and `docs/copy-inventory.md` stay `copy-desk`'s. So *change a UI string, regenerate the registry* becomes authorable by no single goal, and that is the ship-check freshness gate every UI task hits. Decision: **protection becomes per-path at the goal-scope wall, as it already is at the other three** — `matchProtected` decides task-authoring, the grader and gate decide ship, the post-push audit decides the dishonest case; only goal-creation asked about the MODULE, and that inconsistency is the whole defect. The derivation stays computed from `PROTECTED_GLOBS` and no flag is hand-set ([ADR 0086](0086-goal-scoped-work-hierarchy.md) §7 / SEC #863), and the Metic floor on those paths does not move. **Corrects the task's own numbers**: 12 of 26 modules derive protected, not 11, and `agents` (protected by `modules/agents/`, itself a protected glob) is absent from its list of seven — which is why the one thing this ADR SHIPS is a test pinning the full protected key set **by name**, a count assertion passing on the wrong twelve being exactly how that drifted. Implementation is deferred to task 1004109, per the task's own instruction to stop after the ADR. Also filed: `builder-settings` (task 1004110), whose overlap rides the blanket `src/bongos/routes/` prefix and so needs either the `me.js` strangler move or its own security ADR; and **nine protected paths owned by no module at all** (task 1004111) — the freshness generators, the merge-driver pair and `cli-lib.js` among them — found while checking whether deletion was viable, a pre-existing hole rather than one this opens. Does not decide: the floor a protected path carries, whether `scripts/gds/` deserves a shared-floor entry the way `tests/` has one, or how the nine unowned paths get a home.](0314-module-protection-is-per-path-not-per-module.md) | modules / permissions |
409
+ | 0316 | [**The ignite floor is a scheduling dial, not the trusted tier** ([task 1004084](https://cloudbongos.com/builders#/task/1004084), goal 1000095 — BONGOS-V2). BV2 decision 3 makes the ignite floor — who may turn a quick idea straight into claimable work — a per-instance setting, and decision 16 adds the descent decay rate beside it, the two to share one home. Both the spec and the seeded task described the mechanism the same way: the floor IS the `METIC_PLUS` constant in `modules/ideas/routing.js`, so this is "a setting, not a refactor". Tracing the code before building refuted that: `METIC_PLUS` is spelled once but READ for four semantically different gates — the two `decideLanding` bug branches (land at `ready` or at `backlog`), `mayProposeCriterion` (who may file a `kind='criterion-proposal'`, a planning-scope write), and the protected-scope wall in `authorizeRoutedLanding` (who may route work into the permission / ship-grade / deploy / migration core). Only the first two are the ignite floor, so making the CONSTANT configurable would mean an instance setting its floor to `xenos` — exactly the "project of one wants every thought claimable" case the owner asked for — ALSO gaining the right to file criterion-proposals and to breach the protected core. The codebase already said so in three places nobody had read into the spec: the constant's own header ("It grants nothing — the server-side rank floor and the goal's scope wall are separate, load-bearing gates"), the protected-scope branch ("the two are layers, not alternatives"), and `tests/filing_abuse_matrix.mjs`, which models the widening as an ATTACK and pins the literal. Decision: a separate dial. `modules/ideas/config.js` + `config/ideas.neutral.json` carry both knobs on the `modules/government/config.js` precedence (env -> instance root `config/ideas.json` -> core root neutral; the ADR 0268 dual-root split matters, since one root reads the host pack out of `node_modules` where `npm ci` erases it). `igniteFloor` defaults to `metic`, resolving to exactly the `METIC_PLUS` set, and is threaded into `decideLanding` as a ready-made `trustedRanks` **Set** by the callers — `routing.js` is TOLD the floor and never reads config, staying pure and DB-free — while `METIC_PLUS` stays a FIXED constant for the other two gates, neither of which takes a floor argument (pinned structurally). `descentDecayRate` defaults to 0.5 and ships with no production caller, because R11 is its intended reader and depends on the unbuilt R08 — the same deliberate slice boundary `modules/government/config.js` documents for itself, and what decision 16 asked for. Everything fails to VANILLA rather than dead: a malformed pack, an unknown rank or an out-of-band rate falls back, and an unrecognised floor yields the DEFAULT set, never an empty one (failing closed would stop every ignition on a typo). Rejected: configuring `METIC_PLUS` as written (a security widening in a config file's clothes); splitting it into four named constants (the other three want the same fixed tier, and naming them invites configuring them); having `routing.js` read the config itself (makes the landing matrix impure and untestable at a different floor). Spec decision 3's "spelled in one place" describes the intent, not the mechanism; the spec was not edited and the task description was amended in flight.](0316-the-ignite-floor-is-not-the-trusted-tier.md) | ideas / permissions |
409
410
  | 0315 | [**A plane with no proxy is refused, not given a no-op ingress** ([task 1004017](https://cloudbongos.com/builders#/task/1004017), goal 1000106 — BONGOS-V2). The piece [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) explicitly deferred: the self-host image ships no proxy, so provisioning step 6's `<slug>.caddy` snippet write and `systemctl reload caddy` cannot work there and a child instance gets no front door. **Two failure shapes, only one visible**: a DOMAINED child already half-refused, because `caddy adapt` fails with no binary and [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md)'s own catch turns that into a gap — but the message told the operator to add a directive to `/etc/caddy/Caddyfile`, a file the container does not have and that ADR 0303 §1 forbids provisioning from creating; a DOMAINLESS child skipped that preflight entirely (it is gated on `inst.domain`), reached step 6 and died on the `sudo` write, an uncaught throw that bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt one — a crash recorded where a refusal was the honest answer. Decision: `ingressPlaneGap(exec)` probes the plane with `caddy version` — one cheap unprivileged read, the same posture as `checkCaddyWiring` — and both standup legs refuse with `Ingress preflight failed: …`, deliberately NOT domain-gated, because the domainless child is the one 0303 could not see. **Only a missing binary proves there is no proxy**: under remote exec `boxExec` is `ssh <host> bash -s`, so an unreachable co-hosting box throws here too, and diagnosing a network blip as 'this plane has no proxy, provision elsewhere' would be a confident wrong answer that burns the intent's retry budget — exit 127 is read as proof, anything else answers 'could not confirm a reverse proxy on this plane' with the real cause leading. The three refusals are ordered **capacity → ingress → wiring**, cheapest and most fundamental first and all before any mutation, which moves 0303's preflight behind the ADR 0285 capacity measurement; both still refuse before anything changes, so only the precedence between two refusals moves. **No ingress seam and no no-op backend**, despite the obvious symmetry with 0306: a `none` implementation whose `writeSite`/`reload` are no-ops lets the standup SUCCEED while leaving the child unreachable — neither half of the task's done-when, and exactly the silently-broken instance it exists to prevent. Does not decide: what a child's front door IS on a container plane (a published port, a bundled proxy, an explicit localhost-only shape) — a future ADR that adds one replaces this refusal rather than extending it. Teardown is left alone on purpose: it already reloads `allowFail`, and refusing there would strand a row instead of cleaning it.](0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md) | provisioning / ingress |
@@ -2300,5 +2300,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2300
2300
  landed since 1.19.911 with no explicit bump. run 35522352719. (task 1002620)
2301
2301
  1.19.913 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2302
2302
  landed since 1.19.912 with no explicit bump. run 35523207325. (task 1002620)
2303
+ 1.19.914 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2304
+ landed since 1.19.913 with no explicit bump. run 35523700531. (task 1002620)
2305
+ 1.19.915 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2306
+ landed since 1.19.914 with no explicit bump. run 35524223153. (task 1002620)
2303
2307
  ---------------------------------------------------------------------------
2304
2308
  ```
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/config.js — the two PER-INSTANCE IDEATOR DIALS (BV2.R05, spec
4
+ // decisions 3 and 16).
5
+ //
6
+ // The owner's ruling is that both of these belong to the PROJECT, not to the
7
+ // platform: a project of one wants every thought claimable, a project of fifty
8
+ // wants a nod. They live in ONE home, deliberately, so that the second dial did
9
+ // not grow a separate one — which is the whole point of R05 scoping them
10
+ // together rather than as two tasks.
11
+ //
12
+ // Precedence mirrors modules/government/config.js, which mirrors src/branding.js:
13
+ //
14
+ // env override -> instance config (config/ideas.json) -> neutral starter
15
+ // (config/ideas.neutral.json)
16
+ //
17
+ // A fresh instance with NO config resolves to the neutral starter, and the
18
+ // neutral starter is chosen so that resolving it changes NOTHING about today's
19
+ // behaviour. That is the fail-to-vanilla posture branding and government already
20
+ // take, and it is what lets this land ahead of its consumers.
21
+ //
22
+ // ── WHAT THIS FILE IS NOT ────────────────────────────────────────────────────
23
+ //
24
+ // It is NOT an authority surface, and the ignite floor is NOT the trusted tier.
25
+ //
26
+ // modules/ideas/routing.js spells `METIC_PLUS` once but READS it for four
27
+ // semantically different gates:
28
+ //
29
+ // 1. decideLanding, homeless-bug branch — ready vs backlog
30
+ // 2. decideLanding, goal-carrying branch — ready vs backlog
31
+ // 3. mayProposeCriterion — who may FILE a criterion-proposal
32
+ // 4. the protected-scope wall — who may route into the
33
+ // permission / ship / deploy core
34
+ //
35
+ // ONLY (1) and (2) are the ignite floor, and only they read this dial. Gates (3)
36
+ // and (4) stay pinned to the fixed Metic+ constant, because an instance lowering
37
+ // its ignite floor to `xenos` — exactly the "project of one" case the owner
38
+ // asked for — must not thereby gain the ability to file criterion-proposals or
39
+ // to put rows into the protected core's queue. routing.js says this itself at
40
+ // the constant's definition ("It grants nothing — the server-side rank floor and
41
+ // the goal's scope wall are separate, load-bearing gates"), the protected-scope
42
+ // branch says "the two are layers, not alternatives", and
43
+ // tests/filing_abuse_matrix.mjs models the widening as an ATTACK: its header
44
+ // records "METIC_PLUS widened to include xenos ... 4 of 23 fail".
45
+ //
46
+ // So the dial below decides one thing only: whether a filed bug lands CLAIMABLE
47
+ // or lands AWAITING A NOD. Both landings create the same row, in the same goal,
48
+ // with the same rank floor derived by createTask; the difference is a status a
49
+ // human can flip either way. That is a scheduling preference, which is why it is
50
+ // safe to configure — and it is why nothing else in this module reads it.
51
+ //
52
+ // ── THE DECAY DIAL'S SLICE BOUNDARY ──────────────────────────────────────────
53
+ //
54
+ // `descentDecayRate` has NO production consumer in this commit. R11 (the ideator
55
+ // payout following the descent) is its intended reader and has not landed — it
56
+ // depends on R08, which is not built. Shipping the contract ahead of the caller
57
+ // is the same deliberate slice boundary modules/government/config.js documents
58
+ // for itself ("deliberately has no production caller yet ... the tests pin the
59
+ // contract until they land"), and it is what decision 16 asked for when it said
60
+ // the two dials should share one home "rather than growing separately".
61
+ //
62
+ // Landing it now is the cheap half. Landing it with R11 would mean R11 either
63
+ // inventing a second home or retrofitting this one mid-build.
64
+
65
+ const fs = require('node:fs');
66
+ const path = require('node:path');
67
+ const api = require('../../src/module-api');
68
+
69
+ // THE TWO FILES COME FROM TWO DIFFERENT ROOTS (ADR 0268, task 1003739). Under a
70
+ // PINNED core, __dirname is inside node_modules/@cloudbongos/core, so resolving
71
+ // both from one root reads the INSTANCE pack out of node_modules — a path no
72
+ // host authors and every `npm ci` erases. Core content from resolveCoreRoot(),
73
+ // host content from resolveInstanceRoot(), reached through the module doorway.
74
+ //
75
+ // Resolved at load rather than per call, matching government/config.js: a lazy
76
+ // resolveInstanceRoot() falls back to process.cwd(), so a chdir mid-process
77
+ // would move the config file underneath a running server.
78
+ const CORE_ROOT = api.resolveCoreRoot();
79
+ const INSTANCE_ROOT = api.resolveInstanceRoot();
80
+ const NEUTRAL_PATH = path.join(CORE_ROOT, 'config', 'ideas.neutral.json');
81
+ const INSTANCE_PATH = path.join(INSTANCE_ROOT, 'config', 'ideas.json');
82
+
83
+ // The rank ladder, lowest authority first, DERIVED from the doorway rather than
84
+ // restated. `api.LIVE_RANK_LADDER` is the single source of truth, and
85
+ // tests/rank_tier_single_source.mjs enforces that: a literal ladder array in any
86
+ // tracked src/modules/scripts file fails CI unless it is allowlisted with a
87
+ // pinning assertion beside it. Deriving is better than restating here anyway —
88
+ // it removes the drift risk instead of merely detecting it, and adding a rank
89
+ // widens this floor's vocabulary automatically.
90
+ //
91
+ // It is a lazy getter on the doorway, so reading it at load costs a require of
92
+ // the kernel this module's callers already load; it opens no connection.
93
+ const RANK_ORDER = Object.freeze([...api.LIVE_RANK_LADDER]);
94
+
95
+ // The defaults, as code. They are ALSO in config/ideas.neutral.json, and that is
96
+ // not duplication for its own sake: the neutral file is what a host copies and
97
+ // edits, and these are what the resolver falls back to when the neutral file is
98
+ // missing, unreadable or malformed. A core whose config/ directory did not ship
99
+ // must still boot to today's behaviour rather than to zero.
100
+ const DEFAULT_IGNITE_FLOOR = 'metic';
101
+ const DEFAULT_DECAY_RATE = 0.5;
102
+
103
+ // Read a JSON pack off disk. A MISSING file is not an error — it is the ordinary
104
+ // case for config/ideas.json, which most instances never author. A file that
105
+ // exists but does not parse returns null and is skipped, so a typo in an
106
+ // instance pack falls back to the neutral defaults rather than bricking boot.
107
+ // That is the same "fail to vanilla, do not fail to dead" posture as the rest of
108
+ // the config layer.
109
+ function readPack(file) {
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(file, 'utf8');
113
+ } catch {
114
+ return null;
115
+ }
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // A floor is valid only if it NAMES A RANK. Case is normalised but whitespace is
125
+ // NOT trimmed — 'METIC ' is a hostile shape, not a rank, the same judgment
126
+ // mayProposeCriterion already records. A non-string, an unknown rank or an empty
127
+ // value falls back to the default rather than being coerced, because a floor
128
+ // that silently became `xenos` through a typo is precisely the widening this
129
+ // whole file exists to prevent.
130
+ function normaliseFloor(value) {
131
+ if (typeof value !== 'string') return null;
132
+ const lower = value.toLowerCase();
133
+ return RANK_ORDER.includes(lower) ? lower : null;
134
+ }
135
+
136
+ // The decay rate is a SHARE, so it lives in [0, 1]. 0 means a descendant's ship
137
+ // pays its origin nothing beyond the first hop; 1 means no decay at all, every
138
+ // hop paying in full. Both ends are legitimate project choices, so both are
139
+ // admitted — what is refused is a value outside the band, a non-finite number
140
+ // (NaN, Infinity) or a non-number, each of which falls back to the default.
141
+ // Refusing rather than clamping is deliberate: a config that asked for 1.5 has
142
+ // asked for something incoherent, and silently paying 100% would be a payout bug
143
+ // wearing a config file's clothes.
144
+ function normaliseDecayRate(value) {
145
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
146
+ return value >= 0 && value <= 1 ? value : null;
147
+ }
148
+
149
+ // The env layer. It exists for the co-tenant case branding's AUTH_IDP override
150
+ // exists for — several hosted instances sharing one neutral pack, differentiated
151
+ // only by env, with no config file of their own. Unlike government's, where the
152
+ // env layer is deliberately labels-only because an env var must never widen
153
+ // authority, BOTH dials are exposed here: neither one grants any authority (see
154
+ // the header), so neither is an authority knob.
155
+ function applyEnvOverrides(resolved, env) {
156
+ const out = { ...resolved };
157
+
158
+ const floorRaw = api.resolveEnv('IDEAS_IGNITE_FLOOR', { env });
159
+ const floor = normaliseFloor(floorRaw);
160
+ if (floor) out.igniteFloor = floor;
161
+
162
+ const rateRaw = api.resolveEnv('IDEAS_DESCENT_DECAY_RATE', { env });
163
+ if (rateRaw != null && String(rateRaw).trim() !== '') {
164
+ const rate = normaliseDecayRate(Number(rateRaw));
165
+ if (rate != null) out.descentDecayRate = rate;
166
+ }
167
+
168
+ return out;
169
+ }
170
+
171
+ // loadIdeasConfig — resolve the dials. Never throws: every layer that cannot be
172
+ // read or does not validate is skipped in favour of the one beneath it, and the
173
+ // bottom layer is the in-code defaults. Pass `env` and `roots` to test without
174
+ // touching the real filesystem or process.env.
175
+ function loadIdeasConfig({ env = process.env, roots = null } = {}) {
176
+ const neutralPath = roots && roots.neutralPath ? roots.neutralPath : NEUTRAL_PATH;
177
+ const instancePath = roots && roots.instancePath ? roots.instancePath : INSTANCE_PATH;
178
+
179
+ const neutral = readPack(neutralPath) || {};
180
+ const instance = readPack(instancePath) || {};
181
+
182
+ const resolved = {
183
+ igniteFloor:
184
+ normaliseFloor(instance.igniteFloor)
185
+ || normaliseFloor(neutral.igniteFloor)
186
+ || DEFAULT_IGNITE_FLOOR,
187
+ descentDecayRate:
188
+ normaliseDecayRate(instance.descentDecayRate)
189
+ ?? normaliseDecayRate(neutral.descentDecayRate)
190
+ ?? DEFAULT_DECAY_RATE,
191
+ };
192
+
193
+ return Object.freeze(applyEnvOverrides(resolved, env));
194
+ }
195
+
196
+ // trustedRanksForFloor — turn a floor into the SET decideLanding actually asks.
197
+ //
198
+ // A floor is a point on the ladder and admits everything at or above it, so
199
+ // 'metic' yields {metic, archon} — byte-for-byte the behaviour of the METIC_PLUS
200
+ // constant, which is what makes `igniteFloor: 'metic'` a true no-op default.
201
+ // An unrecognised floor yields the default's set rather than an empty one:
202
+ // failing closed here would mean NOBODY could ignite, turning a config typo into
203
+ // a silent queue stoppage.
204
+ function trustedRanksForFloor(floor) {
205
+ const at = RANK_ORDER.indexOf(normaliseFloor(floor) || DEFAULT_IGNITE_FLOOR);
206
+ const from = at === -1 ? RANK_ORDER.indexOf(DEFAULT_IGNITE_FLOOR) : at;
207
+ return Object.freeze(new Set(RANK_ORDER.slice(from)));
208
+ }
209
+
210
+ // The resolved dials, read ONCE at load. Config is a file on disk that does not
211
+ // change under a running process; re-reading it per filing would put a
212
+ // synchronous fs call on the capture path for a value that cannot have moved.
213
+ const CONFIG = loadIdeasConfig();
214
+
215
+ // The set decideLanding's two landing branches consume, derived once from the
216
+ // resolved floor for the same reason.
217
+ const TRUSTED_FILER_RANKS = trustedRanksForFloor(CONFIG.igniteFloor);
218
+
219
+ module.exports = {
220
+ loadIdeasConfig,
221
+ trustedRanksForFloor,
222
+ igniteFloor: CONFIG.igniteFloor,
223
+ descentDecayRate: CONFIG.descentDecayRate,
224
+ TRUSTED_FILER_RANKS,
225
+ RANK_ORDER,
226
+ DEFAULT_IGNITE_FLOOR,
227
+ DEFAULT_DECAY_RATE,
228
+ NEUTRAL_PATH,
229
+ INSTANCE_PATH,
230
+ };
@@ -242,8 +242,8 @@ async function captureIdea({
242
242
  }
243
243
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
244
244
 
245
- // The decision, before any write. Pure — see ./routing.js.
246
- const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank, vector });
245
+ // The decision, before any write. Pure — see ./routing.js. `trustedRanks` is the per-instance ignite floor (BV2.R05; ./config.js says why it is narrow); required inline because this file sits ON the 1500-line ratchet ceiling and require() is cached.
246
+ const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank, vector, trustedRanks: require('./config').TRUSTED_FILER_RANKS });
247
247
  if (landing.route) {
248
248
  return routeIdeaToGoal({
249
249
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
@@ -40,6 +40,7 @@
40
40
  const api = require('../../src/module-api');
41
41
  const { pool } = api;
42
42
  const { decideLanding, authorizeRoutedLanding } = require('./routing');
43
+ const ideasConfig = require('./config');
43
44
 
44
45
  // The verdict vocabulary the composer renders. Stable machine tokens, never
45
46
  // prose: the wording is the UI's business and the copy registry's, and a client
@@ -85,7 +86,14 @@ async function previewLanding(input, deps = {}) {
85
86
  const { goalId = null, kind = null, grade = null, filerRank = null, vector = null } = input || {};
86
87
  const activePool = ((deps || {}).pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
87
88
  try {
88
- const landing = decideLanding({ goalId, kind, grade, filerRank, vector });
89
+ // The SAME ignite floor the real filing will use a moment later (BV2.R05).
90
+ // Reading it from the same place is the whole point of this surface: a
91
+ // preview that resolved the floor differently from the capture path would be
92
+ // the exact drift task 1004083 landed this endpoint to prevent.
93
+ const landing = decideLanding({
94
+ goalId, kind, grade, filerRank, vector,
95
+ trustedRanks: ideasConfig.TRUSTED_FILER_RANKS,
96
+ });
89
97
 
90
98
  if (!landing.route) {
91
99
  // A goal was named and the matrix declined it — the second lie above. The
@@ -111,6 +111,23 @@ function decideLanding(input) {
111
111
  // decision that CRASHES is the worst of the three outcomes: it takes down the
112
112
  // capture that was supposed to fail closed. Normalise first, decide after.
113
113
  const { goalId = null, kind = null, grade = null, filerRank = null, vector = null } = input || {};
114
+ // THE IGNITE FLOOR, as a parameter rather than a constant (BV2.R05, spec
115
+ // decision 3). The set of ranks whose filed bug lands CLAIMABLE instead of
116
+ // awaiting a nod is now per-instance, resolved by modules/ideas/config.js and
117
+ // handed in by the caller. It arrives as a ready-made Set so this file stays
118
+ // pure and DB-free — it does not read config, it is TOLD the floor.
119
+ //
120
+ // Defaulting to METIC_PLUS is what makes every existing caller and test
121
+ // unchanged: omit it and this function behaves exactly as it did.
122
+ //
123
+ // SCOPE, and it is narrow on purpose. This governs ONLY the two landing-status
124
+ // decisions below. `mayProposeCriterion` and the protected-scope wall in
125
+ // `authorizeRoutedLanding` keep reading the fixed METIC_PLUS constant, because
126
+ // an instance lowering its ignite floor must not thereby gain the right to file
127
+ // criterion-proposals or to route work into the protected core. Those are the
128
+ // separate load-bearing gates the constant's own header names.
129
+ const trusted = input && input.trustedRanks instanceof Set ? input.trustedRanks : METIC_PLUS;
130
+ const isTrustedFiler = trusted.has(String(filerRank || '').toLowerCase());
114
131
  // No goal named ⇒ the inbox is exactly right, and this is the DEFAULT PATH.
115
132
  // Every capture vector that never learns about goals keeps working unchanged;
116
133
  // routing is strictly additive (criterion C1 testable #5).
@@ -145,7 +162,7 @@ function decideLanding(input) {
145
162
  // rather than an inferred `goalId == null` so the intent survives into the
146
163
  // caller and into the tests, and so a future landing that happens to carry
147
164
  // no goal cannot silently inherit the maintenance-goal behaviour.
148
- return METIC_PLUS.has(String(filerRank || '').toLowerCase())
165
+ return isTrustedFiler
149
166
  ? { route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' }
150
167
  : { route: true, status: 'backlog', homeless: true, reason: 'bug_homeless_awaiting_nod' };
151
168
  }
@@ -163,7 +180,7 @@ function decideLanding(input) {
163
180
  // The rank split. An absent/unknown rank is NOT treated as trusted — see the
164
181
  // fail-closed note above. This is the DECISION only; R05 adds the
165
182
  // server-side enforcement (auto-derived requires_rank floor + scope wall).
166
- return METIC_PLUS.has(String(filerRank || '').toLowerCase())
183
+ return isTrustedFiler
167
184
  ? { route: true, status: 'ready', reason: 'bug_trusted_filer' }
168
185
  : { route: true, status: 'backlog', reason: 'bug_awaiting_nod' };
169
186
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.913",
3
+ "version": "1.19.915",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.913",
9
+ "version": "1.19.915",
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.913",
3
+ "version": "1.19.915",
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",
@@ -63,6 +63,12 @@ const MODULE_GLOBS = {
63
63
  // red-team FEATURE carved to modules/security/ (BV1.R79 / 02-module-map §5).
64
64
  'src/bongos/routes/security.js',
65
65
  'server.js', 'config/',
66
+
67
+ // ADOPTED (task 1004111): a protected path owned by no module is authorable
68
+ // by NO goal, because MODULE_GLOBS is the authoring territory and the shared
69
+ // floor is only tests/. cli-lib.js is the CLI's own argv/session plumbing —
70
+ // core library surface, so it sits with the rest of the kernel.
71
+ 'scripts/gds/cli-lib.js',
66
72
  ],
67
73
  // --- the build state machine + ship/grade/deploy orchestration + schema ---
68
74
  // Carved into modules/lifecycle/ (BV1.R86 / ADR 0093 §1,§3): the task/claim/
@@ -81,6 +87,12 @@ const MODULE_GLOBS = {
81
87
  'scripts/gds/gate-review.js', 'scripts/gds/push-main.js', 'scripts/gds/ci-grade.js',
82
88
  'scripts/gds/merge-lock.js', 'scripts/gds/version-close.js',
83
89
  'migrations/',
90
+
91
+ // ADOPTED (task 1004111): the deploy half of the ship pipeline lifecycle
92
+ // already owns end to end — migrate.sh runs the migrations it owns, and
93
+ // scripts/deploy/ is what a merge to main triggers.
94
+ 'scripts/migrate.sh',
95
+ 'scripts/deploy/',
84
96
  ],
85
97
  // --- credit-allocation model + ledger + pricing + gamification ---
86
98
  economy: [
@@ -316,6 +328,17 @@ const MODULE_GLOBS = {
316
328
  'modules/dev-box/', 'devbox-app/',
317
329
  'scripts/gds/box.js', 'scripts/gds/box-auth-check.js', 'scripts/gds/box-connect-lib.js',
318
330
  'scripts/gds/smoke-box.sh',
331
+
332
+ // ADOPTED (task 1004111): the box BRING-UP surface. box.js (already here)
333
+ // orchestrates these five provisioning CLIs, and infra/ is the shell-script
334
+ // tree it resolves from the instance root — the comment above has described
335
+ // infra/ as dev-box's since task 1002386 without the glob saying so.
336
+ 'scripts/gds/provision-config.js',
337
+ 'scripts/gds/provision-net.js',
338
+ 'scripts/gds/box-infra.js',
339
+ 'scripts/gds/cf-tunnel.js',
340
+ 'scripts/gds/do-api.js',
341
+ 'infra/',
319
342
  ],
320
343
  // --- the dev-hygiene enforcement surfaces: CI workflows, the agent permission/
321
344
  // hook files, git hooks, root manifests, and the fitness/entropy tooling
@@ -368,6 +391,20 @@ const MODULE_GLOBS = {
368
391
  'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
369
392
  'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
370
393
  'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.js',
394
+
395
+ // ADOPTED (task 1004111): the enforcement surfaces this module already IS.
396
+ // install-git-hooks.js + scripts/gds/hooks/ are the pre-push hook beside the
397
+ // .husky/ it owns; git-merge-regen.js + .gitattributes are the merge-driver
398
+ // PAIR and must not straddle a module boundary; run-unit-tests.js is the CI
399
+ // runner; gen-api-client.js is the sibling of the gen-api-docs.js above.
400
+ // CODEOWNERS is review routing, which is this module's job.
401
+ 'scripts/gds/install-git-hooks.js',
402
+ 'scripts/gds/hooks/',
403
+ 'scripts/gds/git-merge-regen.js',
404
+ '.gitattributes',
405
+ 'scripts/gds/run-unit-tests.js',
406
+ 'scripts/gds/gen-api-client.js',
407
+ 'CODEOWNERS',
371
408
  ],
372
409
  // --- the METHODOLOGY surfaces: the instruction core an agent session actually
373
410
  // reads — the always-loaded root CLAUDE.md, the docs library it indexes, the
@@ -415,6 +452,13 @@ const MODULE_GLOBS = {
415
452
  'docs/',
416
453
  '.claude/skills/', '.claude/rules/',
417
454
  'scripts/gds/start.js', 'scripts/gds/discipline-modes.json',
455
+
456
+ // ADOPTED (task 1004111): the three freshness generators that write the docs
457
+ // this module owns — repo-map, file-map and the session index all emit into
458
+ // docs/ (and CLAUDE.md), so the generator belongs with its output.
459
+ 'scripts/gds/gen-repo-map.js',
460
+ 'scripts/gds/gen-file-map.js',
461
+ 'scripts/gds/gen-session-index.js',
418
462
  ],
419
463
  };
420
464
 
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.913'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.915'; // 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,271 @@
1
+ // tests/ideas_config.mjs — the two per-instance ideator dials (BV2.R05).
2
+ //
3
+ // What this file is actually guarding is not "the config parses". It is the
4
+ // thing the task description originally got wrong: that the ignite floor is a
5
+ // SCHEDULING preference and the trusted tier is an AUTHORITY boundary, and that
6
+ // turning the first dial must not move the second. Most of the assertions below
7
+ // exist to fail loudly if someone later "simplifies" this by making METIC_PLUS
8
+ // itself configurable.
9
+
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ // fileURLToPath, never URL.pathname — a space becomes %20 and a Windows drive
19
+ // gains a leading slash, so require()/fs throw MODULE_NOT_FOUND at load
20
+ // (task 1002889; both tests/test_path_guard.mjs and fitness.js enforce it).
21
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
22
+
23
+ const ideasConfig = require(path.join(ROOT, 'modules/ideas/config.js'));
24
+ const routing = require(path.join(ROOT, 'modules/ideas/routing.js'));
25
+ const catalog = require(path.join(ROOT, 'modules/government/catalog.js'));
26
+ const instanceConfig = require(path.join(ROOT, 'src/instance-config.js'));
27
+ const api = require(path.join(ROOT, 'src/module-api.js'));
28
+
29
+ // Env knobs are read through api.resolveEnv, which PREFIXES the suffix with the
30
+ // instance's own env prefix (OTB_, CLOUDBONGOS_, ...). Resolving it here rather
31
+ // than hard-coding one keeps this test honest on a rebranded instance — and
32
+ // hard-coding the bare suffix is exactly how the first cut of this file passed a
33
+ // name nothing reads.
34
+ const ENV_PREFIX = instanceConfig.envPrefix();
35
+ const envName = (suffix) => `${ENV_PREFIX}_${suffix}`;
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ async function test(name, fn) {
40
+ try { await fn(); passed++; console.log(` ok ${name}`); }
41
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
42
+ }
43
+
44
+ // A scratch pair of config paths, so nothing here touches the real files.
45
+ function withPacks(neutral, instance, fn) {
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideas-config-'));
47
+ const neutralPath = path.join(dir, 'ideas.neutral.json');
48
+ const instancePath = path.join(dir, 'ideas.json');
49
+ if (neutral !== null) fs.writeFileSync(neutralPath, typeof neutral === 'string' ? neutral : JSON.stringify(neutral));
50
+ if (instance !== null) fs.writeFileSync(instancePath, typeof instance === 'string' ? instance : JSON.stringify(instance));
51
+ try { return fn({ neutralPath, instancePath }); }
52
+ finally { fs.rmSync(dir, { recursive: true, force: true }); }
53
+ }
54
+ const load = (neutral, instance, env = {}) =>
55
+ withPacks(neutral, instance, (roots) => ideasConfig.loadIdeasConfig({ env, roots }));
56
+
57
+ // --- the defaults change nothing -------------------------------------------
58
+
59
+ await test('the shipped neutral starter resolves to today\'s behaviour', () => {
60
+ const shipped = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/ideas.neutral.json'), 'utf8'));
61
+ assert.equal(shipped.igniteFloor, 'metic', 'the neutral ignite floor must be metic — anything else widens on upgrade');
62
+ assert.equal(shipped.descentDecayRate, 0.5, 'decision 16 ruled halving');
63
+ });
64
+
65
+ await test('the default floor yields EXACTLY the METIC_PLUS set', () => {
66
+ // This is the assertion that makes `igniteFloor: 'metic'` a true no-op. If the
67
+ // ladder slice is ever off by one, these two sets diverge and every filing
68
+ // changes landing status silently.
69
+ const trusted = ideasConfig.trustedRanksForFloor('metic');
70
+ assert.deepEqual([...trusted].sort(), [...routing.METIC_PLUS].sort());
71
+ });
72
+
73
+ await test('a floor admits everything at or above it, and nothing below', () => {
74
+ assert.deepEqual([...ideasConfig.trustedRanksForFloor('xenos')].sort(), ['archon', 'metic', 'thetes', 'xenos']);
75
+ assert.deepEqual([...ideasConfig.trustedRanksForFloor('archon')].sort(), ['archon']);
76
+ assert.deepEqual([...ideasConfig.trustedRanksForFloor('thetes')].sort(), ['archon', 'metic', 'thetes']);
77
+ });
78
+
79
+ // --- the drift pin ----------------------------------------------------------
80
+
81
+ await test('the rank ladder is DERIVED from the doorway, not restated', () => {
82
+ // tests/rank_tier_single_source.mjs forbids a ladder literal outside its
83
+ // allowlist; this is the behavioural half of that rule. Deriving means a rank
84
+ // added to the kernel widens this floor's vocabulary with no edit here.
85
+ assert.deepEqual([...ideasConfig.RANK_ORDER], [...api.LIVE_RANK_LADDER]);
86
+ // The government catalog is a third statement of the same ladder (allowlisted
87
+ // there). If the two sources ever disagree, the ignite floor and the rank
88
+ // gates would be reading different vocabularies, so pin them together.
89
+ assert.deepEqual([...api.LIVE_RANK_LADDER], [...catalog.RANK_ORDER],
90
+ 'the kernel ladder and modules/government/catalog.js have drifted');
91
+ });
92
+
93
+ // --- precedence -------------------------------------------------------------
94
+
95
+ await test('an instance pack overrides the neutral starter', () => {
96
+ const c = load({ igniteFloor: 'metic', descentDecayRate: 0.5 }, { igniteFloor: 'xenos', descentDecayRate: 0.25 });
97
+ assert.equal(c.igniteFloor, 'xenos');
98
+ assert.equal(c.descentDecayRate, 0.25);
99
+ });
100
+
101
+ await test('an instance pack may set one dial without restating the other', () => {
102
+ const c = load({ igniteFloor: 'metic', descentDecayRate: 0.5 }, { igniteFloor: 'thetes' });
103
+ assert.equal(c.igniteFloor, 'thetes');
104
+ assert.equal(c.descentDecayRate, 0.5, 'the unnamed dial must fall through to neutral, not to zero');
105
+ });
106
+
107
+ await test('env overrides both dials, above the instance pack', () => {
108
+ const c = load({ igniteFloor: 'metic' }, { igniteFloor: 'thetes' },
109
+ { [envName('IDEAS_IGNITE_FLOOR')]: 'archon', [envName('IDEAS_DESCENT_DECAY_RATE')]: '0.75' });
110
+ assert.equal(c.igniteFloor, 'archon');
111
+ assert.equal(c.descentDecayRate, 0.75);
112
+ });
113
+
114
+ await test('an UNPREFIXED env name is inert, not a second doorway', () => {
115
+ // resolveEnv only reads <PREFIX>_<SUFFIX>. A bare name must change nothing —
116
+ // otherwise any process with a generic IDEAS_IGNITE_FLOOR in its environment
117
+ // would silently move another instance's floor.
118
+ const c = load({ igniteFloor: 'metic' }, null,
119
+ { IDEAS_IGNITE_FLOOR: 'xenos', IDEAS_DESCENT_DECAY_RATE: '1' });
120
+ assert.equal(c.igniteFloor, 'metic');
121
+ assert.equal(c.descentDecayRate, 0.5);
122
+ });
123
+
124
+ await test('an invalid env value falls through to the pack, not to nothing', () => {
125
+ const c = load({ igniteFloor: 'metic', descentDecayRate: 0.5 }, { igniteFloor: 'thetes' },
126
+ { [envName('IDEAS_IGNITE_FLOOR')]: 'everyone', [envName('IDEAS_DESCENT_DECAY_RATE')]: 'soon' });
127
+ assert.equal(c.igniteFloor, 'thetes');
128
+ assert.equal(c.descentDecayRate, 0.5);
129
+ });
130
+
131
+ await test('no config at all still resolves to the in-code defaults', () => {
132
+ // The core whose config/ directory did not ship. It must boot to today's
133
+ // behaviour, not to an empty floor or a zero rate.
134
+ const c = load(null, null);
135
+ assert.equal(c.igniteFloor, 'metic');
136
+ assert.equal(c.descentDecayRate, 0.5);
137
+ });
138
+
139
+ // --- fail to vanilla, never to dead ----------------------------------------
140
+
141
+ await test('a malformed instance pack falls back rather than bricking boot', () => {
142
+ const c = load({ igniteFloor: 'thetes', descentDecayRate: 0.25 }, '{ this is not json');
143
+ assert.equal(c.igniteFloor, 'thetes');
144
+ assert.equal(c.descentDecayRate, 0.25);
145
+ });
146
+
147
+ await test('an unknown rank name is refused, not coerced', () => {
148
+ // The dangerous direction. A typo must not silently become the widest floor.
149
+ for (const bad of ['everyone', 'METIC ', '', 'admin', 'Xeno', null, 7, {}]) {
150
+ const c = load({ igniteFloor: 'metic' }, { igniteFloor: bad });
151
+ assert.equal(c.igniteFloor, 'metic', `floor "${String(bad)}" should have been refused`);
152
+ }
153
+ });
154
+
155
+ await test('a rank name is case-normalised but NOT trimmed', () => {
156
+ assert.equal(load({ igniteFloor: 'metic' }, { igniteFloor: 'ARCHON' }).igniteFloor, 'archon');
157
+ assert.equal(load({ igniteFloor: 'metic' }, { igniteFloor: ' archon' }).igniteFloor, 'metic',
158
+ 'a padded rank is a hostile shape, not a rank');
159
+ });
160
+
161
+ await test('a decay rate outside [0,1] is refused rather than clamped', () => {
162
+ for (const bad of [1.5, -0.1, NaN, Infinity, -Infinity, '0.5', null, {}]) {
163
+ const c = load({ descentDecayRate: 0.5 }, { descentDecayRate: bad });
164
+ assert.equal(c.descentDecayRate, 0.5, `rate ${String(bad)} should have been refused`);
165
+ }
166
+ });
167
+
168
+ await test('both ends of the decay band are legitimate choices', () => {
169
+ assert.equal(load({ descentDecayRate: 0.5 }, { descentDecayRate: 0 }).descentDecayRate, 0);
170
+ assert.equal(load({ descentDecayRate: 0.5 }, { descentDecayRate: 1 }).descentDecayRate, 1);
171
+ });
172
+
173
+ await test('an unrecognised floor yields the DEFAULT set, never an empty one', () => {
174
+ // Failing closed here would stop every ignition on a config typo. The floor is
175
+ // a scheduling preference; a stopped queue is a worse failure than a default.
176
+ assert.deepEqual([...ideasConfig.trustedRanksForFloor('nonsense')].sort(), [...routing.METIC_PLUS].sort());
177
+ assert.deepEqual([...ideasConfig.trustedRanksForFloor(null)].sort(), [...routing.METIC_PLUS].sort());
178
+ });
179
+
180
+ // --- the dial actually moves the landing ------------------------------------
181
+
182
+ await test('lowering the floor makes a sub-Metic bug land claimable', () => {
183
+ const trusted = ideasConfig.trustedRanksForFloor('xenos');
184
+ const d = routing.decideLanding({ goalId: 5, kind: 'bug', filerRank: 'xenos', trustedRanks: trusted });
185
+ assert.equal(d.status, 'ready');
186
+ assert.equal(d.reason, 'bug_trusted_filer');
187
+ });
188
+
189
+ await test('lowering the floor also moves the HOMELESS bug branch', () => {
190
+ // Two call sites read the floor. A fix that threaded only one of them would
191
+ // leave the dial half-connected and this is what catches it.
192
+ const trusted = ideasConfig.trustedRanksForFloor('xenos');
193
+ const d = routing.decideLanding({ goalId: null, kind: 'bug', filerRank: 'xenos', vector: 'api', trustedRanks: trusted });
194
+ assert.equal(d.status, 'ready');
195
+ assert.equal(d.reason, 'bug_homeless_trusted_filer');
196
+ });
197
+
198
+ await test('raising the floor makes a Metic bug await a nod', () => {
199
+ const trusted = ideasConfig.trustedRanksForFloor('archon');
200
+ const d = routing.decideLanding({ goalId: 5, kind: 'bug', filerRank: 'metic', trustedRanks: trusted });
201
+ assert.equal(d.status, 'backlog');
202
+ assert.equal(d.reason, 'bug_awaiting_nod');
203
+ });
204
+
205
+ await test('omitting trustedRanks reproduces the constant exactly', () => {
206
+ // Every pre-R05 caller and test omits it. If this ever diverges, the whole
207
+ // "defaults preserve today's behaviour" claim is false.
208
+ for (const rank of ['xenos', 'thetes', 'metic', 'archon', null, 'nonsense']) {
209
+ const before = routing.decideLanding({ goalId: 5, kind: 'bug', filerRank: rank });
210
+ const after = routing.decideLanding({
211
+ goalId: 5, kind: 'bug', filerRank: rank,
212
+ trustedRanks: ideasConfig.trustedRanksForFloor('metic'),
213
+ });
214
+ assert.deepEqual(after, before, `rank ${String(rank)} diverged`);
215
+ }
216
+ });
217
+
218
+ await test('a non-Set trustedRanks is ignored, not trusted', () => {
219
+ // A caller handing an array, a string or an object must not accidentally widen
220
+ // anything — the guard is `instanceof Set`, and this pins it.
221
+ for (const bad of [['xenos'], 'xenos', { has: () => true }, true]) {
222
+ const d = routing.decideLanding({ goalId: 5, kind: 'bug', filerRank: 'xenos', trustedRanks: bad });
223
+ assert.equal(d.status, 'backlog', `trustedRanks ${JSON.stringify(bad)} was honoured and should not have been`);
224
+ }
225
+ });
226
+
227
+ // --- THE WALL: the floor is not the trusted tier ----------------------------
228
+
229
+ await test('lowering the ignite floor does NOT let a sub-Metic propose a criterion', () => {
230
+ // The security claim the amended description turns on. mayProposeCriterion
231
+ // reads the fixed constant and takes no floor, so there is no argument this
232
+ // test could pass that would widen it.
233
+ assert.equal(ideasConfig.trustedRanksForFloor('xenos').has('xenos'), true, 'precondition: floor is wide open');
234
+ for (const rank of ['xenos', 'thetes']) {
235
+ assert.equal(routing.mayProposeCriterion(rank), false, `${rank} may not propose a criterion at any floor`);
236
+ }
237
+ assert.equal(routing.mayProposeCriterion('metic'), true);
238
+ assert.equal(routing.mayProposeCriterion('archon'), true);
239
+ });
240
+
241
+ await test('lowering the ignite floor does NOT breach the protected-scope wall', () => {
242
+ const trusted = ideasConfig.trustedRanksForFloor('xenos');
243
+ const landing = routing.decideLanding({ goalId: 5, kind: 'bug', filerRank: 'xenos', trustedRanks: trusted });
244
+ assert.equal(landing.status, 'ready', 'precondition: the dial is turned all the way down');
245
+
246
+ const verdict = routing.authorizeRoutedLanding({
247
+ landing, filerRank: 'xenos', scopeProtected: true,
248
+ });
249
+ assert.equal(verdict.ok, false, 'a wide-open ignite floor must not open the protected core');
250
+ assert.equal(verdict.code, 'protected_scope');
251
+ });
252
+
253
+ await test('authorizeRoutedLanding takes no floor argument at all', () => {
254
+ // Structural, not behavioural: if someone later adds one, this reds and makes
255
+ // them read the header rather than discovering the widening in production.
256
+ const src = fs.readFileSync(path.join(ROOT, 'modules/ideas/routing.js'), 'utf8');
257
+ const fn = src.slice(src.indexOf('function authorizeRoutedLanding'));
258
+ const sig = fn.slice(0, fn.indexOf(')') + 1);
259
+ assert.ok(!/trustedRanks|igniteFloor/.test(sig), `the scope wall grew a floor argument: ${sig}`);
260
+ });
261
+
262
+ await test('METIC_PLUS is still a hard-coded constant', () => {
263
+ // filing_abuse_matrix.mjs holds the same line. Keeping a copy here means the
264
+ // R05 test file itself refuses the shortcut it exists to prevent.
265
+ const src = fs.readFileSync(path.join(ROOT, 'modules/ideas/routing.js'), 'utf8');
266
+ assert.match(src, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/,
267
+ 'METIC_PLUS must stay a constant — it is the trusted tier, not the ignite floor');
268
+ });
269
+
270
+ console.log(`\nideas_config: ${passed} passed, ${failed} failed`);
271
+ process.exit(failed ? 1 : 0);
@@ -14,7 +14,7 @@ const { PROTECTED_GLOBS } = require(path.join(ROOT, 'src', 'bongos', 'permission
14
14
  const gsc = require(path.join(ROOT, 'src', 'bongos', 'goal-scope-check.js'));
15
15
  // the OLD protection rule, recomputed in-test so the shrink-only property below
16
16
  // is proved against real globs rather than a copied literal (ADR 0314).
17
- const { touchesOverlap } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
17
+ const { touchesOverlap, matchOne } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
18
18
 
19
19
  test('roster covers the decided ~16 modules + kernel, each with non-empty globs', () => {
20
20
  const keys = scope.moduleKeys();
@@ -550,3 +550,104 @@ test('moduleDriftFiles degrades safely on junk input', () => {
550
550
  assert.deepEqual(scope.moduleDriftFiles('lifecycle', null), []);
551
551
  assert.deepEqual(scope.moduleDriftFiles(null, ['modules/dev-box/app.js']), []);
552
552
  });
553
+
554
+ // EVERY PROTECTED PATH HAS AN OWNING MODULE (task 1004111).
555
+ //
556
+ // MODULE_GLOBS is the territory deciding who may AUTHOR a task touching a path,
557
+ // and the shared floor is only tests/. So a protected path owned by no module is
558
+ // authorable by NO goal at all — scopeSubset returns out_of_scope whatever the
559
+ // goal is scoped to. Nineteen paths were in that state, including infra/,
560
+ // scripts/deploy/, scripts/migrate.sh, CODEOWNERS and the merge-driver pair.
561
+ //
562
+ // This is a TRIPWIRE, not a snapshot: it is computed over PROTECTED_GLOBS, so a
563
+ // path added to the protected roster without an owner reds the build on the spot.
564
+ //
565
+ // THE PROBE TRAP (ADR 0314's own measurement nearly got this wrong in both
566
+ // directions): matchOne(file, touches) takes an ARRAY as its second argument.
567
+ // Passing a joined string iterates its characters and reports everything
568
+ // unowned. The guard test below pins that, so this one cannot rot into a
569
+ // vacuous pass.
570
+ // Ownership is DIRECTIONAL, deliberately. touchesOverlap is symmetric, so a
571
+ // module owning ONE file inside a protected directory would "own" the whole
572
+ // directory and this tripwire would pass on a path no goal can actually author —
573
+ // which is exactly what it caught: kernel owns src/bongos/routes/security.js,
574
+ // and under symmetric overlap that looked like ownership of src/bongos/routes/.
575
+ // matchOne(p, globs) asks the question the wall asks: does some module's
576
+ // territory COVER this path?
577
+ function ownersOf(p) {
578
+ return scope.moduleKeys().filter((k) => matchOne(p, scope.scopeForModule(k).globs) !== null);
579
+ }
580
+
581
+ // The one PROTECTED_GLOBS entry still owned by nobody, exempted BY NAME so the
582
+ // tripwire stays true rather than being weakened to accommodate it.
583
+ // src/bongos/routes/ is a blanket directory: protection covers all 18 files under
584
+ // it, authoring covers 2 (me.js -> builder-settings, security.js -> kernel).
585
+ // Closing it means deciding who owns the other sixteen live route files, which is
586
+ // an ownership call and not a missing glob — filed as task 1004116. Removing this
587
+ // entry is that task's first step.
588
+ const UNOWNED_EXEMPT = ['src/bongos/routes/'];
589
+
590
+ test('every PROTECTED_GLOBS entry is owned by at least one module', () => {
591
+ const unowned = PROTECTED_GLOBS
592
+ .filter((p) => !UNOWNED_EXEMPT.includes(p))
593
+ .filter((p) => ownersOf(p).length === 0);
594
+ assert.deepEqual(
595
+ unowned, [],
596
+ 'these protected paths are authorable by NO goal — give each an owning module in MODULE_GLOBS (task 1004111)',
597
+ );
598
+ });
599
+
600
+ test('the exemption list is not a place to hide a new hole', () => {
601
+ // An exemption that silently stops applying is worse than none: it would sit
602
+ // in the file implying a known gap that no longer exists, and the next unowned
603
+ // path could be parked beside it. So assert the exemption is still REQUIRED.
604
+ for (const p of UNOWNED_EXEMPT) {
605
+ assert.ok(PROTECTED_GLOBS.includes(p), `${p} is exempted but is no longer a protected glob — drop it`);
606
+ assert.equal(
607
+ ownersOf(p).length, 0,
608
+ `${p} now HAS an owning module — delete it from UNOWNED_EXEMPT (task 1004116 is done)`,
609
+ );
610
+ }
611
+ });
612
+
613
+ test('...and the owner is a real module whose goal can actually author the path', () => {
614
+ // Ownership on paper is not ownership: assert through the REAL wall, so a glob
615
+ // that resolves but does not cover cannot pass. Metic is used deliberately —
616
+ // these are protected paths, so a sub-Metic is refused by the path wall and
617
+ // would prove nothing about scope.
618
+ for (const p of PROTECTED_GLOBS.filter((x) => !UNOWNED_EXEMPT.includes(x))) {
619
+ const [owner] = ownersOf(p);
620
+ assert.ok(owner, `${p} has no owning module`);
621
+ assert.equal(
622
+ gsc.scopeSubset([owner], [p]).ok, true,
623
+ `${p} resolves to owner "${owner}" but that goal's territory does not cover it`,
624
+ );
625
+ }
626
+ });
627
+
628
+ test('adopting the unowned paths did NOT change which modules are protected', () => {
629
+ // The property that made assignment safe rather than a re-coupling: every
630
+ // receiving module was ALREADY protected, so no goal became Archon-only that
631
+ // was not before. If this fails, an adoption widened the trust boundary.
632
+ for (const k of ['kernel', 'lifecycle', 'dev-box', 'devsecops', 'methodology']) {
633
+ assert.equal(scope.scopeForModule(k).protected, true, `${k} was already protected and must stay so`);
634
+ }
635
+ // and the ADR 0314 nine are still exactly the nine — PROTECTED_MODULES above
636
+ // is the by-name pin; this states the intent of THIS task against it.
637
+ assert.equal(scope.protectedModules().length, 9, 'adoption must not change the size of the protected set');
638
+ });
639
+
640
+ test('the probe trap: matchOne takes an ARRAY, and a string reports everything unowned', () => {
641
+ // Pinned because it is how this hole was nearly mis-measured twice. If a future
642
+ // edit passes a joined string, the tripwire above would silently "find" the
643
+ // whole roster unowned — or, inverted, find nothing. Prove the array form is
644
+ // the one with the real answer.
645
+ const p = '.gitattributes';
646
+ assert.ok(PROTECTED_GLOBS.includes(p), 'fixture assumption: .gitattributes is protected');
647
+ assert.ok(ownersOf(p).length > 0, 'the ARRAY form finds the real owner');
648
+ assert.ok(touchesOverlap([p], scope.scopeForModule('devsecops').globs), 'and the symmetric form agrees for an exactly-owned path');
649
+ assert.equal(
650
+ matchOne(p, scope.scopeForModule('devsecops').globs.join(',')), null,
651
+ 'a STRING second argument must not be mistaken for a working query',
652
+ );
653
+ });