@bongos/core 1.19.831 → 1.19.833

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.831",
6
- "core_contract": "1.19.831",
7
- "source_commit": "30cc1cbe10b2f1a3d1cb907ee320d18c78e23eb0",
5
+ "core_version": "1.19.833",
6
+ "core_contract": "1.19.833",
7
+ "source_commit": "2b0af43d93e1f2c1cce0e30cf7b2f0893b5d7e51",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T15:15:18.923Z",
9
+ "built_at": "2026-09-17T15:53:43.843Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 491,
12
+ "docs_redacted": 492,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2244,
14
+ "functional_verbatim": 2245,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2761,
20
- "tree_sha256": "484014065f907c382c6fdbaadc2ae5bc3ff3af2f1b79a02119f7acf9d3ccd2fc",
19
+ "file_count": 2763,
20
+ "tree_sha256": "c96978c58f5f47d2eb821f10b23aa5feafd029cd7a55fa921ab7f4db9f201c21",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1984,10 +1984,15 @@
1984
1984
  "mode": "0000644",
1985
1985
  "sha256": "f52bf2568d02d5cf8589dc394dc76cbf0ea3a7ba193275f300e320c2ab64a51c"
1986
1986
  },
1987
+ {
1988
+ "path": "docs/adr/0300-a-missing-protected-surface-registry-is-a-broken-install.md",
1989
+ "mode": "0000644",
1990
+ "sha256": "7c5d4c49dc98d37a8a41aa0cddd158c3a46a60cdb4e79b018d68c6b5222b7e79"
1991
+ },
1987
1992
  {
1988
1993
  "path": "docs/adr/README.md",
1989
1994
  "mode": "0000644",
1990
- "sha256": "0b4e0cd78a8bd6ae3d41c0d010634dfdc29ecd4dca1f8b49127791d1b4cd7450"
1995
+ "sha256": "8af8b8c5ea9b368bb790280eadd167b99ed9f864350e101ba18a4b0ec8f5ed5e"
1991
1996
  },
1992
1997
  {
1993
1998
  "path": "docs/api-reference.md",
@@ -2552,7 +2557,7 @@
2552
2557
  {
2553
2558
  "path": "docs/module-api-changelog.md",
2554
2559
  "mode": "0000644",
2555
- "sha256": "eb4a8912413adbed5f2677ea334c86aeaac50dab4acc03e618dcff866286d505"
2560
+ "sha256": "0e95a54bd91d5d34612789b30d12a1dc5a6dc679716438727170e65d14c009b4"
2556
2561
  },
2557
2562
  {
2558
2563
  "path": "docs/modules-contract.md",
@@ -7182,7 +7187,7 @@
7182
7187
  {
7183
7188
  "path": "modules/security/artifact-scan/quarantine.js",
7184
7189
  "mode": "0000644",
7185
- "sha256": "9ac4695e571efdda2b991ff42d4635c9b493383733819c8c97ca93f1066220a1"
7190
+ "sha256": "af3f54455c88b9299a1eb4132709071cf7c92707ece4a947c9e18db86db8f7f1"
7186
7191
  },
7187
7192
  {
7188
7193
  "path": "modules/security/artifact-scan/rebuild-spec.js",
@@ -7197,7 +7202,7 @@
7197
7202
  {
7198
7203
  "path": "modules/security/artifact-scan/sanitize.js",
7199
7204
  "mode": "0000644",
7200
- "sha256": "861abc56de494363d4420d8e5826eeda01ba6ba79f113deceea094db4d9ab6a8"
7205
+ "sha256": "e70485e457a496bc518fc8b26dfaa0ceca1768158cfb95407668c659a6f2b24b"
7201
7206
  },
7202
7207
  {
7203
7208
  "path": "modules/security/migrations/security_001_tool_scans.sql",
@@ -7252,7 +7257,7 @@
7252
7257
  {
7253
7258
  "path": "modules/security/tests/mirror.mjs",
7254
7259
  "mode": "0000644",
7255
- "sha256": "b1bceda3d21897133db69f22d02fc91ed9671c536432d32959409637bfaf3bdb"
7260
+ "sha256": "fbded0d3f137a62c9f6255297b2aa5752e0dc2a6175ce0e344d461cf59bc53b1"
7256
7261
  },
7257
7262
  {
7258
7263
  "path": "modules/security/tests/sanitize_rebuild.mjs",
@@ -7837,12 +7842,12 @@
7837
7842
  {
7838
7843
  "path": "package-lock.json",
7839
7844
  "mode": "0000644",
7840
- "sha256": "9ef226bf93c85c85d63babd32469fb3a3f4a69fdf2dc94f57d80c3b54b3e9c0d"
7845
+ "sha256": "85dfac1e9f80f5a7b407616bacbad449156497c602357254482150f7f65e8725"
7841
7846
  },
7842
7847
  {
7843
7848
  "path": "package.json",
7844
7849
  "mode": "0000644",
7845
- "sha256": "3a966e21858e712dbb9e8bedc875e4bc92095f1c4489b471221e51b42d701aed"
7850
+ "sha256": "f55eb207a04e67907fb715c75ca37cdaf74f9463389c4e2c9c19b781666cf934"
7846
7851
  },
7847
7852
  {
7848
7853
  "path": "public-docs/index.html",
@@ -8382,7 +8387,7 @@
8382
8387
  {
8383
8388
  "path": "scripts/gds/fitness.js",
8384
8389
  "mode": "0000644",
8385
- "sha256": "aa1a26c0b2871cd6e49b619254ce2b11d477780da8f4b037c38cb16b0ccd68fc"
8390
+ "sha256": "42c49bcab43be3367f05b99f4dfded590edf89ae10c1a3df2d9312b6e0d33fc9"
8386
8391
  },
8387
8392
  {
8388
8393
  "path": "scripts/gds/gate-review.js",
@@ -8622,7 +8627,7 @@
8622
8627
  {
8623
8628
  "path": "scripts/gds/migration-namespace.js",
8624
8629
  "mode": "0000644",
8625
- "sha256": "f90c5e21f17cb613d86de1bad8561783d6b823f54efe2fa8dc16e909f0dbc77c"
8630
+ "sha256": "d5a4715d0e062a2fa9aabdfa19cf668521907df421f4a3532dbfe3d26c9cab11"
8626
8631
  },
8627
8632
  {
8628
8633
  "path": "scripts/gds/mirror-lineage-check.js",
@@ -9592,7 +9597,7 @@
9592
9597
  {
9593
9598
  "path": "src/bongos/permission-path-check.js",
9594
9599
  "mode": "0000644",
9595
- "sha256": "14ba2ffb603cbea7d8388c196b74f8e7db92b777fb0da7c69c7d3c7c9e27f386"
9600
+ "sha256": "75264cfa68b2a8344c38565d96d16c28e101d286ad0b5794baa83ab774269d74"
9596
9601
  },
9597
9602
  {
9598
9603
  "path": "src/bongos/platform-visibility-gate.js",
@@ -9762,7 +9767,7 @@
9762
9767
  {
9763
9768
  "path": "src/module-api.js",
9764
9769
  "mode": "0000644",
9765
- "sha256": "0e198cb1212d9a60178cb27ffed61166f60e097d3bed2afb564c9960b10c81e7"
9770
+ "sha256": "f98bb83cf9fd6b0cc03f1e1d69b546a9fbf79e9e63c6e9121f4e225a898e7ea8"
9766
9771
  },
9767
9772
  {
9768
9773
  "path": "src/module-loader/catalog.js",
@@ -10022,7 +10027,7 @@
10022
10027
  {
10023
10028
  "path": "tests/artifact_staleness.mjs",
10024
10029
  "mode": "0000644",
10025
- "sha256": "8eaeb56d805615a65f30a62e32dd4d180f1b61d7ac084725bc9d9bde8a1d67d5"
10030
+ "sha256": "95156a6c54388afca8894ca6eb16d2472ede78e2ae3cf34ff00942c4551548c3"
10026
10031
  },
10027
10032
  {
10028
10033
  "path": "tests/artist_gate.mjs",
@@ -11377,7 +11382,7 @@
11377
11382
  {
11378
11383
  "path": "tests/government_protected_surfaces.mjs",
11379
11384
  "mode": "0000644",
11380
- "sha256": "4478853e79266130c2ad3ab7a523f0f20fb98a97e88f1dd2476ef09db6b96dcc"
11385
+ "sha256": "1d8502e8d25eedb54ed0fcbe3ce1a0fe87495d03bd8bda7322e55d6004669a63"
11381
11386
  },
11382
11387
  {
11383
11388
  "path": "tests/government_rank_sync.mjs",
@@ -12092,7 +12097,7 @@
12092
12097
  {
12093
12098
  "path": "tests/migration_namespace.mjs",
12094
12099
  "mode": "0000644",
12095
- "sha256": "511b7b0129abc708b1cf687f1813affff522cf42e8a70bcff8839f073f566059"
12100
+ "sha256": "42266f01d6ce88fdcdfddf45c2a11bb4599a87ea05b603d41120674900a59c95"
12096
12101
  },
12097
12102
  {
12098
12103
  "path": "tests/migrations_forward_safe.mjs",
@@ -12754,6 +12759,11 @@
12754
12759
  "mode": "0000644",
12755
12760
  "sha256": "aea6f9ec053bd9477288025b58ed76c166c2d59a24d7ea3481edadba90113887"
12756
12761
  },
12762
+ {
12763
+ "path": "tests/quarantine_canonical.mjs",
12764
+ "mode": "0000644",
12765
+ "sha256": "d8a2376c5ff96d77378626c9b4d1c32660e8412afb2531cf93085564890ffdef"
12766
+ },
12757
12767
  {
12758
12768
  "path": "tests/rank_floor_wiring.mjs",
12759
12769
  "mode": "0000644",
@@ -12977,7 +12987,7 @@
12977
12987
  {
12978
12988
  "path": "tests/search_index_poller.mjs",
12979
12989
  "mode": "0000644",
12980
- "sha256": "f665d4f0a9ccfb5b92564dfda39d13d6d8ed3146cde3fe2e05bb9f9aec029558"
12990
+ "sha256": "5015bb705395336000e60ef957a48376ad7267bfeb74e3727213684dd30632e7"
12981
12991
  },
12982
12992
  {
12983
12993
  "path": "tests/search_ingest.mjs",
@@ -12997,7 +13007,7 @@
12997
13007
  {
12998
13008
  "path": "tests/search_isolation.mjs",
12999
13009
  "mode": "0000644",
13000
- "sha256": "869d47dc66f8e8a9913bbb875983796225209b3abc3c360c67eaa8e4a0fd8145"
13010
+ "sha256": "225bebacb2660252bae17dd2274aac4ad1e322348480a781ee13f6b1060714b4"
13001
13011
  },
13002
13012
  {
13003
13013
  "path": "tests/search_route.mjs",
@@ -0,0 +1,109 @@
1
+ # ADR 0300 — A missing protected-surface registry is a broken install, not a module opt-out
2
+
3
+ **Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003662](https://cloudbongos.com/builders#/task/1003662) (goal 1000091 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md) (the protected-surface boundary this registry enforces) · [ADR 0083](0083-modular-architecture-strangler-build-plan.md) (core never depends on a module's behavior) · [ADR 0108](0108-instance-composition-configurable-root.md) §1 (the core is consumed as a versioned npm package, and `modules/` ships inside it)
6
+
7
+ ## Context
8
+
9
+ [task 1003662](https://cloudbongos.com/builders#/task/1003662) was filed as a bug with a
10
+ concrete remedy attached:
11
+
12
+ > `require('./module-scope-map')` throws on any checkout where
13
+ > `modules/government/protected-surfaces.json` is absent … This breaks any checkout that has
14
+ > disabled or not yet materialized the government module … Make `module-scope-map.js` degrade
15
+ > gracefully (empty protected set + a loud warning) when the government module isn't present,
16
+ > matching the `resolveOptional` pattern used elsewhere for optional module capabilities.
17
+
18
+ The throw is real and reproduces exactly as described. Moving the registry aside and requiring
19
+ the kernel file gives:
20
+
21
+ ```
22
+ protected-surface registry unreadable at <root>/modules/government/protected-surfaces.json:
23
+ ENOENT: no such file or directory. Refusing to run with an empty protected set (ADR 0043).
24
+ ```
25
+
26
+ Everything after that first sentence is wrong, in four separate ways — and the remedy it
27
+ proposes would disable the ADR 0043 boundary on exactly the installs least able to notice.
28
+
29
+ **1. The throw is not in the file the task names.** `module-scope-map.js` contains no `throw`
30
+ and no file read. It does `require('./permission-path-check')` at line 39, and
31
+ `permission-path-check.js`'s `loadRegistry()` throws at module scope. Any "fix" applied to the
32
+ file in the title would be applied to the wrong file; the behavior is owned by its sibling.
33
+
34
+ **2. The throw is a written contract, not an oversight.** `permission-path-check.js` states it
35
+ in its own header, in capitals, above the code:
36
+
37
+ > FAIL-CLOSED: a missing/empty/malformed registry THROWS. It never degrades to "nothing is
38
+ > protected" — an unreadable registry is a broken install, not an opted-out module.
39
+
40
+ The remedy asks for the one behavior the header names and refuses. That header also pre-answers
41
+ the architectural worry the title gestures at (a kernel file reaching into `modules/`): the read
42
+ is `fs`, never `require`, because ADR 0083's one-way rule forbids core from requiring a module's
43
+ *code* — and this is not code, it is a static declarative data file. The sibling trust primitive
44
+ `route-rank-check.js` reads `modules/*/routes/*.js` off disk the same way. Code still flows one
45
+ way; nothing in core requires the module.
46
+
47
+ **3. Disabling the government module cannot produce the ENOENT.** Module enablement is a
48
+ runtime flag, not a file deletion — disabling a module leaves its files on disk untouched. The
49
+ registry read never consults enablement, deliberately: per the same header, a module's
50
+ enable/disable state must never be something the kernel depends on. So the "disabled" half of
51
+ the premise describes a state that cannot occur.
52
+
53
+ **4. The "not yet materialized" half does not occur either, because the file ships with the
54
+ core.** `package.json` declares no `files[]` allowlist, so the whole directory ships.
55
+ `npm pack --dry-run` on this commit puts `modules/government/protected-surfaces.json` in the
56
+ tarball at 13,274 bytes, one of 3,419 files. Per [ADR 0108](0108-instance-composition-configurable-root.md)
57
+ §1 the core is consumed as a versioned npm dependency and `resolveCoreRoot()` resolves the core
58
+ package's own `modules/` inside `node_modules`. You cannot have `@bongos/core` without this
59
+ file. If it is absent, the install is damaged — a partial extract, a lossy copy, a `.gitignore`
60
+ that ate a JSON file — and that is a fact worth surfacing loudly, not smoothing over.
61
+
62
+ **The `resolveOptional` precedent does not transfer.** `resolveOptional` is real and used in
63
+ roughly a dozen places, but it is the module **seam/port resolver**: `api.resolveOptional('grade')`
64
+ lets one module degrade when another module's *runtime capability* is absent, which is exactly
65
+ right for an optional capability. A protected-surface registry is not a capability being
66
+ resolved at a seam. It is the static data the trust boundary is made of, read synchronously from
67
+ disk by enforcers that run where there is no server and no database — a git hook on a laptop, a
68
+ CI job. Borrowing the seam pattern here reads as consistency, but it silently converts "the
69
+ boundary is unreadable" into "there is no boundary".
70
+
71
+ ## Decision
72
+
73
+ **Fail-closed stays. The registry is not optional, and a missing registry is a broken install.**
74
+
75
+ Concretely:
76
+
77
+ 1. **No behavior change to `permission-path-check.js` or `module-scope-map.js`.** The throw is
78
+ correct, its placement is correct, and the header already documents why.
79
+ 2. **The task's described remedy is rejected**, on the grounds above — chiefly that an empty
80
+ protected set is not a degraded mode of a trust boundary, it is the absence of one.
81
+ 3. **The error message gains a remediation line.** This is the one genuine defect the report
82
+ surfaced: the message states the refusal and cites ADR 0043, but never tells the reader what
83
+ to *do*, which is precisely how a reader concludes the module must be optional and files this
84
+ task. It now names the likely cause and the fix.
85
+
86
+ The asymmetry is the whole point. A false "nothing is protected" is silent, permanent, and
87
+ grants authority; a false "this install is broken" is loud, immediate, and grants nothing. Only
88
+ one of those is safe to be wrong about.
89
+
90
+ ## Consequences
91
+
92
+ **A damaged install fails loudly at require time, and now says what to do about it.** That was
93
+ already true of the first half; the second half is new. The reader who hits this next gets
94
+ pointed at reinstalling the core rather than at weakening the check.
95
+
96
+ **The report will recur in some form, and that is acceptable.** Anyone meeting this ENOENT
97
+ cold sees a kernel file reaching into `modules/` and reasonably suspects a layering bug. This
98
+ ADR is the durable answer to that suspicion, which is why it exists rather than a won't-fix
99
+ note: the next agent to wonder can be pointed at a number.
100
+
101
+ **If a legitimate need for a core-only checkout without `modules/` ever appears, this decision
102
+ is the thing to revisit** — and the revisit is "where should the registry live", not "should it
103
+ be optional". Relocating the canonical data into `src/` and having the government module
104
+ contribute to it would preserve fail-closed while removing the cross-directory read. Nothing
105
+ today needs that, and doing it speculatively would move a trust primitive for no gain.
106
+
107
+ **What this does not decide:** whether other `modules/` reads from core (`route-rank-check.js`
108
+ reading `modules/*/routes/*.js`) should follow the same rule. They share the mechanism and the
109
+ justification, but each is its own trust surface and none is currently in question.
@@ -391,3 +391,4 @@ This keeps the decision history honest and traceable.
391
391
  | 0297 | [**A task’s creator may retire it, and that is the ownership axis’s first route** ([task 1002443](https://cloudbongos.com/builders#/task/1002443), goal 1000095 — owner request 2026-07-25: *"the creator of that task should be able to abandon that task directly inside of the UI"*). Attribution itself shipped earlier and elsewhere (`core_227`, [task 1003278](https://cloudbongos.com/builders#/task/1003278)); what had not moved is that **retiring a task was a rank power and nothing else** — `task.abandon` floors at `metic` (ADR 0152), so a sub-Metic builder could file a task and then could not withdraw their own proposal. The model already had the vocabulary and had never used it: ADR 0151 §3 defines an **ownership axis** where a `scope:'own'` key authorizes nothing until a handler supplies the row's owner, R100 made `requirePermission` refuse such a key outright so `.own` can never degrade into `.any`, and it recorded that **zero call sites passed one**. This is that axis’s first real consumer. **Decision: two authorities, only one rank-shaped** — `task.abandon` (metic, any task) OR new `task.abandon.own` (xenos, a task you created, while it is still backlog/ready/blocked). Route becomes `requireBuilder` + an in-handler decision (the `POST /goals/:id/members` shape), pin `metic+archon`→`any-builder` so it now catches an accidental **tightening** as loudly as a gate removal. **The two facts resolve in two places on purpose:** the RANK fact is request-scoped and resolves before the transaction (uncached, ADR 0016); the OWNER fact is a column that can change under us, so `created_by` joins `abandonTask`’s existing `FOR UPDATE` SELECT and a new optional `authorize` callback decides under that lock. Reading `created_by` in the route would be a TOCTOU window; resolving permissions inside the transaction would hold a row lock across a second pool acquire. The decision is a PURE module (`abandon-authz.js`, the `goal-route-authz.js` precedent) so every branch is pinned DB-free and the hall hint and server verdict provably read one rule. **A creator’s window stops at the grader** — completed/confirmed stay cross-owner, because past the grader a retire is a ledger correction, not an author changing their mind; nothing is subtracted from `task.abandon`, pinned by a test. `counterpart: ['task.abandon']` is declared not derived, verb `act` not `manage` — `manage` would silently dominate every future narrower `task.*.own` key (the verb-blind escalation task 1003195 closed). **A null creator belongs to nobody and that is load-bearing:** ~1,357 pre-`core_227` tasks plus seed/restock/discord vectors are unattributed, so the owner comparison is the government resolver’s own — injected, never re-implemented, since a duplicated ownership comparison is how one quietly widens — and one slip would open every historical task to a xenos grant at once. Fail-closed throughout: a missing `government` port, a throwing resolver, and anything but an explicit `{ok:true}` all deny, and `authorize` runs BEFORE the idempotent already-abandoned return and the shipped/active-claim guards so an unauthorized caller cannot probe a row through a 200. No guard removed. Does NOT widen edit/promote/dependency (still Metic), does not touch `task.merge`, and does not backfill attribution — the control simply does not appear on tasks predating `core_227`.](0297-a-tasks-creator-may-retire-it.md) | authority / lifecycle / ownership |
392
392
  | 0298 | [**The grader gets a reader lens: advisory, scoped to copy-bearing diffs, and it waits on a written voice** ([task 1003937](https://cloudbongos.com/builders#/task/1003937), goal 1000095 — Working area 6; owner decision 2026-09-17). Every artist review the cascade files opened with *“REVIEW THE GRADER … coach it”*, and it could not be followed: the v4 panel runs narc, quality, hacker and efficiency — completion, code quality, security, runtime cost — and not one of them reads a sentence for whether it is true, clear, in the project’s voice or worth a person’s attention. Four consecutive reviews hit the same wall and all four graded ships deservedly PASSED on the axes that were measured; the gate is not lenient, it is not pointed at this. `grader-prompt.js` already injects the craft’s written responsibility, but only when `discipline === ‘artist’`, so the unjudged case is copy shipped by someone whose craft is not the copy. **Decision: a fifth ADVISORY `reader` worker, on three settled terms.** It runs only where `modules/lifecycle/cascade.js`’s existing `hasCopyOrVisualWork` says the ship is copy-bearing — the same predicate the after-the-fact review uses, so the two can never disagree about coverage, and every other ship costs exactly what it costs today. It records `issues[]` and a `copy_score` and can never verdict `fail` or drag the panel to `concerns`, because [ADR 0241](0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md) put the artist’s judgement on the DEPLOY and off the ship path; gating would need that ADR amended first. And it is NOT BUILT until the instance writes a voice guide — today the whole written standard is one dash rule in `DESIGN.md`, a tagline, a registry of which 1,860 strings exist, and one responsibility sentence, so a lens built now would record taste and call it a finding. Until the reader exists, STEP 1 asks the artist to WRITE DOWN what the grade could not see rather than to coach a lens that is not listening — followable today, and the corpus both the guide and the lens get written from. Nothing in the rubric, the active panel or any existing worker changes.](0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md) | grading / the artist’s loop / voice |
393
393
  | 0299 | [**The nod queue stays scoped to routed filings; the backlog walk is the widening** ([task 1003748](https://cloudbongos.com/builders#/task/1003748), goal 1000089). `listRoutedAwaitingNod` filters `origin='route'`, and on 2026-09-08 showed 11 of 232 backlog rows. Re-measured after `/backlog-review` (task 1003746) shipped: 115 backlog rows = 109 dep-gated + 2 spikes (not a person's decision) + 1 stranded + 3 awaiting a nod, all four walked by `/backlog-review`. **Decision: do not widen the card.** The omitted population already has a walkable review with the dependency predicate any widening needed; the copy says *reported* work, so the name is honest; a card slice would be a second copy of the walk's four-bucket partition. Revisit if the un-routed nod bucket outgrows a daily walk (then a count + link, derived from the same partition) or if the walk stops being run.](0299-the-nod-queue-stays-scoped-to-routed-filings.md) | ideas / backlog / attention |
394
+ | 0300 | [**A missing protected-surface registry is a broken install, not a module opt-out** ([task 1003662](https://cloudbongos.com/builders#/task/1003662), goal 1000091). Filed as a bug with a remedy attached: `require('./module-scope-map')` throws when `modules/government/protected-surfaces.json` is absent, so make it degrade to an empty protected set + a warning, matching `resolveOptional`. The throw reproduces, and the rest is wrong four times over. **The throw is not in the named file** — `module-scope-map.js` has no throw and no file read; it requires `permission-path-check.js`, whose `loadRegistry()` throws at module scope. **It is a written contract, not an oversight** — that file's header says FAIL-CLOSED in capitals and already pre-answers the layering worry (the read is `fs`, never `require`, so ADR 0083's one-way rule holds; static data, not code; `route-rank-check.js` reads `modules/*/routes/*.js` the same way). **Disabling the module cannot cause it** — enablement is a runtime flag, never a file deletion, and the read never consults it. **"Not yet materialized" cannot either** — no `files[]` allowlist, so `npm pack --dry-run` puts the 13,274-byte registry in the tarball; per ADR 0108 §1 you cannot have `@bongos/core` without it. The `resolveOptional` precedent does not transfer: that is the module **seam/port** resolver for a missing runtime *capability*, whereas this is the static data the boundary is made of, read where there is no server and no DB (a git hook, a CI job) — borrowing it converts "the boundary is unreadable" into "there is no boundary". **Decision: fail-closed stays, no behavior change, the remedy is rejected — and the one genuine defect is fixed: the message now names the cause and the fix** (reinstall the core; do not empty the set), because stating a refusal without a remedy is exactly how a reader concludes the module is optional and files this task. The asymmetry is the point: a false "nothing is protected" is silent, permanent and grants authority; a false "this install is broken" is loud and grants nothing. Revisit only if a core-only checkout without `modules/` becomes legitimate — and then the question is *where the registry lives*, not whether it is optional.](0300-a-missing-protected-surface-registry-is-a-broken-install.md) | governance / permissions |
@@ -2136,5 +2136,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2136
2136
  landed since 1.19.829 with no explicit bump. run 35235005422. (task 1002620)
2137
2137
  1.19.831 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2138
2138
  landed since 1.19.830 with no explicit bump. run 35238952391. (task 1002620)
2139
+ 1.19.832 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2140
+ landed since 1.19.831 with no explicit bump. run 35240512571. (task 1002620)
2141
+ 1.19.833 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2142
+ landed since 1.19.832 with no explicit bump. run 35243115057. (task 1002620)
2139
2143
  ---------------------------------------------------------------------------
2140
2144
  ```
@@ -58,11 +58,123 @@ function isSyncBacked(p) {
58
58
  .some((seg) => SYNC_SEGMENTS.includes(seg.toLowerCase()));
59
59
  }
60
60
 
61
- // Lexical containment: is `candidate` equal to, or under, `root`? Directional on
62
- // purpose — path.relative escaping upward ('..') or returning an absolute path
63
- // both mean "outside". Used to reject a quarantine site, and by callers to keep
64
- // derived paths inside the pen.
61
+ // CANONICALISE BEFORE COMPARING (task 1003848). Windows hands out 8.3 short names:
62
+ // os.tmpdir() here returns C:\Users\GMK1_P~1\AppData\Local\Temp while other sides
63
+ // report C:\Users\GMK1_ppl51\..., and path.resolve does not reconcile the two — so
64
+ // a purely lexical comparison called the same directory 'outside' itself.
65
+ //
66
+ // fs.realpathSync.native expands 8.3; plain fs.realpathSync does NOT. That single
67
+ // fact is the whole fix, and it is worth stating because the plain form looks like
68
+ // it should work.
69
+ //
70
+ // THIS IS NOT ONLY A TEST BUG. siteRefusal below asks isInside(REPO_ROOT, site) to
71
+ // refuse a quarantine site inside the repository. Lexically, a site spelled with a
72
+ // short name would not match the long-form REPO_ROOT — so the refusal could be
73
+ // evaded by spelling, on the one platform that hands out both spellings. Comparing
74
+ // canonical paths closes that as well as the false negative in the pen check.
75
+ //
76
+ // Falls back to the lexical answer when a path does not exist yet, which is the
77
+ // ordinary case for a derived path being validated BEFORE it is written: realpath
78
+ // throws on a missing leaf, so the nearest existing ancestor is canonicalised and
79
+ // the remainder appended.
80
+ // MEMOISED, and that is not an optimisation detail — isInside() is called twice per
81
+ // entry inside mirror.js's per-file loop (capped at 5,000 files), so an unmemoised
82
+ // realpath here would put 20,000 sync syscalls on a path whose own header is explicit
83
+ // about staying cheap. A directory cannot move under a running scan, so the answer is
84
+ // stable for the life of the process.
85
+ //
86
+ // Keyed on the RESOLVED input, so the two spellings of one path — which is the entire
87
+ // problem being solved — share an entry only after they canonicalise to the same value,
88
+ // never before.
89
+ const canonicalCache = new Map();
90
+
91
+ function canonicalUncached(p) {
92
+ let abs = path.resolve(p);
93
+ const tail = [];
94
+ for (;;) {
95
+ try {
96
+ const real = fs.realpathSync.native(abs);
97
+ // `exact` is true only when the FULL path resolved — nothing was walked off.
98
+ // That is the whole cacheability test; see canonical() below.
99
+ return { value: path.join(real, ...tail.reverse()), exact: tail.length === 0 };
100
+ } catch (e) {
101
+ // ENOENT is the ordinary case: a derived path being validated BEFORE it is
102
+ // written. Walk up to the nearest ancestor that exists and re-attach the tail.
103
+ //
104
+ // ANY OTHER ERROR (EACCES, EPERM, ELOOP) used to return the raw lexical path,
105
+ // which silently re-opened the 8.3 gap this function exists to close — an
106
+ // unreadable leaf would have made a short-name spelling pass containment again.
107
+ // Walking up instead keeps as much canonical prefix as we can actually read, so
108
+ // the comparison degrades gradually rather than reverting.
109
+ const parent = path.dirname(abs);
110
+ // hit the root and found nothing real — a lexical answer, never cacheable
111
+ if (parent === abs) return { value: path.resolve(p), exact: false };
112
+ tail.push(path.basename(abs));
113
+ abs = parent;
114
+ }
115
+ }
116
+ }
117
+
118
+ // ONLY AN EXACT ANSWER IS CACHEABLE (task 1003848, second pass).
119
+ //
120
+ // The first pass cached every answer, including the provisional one computed by
121
+ // walking up to the nearest EXISTING ancestor. That is the answer for a path being
122
+ // validated BEFORE it is written — and it is only true until the path is written.
123
+ // Create it as a symlink (or, on Windows, a junction) pointing out of the pen and the
124
+ // cache keeps serving the pre-creation answer, so isInside() still calls it INSIDE and
125
+ // the path_escape refusal is lost. That is the containment evasion this function
126
+ // exists to close, re-opened one layer down by its own memoisation.
127
+ //
128
+ // Proven by control: with a warm cache isInside(pen, probed) answered true; after
129
+ // clearCanonicalCache() the same call answered false, with nothing else changed.
130
+ //
131
+ // So a walked-up answer is returned and NOT stored. This keeps every bit of the
132
+ // memoisation that mattered — mirror.js's hot loop canonicalises EXISTING directories
133
+ // over and over, and those still resolve exactly and still cache on first touch — while
134
+ // a path that does not exist yet is re-read each time, which is precisely when the
135
+ // filesystem is allowed to disagree with the last answer.
136
+ function canonical(p) {
137
+ const key = path.resolve(p);
138
+ const hit = canonicalCache.get(key);
139
+ if (hit !== undefined) return hit;
140
+ const { value, exact } = canonicalUncached(key);
141
+ if (exact) canonicalCache.set(key, value);
142
+ return value;
143
+ }
144
+
145
+ // For tests, and for a caller that genuinely needs to re-read the filesystem.
146
+ function clearCanonicalCache() { canonicalCache.clear(); }
147
+
148
+ // Containment: is `candidate` equal to, or under, `root`? Directional on purpose —
149
+ // path.relative escaping upward ('..') or returning an absolute path both mean
150
+ // "outside". Used to reject a quarantine site, and by callers to keep derived paths
151
+ // inside the pen.
65
152
  function isInside(root, candidate) {
153
+ const rel = path.relative(canonical(root), canonical(candidate));
154
+ if (rel === '') return true;
155
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
156
+ }
157
+
158
+ // LEXICAL containment — path arithmetic only, never the filesystem (task 1003848,
159
+ // grade round 2).
160
+ //
161
+ // isInside() above canonicalises, which RESOLVES SYMLINKS. That is right for a
162
+ // physical check ("where does this actually point?") and wrong for a lexical one
163
+ // ("does this manifest path try to climb out?"). sanitize.js runs both, in that
164
+ // order, on purpose: lexical first to reject a traversing manifest path, then
165
+ // physical to reject a link whose target escapes, then lstat to reject a link the
166
+ // manifest called a file — each with its own refusal message.
167
+ //
168
+ // Canonicalising the lexical arm collapsed those three into one: a symlink inside
169
+ // pristine/ resolved to its target outside, so the LEXICAL arm threw "escapes the
170
+ // pristine tree" and pre-empted the two later refusals. The artifact was still
171
+ // refused — containment never weakened — but the diagnosis became wrong, and the
172
+ // two tests pinning those messages failed. Those tests only run where symlinks can
173
+ // be created, so Windows skips them and the regression is invisible off-CI.
174
+ //
175
+ // There is deliberately no canonical() call here: this answer must not depend on
176
+ // what the leaf currently points at.
177
+ function isInsideLexical(root, candidate) {
66
178
  const rel = path.relative(path.resolve(root), path.resolve(candidate));
67
179
  if (rel === '') return true;
68
180
  return !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -140,6 +252,10 @@ function createQuarantine({ tmpRoot = os.tmpdir(), forbiddenRoots } = {}) {
140
252
  }
141
253
 
142
254
  module.exports = {
255
+ canonical,
256
+ isInsideLexical,
257
+ canonicalUncached,
258
+ clearCanonicalCache,
143
259
  CAPS,
144
260
  REPO_ROOT,
145
261
  createQuarantine,
@@ -38,7 +38,7 @@ const crypto = require('node:crypto');
38
38
  const fsp = require('node:fs/promises');
39
39
  const path = require('node:path');
40
40
 
41
- const { isInside } = require('./quarantine');
41
+ const { isInside, isInsideLexical } = require('./quarantine');
42
42
  const { excerpt } = require('./detectors/shared');
43
43
 
44
44
  const SANITIZED_DIRNAME = 'sanitized';
@@ -240,8 +240,12 @@ async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, remova
240
240
  }
241
241
 
242
242
  // Confinement, same discipline as mirror.js: lexical first, then physical.
243
+ // isInsideLexical, NOT isInside: this arm asks whether the MANIFEST PATH climbs
244
+ // out, which is pure path arithmetic. isInside canonicalises and so follows a
245
+ // symlink to its target, which would make this arm fire for a link that escapes
246
+ // and pre-empt the two specific refusals below (task 1003848).
243
247
  if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
244
- || !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
248
+ || !isInsideLexical(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
245
249
  throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
246
250
  }
247
251
  const abs = path.resolve(pristineDir, ...rel.split('/'));
@@ -24,6 +24,12 @@ import path from 'node:path';
24
24
 
25
25
  const require = createRequire(import.meta.url);
26
26
  const { buildMirror, MIRROR_CAPS } = require('../artifact-scan/mirror.js');
27
+ // Containment is asked of the module's own predicate, not of String.startsWith
28
+ // (task 1003848). A prefix match is both too weak - '/pen-evil' starts with '/pen' -
29
+ // and, on Windows, wrong: os.tmpdir() hands back an 8.3 short name while the built
30
+ // path carries the long form, so the two spellings of ONE directory did not share a
31
+ // prefix and the pen looked like it was outside itself.
32
+ const { isInside } = require('../artifact-scan/quarantine.js');
27
33
  const fenceLocal = require('../artifact-scan/fence-local.js');
28
34
  const { scrubUntrusted, newFenceDelim, FENCE_MARKER, DANGEROUS_CHARS } = fenceLocal;
29
35
 
@@ -322,7 +328,7 @@ async function run() {
322
328
  pristineDir: pen.pristineDir, mirrorDir: pen.mirrorDir, quarantineRoot: pen.root,
323
329
  entries: [fileEntry('ok.txt', 4), fileEntry('bad.bin', 3)],
324
330
  });
325
- assert.ok(res.indexPath.startsWith(pen.root), 'index lives inside the pen');
331
+ assert.ok(isInside(pen.root, res.indexPath), 'index lives inside the pen');
326
332
  const idx = JSON.parse(fs.readFileSync(res.indexPath, 'utf8'));
327
333
  assert.equal(idx.mirrored.length, 1);
328
334
  assert.equal(idx.stubs.length, 1);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.831",
3
+ "version": "1.19.833",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.831",
9
+ "version": "1.19.833",
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.831",
3
+ "version": "1.19.833",
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",
@@ -26,7 +26,7 @@ const { execFileSync } = require('node:child_process');
26
26
  const { checkRankGuards, checkModulePinKeysResolve } = require('./fitness-checks-route-pins.js'); // Checks 4 + 4b — carved out by task 1003192 (the 1,500-line ratchet)
27
27
  const genApiDocs = require('./gen-api-docs'); // R12 (#1999): reuse the route model (readsBody/validated) that drives the OpenAPI spec
28
28
  const { parseRoutineFrontmatter } = require('./run-routine'); // System 4 (ADR 0078): deterministic-routine boundary
29
- const { filenameViolation, stemCollisions, CORE_NS, INSTANCE_NS } = require('./migration-namespace'); // ADR 0108 §5: cross-repo migration namespacing
29
+ const { filenameViolation, stemCollisions, numberCollisions, CORE_NS, INSTANCE_NS } = require('./migration-namespace'); // ADR 0108 §5: cross-repo migration namespacing
30
30
  const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
31
31
 
32
32
  // task 1003206 (goal 1000079): fitness.js was 2,004 lines. Four cohesive pieces now
@@ -965,13 +965,23 @@ function checkMigrationNamespacing() {
965
965
  violations.push(`migration stem "${c.stem}" is produced by more than one source (${c.files.join(', ')}) — schema_migrations is stem-keyed, so one applies and the rest are SILENTLY SKIPPED; namespace them (ADR 0108 §5).`);
966
966
  }
967
967
 
968
+ // Two DIFFERENT stems at one number inside ONE series (task 1003646). Not the
969
+ // silent-skip above — all of them apply — but migrate.sh orders by basename, so
970
+ // their relative order is the alphabetical accident of the slug, and a from-scratch
971
+ // replay can order them differently from the merge order prod applied. Renumbering an
972
+ // APPLIED migration re-runs it, so the fix for a new one is to renumber BEFORE it
973
+ // lands; the single pre-existing case is grandfathered in migration-namespace.js.
974
+ for (const c of numberCollisions(files)) {
975
+ violations.push(`migration number ${String(c.number).padStart(3, '0')} is used by ${c.files.length} different migrations in one series (${c.series}): ${c.files.join(', ')} — each was allocated max+1 from its own branch point. migrate.sh applies in BASENAME order, so their order is decided by the slug alphabetically and a fresh replay can differ from the order prod applied. Renumber the one that has NOT landed yet (renaming an applied migration re-runs it) (task 1003646).`);
976
+ }
977
+
968
978
  return {
969
- name: 'migration filenames namespaced + no stem collision (ADR 0108 §5)',
979
+ name: 'migration filenames namespaced + no stem/number collision (ADR 0108 §5)',
970
980
  ok: violations.length === 0,
971
981
  hardFail: violations.length > 0,
972
982
  violations,
973
983
  warnings: [],
974
- note: `${coreFiles.length} core + ${instFiles.length} instance + ${moduleCount} module migration file(s): new core→core_, new module→<key>_, instance bare; no cross-root stem collision.`,
984
+ note: `${coreFiles.length} core + ${instFiles.length} instance + ${moduleCount} module migration file(s): new core→core_, new module→<key>_, instance bare; no cross-root stem collision, and no two migrations share a number within one series.`,
975
985
  };
976
986
  }
977
987
 
@@ -154,6 +154,106 @@ function stemCollisions(files) {
154
154
  return out.sort((a, b) => a.stem.localeCompare(b.stem));
155
155
  }
156
156
 
157
+ // ---- same-series number-collision detector ---------------------------------
158
+
159
+ // The stem detector above catches the CROSS-source hazard (one stem, two sources →
160
+ // migrate.sh applies one and silently skips the rest). It deliberately does not care
161
+ // that `core_012_x` and `platform_identity_012_y` share a number: they are separate
162
+ // allocators with separate namespaces, and the comment on lineageReport says so.
163
+ //
164
+ // THE GAP (task 1003646). Two DIFFERENT stems sharing a number inside ONE series is a
165
+ // different bug, and nothing watched it. next-number.js allocates max+1 by scanning the
166
+ // series as it stands at the caller's BRANCH POINT, so two claim sessions that branch
167
+ // before either lands both compute the same "next". On 2026-08-28 that put three files
168
+ // at platform_identity_012 on main. stemCollisions() returns [] for them — the stems
169
+ // differ — so CI was green.
170
+ //
171
+ // WHY IT MATTERS, given nothing is skipped. migrate.sh applies in BASENAME order
172
+ // (scripts/migrate.sh: core+instance merge-sorted by basename; modules by shell glob),
173
+ // so a colliding pair's relative order is decided by the alphabetical accident of its
174
+ // SLUG. On prod they applied in the order the branches merged. On a from-scratch replay
175
+ // — a restore, a fresh instance, the CI replay guard — they apply alphabetically. When
176
+ // those two orders differ, a rebuilt database is not the database that was built, which
177
+ // is the exact hazard migrations/README.md names as "the one rule that has actually
178
+ // bitten us". Nothing detects the divergence, because both databases report every
179
+ // migration applied.
180
+ //
181
+ // The fix is a guard, not a renumber: renaming an applied migration changes its stem,
182
+ // which is the schema_migrations key, so migrate.sh would see it as unapplied and RE-RUN
183
+ // it — the same reason LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES exist above.
184
+ // The one pre-existing collision is therefore grandfathered by name, and the guard binds
185
+ // on everything new.
186
+ // Keyed series/number → the exact STEMS that are exempt. Deliberately not a bare
187
+ // series/number exemption: that would also wave through a FOURTH file landing at 012,
188
+ // which is the very bug this detects. Only the already-applied files are forgiven, so
189
+ // the number stays closed to anything new.
190
+ // Seven races that already landed. Every one of these files is applied on prod, and
191
+ // renaming an applied migration changes its schema_migrations stem, so migrate.sh would
192
+ // see it as unapplied and RE-RUN it — the same constraint that produced
193
+ // LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES above. They are recorded rather than
194
+ // fixed, and the guard binds on everything new.
195
+ //
196
+ // These are history, not a pattern to copy: the number is the SECOND-oldest of these
197
+ // (067) through to 2026-08-28 (platform_identity 012), i.e. the race has been happening
198
+ // for as long as parallel claims have, which is why a keyword-free structural check is
199
+ // worth having at all.
200
+ const GRANDFATHERED_NUMBER_COLLISIONS = new Map([
201
+ ['core/(bare)/067', new Set(['067_builder_boxes', '067_discord_bot_foundation'])],
202
+ ['core/(bare)/176', new Set(['176_promotion_respects_open_blockers', '176_ui_discipline'])],
203
+ ['core/(bare)/187', new Set(['187_goal_sort_order', '187_tasks_kind_check_add_verify'])],
204
+ ['core/core/189', new Set(['core_189_core_upgrades_ledger', 'core_189_credit_log_task_id_index'])],
205
+ ['core/core/223', new Set(['core_223_blocker_resolve_respects_other_open_blockers', 'core_223_task_visual'])],
206
+ ['core/core/240', new Set(['core_240_artist_gate_indexes', 'core_240_goal_members_drop_legacy_role'])],
207
+ ['module:platform-identity/platform_identity/012', new Set([
208
+ // 2026-08-28, the collision task 1003646 was filed for: three parallel sessions each
209
+ // allocated 012 from its own branch point. Order between them does not matter — they
210
+ // alter three different tables (platform_identity_accounts, _projects,
211
+ // _builder_project_activity), so the basename-order vs merge-order divergence has
212
+ // nothing to bite on here.
213
+ 'platform_identity_012_account_privacy',
214
+ 'platform_identity_012_project_template',
215
+ 'platform_identity_012_recent_ships',
216
+ ])],
217
+ ]);
218
+
219
+ // Given the same flat { source, moduleKey?, basename } list stemCollisions() takes,
220
+ // return [{ series, number, files:[…] }] for every number carrying >1 distinct stem
221
+ // within ONE series. Grandfathered entries are omitted.
222
+ function numberCollisions(files) {
223
+ const bySeriesNumber = new Map();
224
+ for (const f of files || []) {
225
+ const p = parseMigrationName(f.basename);
226
+ if (!p.ok) continue;
227
+ // A SERIES is one allocator's number space, and that is identified by the filename
228
+ // PREFIX, not by the directory. A renamed module keeps its historical prefix for
229
+ // already-applied files (RENAMED_MODULE_PREFIXES) and restarts the new prefix at 001,
230
+ // so modules/government/ holds governance_001…007 beside government_001…007. Those are
231
+ // two namespaces sharing a directory, not a race: the prefixes sort into separate
232
+ // contiguous blocks, so their order is fixed and identical on prod and on replay.
233
+ // Keying by directory would report all seven as collisions and the check would be
234
+ // wrong seven times before it was right once. moduleKey still participates so that two
235
+ // LEGACY BARE modules (game, demo) each numbering from 001 do not collide with each other.
236
+ const owner = f.source === 'module' ? `module:${f.moduleKey}` : f.source;
237
+ const series = `${owner}/${p.bare ? '(bare)' : p.prefix}`;
238
+ const key = `${series}/${pad3(p.number)}`;
239
+ if (!bySeriesNumber.has(key)) bySeriesNumber.set(key, { series, number: p.number, stems: new Map() });
240
+ // Keyed by STEM, so the same file counted twice is not a collision — only two
241
+ // genuinely different migrations at one number are.
242
+ bySeriesNumber.get(key).stems.set(f.basename.replace(/\.sql$/, ''), f.basename);
243
+ }
244
+ const out = [];
245
+ for (const [key, v] of bySeriesNumber) {
246
+ if (v.stems.size < 2) continue;
247
+ // Exempt only when EVERY stem at this number is one of the already-applied files.
248
+ // One unrecognised stem re-opens the whole group, because that new file is exactly
249
+ // what must not be allowed to sit on a taken number.
250
+ const allowed = GRANDFATHERED_NUMBER_COLLISIONS.get(key);
251
+ if (allowed && [...v.stems.keys()].every((stem) => allowed.has(stem))) continue;
252
+ out.push({ series: v.series, number: v.number, files: [...v.stems.values()].sort() });
253
+ }
254
+ return out.sort((a, b) => (a.series === b.series ? a.number - b.number : a.series.localeCompare(b.series)));
255
+ }
256
+
157
257
  // ---- numbering / lineage (mirrors the public-mirror pattern) ---------------
158
258
 
159
259
  // next free = max + 1; empty starts at `start` (same shape as next-number.js nextFree,
@@ -207,6 +307,7 @@ module.exports = {
207
307
  migrationNumberOf,
208
308
  filenameViolation,
209
309
  stemCollisions,
310
+ numberCollisions,
210
311
  nextInNamespace,
211
312
  lineageReport,
212
313
  };
@@ -73,16 +73,31 @@ const RANK_TIER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
73
73
  // hook.
74
74
  const REGISTRY_PATH = path.join(__dirname, '..', '..', 'modules', 'government', 'protected-surfaces.json');
75
75
 
76
+ // WHY THE REFUSAL NAMES A FIX: the FAIL-CLOSED note above tells the reader what
77
+ // this throw refuses to do, but nothing told them what to DO — and a reader who
78
+ // meets this cold sees a core file reaching into modules/ and concludes the
79
+ // module must be optional. That is not hypothetical: task 1003662 was filed
80
+ // asking for exactly the degrade-to-empty-set this refuses, on that reading. The
81
+ // registry SHIPS INSIDE @bongos/core (modules/ is in the published tarball, ADR
82
+ // 0108 §1), so it cannot be absent on an intact install and disabling the
83
+ // government module cannot remove it — a module's enable/disable state is a
84
+ // runtime flag, never a file deletion. Absent therefore means the install is
85
+ // damaged, and reinstalling is the fix. Full reasoning: ADR 0300.
86
+ const REMEDIATION =
87
+ 'modules/government/protected-surfaces.json ships inside @bongos/core, so an intact install always has it '
88
+ + '— this is a damaged install (partial extract or lossy copy), not a disabled module. Reinstall/repair the '
89
+ + 'core package; do NOT degrade to an empty protected set (ADR 0043, ADR 0300).';
90
+
76
91
  function loadRegistry(file) {
77
92
  let raw;
78
93
  try {
79
94
  raw = JSON.parse(fs.readFileSync(file, 'utf8'));
80
95
  } catch (e) {
81
- throw new Error(`protected-surface registry unreadable at ${file}: ${e.message}. Refusing to run with an empty protected set (ADR 0043).`);
96
+ throw new Error(`protected-surface registry unreadable at ${file}: ${e.message}. Refusing to run with an empty protected set (ADR 0043). ${REMEDIATION}`);
82
97
  }
83
98
  const surfaces = Array.isArray(raw && raw.surfaces) ? raw.surfaces : null;
84
99
  if (!surfaces || surfaces.length === 0) {
85
- throw new Error(`protected-surface registry ${file} declares no surfaces. Refusing to run with an empty protected set (ADR 0043).`);
100
+ throw new Error(`protected-surface registry ${file} declares no surfaces. Refusing to run with an empty protected set (ADR 0043). ${REMEDIATION}`);
86
101
  }
87
102
  const defaultFloor = raw.floor;
88
103
  const defaultPermission = raw.permission;
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.831'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.833'; // 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');
@@ -21,6 +21,7 @@
21
21
  // Run: node --test tests/artifact_staleness.mjs
22
22
 
23
23
  import { test } from 'node:test';
24
+ import path from 'node:path';
24
25
  import assert from 'node:assert/strict';
25
26
  import { createRequire } from 'node:module';
26
27
  import { readFileSync, readdirSync } from 'node:fs';
@@ -123,7 +124,11 @@ test('a generator that cannot answer is UNKNOWN, never stale — it is not a fin
123
124
 
124
125
  const runFor = (byGenerator) => (cmd, args) => {
125
126
  if (cmd === 'git') return fakeGit()(cmd, args);
126
- const which = String(args[0] || '').split('/').pop();
127
+ // path.basename, not split('/') — the checker passes an ABSOLUTE generator path,
128
+ // which is backslash-separated on Windows, so this returned the whole path, matched
129
+ // no key in byGenerator, and every generator reported `status: undefined` -> "exit
130
+ // undefined". Three suites carried this same idiom (task 1003848).
131
+ const which = path.basename(String(args[0] || ''));
127
132
  return { status: byGenerator[which], stdout: '', stderr: '' };
128
133
  };
129
134
 
@@ -742,6 +742,55 @@ test('a missing registry fails CLOSED — it never degrades to "nothing is prote
742
742
  }, /unreadable|Refusing/i);
743
743
  });
744
744
 
745
+ // The refusal text, not just the refusal. Run the kernel load in a cold subprocess
746
+ // with a patched read and hand back stderr, so a message assertion sees what an
747
+ // operator would actually read.
748
+ function refusalStderr(patch) {
749
+ try {
750
+ execFileSync(process.execPath, ['-e',
751
+ "const fs=require('node:fs');const real=fs.readFileSync;" + patch
752
+ + "require('./src/bongos/permission-path-check');",
753
+ ], { cwd: ROOT, stdio: 'pipe' });
754
+ } catch (e) {
755
+ return String(e.stderr || '');
756
+ }
757
+ return '';
758
+ }
759
+
760
+ // WHY ASSERT ON THE WORDS (task 1003662, ADR 0300). The sibling probe above proves
761
+ // the load REFUSES; it cannot tell whether the refusal is actionable. It was not:
762
+ // the message stated the refusal and cited ADR 0043 but never named a fix, and a
763
+ // reader who meets it cold sees a core file reaching into modules/ and concludes
764
+ // the module must be optional. Task 1003662 was filed on exactly that reading,
765
+ // asking for the degrade-to-empty-set this refuses. The remediation line is the
766
+ // fix for that, so it is part of the contract and not decoration.
767
+ //
768
+ // The EMPTY-surfaces branch is covered here for the first time. Until now only the
769
+ // unreadable branch had a probe, so `surfaces: []` — the shape an out-of-band
770
+ // registry write most plausibly produces — could have degraded silently.
771
+ test('both refusal branches name the fix, not just the refusal (ADR 0300)', () => {
772
+ const gone = refusalStderr(
773
+ "fs.readFileSync=(p,...r)=>{ if(String(p).includes('protected-surfaces.json')) throw new Error('gone'); return real(p,...r); };",
774
+ );
775
+ const empty = refusalStderr(
776
+ "fs.readFileSync=(p,...r)=>(String(p).includes('protected-surfaces.json') ? '{\"surfaces\":[]}' : real(p,...r));",
777
+ );
778
+
779
+ assert.match(empty, /declares no surfaces/,
780
+ 'an empty surfaces[] must refuse too — not only an unreadable file');
781
+
782
+ for (const [name, text] of [['unreadable', gone], ['empty', empty]]) {
783
+ assert.match(text, /Refusing to run with an empty protected set/,
784
+ `the ${name} branch must still state the refusal`);
785
+ assert.match(text, /ships inside @bongos\/core/,
786
+ `the ${name} branch must say the registry ships with the core (so absent means damaged, not disabled)`);
787
+ assert.match(text, /[Rr]einstall/,
788
+ `the ${name} branch must name the action that fixes it`);
789
+ assert.match(text, /ADR 0300/,
790
+ `the ${name} branch must cite the ADR that rejected degrading to an empty set`);
791
+ }
792
+ });
793
+
745
794
  // ---------- the matcher, driven by registry data ----------
746
795
 
747
796
  test('a known path is protected at the right floor; an unknown path is not', () => {
@@ -8,6 +8,8 @@
8
8
  // while grandfathering the existing bare files (core ≤ 187; game/demo modules).
9
9
  // 3. stemCollisions catches the real bug: two sources sharing a schema_migrations stem.
10
10
  // 4. nextInNamespace / lineageReport count both forms so an allocator can't reuse a number.
11
+ // 5. numberCollisions catches the SAME-SERIES race stemCollisions is blind to: two
12
+ // different stems on one number, which parallel claims produce (task 1003646).
11
13
  //
12
14
  // No fs/git — the helpers are pure. Run: node tests/migration_namespace.mjs
13
15
 
@@ -150,6 +152,142 @@ t('stemCollisions: namespacing REMOVES the collision', () => {
150
152
  assert.deepEqual(ns.stemCollisions(files), []);
151
153
  });
152
154
 
155
+ // ---- numberCollisions (task 1003646) ---------------------------------------
156
+ // The SAME-SERIES hazard, which stemCollisions is blind to by design: two different
157
+ // stems at one number inside one series. Nothing is skipped — but migrate.sh applies in
158
+ // BASENAME order, so the pair's order is the alphabetical accident of its slug, and a
159
+ // from-scratch replay can order them differently from the merge order prod applied.
160
+ t('numberCollisions: none when each number is used once in its series', () => {
161
+ const files = [
162
+ { source: 'core', basename: 'core_188_a.sql' },
163
+ { source: 'core', basename: 'core_189_b.sql' },
164
+ { source: 'module', moduleKey: 'economy', basename: 'economy_001_x.sql' },
165
+ ];
166
+ assert.deepEqual(ns.numberCollisions(files), []);
167
+ });
168
+
169
+ t('numberCollisions: the SAME number in DIFFERENT series is not a collision', () => {
170
+ // The deliberate tolerance lineageReport documents: separate allocators, separate
171
+ // namespaces, distinct stems. core_012 and economy_012 are unrelated.
172
+ const files = [
173
+ { source: 'core', basename: 'core_012_a.sql' },
174
+ { source: 'instance', basename: '012_b.sql' },
175
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_c.sql' },
176
+ { source: 'module', moduleKey: 'ideas', basename: 'ideas_012_d.sql' },
177
+ ];
178
+ assert.deepEqual(ns.numberCollisions(files), []);
179
+ });
180
+
181
+ t('numberCollisions: catches two stems at one number in ONE series (the 1003646 bug)', () => {
182
+ const files = [
183
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
184
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
185
+ ];
186
+ const c = ns.numberCollisions(files);
187
+ assert.equal(c.length, 1);
188
+ assert.equal(c[0].series, 'module:economy/economy');
189
+ assert.equal(c[0].number, 12);
190
+ assert.deepEqual(c[0].files, ['economy_012_alpha.sql', 'economy_012_beta.sql']);
191
+ });
192
+
193
+ t('numberCollisions: stemCollisions is blind to it — the two detectors are not redundant', () => {
194
+ // If this ever starts failing because stemCollisions caught it, one of the two is
195
+ // doing the other's job and the pair should be revisited.
196
+ const files = [
197
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
198
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
199
+ ];
200
+ assert.deepEqual(ns.stemCollisions(files), [], 'different stems — not a stem collision');
201
+ assert.equal(ns.numberCollisions(files).length, 1, 'but it IS a number collision');
202
+ });
203
+
204
+ t('numberCollisions: core and instance series are checked too, not just modules', () => {
205
+ const files = [
206
+ { source: 'core', basename: 'core_190_a.sql' },
207
+ { source: 'core', basename: 'core_190_b.sql' },
208
+ { source: 'instance', basename: '042_x.sql' },
209
+ { source: 'instance', basename: '042_y.sql' },
210
+ ];
211
+ const c = ns.numberCollisions(files);
212
+ assert.equal(c.length, 2);
213
+ assert.deepEqual(c.map((x) => x.series), ['core/core', 'instance/(bare)']);
214
+ });
215
+
216
+ t('numberCollisions: a RENAMED module\'s two prefixes are two series, not a collision', () => {
217
+ // modules/government/ really holds governance_001…007 beside government_001…007: the
218
+ // key was renamed (RENAMED_MODULE_PREFIXES, ADR 0174) and the new prefix restarted at
219
+ // 001, while the applied files keep the old one because the stem is the
220
+ // schema_migrations key. Keying a series by DIRECTORY reported all seven as collisions
221
+ // — the check would have been wrong seven times and right once. It is keyed by PREFIX
222
+ // for exactly this reason, and the prefixes sort into separate contiguous blocks
223
+ // (governance_ < government_), so their order is fixed on prod and on replay alike.
224
+ const files = [
225
+ { source: 'module', moduleKey: 'government', basename: 'governance_001_tables.sql' },
226
+ { source: 'module', moduleKey: 'government', basename: 'government_001_rename_from_governance.sql' },
227
+ { source: 'module', moduleKey: 'government', basename: 'governance_007_delegate_archon_ops_to_metic.sql' },
228
+ { source: 'module', moduleKey: 'government', basename: 'government_007_amendments.sql' },
229
+ ];
230
+ assert.deepEqual(ns.numberCollisions(files), []);
231
+ });
232
+
233
+ t('numberCollisions: two LEGACY BARE modules numbering from 001 do not collide', () => {
234
+ // game and demo both predate the <key>_ convention, so both hold a bare 001_. They are
235
+ // different directories and different allocators — moduleKey keeps them apart.
236
+ const files = [
237
+ { source: 'module', moduleKey: 'game', basename: '001_initial.sql' },
238
+ { source: 'module', moduleKey: 'demo', basename: '001_demo_pings.sql' },
239
+ ];
240
+ assert.deepEqual(ns.numberCollisions(files), []);
241
+ });
242
+
243
+ t('numberCollisions: the SAME file listed twice is not a collision', () => {
244
+ // Keyed by stem, so a double-scan of one directory cannot manufacture a violation.
245
+ const files = [
246
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
247
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
248
+ ];
249
+ assert.deepEqual(ns.numberCollisions(files), []);
250
+ });
251
+
252
+ t('numberCollisions: the one pre-existing collision is grandfathered', () => {
253
+ // platform_identity_012 x3 is applied on prod. Renaming an applied migration changes
254
+ // its schema_migrations stem, so migrate.sh would RE-RUN it — the same reason
255
+ // LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES exist. It is exempt BY NAME, so a
256
+ // fourth file at that number still fails.
257
+ const three = ['account_privacy', 'project_template', 'recent_ships'].map((s) => ({
258
+ source: 'module', moduleKey: 'platform-identity', basename: `platform_identity_012_${s}.sql`,
259
+ }));
260
+ assert.deepEqual(ns.numberCollisions(three), [], 'the known trio is exempt');
261
+
262
+ // The exemption names STEMS, not the number, so 012 stays CLOSED. A fourth file
263
+ // landing there is the bug this check exists for and must still fail — a bare
264
+ // series/number exemption would have waved it through.
265
+ const four = [...three, {
266
+ source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_012_new.sql',
267
+ }];
268
+ const c4 = ns.numberCollisions(four);
269
+ assert.equal(c4.length, 1, 'a NEW fourth file at the grandfathered number still fails');
270
+ assert.ok(c4[0].files.includes('platform_identity_012_new.sql'),
271
+ 'and the new file is named in the violation');
272
+
273
+ // …but a DIFFERENT number in the same module is not exempt — the grandfather is not
274
+ // a blanket pass for platform-identity.
275
+ const other = [
276
+ { source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_a.sql' },
277
+ { source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_b.sql' },
278
+ ];
279
+ assert.equal(ns.numberCollisions(other).length, 1, '013 in the same module still fails');
280
+ });
281
+
282
+ t('numberCollisions: non-migration filenames are ignored', () => {
283
+ const files = [
284
+ { source: 'module', moduleKey: 'economy', basename: 'README.md' },
285
+ { source: 'module', moduleKey: 'economy', basename: '.keep' },
286
+ { source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
287
+ ];
288
+ assert.deepEqual(ns.numberCollisions(files), []);
289
+ });
290
+
153
291
  // ---- nextInNamespace / lineageReport ---------------------------------------
154
292
  t('nextInNamespace: counts bare AND prefixed forms (no number reuse)', () => {
155
293
  assert.equal(ns.nextInNamespace(['003_a.sql', 'core_188_b.sql', 'core_190_c.sql']), 191,
@@ -0,0 +1,245 @@
1
+ // tests/quarantine_canonical.mjs — containment compares WHERE a path points, not how it
2
+ // is spelled (task 1003848).
3
+ //
4
+ // Windows hands out 8.3 short names: os.tmpdir() here is C:\Users\GMK1_P~1\AppData\Local\Temp
5
+ // while the same directory reports as C:\Users\GMK1_ppl51\... elsewhere, and path.resolve
6
+ // does not reconcile them. fs.realpathSync.native expands 8.3; plain fs.realpathSync does NOT.
7
+ //
8
+ // This is not only a test bug. siteRefusal() asks isInside(REPO_ROOT, site) to refuse a
9
+ // quarantine site inside the repository — so on the one platform that hands out two
10
+ // spellings, that refusal was evadable by spelling.
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const q = require('../modules/security/artifact-scan/quarantine.js');
21
+
22
+ const SHORT = os.tmpdir();
23
+ const LONG = fs.realpathSync.native(SHORT);
24
+
25
+ test('canonical expands an 8.3 short name — plain realpathSync does not', () => {
26
+ assert.equal(q.canonical(SHORT), LONG);
27
+ // The premise, stated so this test explains itself on a machine where it does not apply:
28
+ // if the two spellings are identical here, the rest is trivially true rather than wrong.
29
+ if (SHORT === LONG) return;
30
+ assert.notEqual(fs.realpathSync(SHORT), LONG, 'this is the fact the obvious fix gets wrong');
31
+ });
32
+
33
+ test('the two spellings of ONE directory contain each other', () => {
34
+ assert.equal(q.isInside(SHORT, path.join(LONG, 'x')), true);
35
+ assert.equal(q.isInside(LONG, path.join(SHORT, 'x')), true);
36
+ });
37
+
38
+ test('a genuinely outside path is still outside — the fix narrows nothing', () => {
39
+ assert.equal(q.isInside(LONG, path.resolve('/definitely/elsewhere')), false);
40
+ // and a sibling whose name merely starts with the root is not inside it: this is what
41
+ // a String.startsWith containment check gets wrong, which is how security/mirror failed.
42
+ assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
43
+ });
44
+
45
+ test('a path that does not exist yet still canonicalises via its nearest real ancestor', () => {
46
+ // The ordinary case: a derived destination is validated BEFORE it is written.
47
+ const notYet = path.join(SHORT, 'no-such-dir-1003848', 'deep', 'leaf.txt');
48
+ assert.equal(q.canonical(notYet), path.join(LONG, 'no-such-dir-1003848', 'deep', 'leaf.txt'));
49
+ assert.equal(q.isInside(SHORT, notYet), true);
50
+ });
51
+
52
+ test('a fully nonexistent absolute path degrades to the lexical answer rather than throwing', () => {
53
+ const nowhere = path.resolve('/no-such-root-1003848/a/b');
54
+ assert.equal(typeof q.canonical(nowhere), 'string');
55
+ assert.equal(q.isInside(path.resolve('/no-such-root-1003848'), nowhere), true);
56
+ });
57
+
58
+ test('canonical is MEMOISED for paths that EXIST — the hot-loop case', () => {
59
+ // mirror.js's header is explicit about staying cheap; an unmemoised realpath here would
60
+ // put ~20,000 sync syscalls on its 5,000-file loop.
61
+ //
62
+ // BOTH OPERANDS MUST EXIST. Only an EXACT resolution is cacheable (see canonical()):
63
+ // an answer reached by walking up to the nearest existing ancestor is deliberately
64
+ // NOT stored, because the leaf can still be created as a link that escapes. So a
65
+ // not-yet-existing path re-reads every call BY DESIGN, and asserting memoisation
66
+ // against one asserts the opposite of the rule.
67
+ //
68
+ // The first version of this test used <tmpdir>/x and passed only because a stray
69
+ // file of that name happened to exist on the author's box; on CI it did not, the
70
+ // walk-up path was taken, and 50 iterations cost ~100 syscalls instead of 2.
71
+ const dir = fs.mkdtempSync(path.join(LONG, 'memo-'));
72
+ try {
73
+ q.clearCanonicalCache();
74
+ const orig = fs.realpathSync.native;
75
+ let calls = 0;
76
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
77
+ try {
78
+ for (let i = 0; i < 50; i += 1) q.isInside(SHORT, dir);
79
+ } finally {
80
+ fs.realpathSync.native = orig;
81
+ }
82
+ assert.ok(calls <= 4, `50 containment checks over existing paths should not cost ${calls} realpath syscalls`);
83
+ } finally {
84
+ fs.rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test('a path that does NOT exist is re-read every time — the cache must not answer for it', () => {
89
+ // The companion rule, and a security property rather than a performance one: a
90
+ // walked-up answer is provisional. Cache it and creating the leaf as an escaping
91
+ // link would keep serving "inside" forever. Pinning it here stops a future
92
+ // optimisation from caching provisional answers to make the test above cheaper.
93
+ const notYet = path.join(LONG, 'no-such-memo-1003848', 'leaf');
94
+ q.clearCanonicalCache();
95
+ const orig = fs.realpathSync.native;
96
+ let calls = 0;
97
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
98
+ try {
99
+ q.canonical(notYet);
100
+ q.canonical(notYet);
101
+ } finally {
102
+ fs.realpathSync.native = orig;
103
+ }
104
+ assert.ok(calls >= 4, `two lookups of a non-existent path must both hit the filesystem, saw ${calls}`);
105
+ });
106
+
107
+ test('a path that does NOT exist yet is never cached — the escape must stay visible', () => {
108
+ // THE SECURITY PROPERTY, and the one the first pass of this fix got wrong.
109
+ //
110
+ // A derived destination is canonicalised BEFORE it is written, so its answer comes from
111
+ // walking up to the nearest existing ancestor. That answer is true only until the path
112
+ // is written. Cache it and the window never closes: create the path as a symlink (a
113
+ // junction on Windows) pointing out of the pen and isInside() keeps returning the
114
+ // pre-creation verdict — INSIDE — so mirror.js's path_escape refusal is silently lost.
115
+ // The containment check this file exists to harden is then evadable one layer down, by
116
+ // its own memoisation.
117
+ //
118
+ // Asserted as the MECHANISM rather than by building a symlink, deliberately: creating
119
+ // one needs privileges this suite does not have on Windows, so a symlink-shaped test
120
+ // would skip on the very platform whose 8.3 spellings motivated the whole change. The
121
+ // mechanism is platform-free — a missing path must cost a fresh filesystem read every
122
+ // time — and modules/security/tests/mirror.mjs covers the real symlink end-to-end
123
+ // wherever symlinks can be made.
124
+ q.clearCanonicalCache();
125
+ const missing = path.join(SHORT, 'not-written-yet-1003848', 'leaf.txt');
126
+
127
+ const orig = fs.realpathSync.native;
128
+ let calls = 0;
129
+ fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
130
+ try {
131
+ q.canonical(missing);
132
+ const afterFirst = calls;
133
+ q.canonical(missing);
134
+ assert.ok(calls > afterFirst,
135
+ 'a non-existent path must be re-read, not served from cache — otherwise a symlink '
136
+ + 'created there afterwards keeps the stale INSIDE verdict');
137
+ } finally {
138
+ fs.realpathSync.native = orig;
139
+ }
140
+ });
141
+
142
+ test('an EXISTING directory is still cached — the fix keeps the memoisation that mattered', () => {
143
+ // The counterweight to the test above: mirror.js's hot loop canonicalises real
144
+ // directories over and over, and those answers are stable (a directory cannot move
145
+ // under a running scan). Only the provisional ones are re-read.
146
+ q.clearCanonicalCache();
147
+ q.canonical(SHORT);
148
+ const orig = fs.realpathSync.native;
149
+ let calls = 0;
150
+ fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
151
+ try {
152
+ for (let i = 0; i < 20; i += 1) q.canonical(SHORT);
153
+ } finally {
154
+ fs.realpathSync.native = orig;
155
+ }
156
+ assert.equal(calls, 0, 'an existing directory resolved once must not be resolved again');
157
+ });
158
+
159
+ test('clearing the cache makes it read the filesystem again', () => {
160
+ q.clearCanonicalCache();
161
+ const orig = fs.realpathSync.native;
162
+ let calls = 0;
163
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
164
+ try { q.canonical(SHORT); } finally { fs.realpathSync.native = orig; }
165
+ assert.ok(calls >= 1, 'a cleared cache must not keep answering from memory');
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // LEXICAL vs PHYSICAL containment (task 1003848, grade round 2).
170
+ //
171
+ // sanitize.js runs three confinement arms in order, each with its own refusal:
172
+ // lexical (does the manifest path climb out?), then physical (does it RESOLVE
173
+ // out?), then lstat (is it a link the manifest called a file?). Canonicalising
174
+ // the lexical arm made it follow the link and fire first, pre-empting the other
175
+ // two — the artifact was still refused, but with the wrong diagnosis, and the two
176
+ // tests pinning those messages went red.
177
+ //
178
+ // The existing cases live in modules/security/tests/sanitize_rebuild.mjs and are
179
+ // SKIPPED wherever symlinks cannot be created — which is this platform, so that
180
+ // regression was invisible locally and only CI saw it. This case therefore falls
181
+ // back to a DIRECTORY JUNCTION, which Windows allows without elevation, so the
182
+ // divergence is pinned on both platforms.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const linkKind = (() => {
186
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'linkprobe-'));
187
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'linktarget-'));
188
+ try {
189
+ for (const kind of ['dir', 'junction']) {
190
+ try { fs.symlinkSync(target, path.join(probe, `l-${kind}`), kind); return kind; }
191
+ catch { /* try the next form */ }
192
+ }
193
+ return null;
194
+ } finally {
195
+ fs.rmSync(probe, { recursive: true, force: true });
196
+ fs.rmSync(target, { recursive: true, force: true });
197
+ }
198
+ })();
199
+
200
+ test('a link that escapes is LEXICALLY inside but PHYSICALLY outside', { skip: linkKind ? false : 'this platform cannot create links' }, () => {
201
+ const pen = fs.mkdtempSync(path.join(os.tmpdir(), 'pen-'));
202
+ const pristine = path.join(pen, 'pristine');
203
+ fs.mkdirSync(pristine);
204
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
205
+ try {
206
+ const link = path.join(pristine, 'escape');
207
+ fs.symlinkSync(outside, link, linkKind);
208
+ q.clearCanonicalCache();
209
+
210
+ // The lexical arm must NOT fire: nothing in the SPELLING of this path climbs
211
+ // out. If it fired, sanitize.js would report "escapes the pristine tree" and
212
+ // never reach the two refusals that describe what is actually wrong.
213
+ assert.equal(q.isInsideLexical(pristine, link), true);
214
+
215
+ // The physical arm is what catches it — this is the containment that matters.
216
+ assert.equal(q.isInside(pristine, link), false);
217
+ } finally {
218
+ fs.rmSync(pen, { recursive: true, force: true });
219
+ fs.rmSync(outside, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ test('on ordinary paths the two predicates agree — the split narrows nothing', () => {
224
+ const inside = path.join(LONG, 'a', 'b');
225
+ assert.equal(q.isInsideLexical(LONG, inside), q.isInside(LONG, inside));
226
+ assert.equal(q.isInsideLexical(LONG, inside), true);
227
+ const climbs = path.join(LONG, '..', 'elsewhere');
228
+ assert.equal(q.isInsideLexical(LONG, climbs), false);
229
+ assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
230
+ assert.equal(q.isInsideLexical(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
231
+ });
232
+
233
+ test('the lexical predicate never touches the filesystem', () => {
234
+ // The whole point: its answer must not depend on what the leaf points at right
235
+ // now. A realpath call here would reintroduce exactly the coupling being removed.
236
+ const orig = fs.realpathSync.native;
237
+ let calls = 0;
238
+ fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
239
+ try {
240
+ q.isInsideLexical(LONG, path.join(LONG, 'whatever', 'deep.txt'));
241
+ } finally {
242
+ fs.realpathSync.native = orig;
243
+ }
244
+ assert.equal(calls, 0, 'isInsideLexical must be pure path arithmetic');
245
+ });
@@ -13,6 +13,7 @@
13
13
  // Run: node tests/search_index_poller.mjs
14
14
 
15
15
  import { strict as assert } from 'node:assert';
16
+ import path from 'node:path';
16
17
  import { createRequire } from 'node:module';
17
18
 
18
19
  const require = createRequire(import.meta.url);
@@ -104,7 +105,11 @@ function fakePool(seq) {
104
105
  await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
105
106
  const called = [];
106
107
  const exec = (_p, args, _o, cb) => {
107
- called.push(args[0].split('/').pop());
108
+ // path.basename, not split('/') — the sweep passes an ABSOLUTE path, and on
109
+ // Windows that is backslash-separated, so splitting on a forward slash returned
110
+ // the whole path and this assertion compared a path against a basename (task
111
+ // 1003848). The production code was never wrong; the test helper was.
112
+ called.push(path.basename(args[0]));
108
113
  cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
109
114
  };
110
115
  const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
@@ -161,6 +161,14 @@ const client = await dbReachable();
161
161
  if (!client) {
162
162
  console.log(' SKIP no reachable Postgres — Layer C runs in the integration gate / on a dev box.');
163
163
  console.log(' (The same scoping cases were proven on the prod DB in a rolled-back transaction at ship time.)');
164
+ // END THE POOL ON THE SKIP PATH TOO (task 1003848). Only the else-branch below
165
+ // ended it, so when Postgres was unreachable the Pool survived with libuv handles
166
+ // still closing from its failed connect — and the process.exit() at the bottom of
167
+ // this file then tore down the loop underneath them. On Windows that surfaces as
168
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`, exit
169
+ // 0xC0000409: a process-teardown crash AFTER '5 passed, 0 failed', which reads as
170
+ // a failing suite in the unit gate while every assertion actually passed.
171
+ if (pool) await pool.end().catch(() => {});
164
172
  } else {
165
173
  try {
166
174
  await client.query('BEGIN');
@@ -204,4 +212,11 @@ if (!client) {
204
212
  }
205
213
 
206
214
  console.log(`\n${passed} passed, ${failed} failed`);
207
- process.exit(failed === 0 ? 0 : 1);
215
+ // EXIT CODE, NOT process.exit() (task 1003848). A hard exit tears the event loop down
216
+ // underneath any handle that is still closing — and pg leaves one behind after a
217
+ // FAILED connect, which is exactly the skip path. On Windows that surfaced as
218
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`
219
+ // and exit 0xC0000409 AFTER "5 passed, 0 failed" — a process-teardown crash that the
220
+ // unit gate can only read as a failing suite. Setting exitCode lets node drain the
221
+ // handles and then exit with the same status.
222
+ process.exitCode = failed === 0 ? 0 : 1;