@bongos/core 1.19.431 → 1.19.432

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.431",
6
- "core_contract": "1.19.431",
7
- "source_commit": "4fd9f0748403564b999e3e0459d44c169c751d4b",
5
+ "core_version": "1.19.432",
6
+ "core_contract": "1.19.432",
7
+ "source_commit": "4eb69cf372f019d1c3948b76a4622fcd52d1f366",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-29T21:48:51.014Z",
9
+ "built_at": "2026-08-29T22:45:12.145Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 365,
12
+ "docs_redacted": 366,
13
13
  "agent_docs_stubbed": 23,
14
14
  "functional_verbatim": 1740,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2128,
20
- "tree_sha256": "bc4e3ea3fba769c809456cccf0abb0abcc8d513d047083307f7431afbc38f780",
19
+ "file_count": 2129,
20
+ "tree_sha256": "d3a5f9d13efe32c537eb55d7798e144187ea1f6ca7d55d178e93f76265fe0a0e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1574,10 +1574,15 @@
1574
1574
  "mode": "0000644",
1575
1575
  "sha256": "8c248b7e041f21da26ef55cb79c8bcaf20713d3e271ea7ee0de851d452c19e58"
1576
1576
  },
1577
+ {
1578
+ "path": "docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md",
1579
+ "mode": "0000644",
1580
+ "sha256": "779df4072b2096f1f6a106013a1600e694908c009f4608a9b6a86a82cf225d7d"
1581
+ },
1577
1582
  {
1578
1583
  "path": "docs/adr/README.md",
1579
1584
  "mode": "0000644",
1580
- "sha256": "5cf8b81ec623dd3614683c2114c1fee38ddc5c3a1bf73e2c69ac4fe8ef79ff25"
1585
+ "sha256": "ce67295dd5d29df98dcb2fedc1998d980a575e11a46fb3fb9b78f56b98660db7"
1581
1586
  },
1582
1587
  {
1583
1588
  "path": "docs/api-reference.md",
@@ -1977,7 +1982,7 @@
1977
1982
  {
1978
1983
  "path": "docs/module-api-changelog.md",
1979
1984
  "mode": "0000644",
1980
- "sha256": "bd4bbd81fbdd0cbffbcc882bb8bd876caf4aacc863a0e4ae3b331ee8133053e6"
1985
+ "sha256": "d05842c9eadbfddf0b3d29e0ccb27cabd6d9416bb2d3a86cf40717fde68a2314"
1981
1986
  },
1982
1987
  {
1983
1988
  "path": "docs/modules-contract.md",
@@ -5587,12 +5592,12 @@
5587
5592
  {
5588
5593
  "path": "package-lock.json",
5589
5594
  "mode": "0000644",
5590
- "sha256": "bf42d45381a85e4f5794f08f0deaf9e7baee6787599cbb0f034153c03e06affb"
5595
+ "sha256": "600722d9ed042b5ca38ffcf70b4db65df50e730d58a717a65a86334d83cb7a62"
5591
5596
  },
5592
5597
  {
5593
5598
  "path": "package.json",
5594
5599
  "mode": "0000644",
5595
- "sha256": "b2c2d95bd1b8ef2a9d5c6dfa42362827ddf2e50beddbc8765460885ecabd4cf5"
5600
+ "sha256": "d6ec836d3cf795c5aa9c7f84fff005ce337e30bcf5855fc99875437e76e851b9"
5596
5601
  },
5597
5602
  {
5598
5603
  "path": "public-docs/index.html",
@@ -7722,7 +7727,7 @@
7722
7727
  {
7723
7728
  "path": "src/module-api.js",
7724
7729
  "mode": "0000644",
7725
- "sha256": "a5f2a1e430a0dc50156d93a88ed4bd1b6c6bc232eed2ea1aab4ae017ee768777"
7730
+ "sha256": "6838d7c37cb8446a2f2e8f5a0c672049a4ede70feea5f0f9de0d9386eef797c6"
7726
7731
  },
7727
7732
  {
7728
7733
  "path": "src/module-loader/catalog.js",
@@ -7862,12 +7867,12 @@
7862
7867
  {
7863
7868
  "path": "tests/application_echo.mjs",
7864
7869
  "mode": "0000644",
7865
- "sha256": "6272bf127a71c682b482353dc2f56e527f54a351805f8b5d7d66be66db111c25"
7870
+ "sha256": "f32263937f78ac7497fb940e1b7859151f7d794a729c5c9305b149f8677ad722"
7866
7871
  },
7867
7872
  {
7868
7873
  "path": "tests/application_lifecycle.mjs",
7869
7874
  "mode": "0000644",
7870
- "sha256": "ddf0dd72bda85e452485ed8869feb2fe39d0d230c6647a1573c5ea87efbee42b"
7875
+ "sha256": "ac8128aa0834f420ee744e057cd2baa01569611052d199dba9e5d28986ff1166"
7871
7876
  },
7872
7877
  {
7873
7878
  "path": "tests/architect.mjs",
@@ -8312,7 +8317,7 @@
8312
8317
  {
8313
8318
  "path": "tests/connections_ownership.mjs",
8314
8319
  "mode": "0000644",
8315
- "sha256": "58fa2cbe6866697402fc22037a4b615c642311f40c85e4b19555de55e7591eb1"
8320
+ "sha256": "ddd4a2dbd488b4455e1f227745cfd90456f8b4971e647d2be007e7783cc0a28c"
8316
8321
  },
8317
8322
  {
8318
8323
  "path": "tests/consumer_layout_boot.mjs",
@@ -8592,7 +8597,7 @@
8592
8597
  {
8593
8598
  "path": "tests/effective_visibility_predicate.mjs",
8594
8599
  "mode": "0000644",
8595
- "sha256": "88ff4b377f71785545e3aed908df70b505af40453f47695646a9f72bf7250ab0"
8600
+ "sha256": "17b7292dba01d25f160d2dd292661e5c236b3f390399e66bfab09124b551eb8f"
8596
8601
  },
8597
8602
  {
8598
8603
  "path": "tests/est_advisory.mjs",
@@ -9107,7 +9112,7 @@
9107
9112
  {
9108
9113
  "path": "tests/handle_machinery.mjs",
9109
9114
  "mode": "0000644",
9110
- "sha256": "a86e615af8b148572abf562b3c7b0d556076286e76e2d8d576f782a90ba3d457"
9115
+ "sha256": "209c1ebf3e57c6fed05eda28ef57033c7ed7da3e1a41670fc9aae05497b29698"
9111
9116
  },
9112
9117
  {
9113
9118
  "path": "tests/help_requests.mjs",
@@ -9142,7 +9147,7 @@
9142
9147
  {
9143
9148
  "path": "tests/hub_profile_leak.mjs",
9144
9149
  "mode": "0000644",
9145
- "sha256": "ea992d1ab2ddedc27aaa5348a397356bef91a4c1ad9b295eb1a0ed1c4fc707e9"
9150
+ "sha256": "e9609a8adad0c460c24db69211f02beef3699e3e841d44c1385105c4f5c93385"
9146
9151
  },
9147
9152
  {
9148
9153
  "path": "tests/hub_recent_ships.mjs",
@@ -9672,7 +9677,7 @@
9672
9677
  {
9673
9678
  "path": "tests/own_session_revoke.mjs",
9674
9679
  "mode": "0000644",
9675
- "sha256": "756920320b74c9094d1be12108efb8ddbb39341b8d122afd9454ffd084a2efe5"
9680
+ "sha256": "34ae2846e314ad4ba732a40257fb81f7b4e4be3e81e3b6d1f478c3bb0690f23d"
9676
9681
  },
9677
9682
  {
9678
9683
  "path": "tests/package_core.mjs",
@@ -9752,7 +9757,7 @@
9752
9757
  {
9753
9758
  "path": "tests/profile_connections_ui.mjs",
9754
9759
  "mode": "0000644",
9755
- "sha256": "1e6923437bcafbbb396d43202a4fe5c9d49dd9baef059d84aa2520bd64a195b6"
9760
+ "sha256": "6a5e6bd1fa52d8d757436efcfca19ffb5c2a7470f1a6583f26802a24689b4eb8"
9756
9761
  },
9757
9762
  {
9758
9763
  "path": "tests/profile_fields.mjs",
@@ -9767,12 +9772,12 @@
9767
9772
  {
9768
9773
  "path": "tests/profile_privacy_owner_view.mjs",
9769
9774
  "mode": "0000644",
9770
- "sha256": "75cc928587baac3151104c3ff4a1cfd39436fc7255f3afac818079724d6dbab1"
9775
+ "sha256": "f5e664627bf60830c241539cd001b5eebc55a5786c191154bdd6b6f3dbd745ed"
9771
9776
  },
9772
9777
  {
9773
9778
  "path": "tests/profile_rollup_consent.mjs",
9774
9779
  "mode": "0000644",
9775
- "sha256": "b926ffa5d4e96ef223e4057ecc3e64d678ca1e16ec4afe79cb90cbf3e35e65e0"
9780
+ "sha256": "130672f5f844f2552f98bdc3e7b30fa9d49dd460e81c02a60c5684db6617f804"
9776
9781
  },
9777
9782
  {
9778
9783
  "path": "tests/profile_route.mjs",
@@ -10062,7 +10067,7 @@
10062
10067
  {
10063
10068
  "path": "tests/scouting_owner_access_and_reach.mjs",
10064
10069
  "mode": "0000644",
10065
- "sha256": "db4aa5ab365d1d2e9884b2c5a3f2abc40942e123844b7d5434434ddce680fd13"
10070
+ "sha256": "db4f7dc8fc076c908bbb09b00672a8e3619ef6234d7a0574954b06637c989e10"
10066
10071
  },
10067
10072
  {
10068
10073
  "path": "tests/scouting_page_gate.mjs",
@@ -10072,7 +10077,7 @@
10072
10077
  {
10073
10078
  "path": "tests/scouting_rollup_fanout.mjs",
10074
10079
  "mode": "0000644",
10075
- "sha256": "3451cb25abd4eb819a7b951d1e06310d575faf6145808711fcdd2e2afb655ec3"
10080
+ "sha256": "dd40bf29dd83c4424cd23bfe3985381226898653f2edf4d883a5b32f108bafa3"
10076
10081
  },
10077
10082
  {
10078
10083
  "path": "tests/scrubber_corpus.mjs",
@@ -0,0 +1,102 @@
1
+ # ADR 0216 — A fake-pool interpreter must FAIL LOUDLY on what it cannot model; the refusal is uniform, the interpreters stay separate
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1003354](https://cloudbongos.com/builders#/task/1003354) (folding in idea 1000877, raised by the adversarial review of [task 1002968](https://cloudbongos.com/builders#/task/1002968))
6
+ - **Relates to:** [ADR 0202](<redacted>.md) D4 and [ADR 0213](<redacted>.md) (the predicates these interpreters stand in for), [ADR 0215](<redacted>.md) D7 (where the structure guard was first written), [ADR 0091](<redacted>.md) §2 (deliberate duplication across a boundary)
7
+
8
+ ## Context
9
+
10
+ Nine test files in this repo carry a **fake pool** — an in-memory Postgres substitute whose row filter is derived from *each statement's own `WHERE` text* rather than hand-written. That design is deliberate and good: it is what makes a proof like `tests/connections_ownership.mjs` say something about the real query instead of about a canned response. Delete `addressee_github_id = $2::bigint` from the real SQL and the fake genuinely stops applying it, so a stranger's row genuinely moves and the test genuinely reds.
11
+
12
+ The design has one failure mode, and it is silent: **a term the interpreter does not recognise is simply not applied.** A predicate the fake cannot read degrades to *no* predicate, the boundary becomes vacuous, and every assertion above it keeps passing. Each file already understood a piece of this and guarded it:
13
+
14
+ - an unread `$N` placeholder throws (all nine);
15
+ - a literal comparison on an unknown column throws (four);
16
+ - an unmodelled `IS [NOT] NULL` throws (two, added by [ADR 0213](<redacted>.md)).
17
+
18
+ Every one of those guards is a **term** guard, and three separate reviews found the same thing: the term list is not the predicate.
19
+
20
+ **The missing term.** ADR 0213 made consent two columns — `profile_state = 'active' AND terms_accepted_at IS NOT NULL`. `tests/scouting_owner_access_and_reach.mjs` modelled the first and not the second. A `NULL` test is a *third syntax*: the placeholder sweep scans `$N`, the literal sweep matches `col = '…'`, and neither can see `IS NOT NULL` at all. So the term was dropped in silence and the suite reported **17 passed, 0 failed** while modelling a predicate the database no longer had — inside a file whose own header states that exact failure is impossible.
21
+
22
+ **The rewritten joiner.** The adversarial review of task 1002968 found the deeper one. An interpreter that COLLECTS the terms it recognises and `AND`s them **re-imposes an `AND` the SQL may no longer contain.** Rewrite a predicate's `AND` to `OR` and Postgres serves every *active* account on the platform — private ones included — or lets any builder revoke any session by id; the term-collecting fake answers exactly as it did before, and every isolation and 404 assertion in the file stays green. Task 1002968 fixed the three copies it owned and filed the rest as idea 1000877.
23
+
24
+ **Recognition is not application.** ADR 0213's own first draft checked only that the column *name* was recognised, and a mutation deleting the filter still passed. It needed a second set to prove the term had been turned into a filter — which is bookkeeping that can itself go stale.
25
+
26
+ The common shape of all three is one sentence: **the interpreter silently answered a question it was not asked.**
27
+
28
+ ## Decision
29
+
30
+ ### D1 — The governing rule: an interpreter that cannot model a term must REFUSE the statement, never ignore the term
31
+
32
+ This is the rule the fake-pool pattern always implied and never stated. It is not "model every term" — a test fake is allowed to be incomplete. It is that **incompleteness must be loud.** A statement carrying anything this interpreter cannot faithfully reproduce is an error thrown at the test, not a filter quietly dropped from the answer.
33
+
34
+ Refusing is safe in a way ignoring is not, and the asymmetry is the whole argument: a refusal fails the build *today*, in the session that changed the SQL, with the unreadable text in the message. A silent drop fails nothing, ever, and the proof above it becomes decorative without anyone learning that it did.
35
+
36
+ ### D2 — The mechanism is a CONSUMING parse with a pure-conjunction residue check
37
+
38
+ Term guards cannot express D1, because a guard has to know a term exists before it can complain about it — and the whole problem is the terms nobody thought of. So the interpreter is inverted: instead of listing what it recognises, it accounts for **everything the clause contains**.
39
+
40
+ Each recognised term is matched, modelled, and **spliced out of the text**. Whatever survives is exactly the part the interpreter did not understand, and it must be nothing but whitespace, parentheses and `AND`:
41
+
42
+ ```js
43
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) throw new Error(`fake pool: unmodelled boolean structure …`);
44
+ ```
45
+
46
+ That single check subsumes every term guard that came before it and closes all three defects at once. An unmodelled `IS NOT NULL` survives in the residue. An `OR`, a `NOT`, a `CASE` survives in the residue. And **recognition can no longer come apart from application** — a term the interpreter recognised but did not apply is, by construction, still there — which retires ADR 0213's `modelled`-set bookkeeping rather than maintaining it.
47
+
48
+ A legitimate `OR` — the handle rename window, connections' either-side clause, the session-expiry disjunction, the rescind transition guard — is consumed as a **whole unit**, which is both how it survives the check and how it stays honest: a matcher that tore one in half would leave a dangling `OR` and be refused.
49
+
50
+ Two term guards are kept in front of the structure guard, not because they add coverage but because they add a **better error**: an unreadable `$N` or an unknown column is reported as the thing it is, rather than as leftover text.
51
+
52
+ ### D3 — The interpreters stay SEPARATE; only the refusal is uniform
53
+
54
+ Nine copies of nearly-identical machinery is the shape that normally demands extraction, and it was considered and rejected. Task 1002968 concluded these are independent by construction; this ADR agrees, with the reason written down:
55
+
56
+ **What differs is the term vocabulary, and it differs completely.** These interpreters stand in for different tables — `platform_identity_accounts`, `platform_identity_connections`, `builder_sessions`, `access_requests`, `platform_identity_application_echoes`, `<redacted>` — and share almost no term. A shared registry would have to accept every table's vocabulary, which makes it a small SQL engine: more code than the copies it replaces, harder to reason about, and a new place for a bug to hide *behind* the guard rather than in front of it. It would also couple unrelated proofs — a session-revoke test would fail to a change in a connections matcher.
57
+
58
+ **What must never differ is the refusal**, and that is three lines and one regex. So the discipline is: identical guard, identical error message, in every copy — and each copy carries its **own calibration test** pinning the four refusals (unknown literal, unread placeholder, unmodelled `NULL` test, `OR`) plus a `doesNotThrow` over the real composed predicate, so the guard is a filter on the unreadable and not a blanket refusal.
59
+
60
+ That per-file calibration is the anti-drift mechanism a shared helper would otherwise have provided, and it is strictly better at the job: a shared helper proves the guard exists *somewhere*; a calibration test in each file proves the guard is *reached* by that file's own statements.
61
+
62
+ ### D4 — A modelled term proves nothing until a fixture makes it observable
63
+
64
+ The corollary, learned twice in one session. Modelling `terms_accepted_at IS NOT NULL` while every fixture carries an acceptance is a filter that filters nothing: delete the term from the real predicate and the answer does not change, so the mutation does not bite and the proof is cosmetic.
65
+
66
+ Every axis a predicate carries therefore needs **one fixture that differs on that axis alone**. Three were added here — a pre-terms account in the scouting directory and in the real-DB rollup proof, and a pending membership in the application echo — each differing from the control in exactly one column, so the mutation that deletes the term has somewhere to land.
67
+
68
+ Where a file deliberately does *not* carry such a fixture because another file owns that matrix, the comment says so. An absence that is a decision reads differently from an absence that is an oversight, and only the first survives the next reader.
69
+
70
+ ### D5 — Every copy is verified BY MUTATION, against the real source
71
+
72
+ No copy was accepted on green. For each, a term was deleted or an `AND` rewritten to `OR` **in the real module**, the file was required to go red, and the source was reverted. Ten mutations, ten reds, every one of them a 0-failure pass before this work:
73
+
74
+ | mutation (in real source, reverted) | file | before → after |
75
+ |---|---|---|
76
+ | `accountActiveSql`: delete the acceptance term | `scouting_owner_access_and_reach` | 0 → 2 failed |
77
+ | `accountVisibleSql`: `AND` → `OR` | `scouting_owner_access_and_reach` | 0 → 10 failed |
78
+ | `accountActiveSql`: delete the acceptance term | `effective_visibility_predicate` | 0 → 2 failed |
79
+ | `accountVisibleSql`: `AND` → `OR` | `effective_visibility_predicate` | 0 → 8 failed |
80
+ | `accountVisibleSql`: `AND` → `OR` | `profile_privacy_owner_view` | 0 → 3 failed |
81
+ | `listRecentApplications`: `AND` → `OR` | `application_echo` | 0 → 1 failed |
82
+ | `listMembershipsForAccount`: `AND` → `OR` | `application_echo` | 0 → 6 failed |
83
+ | `listMembershipsForAccount`: delete the pending term | `application_echo` | 0 → 3 failed |
84
+ | `acceptConnection`: `AND` → `OR` | `connections_ownership` · `profile_connections_ui` | 0 → 4 · 0 → 1 failed |
85
+ | `revokeSessionForBuilder`: `AND` → `OR` | `own_session_revoke` | 0 → 4 failed |
86
+ | `listActiveSessionsForBuilder`: delete `revoked_at IS NULL` | `own_session_revoke` | 0 → 2 failed |
87
+
88
+ ## Consequences
89
+
90
+ - **Nine fake pools now refuse what they cannot read.** `tests/recruiter_sliver_boundary.mjs` (where the pattern originated), `effective_visibility_predicate`, `profile_privacy_owner_view`, `scouting_owner_access_and_reach` (both of its filters), `connections_ownership`, `profile_connections_ui`, `own_session_revoke`, `application_echo`, `application_lifecycle`. `canonical_profile_url` is deliberately untouched: its residue must be **empty**, which is strictly stronger, so it already refuses an `OR`.
91
+ - **Adding a term to a shared predicate now BREAKS the tests that model it**, loudly, in the session that adds it — instead of being silently absorbed. That is the intended cost and the entire point: ADR 0213 tightened `accountActiveSql` and five proofs absorbed the change without noticing.
92
+ - **`own_session_revoke.mjs` had no calibration test at all** and three silently-permissive paths, including a clause with no readable term yielding an empty filter that matched every row. On a table where the predicate *is* the own-scope boundary.
93
+ - **`application_echo.mjs` was deleting a term before interpreting it** — `membership_kind <> 'pending'` was `.replace`d out of the clause, so dropping it from the real query would have been invisible (the replace would simply no-op). Stripping a term you do not want to model is the same defect as ignoring one; the term is modelled now.
94
+ - **A residue check must strip the statement's tail** (`ORDER BY`, `RETURNING`, `LIMIT`) and consume table aliases, or it refuses correct statements. Both were found by the guard failing honestly on real SQL, which is the guard working.
95
+
96
+ ## Alternatives rejected
97
+
98
+ - **One shared interpreter module.** D3. The vocabulary is per-table and shares almost nothing; the shared thing would be a small SQL engine, and it would couple unrelated proofs.
99
+ - **A fitness check asserting every fake pool carries the guard.** Tempting, and it would catch a *new* copy written without one. Rejected for now: it would have to identify "a fake pool" by heuristic, and a heuristic gate over test internals is a maintenance surface with a false-positive cost. The per-file calibration tests hold the copies that exist; a tenth copy written without one is a review question. Recorded here as the known gap rather than papered over.
100
+ - **Keeping the `KNOWN_NULL_TESTS` + `modelled` pair alongside the structure guard.** Strictly weaker and now redundant: an unapplied term is still in the residue. Two sets to keep in sync, for nothing.
101
+ - **Parsing the boolean tree properly.** A real parser would let the fake *model* an `OR` rather than refuse it. That is a worse trade for a test double: refusing is a one-line invariant, and no statement in this repo needs a disjunction the unit-consumption approach cannot handle.
102
+ - **Accepting green as verification.** The failure this ADR exists for is invisible to a passing suite, by definition. D5.
@@ -302,5 +302,6 @@ This keeps the decision history honest and traceable.
302
302
  | 0210 | [**The recruiting directory graduates on an instance-asserted ownership claim, and the recruiting opt-out gates the directory and nothing else** ([task 1002290](https://cloudbongos.com/builders#/task/1002290), R14, goal 1000045 criterion C5 — "project owners, not only Archons, browse builder profiles to recruit"). Two independent problems wore one task id. The reach flag `recruiter_discoverable` protected nobody — task 1002967 said so, and the Settings row stayed in the future tense because of it. And "widen the Archon-only /scouting" was already stale (ADR 0157 lowered `project.curate` to metic) while hiding the real question: **a project owner is not a rank**, and someone who registers a project is an Archon on their OWN instance and usually a bare `xenos` on the hub where the directory lives, so no floor reaches them without reaching every stranger. **D1** "project owner" on the hub is `<redacted>.membership_kind = 'owner'` — written by the PROJECT itself at `POST /sso/membership/check-in` from its own first-admin config, and refused unless the hub's own `signin_verified_at` witness stands (ADR 0205) — and never `platform_identity_projects.owner_github_id`, the hub-curated column ADR 0201 D3 names and forbids; the prohibition is about a hub-held list consulted before an ADMISSION on someone else's box, and this is the hub reading the instance's own mirrored claim before a read of the hub's OWN directory. **D2** the two doors are an OR in `modules/platform-identity/scouting-authz.js` (the `goal-authz.js` owner-or-rank shape, kept out of `platform-identity.js` at its size ratchet): ownership is asked first and a "no" falls through to the UNMODIFIED `requirePermission`, so a refusal keeps its exact code, `required`/`held` and floors map — the widening can only admit; the atom is spelled in the ROUTE's middleware list, because a gate hidden in the helper would make `route-rank-check.js` and `gen-api-docs.js` record this privileged route as `any-builder`; and a broken ownership read falls closed onto the rank door rather than admitting or 500ing. No atom is minted — `project.recruit` belongs to the console wave (spike 1002786 R3). **D3** `PAGE_GATES` gains an optional `alsoAdmits` naming a function on the `platform-identity.hub` port, declared on `requireProjectCuratePage` alone and consulted only after the permission check refused, so the `/people` shell admits exactly the two audiences its data route does; absent port, absent function or a throw all deny. **D4** the reach COALESCE gets its SQL twin in `account-visibility.js` (its sole owner) and composes into `scoutingListableSql` = rollup AND reach, which `listScoutingRollup` joins — but reach is deliberately NOT folded into `rollupVisibleSql`, because the community spec D3 says the opt-out governs the recruiting surface only and folding it down would silently empty a future public leaderboard. **D5** no migration: both columns exist and migration 003's `(github_id)` index already serves the probe. **D6** the ownership query JOINs `platform_identity_sso_clients` on `status='active'` — a membership row never expires and nothing ages it out, so suspending the client (the lever that already stops a project signing anyone in) had to revoke the read it derives, or suspending a rogue project would leave its owner reading every builder on the platform forever. The task's own adversarial review also found the sole-owner drift guard was matching only the UNALIASED spelling of the reach COALESCE — i.e. not the one that ships — and two fail-open defaults (a prototype-reachable `alsoAdmits`, and an alias guard resting on template evaluation order); all three fixed and mutation-checked. ⚠ Honest limits recorded, not fixed: the hall NAV still hides People from a project owner (task 1003349, sequenced with R15 which owns that surface), the `touchMembership` write path stays as ADR 0205 left it, and `openapi.json` cannot express "or owner". ⚠ And DELEGATES do not reach the directory yet (blocker 1000122 + task 1003350, linked) — an instance reports `owner` for its first admin only, so the registrant half works and the delegate half needs the instance to report `owner` for any archon, which changes a cross-version wire contract and a shipped bucketing; filed as blocker 1000122, and nothing here changes when it is answered. Rejected: lowering the floor further (a floor low enough to admit a registrant excludes nobody); a `project.owner` atom (ADR 0201 D3 rejected it); minting `project.recruit` here; gating on `owner_github_id`; folding reach into the rollup predicate; widening `membershipKind()` on our own judgement; shipping the route without the page.](<redacted>.md) | platform identity / authz + privacy |
303
303
  | 0215 | [**The pre-accept recruiter sliver is a CEILING, not a step: a whitelist keyed by consent-and-reach, on the recruiting surface’s own door** ([task 1002968](https://cloudbongos.com/builders#/task/1002968), PV3 · goal 1000045 criteria C2/C5; privacy spec D3, owner-signed 2026-08-16). Privacy spec D2 has a row nothing could serve — **private · discoverable** — and ADR 0210 D4 named the reason: *“the full rollup is never its disclosure — the D3 sliver is”*. Decisions: **D1** the audience predicate is `recruiterSliverVisibleSql` = `accountActiveSql AND recruiterReachSql`, composed in `account-visibility.js` (its sole owner) and the ONE composed answer that deliberately **omits the existence axis** — ANDing `accountVisibleSql` would empty the sliver of exactly the account it serves; dropping it is safe only because reach is `COALESCE(recruiter_discoverable, visibility=’public’)`, so a private account reaches this surface only by its owner’s explicit opt-IN — and, after adversarial review, that opt-IN must be the one they MEANT: `setAccountPrivacy` now resets `recruiter_discoverable` to NULL when a PATCH turns an account private without naming reach, because a `true` set while PUBLIC meant “recruiters may see my public profile” and this predicate would have activated that stale consent silently. `profile_state=’active’` still floors it (a provisional account is never recruiter-visible) and `hide_stats` is absent, because hide changes the sliver’s SHAPE, never who appears in it; the guard PINS that existence-less predicate to its one reader, since a future public read joining it would look adopted while serving private accounts. **D2** the projection is a literal whitelist — `handle`, `display_name`, `avatar_url`, `disciplines`, `active_recently`, and under `hide_stats` the last two are **absent, not null**, with the activity query never issued at all (the cheapest way for a later edit to leak a number is for it to already be in hand). `last_active` is compared to the 30-day window IN SQL and only the boolean returns, so the date never enters the process; `github_id`/`github_login` are never KEYS, every count and any profile URL stay out — with one limit stated rather than glossed: the avatar D3 grants is GitHub’s own URL and embeds the account id, so the proof pins WHERE the id may appear instead of claiming an absence the code does not deliver. **D3** the handle is an ADDRESS, not a door — `GET /profiles/:handle` joins `accountVisibleSql` and 404s for the very account whose sliver was just read (asserted, with a 200 control), and a PREVIOUS handle resolves to nothing here unlike the public page’s 301, because a recruiting read has no shared links to preserve and a redirect would confirm a rename. **D4** one route, `GET /scouting/:handle`, beside the directory in `routes/scouting.js` but deliberately NOT sharing its gate: **no `project.recruit` atom is minted** (ADR 0201 D3 and ADR 0210 both reserve it for the console wave that defines it), and until it exists the sliver holds spec D1’s declared floor for that atom — `api.requireRank('archon')`. The first draft reused the directory’s door and that was WIDER than the owner-signed floor on both halves (`project.curate` at metic, OR any project owner — typically a bare xenos), on the first surface that discloses a PRIVATE account; narrowing after people are disclosed is not recoverable, widening later is. The audience question is the owner’s and is FILED, not taken — blocker 1000126, with the C5 tension it carries and a one-line swap either way. 404 never 403 (a 403 confirms the handle), `no-store` (ADR 0171 D5). **D5** it grants nothing (ADR 0016): the module is required by exactly ONE first-party server file — the route that serializes it — enumerated from source, and no rank/role/permission/membership travels with it. **D6** the Settings copy widens back from “the People directory” (narrowed by task 1002290 while this was unbuilt) to name **both** recruiting surfaces — “both” and not “every”, because a guard asserts `routes/scouting.js` mounts exactly two routes and that both readers compose `recruiterReachSql`. The half that was actually WRONG was the grant itself: the copy promised a hidden account “your name and nothing else” while the sliver also ships the AVATAR, never named the picture in the shown branch, and never told a private account this is the one thing a recruiter sees while their page answers “not found”. The guard now iterates SLIVER_KEYS from the module instead of checking transcribed phrases, so a sixth granted field cannot ship undescribed. **D7** no migration; the port lives beside `getPublicProfileExtras` in the MODULE, not the file, which is on the 1500-line ratchet. The proof is a whitelist over the module’s own exported key list, planted improbable sentinels asserted absent from the whole body with controls against vacuity, and a fake pool that reads boolean STRUCTURE — the house interpreter collects terms and ANDs them, so rewriting this predicate’s `AND` to `OR` (which in Postgres serves EVERY active account) passed all three test files green until each term was made to be CONSUMED and the residue required to be pure conjunction. That hole is still open in five sibling proofs (idea 1000877). Four further defects came out of the same review: the credits/karma sentinels sat on rows the sliver never reads, a “control” compared a fixture to itself, and the one-reader pin missed the predicate’s JS twin. ⚠ Honest limits: **no UI consumes it yet** (the recruiting view is the console wave’s and task 1002972’s); **the audience is UNRESOLVED** (blocker 1000126) and the archon floor is narrower than goal C5 describes, so project owners cannot use the sliver today; the two-route guard counts only `routes/scouting.js`, so a recruiting read added elsewhere would not trip it; the route is unmetered (idea 1000876); and `active_recently` understates for a builder whose only project never reports a rollup. Rejected: flooring on `accountVisibleSql`; folding reach into `rollupVisibleSql`; minting `project.recruit`; **reusing the directory’s door**; leaving a stale `recruiter_discoverable = true` to read as consent after an account goes private; a second route file; null-instead-of-absent rollup keys; a profile URL / `github_login` / a project count / a raw `last_active`; 301-ing a previous handle; 403 for a non-discoverable builder.](<redacted>.md) | platform identity / privacy |
304
304
  | 0213 | [**The platform T&C acceptance is the hub's own witness, written once, and consent becomes two columns** ([task 1002913](https://cloudbongos.com/builders#/task/1002913), goal 1000045). ADR 0141 §4 deferred consent for cross-project aggregation to "the platform T&C" and ADR 0171 D3 wrote its own definition around the moment that acceptance "can later be captured" - but no acceptance record existed, and while `/terms` DOES exist (task 1003251) it says nothing about work crossing between projects, so the public-profile premise ("already carries platform T&C consent") was untrue for every account. The scouting.js:6 pointer the task named had also been overwritten by ADR 0210, so the debt was going invisible rather than getting paid. Decision: `terms_accepted_at` + `terms_version` on `platform_identity_accounts` (migration 019), written ONLY by `upsertAccount`'s `activate` branch - one caller, /sso/authorize, where `activate: true` is a source literal and every field comes from the hub's resolved session (the ADR 0205 D3 property; a record a caller could assert is evidence of nothing). COALESCE makes it write-once, so the column keeps meaning WHEN consent was given. The consent axis in `account-visibility.js` becomes `profile_state = 'active' AND terms_accepted_at IS NOT NULL`, composed into every other predicate, which is what makes the tie structural rather than a promise. NO backfill (D4): existing active accounts re-accept on their next hub sign-in - ADR 0171 D3's own rule and P03's ratified rationale - and that visible consequence, plus the wording itself, went to the owner on blocker 1000123. Rejected: an acceptances TABLE (no event stream yet), a click-through gate (an interstitial in a redirect built to be invisible; additive later), backfilling (records a consent that never happened), deriving profile_state from the timestamp, and putting the predicate in `terms-acceptance.js` (a second owner of an effective-visibility answer, which ADR 0202 D4 forbids).](<redacted>.md) | platform identity / consent / privacy |
305
+ | 0216 | [**A fake-pool interpreter must FAIL LOUDLY on what it cannot model; the refusal is uniform, the interpreters stay separate** ([task 1003354](https://cloudbongos.com/builders#/task/1003354), folding in idea 1000877 from the adversarial review of [task 1002968](https://cloudbongos.com/builders#/task/1002968)). Nine test files carry a **fake pool** whose row filter is derived from each statement’s own WHERE text — the design that makes an isolation proof say something about the real query rather than about a canned response. It has one silent failure mode: a term the interpreter does not recognise is simply not applied, so the boundary goes vacuous and every assertion above it keeps passing. Each file guarded a piece of that (unread `$N`, unknown literal, unmodelled `IS NULL`) and every one of those is a **term** guard — and the term list is not the predicate. Three reviews found the same shape. **The missing term:** ADR 0213 made consent two columns and `scouting_owner_access_and_reach.mjs` modelled only the first; a NULL test is a third syntax neither sweep can see, so the suite reported 17 passed while modelling a predicate the database no longer had, inside a file whose own header calls that impossible. **The rewritten joiner:** an interpreter that COLLECTS recognised terms and ANDs them re-imposes an `AND` the SQL may no longer contain — rewrite a predicate’s `AND` to `OR` and Postgres serves every active account on the platform, or lets any builder revoke any session by id, while the fake answers exactly as before. **Recognition is not application:** ADR 0213’s own first draft checked only that a column name was recognised, and a mutation deleting the filter still passed. Decisions: **D1** the governing rule — an interpreter that cannot model a term must REFUSE the statement, never ignore the term; incompleteness is allowed, silence is not, because a refusal fails the build in the session that changed the SQL while a silent drop fails nothing ever. **D2** the mechanism is a CONSUMING parse: each recognised term is spliced out of the text and the residue must be a pure conjunction (`/^[\s()]*(?:AND[\s()]*)*$/`), which subsumes every term guard, catches `OR`/`NOT`/`CASE`, and retires ADR 0213’s `modelled`-set bookkeeping because an unapplied term is by construction still there; a legitimate `OR` (the rename window, the either-side clause, the expiry disjunction, the rescind guard) is consumed as a whole unit. **D3** the interpreters stay SEPARATE and only the refusal is uniform — the term vocabulary differs completely across six tables, so a shared registry would be a small SQL engine and would couple unrelated proofs; instead every copy carries the identical guard and its OWN calibration test, which is strictly better anti-drift than extraction (a helper proves the guard exists somewhere; a calibration test proves it is reached by that file’s own statements). **D4** a modelled term proves nothing until a fixture makes it observable — three fixtures added, each differing from its control in exactly one column, so the mutation that deletes the term has somewhere to land; where a file deliberately lacks one because another owns that matrix, the comment says so. **D5** every copy verified BY MUTATION against the real source — ten mutations, ten reds, each a 0-failure pass beforehand. ⚠ Honest limits: no fitness check asserts a NEW fake pool carries the guard (identifying “a fake pool” needs a heuristic, and a heuristic gate over test internals has a false-positive cost) — the per-file calibration tests hold the nine that exist and a tenth is a review question; `canonical_profile_url.mjs` is untouched because its residue must be EMPTY, strictly stronger already. Rejected: one shared interpreter module; a fitness heuristic; keeping `KNOWN_NULL_TESTS` + `modelled` beside the structure guard; parsing the boolean tree properly; accepting green as verification.](<redacted>.md) | testing / proof integrity |
305
306
 
306
307
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -1299,5 +1299,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1299
1299
  landed since 1.19.429 with no explicit bump. run 33275468532. (task 1002620)
1300
1300
  1.19.431 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1301
1301
  landed since 1.19.430 with no explicit bump. run 33277001235. (task 1002620)
1302
+ 1.19.432 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1303
+ landed since 1.19.431 with no explicit bump. run 33279328366. (task 1002620)
1302
1304
  ---------------------------------------------------------------------------
1303
1305
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.431",
3
+ "version": "1.19.432",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.431",
9
+ "version": "1.19.432",
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.431",
3
+ "version": "1.19.432",
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",
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.431'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.432'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -111,9 +111,18 @@ function seed() {
111
111
  { github_id: ME, client_id: 'proj-joined', applied_at: now - 5 * DAY },
112
112
  { github_id: ME, client_id: 'proj-stale', applied_at: now - 40 * DAY },
113
113
  { github_id: 9999, client_id: 'proj-someone-elses', applied_at: now - DAY },
114
+ // Applied to a project that has since INVITED them — a pending membership,
115
+ // which is not building on it. The echo must survive.
116
+ { github_id: ME, client_id: 'proj-invited', applied_at: now - 3 * DAY },
114
117
  ];
115
118
  memberships = [
116
119
  { client_id: 'proj-joined', membership_kind: 'builder', joined_at: now - 4 * DAY, last_active: now, project_name: 'Joined', project_origin: 'https://joined.example', owner_github_id: null },
120
+ // The row `listMembershipsForAccount`'s `membership_kind <> 'pending'` exists
121
+ // to exclude. Without it a pending INVITE would read as membership, and the
122
+ // card would flip to "you build here" for a project the person never joined
123
+ // — so this fixture is what makes that term observable rather than merely
124
+ // modelled (task 1003354).
125
+ { client_id: 'proj-invited', membership_kind: 'pending', joined_at: now - DAY, last_active: null, project_name: 'Invited', project_origin: 'https://invited.example', owner_github_id: null },
117
126
  ];
118
127
  echoFails = false;
119
128
  statements = [];
@@ -140,6 +149,11 @@ function predicateFor(rawWhere, params) {
140
149
  if ((m = /\bclient_id = \$(\d+)/.exec(where))) {
141
150
  const v = p(m[1]); tests.push((r) => r.client_id === v); eat();
142
151
  }
152
+ // listMembershipsForAccount's pending exclusion. Modelled rather than stripped
153
+ // before interpreting, so deleting it from the real query changes this answer.
154
+ if ((m = /\bm\.membership_kind <> 'pending'/.exec(where))) {
155
+ tests.push((r) => r.membership_kind !== 'pending'); eat();
156
+ }
143
157
 
144
158
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
145
159
  if (!consumed.has(ph[1])) {
@@ -155,12 +169,16 @@ function predicateFor(rawWhere, params) {
155
169
  // had already hidden one real filter, so the residual is swept too: this is
156
170
  // what makes "refuses a predicate it cannot read" true of ALL predicates
157
171
  // rather than only parameterised ones.
158
- const residual = where
159
- .replace(/\bTRUE\b/gi, ' ')
160
- .replace(/\b(?:AND|OR)\b/gi, ' ')
161
- .replace(/[()\s]/g, '');
162
- if (residual) {
163
- throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
172
+ //
173
+ // AND THE JOINER IS PART OF THE PREDICATE (task 1003354, idea 1000877). This
174
+ // sweep used to erase `OR` alongside `AND`, so an `AND` rewritten to `OR` left
175
+ // the fake still ANDing its collected terms and every test green — while the
176
+ // real query would return a row matching ANY one of its terms, i.e. every
177
+ // other account's applications. What survives must therefore be a PURE
178
+ // CONJUNCTION: whitespace, parentheses and `AND`, nothing else.
179
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
180
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
181
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, not a silent no-op`);
164
182
  }
165
183
  return (r) => tests.every((fn) => fn(r));
166
184
  }
@@ -182,7 +200,11 @@ const pool = {
182
200
 
183
201
  if (/FROM platform_identity_project_memberships m/.test(sql)) {
184
202
  m = /WHERE (.+?) ORDER BY/.exec(sql);
185
- return { rows: memberships.filter(predicateFor(m[1].replace(/ AND m\.membership_kind <> 'pending'/, ''), params)) };
203
+ // The pending exclusion is MODELLED, not stripped. It used to be deleted
204
+ // from the clause before interpreting, which meant dropping it from the
205
+ // real query would have been invisible here — the `.replace` would simply
206
+ // no-op and the residual sweep would never see the term (task 1003354).
207
+ return { rows: memberships.filter(predicateFor(m[1], params)) };
186
208
  }
187
209
 
188
210
  if (/^DELETE FROM platform_identity_project_memberships WHERE client_id = \$1 AND github_id = \$2$/.test(sql)) {
@@ -296,8 +318,11 @@ const join = (note) => call('POST', '/api/gds/my-projects/join', { origin: 'http
296
318
 
297
319
  test('the interpreter refuses a predicate it cannot read — parameterised or not', () => {
298
320
  assert.throws(() => predicateFor('github_id = $1::bigint AND something = $2', [1, 2]), /unread predicate on \$2/);
299
- assert.throws(() => predicateFor("github_id = $1::bigint AND client_id <> 'x'", [1]), /unmodelled predicate/,
321
+ assert.throws(() => predicateFor("github_id = $1::bigint AND status <> 'x'", [1]), /unmodelled boolean structure/,
300
322
  'a literal-only filter binds nothing, so the placeholder sweep alone would miss it');
323
+ assert.throws(() => predicateFor('github_id = $1::bigint OR client_id = $2', [1, 'p']), /unmodelled boolean structure/,
324
+ 'and an AND rewritten to OR: the real query would then return every OTHER account\'s rows, '
325
+ + 'while an interpreter that re-imposed the AND would stay green (idea 1000877, task 1003354)');
301
326
  });
302
327
 
303
328
  // ── the echo is written only for an application the project actually took ────
@@ -369,7 +394,8 @@ test('GET /my-projects reports what was applied to and when — and no status',
369
394
  const r = await call('GET', '/api/gds/my-projects');
370
395
  assert.equal(r.status, 200);
371
396
  const applied = r.json.applied;
372
- assert.deepEqual(applied.map((a) => a.client_id), ['proj-open']);
397
+ assert.deepEqual(applied.map((a) => a.client_id), ['proj-open', 'proj-invited'],
398
+ 'newest first, and proj-invited is here because a PENDING membership is an invitation, not a join');
373
399
  assert.equal(applied[0].name, 'Target', 'a client_id alone is not renderable');
374
400
  assert.equal(applied[0].origin, 'https://target.example');
375
401
  assert.ok(applied[0].applied_at);
@@ -383,6 +409,14 @@ test('a project the applicant now BUILDS on drops out of applied — membership
383
409
  assert.deepEqual(r.json.building.map((b) => b.client_id), ['proj-joined']);
384
410
  assert.ok(!r.json.applied.some((a) => a.client_id === 'proj-joined'),
385
411
  'an approval becomes visible by the card flipping to member, never by the echo claiming it');
412
+ // The other side of that same term: a PENDING row is an invitation, not a
413
+ // membership, so the project stays in `applied` and out of `building`. Delete
414
+ // `membership_kind <> 'pending'` from listMembershipsForAccount and both
415
+ // halves flip — which is what makes the read's own exclusion provable here.
416
+ assert.ok(!r.json.building.some((b) => b.client_id === 'proj-invited'),
417
+ 'a pending invite is not building on the project');
418
+ assert.ok(r.json.applied.some((a) => a.client_id === 'proj-invited'),
419
+ 'and their application is still on file — nothing has approved it');
386
420
  });
387
421
 
388
422
  test('an echo older than the window is invisible, not deleted', async () => {
@@ -126,12 +126,17 @@ function predicateFor(rawWhere, params) {
126
126
  // had already hidden one real filter, so the residual is swept too: this is
127
127
  // what makes "refuses a predicate it cannot read" true of ALL predicates
128
128
  // rather than only parameterised ones.
129
- const residual = where
130
- .replace(/\bTRUE\b/gi, ' ')
131
- .replace(/\b(?:AND|OR)\b/gi, ' ')
132
- .replace(/[()\s]/g, '');
133
- if (residual) {
134
- throw new Error(`fake pool: unmodelled predicate "${residual}" in "${rawWhere}" — a filter the interpreter cannot read must be a loud error, not a silent no-op`);
129
+ //
130
+ // AND THE JOINER IS PART OF THE PREDICATE (task 1003354, idea 1000877). This
131
+ // sweep used to erase `OR` alongside `AND`, so an `AND` rewritten to `OR` left
132
+ // the fake still ANDing its collected terms and every test green — while the
133
+ // real UPDATE would resolve a row matching ANY one of its terms. What survives
134
+ // must therefore be a PURE CONJUNCTION: whitespace, parentheses and `AND`. The
135
+ // transition guard's legitimate `OR` is consumed as a whole unit above, which
136
+ // is what lets it survive this tightening.
137
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
138
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
139
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, not a silent no-op`);
135
140
  }
136
141
  return (r) => tests.every((fn) => fn(r));
137
142
  }
@@ -312,9 +317,15 @@ test('the interpreter refuses a predicate it cannot read, rather than ignoring i
312
317
  test('the interpreter refuses a LITERAL-ONLY predicate too — the hole review found', () => {
313
318
  assert.throws(
314
319
  () => predicateFor("id = $1 AND resolved_at IS NULL", [1]),
315
- /unmodelled predicate/,
320
+ /unmodelled boolean structure/,
316
321
  'a filter that binds nothing is invisible to the placeholder sweep, so the residual is swept as well'
317
322
  );
323
+ assert.throws(
324
+ () => predicateFor("id = $1 OR status = 'pending'", [1]),
325
+ /unmodelled boolean structure/,
326
+ 'and an AND rewritten to OR: the resolve UPDATE would then act on a row matching ANY one of its '
327
+ + 'terms, while an interpreter that re-imposed the AND stays green (idea 1000877, task 1003354)'
328
+ );
318
329
  // and the modelled shapes still parse clean
319
330
  assert.doesNotThrow(() => predicateFor("lower(github_login) = lower($1) AND status = 'invited'", ['ada']));
320
331
  });
@@ -71,6 +71,22 @@ const row = (id) => rows.find((r) => r.id === id);
71
71
  * Build a row filter from a statement's own WHERE text. Every placeholder the
72
72
  * clause mentions must be consumed by a fragment below; one that is not throws,
73
73
  * so a predicate this cannot read can never degrade into "no constraint".
74
+ *
75
+ * IT READS STRUCTURE, NOT JUST TERMS (task 1003354, idea 1000877). Every matched
76
+ * term is now BLANKED to ` TRUE `, and whatever survives must be a pure
77
+ * conjunction — whitespace, parentheses and `AND`. Two things that used to slip
78
+ * through in silence:
79
+ *
80
+ * A LITERAL-ONLY term binds no placeholder, so the sweep below cannot see it.
81
+ * `AND seen_at IS NULL` appearing (or disappearing) changed nothing here.
82
+ * AN `AND` REWRITTEN TO `OR` is the dangerous one on THIS table: accept and
83
+ * decline are `id = $1 AND addressee_github_id = $2::bigint AND status =
84
+ * 'pending'`, so an `OR` would let anyone accept anyone's request by id —
85
+ * the exact isolation this file exists to prove. A term-collecting
86
+ * interpreter re-imposes the `AND` and stays green through it.
87
+ *
88
+ * The remove path's either-side disjunction is the ONE legitimate `OR`, and it
89
+ * is consumed as a whole unit before the check, which is what lets it survive.
74
90
  */
75
91
  function predicateFor(rawWhere, params) {
76
92
  let where = rawWhere;
@@ -89,13 +105,17 @@ function predicateFor(rawWhere, params) {
89
105
  }
90
106
 
91
107
  let m;
92
- if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); }
93
- if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); }
94
- if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); }
95
- if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); }
108
+ // Each matcher BLANKS what it consumed, so the residue below is exactly the
109
+ // part of the clause this interpreter did not understand.
110
+ const eat = () => { where = where.replace(m[0], ' TRUE '); };
111
+ if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
112
+ if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); eat(); }
113
+ if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); eat(); }
114
+ if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
96
115
  if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
97
116
  const days = Number(p(m[1]));
98
117
  tests.push((r) => r.created_at > Date.now() - days * DAY);
118
+ eat();
99
119
  }
100
120
 
101
121
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
@@ -103,6 +123,10 @@ function predicateFor(rawWhere, params) {
103
123
  throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
104
124
  }
105
125
  }
126
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
127
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
128
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, never a silently different filter`);
129
+ }
106
130
  return (r) => tests.every((fn) => fn(r));
107
131
  }
108
132
 
@@ -209,6 +233,22 @@ test('the interpreter refuses a predicate it cannot read, rather than ignoring i
209
233
  /unread predicate on \$2/,
210
234
  'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens'
211
235
  );
236
+ assert.throws(
237
+ () => predicateFor("id = $1 AND seen_at IS NULL", [1]),
238
+ /unmodelled boolean structure/,
239
+ 'a literal-only term binds no placeholder, so the sweep above cannot see it at all'
240
+ );
241
+ assert.throws(
242
+ () => predicateFor("id = $1 OR addressee_github_id = $2::bigint", [1, ME]),
243
+ /unmodelled boolean structure/,
244
+ 'and the one that matters here: with OR, accept/decline would act on ANY row matching the id '
245
+ + 'alone — anyone accepting anyone\'s request. A term-collecting interpreter re-imposes the AND '
246
+ + 'and every isolation assertion below stays green (idea 1000877, task 1003354)'
247
+ );
248
+ assert.doesNotThrow(
249
+ () => predicateFor("id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'", [1, ME]),
250
+ 'and the real accept/decline clause still parses clean'
251
+ );
212
252
  });
213
253
 
214
254
  test('the interpreter really does narrow on the owner column', () => {