@bongos/core 1.19.626 → 1.19.627

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.626",
6
- "core_contract": "1.19.626",
7
- "source_commit": "15d768a9fe3239e170150c8d2efb98e6de8d98da",
5
+ "core_version": "1.19.627",
6
+ "core_contract": "1.19.627",
7
+ "source_commit": "9132daa97509f28f38b53cf54a6311f0a43f788d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T14:24:41.469Z",
9
+ "built_at": "2026-09-09T18:37:19.003Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 464,
12
+ "docs_redacted": 465,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2096,
14
+ "functional_verbatim": 2098,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2584,
20
- "tree_sha256": "1fbadd48214869e3a766dde80f634c74c81a7ee96bf480937417526afe50a27b",
19
+ "file_count": 2587,
20
+ "tree_sha256": "6f56a454e970eef9adac1d969b0e28f75d79cdc9843f666de389fd2766607e55",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -162,7 +162,7 @@
162
162
  {
163
163
  "path": ".claude/skills/goal-create/SKILL.md",
164
164
  "mode": "0000644",
165
- "sha256": "2df2c9d9ea817c52c47c074ada7aa725a96ea47db90d6d78acd2f986790edc6f"
165
+ "sha256": "535eb9fd7d3effdc2df7e003bb227c65600055b49dc98bc6f832d3198ba417a0"
166
166
  },
167
167
  {
168
168
  "path": ".claude/skills/goal-review/SKILL.md",
@@ -1869,10 +1869,15 @@
1869
1869
  "mode": "0000644",
1870
1870
  "sha256": "cc44045ea59f76dabc62fe43993ffab6004add88c7f6142bfe9215f7f2c33c99"
1871
1871
  },
1872
+ {
1873
+ "path": "docs/adr/0270-rank-floor-derives-without-a-prediction.md",
1874
+ "mode": "0000644",
1875
+ "sha256": "a16ee8bb0d163fec8ffce252e165de4010954e45a7f61bc205163c70077451ee"
1876
+ },
1872
1877
  {
1873
1878
  "path": "docs/adr/README.md",
1874
1879
  "mode": "0000644",
1875
- "sha256": "6b90114abfa5e891a8f2ea16c547c960c982030d6228d43c2a833840123a3f69"
1880
+ "sha256": "26757599a787417d621bd760d61f156f41b12dc73a195d82b912cc6e956d5642"
1876
1881
  },
1877
1882
  {
1878
1883
  "path": "docs/api-reference.md",
@@ -2757,7 +2762,7 @@
2757
2762
  {
2758
2763
  "path": "docs/module-api-changelog.md",
2759
2764
  "mode": "0000644",
2760
- "sha256": "19d4d61c3cd024932aeb068335ef1d9d69e37a59a0e7d653a5e6136bb4972c19"
2765
+ "sha256": "ccefe35e8bc2cd1de16f6164b9d2782d9dbd6c5c8660ab75e7a0bf198078d2dc"
2761
2766
  },
2762
2767
  {
2763
2768
  "path": "docs/modules-contract.md",
@@ -3779,6 +3784,11 @@
3779
3784
  "mode": "0000644",
3780
3785
  "sha256": "c10c4bbbead4a3bafde336f3433a1432818880751f7a21436a6162f0dbde1abf"
3781
3786
  },
3787
+ {
3788
+ "path": "migrations/core_236_rank_floor_backfill.sql",
3789
+ "mode": "0000644",
3790
+ "sha256": "06b53d66e710dfbf602011459ec8d69adce8e281632927daea0735f8078b2f86"
3791
+ },
3782
3792
  {
3783
3793
  "path": "modules/agents/lib/validate.js",
3784
3794
  "mode": "0000644",
@@ -5602,7 +5612,7 @@
5602
5612
  {
5603
5613
  "path": "modules/lifecycle/db-rank-authz.js",
5604
5614
  "mode": "0000644",
5605
- "sha256": "c93780ea71bb2f8f3d2b21f042c21d029f1ed5336a3aaf178fb3e14925dcc19b"
5615
+ "sha256": "ac33825a705a0d541fc7fe94858f967ef8ef9713a2b7a1f1a03ce268494bb317"
5606
5616
  },
5607
5617
  {
5608
5618
  "path": "modules/lifecycle/db-shared.js",
@@ -5617,7 +5627,7 @@
5617
5627
  {
5618
5628
  "path": "modules/lifecycle/db-tasks.js",
5619
5629
  "mode": "0000644",
5620
- "sha256": "65558689ccfcea8f71b98cd51eaf1fb45d39267f7e6f6cf8fc35814da2752a34"
5630
+ "sha256": "d8cce5b44106009987d3d32b711a114e4505b4e504bbc6fdfc84f161024ee0cd"
5621
5631
  },
5622
5632
  {
5623
5633
  "path": "modules/lifecycle/db-versions.js",
@@ -7682,12 +7692,12 @@
7682
7692
  {
7683
7693
  "path": "package-lock.json",
7684
7694
  "mode": "0000644",
7685
- "sha256": "008a893c7efba51a8e8b7feff7b0569454c3b85670369ad736c28ba25b331c75"
7695
+ "sha256": "72fea99ed1a6a192978e5b257da8b6c819a649e210da1395d7fdace85b35cd4d"
7686
7696
  },
7687
7697
  {
7688
7698
  "path": "package.json",
7689
7699
  "mode": "0000644",
7690
- "sha256": "a8f88df15fae0f2566f18b0d78985e82d8d6ab5c72ee35cdf1598130ac2e8ce3"
7700
+ "sha256": "22510ead9bfe323615390297a28fab238a3a053defb3849e071f7ad66b84aa0d"
7691
7701
  },
7692
7702
  {
7693
7703
  "path": "public-docs/index.html",
@@ -9427,7 +9437,7 @@
9427
9437
  {
9428
9438
  "path": "src/module-api.js",
9429
9439
  "mode": "0000644",
9430
- "sha256": "13157fd8a1bf1a5d1b892a3deed40c9fc1a85c7daf5db9bf5283837e261a6081"
9440
+ "sha256": "a28b94a91c283d898bed5fe0810e029e31b3ab33760b2c132a320058d3692bff"
9431
9441
  },
9432
9442
  {
9433
9443
  "path": "src/module-loader/catalog.js",
@@ -12049,10 +12059,15 @@
12049
12059
  "mode": "0000644",
12050
12060
  "sha256": "aea6f9ec053bd9477288025b58ed76c166c2d59a24d7ea3481edadba90113887"
12051
12061
  },
12062
+ {
12063
+ "path": "tests/rank_floor_wiring.mjs",
12064
+ "mode": "0000644",
12065
+ "sha256": "f81f559b5428497b2246580c7921ecd1ba5a2f1034cf29eb5e5c21b04675f216"
12066
+ },
12052
12067
  {
12053
12068
  "path": "tests/rank_gate.mjs",
12054
12069
  "mode": "0000644",
12055
- "sha256": "d8fff57f5fee95bf11a5c1df3e2c0e342d39ef5b22c5e87f47a61dd9a3d2a8cb"
12070
+ "sha256": "78626dc42e6d31685352e43e1e8e094a904f59d9d0562caf6b6e126934df973f"
12056
12071
  },
12057
12072
  {
12058
12073
  "path": "tests/rank_tier_single_source.mjs",
@@ -67,7 +67,7 @@ Everything here is your call. It is presented in Phase 4 as a proposal, not as q
67
67
  - **`category_id`** — the goal's HUMAN category (task 1003277; ADR 0192 — advisory, it gates nothing). Read the live set with `GET /api/gds/work-categories` (never hard-code the labels), infer the best fit from the goal's outcome and its `scope_modules`, and state your pick in the Phase-4 approval round like every other drafted field. **When nothing in the set fits, that is a designed prompt, not an error**: do NOT silently pick the nearest, and do NOT leave it null and move on — surface the gap in the gray-area round and offer to create a new category (`POST /api/gds/work-categories` with `{"label":"..."}`; a duplicate slug converges on the existing row), then use it. Creating uncategorised stays legal — if the user declines to name one, POST the goal without `category_id` and note that the 201 body will carry a `category_advisory` reminding whoever looks next.
68
68
  - **`succeeds_goal_id`** — set it when carrying an achieved goal forward. `POST /goals` validates the predecessor exists *before* creating, so a bad ref can't orphan a goal; it then stamps the predecessor's `succeeded_by_goal_id`. Archiving the predecessor is a **separate** call.
69
69
  - **Criteria — 2 to 4.** A goal's criteria are narrower than a version's: each should plausibly anchor 2–5 tasks. Draft `criterion_id` as a stable kebab slug (position-independent, and what `criterion_ids` on `POST /tasks` should reference); `criterion_md` is the outcome prose.
70
- - **Seed tasks — 3 to 8.** Per criterion, with `kind`, a one-line done-when, `criterion_ids: ["<slug>"]`, and dependency edges. Omit `touches[]` (advisory, ADR 0049 — backfilled from the real diff at ship). Never hand-set `requires_rank` (auto-derives, ADR 0084). Proof tasks use `kind='verify'`; exploration uses `kind='spike'` (and spikes are deliberately left in backlog).
70
+ - **Seed tasks — 3 to 8.** Per criterion, with `kind`, a one-line done-when, `criterion_ids: ["<slug>"]`, and dependency edges. Omit `touches[]` (advisory, ADR 0049 — backfilled from the real diff at ship). Never hand-set `requires_rank`: it auto-derives (ADR 0084) from `needs_migration` and the goal's own scope wall, neither of which needs a prediction (ADR 0270). **Do set `needs_migration: true` on any task that will alter the live database** — it is the input that floors such a task at Metic, and omitting it is how a schema change lands in the open queue. Proof tasks use `kind='verify'`; exploration uses `kind='spike'` (and spikes are deliberately left in backlog).
71
71
 
72
72
  ## Phase 4 — Ask, then get sign-off
73
73
 
@@ -0,0 +1,59 @@
1
+ # ADR 0270 — The rank floor derives from facts, not from a prediction
2
+
3
+ **Date:** 2026-09-09
4
+ **Context:** BONGOS-V2, goal [1000111](https://cloudbongos.com/builders#/task/1003663) (Working area 7 — Government), task [1003663](https://cloudbongos.com/builders#/task/1003663). Repairs an interaction between [ADR 0084](<redacted>.md) (the per-task `requires_rank` floor) and [ADR 0049](<redacted>.md) (the split parallel-safety contract, which retired predictive `touches[]`). Generalises the protected-scope floor task 1003066 (BV1.R05) shipped for the ideas vector.
5
+ **Status:** Accepted.
6
+
7
+ ## Problem
8
+
9
+ ADR 0084 gave every task a `requires_rank` floor, derived at create time by `deriveRequiredRank(touches, securitySensitive)`: a task touching the rank/authz/ship-grade/deploy/migration core floors at `metic`, everything else stays `xenos`. The floor is enforced at claim time, as the `INSUFFICIENT_RANK` rung of the claim refusal ladder.
10
+
11
+ ADR 0049 then retired **predictive** `touches[]`. It is no longer declared up front; it is backfilled from the real committed diff **at ship** — which is *after* the claim the floor exists to gate. So on every create vector that omits it, `matchProtected()` saw an empty array and the floor could not rise. The derivation did not fail loudly; it silently returned `xenos` forever, because it is computed once at creation and never recomputed when `touches` is later backfilled.
12
+
13
+ Measured on this instance before the fix:
14
+
15
+ - **88 tasks declared `needs_migration: true`; 68 of them (77%) sat at `requires_rank: 'xenos'`.**
16
+ - Among them, task 1003372 — *"Hash `builder_sessions.token` at rest"* — was `status: ready`, claimable by any Xenos. A newcomer could claim a rewrite of the auth token store.
17
+ - Goal 1000111 alone (scope wall: `kernel`, `lifecycle`, `government`, `hall-ui`) held 20 claimable tasks at `xenos`, including two that drop columns from the goal-membership authz tables.
18
+
19
+ Two mechanisms kept it invisible:
20
+
21
+ 1. **The authoring routes disagree.** `POST /goals/:id/tasks` makes `touches[]` **required** — it is the blast radius its scope wall checks — so the floor does derive there, and the route comment correctly promises "a protected/sensitive task floors to Metic and a sub-Metic author cannot claim it". Its sibling `POST /tasks` treats `touches[]` as optional, and that is the route `/goal-create` actually files seed tasks through.
22
+ 2. **The skill told authors to disable both inputs.** `goal-create/SKILL.md` said, in one line: *omit `touches[]`* … *never hand-set `requires_rank`*. Omit the only input and never set the output — together those guarantee `xenos`.
23
+
24
+ The task that raised this asked for "an Archon-level review of how the floor should now be derived". Its premise — *every* task since ADR 0049 — turned out to be overstated (August 2026: 83 tasks, 56 `xenos`; September: 11 and 9), because other vectors still pass `touches[]`. The gap is vector-specific. That distinction is what makes the fix cheap.
25
+
26
+ ## Decision
27
+
28
+ **The floor derives from facts a task already carries at create time, never from a prediction.** `deriveRequiredRank` gains a third argument, `opts`, with two touches-free inputs. Any one of four conditions floors a task at `metic`:
29
+
30
+ | Input | Why it is a fact, not a prediction |
31
+ |---|---|
32
+ | `security_sensitive` | Author-declared, unchanged since ADR 0084. |
33
+ | `needs_migration` | The direct successor to the `'migrations/'` entry `POST /tasks` used to fold into `touches[]` before it became a dedicated boolean. A task altering the live database **is** the migration core. |
34
+ | `scopeProtected` | The owning goal's `scope_modules` wall reaches protected territory. Always available — `goal_id` is required on every task since BV1.R60 — and read through the same `scopeIncludesProtected` primitive the pre-push hook, the grader and main-audit use, so it cannot drift from them. |
35
+ | `matchProtected(touches)` | Retained. Still correct on the vectors that *do* declare touches; simply inert on the ones that don't. |
36
+
37
+ `metic` remains the ceiling. `archon` is never auto-assigned — "only the Archon may claim this" stays a deliberate manual override.
38
+
39
+ Three properties travel with the decision:
40
+
41
+ - **Derived at the lowest create layer.** In `db-tasks.createTask`, after `goalIdEffective` resolves — not in each route. This is the argument ADR 0096 already made for creation-time reward assignment: every vector (`POST /tasks`, `POST /goals/:id/tasks`, idea-promotion, capture, newcomer-restock, copy-desk, kickoff-seed, seed scripts) inherits the floor, and a vector added next year cannot forget it. Patching the two authoring routes separately is precisely how they came to disagree.
42
+ - **Raise-only, everywhere.** `highestRank` still clamps upward only, so an explicit `requires_rank` can raise the bar and never drop below the derived floor. `updateTaskRequiresRank` re-derives with the **same four inputs**; without that, an Archon setting a migration task to `xenos` would have succeeded, silently undoing the create-time protection.
43
+ - **Every setter of a floor input ratchets.** A floor derived only at creation is a floor with a back door: `needs_migration` is PATCH-able (task 1002648, to correct the create-time heuristic), so a Metic could have flipped it true on an existing `xenos` task and left the floor untouched — reopening on the PATCH path exactly the gap this ADR closes on the create path. `updateTaskNeedsMigration` is therefore now the exact sibling of `updateTaskSecuritySensitive`: the same `CASE WHEN … requires_rank IN ('xenos','thetes') THEN 'metic'` ratchet, raising only. `tests/rank_floor_wiring.mjs` asserts the ratchet appears in **exactly two** setters, so a third boolean that starts feeding `deriveRequiredRank` cannot quietly ship without one.
44
+ - **Opt-in on a strict `=== true`.** A stray truthy value cannot wall off the open queue.
45
+
46
+ ## What was rejected
47
+
48
+ - **Re-requiring predictive `touches[]` at create.** This is the obvious fix and it is wrong: it re-litigates ADR 0049, whose whole finding was that a wrong up-front prediction blocked or de-credited correct work. The floor is not worth reviving the thing that was deliberately deleted.
49
+ - **Recomputing the floor when `touches` is backfilled at ship.** Too late by construction. The claim it gates has already happened; the recompute would only relabel history.
50
+ - **Deriving from `module_key`.** More precise than the goal wall in principle, but it is null on most tasks (including the one that reported this), so it would leave the same hole.
51
+ - **A new gate or approval step.** Nothing here adds a rung to the claim refusal ladder; the existing `INSUFFICIENT_RANK` rung simply starts receiving the correct input.
52
+
53
+ ## Consequences
54
+
55
+ - **A protected goal wall is blunt, and that is accepted.** `scopeIncludesProtected` is true when *any* module in the wall is protected, so a purely `hall-ui` task inside goal 1000111 (`hall-ui` + `kernel` + `lifecycle` + `government`) also floors at `metic`. This is the same semantics the ideas vector has enforced since task 1003066, it fails in the safe direction, and `metic` is the first *trusted working* rank rather than a rare one. The cost is a smaller Xenos queue in mixed-scope goals. The remedy, when it bites, is to narrow the goal's wall — which is the honest fix anyway.
56
+ - **The newcomer queue is unaffected today** — there are currently **zero** claimable `newcomer_friendly` tasks, in protected-scope goals or otherwise. Worth re-checking if the beginner queue is ever restocked inside a protected-scope goal; a `newcomer_friendly` carve-out was deliberately **not** added, because an exception that lowers a safety floor is exactly the kind of thing that should require a decision rather than exist by default.
57
+ - **A one-time backfill**, `migrations/<redacted>.sql`, raises still-claimable (`backlog`/`ready`) `xenos` rows that meet either new condition. It is scoped to claimable work because the floor is a claim-time gate — rewriting shipped or abandoned rows would change no decision that has not already been made. It raises only, and there is no down-migration, for the reason ADR 0084 gave: lowering a safety floor is never automatic.
58
+ - **The protected-module list is spelled once in that migration**, as a point-in-time snapshot. A migration cannot call into JS, and a historical backfill should record the roster as it stood the day it ran. The live authority remains `module-scope-map.scopeIncludesProtected` over `PROTECTED_GLOBS`.
59
+ - **`goal-create/SKILL.md` now tells authors to set `needs_migration: true`** on any task that will alter the live database. It is the input that floors such a task, and omitting it is how a schema change reaches the open queue.
@@ -361,3 +361,4 @@ This keeps the decision history honest and traceable.
361
361
  | 0267 | [**Unanimity, and the revise-and-re-sit loop** ([task 1003733](https://cloudbongos.com/builders#/task/1003733) · goal 1000111 — *Government*, owner decision 2026-09-08). A Full Idea is ratified by **unanimous** agreement from round one; a sitting that does not carry RETURNS to its author, who revises and re-sits it, unbounded, until the board is unanimous. `unanimous` joins `PASS_RULES` as a fourth answer on the axis these rules actually differ on — **what silence means**: it is two conditions, not one, because "nobody objected" is true of an empty room. Everyone who spoke must have said yes AND somebody must have spoken, so an unvoted sitting RETURNS, which is exactly what makes the clock the owner asked for safe under it ([ADR 0191](<redacted>.md) §4: a deadline may only be given to a rule whose expiry means return). **There is deliberately no membership denominator** — that is the whole difference from `majority`, and it is the owner's "silence does not block" as code: an absent member is not in the reckoning, so one yes out of four carries. The stated cost is that an attentive minority can ratify on a distracted board; the alternative is one person on holiday stopping the pipeline. Trigger changes follow from the rule: a single yes does NOT close it (a later member must still be able to object — the sitting runs its clock, full turnout closes early), while a reasoned objection closes it AT ONCE (no vote-changing in v1 fixes the outcome, and the author needs the feedback to revise). ADR 0191 §3's author rule rides both unchanged. **`BOARD_DEFAULTS` is NOT moved**: a fresh instance stays the day-one monarchy, because under any non-author-yes rule a solo founder can never ratify their own ideas (0191 §5) — adoption is a board amendment, and no env var can change a constitution. The revise-and-re-sit loop already existed (`POST /inbox/:id/resubmit` re-grades and re-fires the window hook); what the board owes it is now pinned by test — a return CLOSES the item so the next window may open, and pays nothing. Named limits: a zero-vote return carries no human objection (the author gets the mechanical weakest-section flag), and this changes nothing live until the separate defect where a ratified amendment does not survive to the next read is fixed.](<redacted>.md) | government / board room |
362
362
  | 0268 | [**The constitution comes from two roots, and a decision that did not take must say so** ([task 1003739](https://cloudbongos.com/builders#/task/1003739) · goal 1000111 — *Government*). On 2026-08-25 cloudbongos.com's board RATIFIED an amendment (rank:metic+ · consent · a 1440-minute sitting) and the constitution never changed — for two weeks `GET /government/constitution` answered the day-one monarchy with the passed amendment sitting in its own `history` array directly beneath the contradicting `board` block. Every sitting since was decided under a rule the board had voted to replace, and it silently un-shipped [ADR 0191](<redacted>.md) (majority) and would have un-shipped [ADR 0267](<redacted>.md) the same way. **Cause:** `modules/government/config.js` resolved BOTH its neutral starter and its instance pack from one `path.resolve(__dirname, '..', '..')`, under a comment saying the roots coincided *today* — true in a single checkout, false on a STANDALONE instance ([ADR 0108](<redacted>.md) §1), where the server runs `node_modules/@cloudbongos/core` with `WorkingDirectory=<instance repo>`. The neutral path stayed right (it really is core content); the INSTANCE path resolved inside the core package, so the host's own `config/government.json` was never read and `applyBoardAmendment` wrote the ratified amendment into `node_modules`, where the nightly core upgrade ([ADR 0161](<redacted>.md) cuts a release per merge) erased it within a day. **Nothing threw and nothing could have** — both paths exist, both are writable, and an absent instance pack is a legal state, so the read fell through to the neutral monarchy exactly as designed. `src/branding.js` had the pattern right three files away. **Fix:** neutral from `resolveCoreRoot()`, instance from `resolveInstanceRoot()`, reached through `src/module-api` — which had exposed NEITHER resolver, and that absence is precisely why the module re-derived the wrong one ([ADR 0083](<redacted>.md) forbids requiring a core internal). Resolved at load, not per call, because `resolveInstanceRoot()` falls back to `process.cwd()` and a lazy resolve would let a `chdir` move the constitution; `applyBoardAmendment` now mkdirs, since the write targets a directory the core does not own. Single-checkout behaviour is byte-identical, asserted rather than claimed. **The detector, which is the durable half:** `constitutionView` carries a `divergence` block comparing the newest PASSED amendment (sanitized as an apply would write it) field-by-field against what is in force, rendered above the hall's dials in words rather than config keys. It REPORTS and never HEALS — the instance pack is also the file a human editing the constitution touches, so a silent re-apply would revert a legitimate hand edit with no way to tell the two apart. A RETURNED amendment is never compared (that is what *returned* means). Does NOT put the 2026-08-25 amendment back in force — that is a board act. Sibling sweep RUN, not promised: one real hit filed as [task 1003745](https://cloudbongos.com/builders#/task/1003745) (quarantine.js's repo-containment refusal is blind to the instance repo).](<redacted>.md) | government / instance roots |
363
363
  | 0269 | [**The CLI session store is host-keyed at a fixed anchor; the per-brand file stays the active pointer** ([task 1003741](https://cloudbongos.com/builders#/task/1003741) · goal 1000090 — *Working area 4, Bongos Core distribution*). The CLI resolved its session path from the BRANDING PACK, read out of whatever checkout the process stood in — one slot, that moved. Standalone (the public CLI's whole situation) no checkout means no brand, so every instance shared `~/.config/cloudbongos/gds-session.json` and signing into a second DESTROYED the first; in-repo it landed in `~/.config/<slug>/` where the standalone CLI could never find it (`npx … api GET /me` from a bare dir returned the cloudbongos builder while a valid hermeslines session sat on the same machine). Not theoretical — the owner's config dir carries a hand-made `gds-session.<redacted>.bak.json` and four more of the same shape. **Decision: one file per instance keyed by HOST at a FIXED anchor (`~/.config/<FALLBACK_DIR>/instances/<host>.json`), with the per-brand `gds-session.json` left exactly where it is as the ACTIVE pointer.** Four load-bearing parts. (1) The anchor must never read `configHome()`/`configDirName()`/the pack — a test asserts the function body names none, since routing it back through the brand silently reinstates the split. (2) The active pointer does NOT move: every existing reader (in-repo skills, the card hook, the dev box, `readSessionToken`) looks there, and the store is purely additive. (3) The OUTGOING session is archived BEFORE the incoming one lands — a session written before the store existed is not in it, so without this the first login after upgrading still loses it, the bug surviving its own fix. (4) A stored token is VERIFIED against `/api/gds/me` before being reinstated; unverified falls through to the real device flow, `--force` signs in as somebody else. `login` also names the other instances and how to switch, since sessions are now kept rather than overwritten. Files 0600 in a 0700 dir; an `api_base` naming no host is not stored rather than stored under a guess, so nothing can write outside the store. The dir is `instances/` NOT `sessions/` — `sessions` is a live module key and [ADR 0083](<redacted>.md) §Decision #4 forbids kernel machinery naming one; the fitness gate caught it. Rejected: moving the active pointer (breaks every reader for no visible gain); one file holding a map (changes a format hooks and the dev box parse today); keying under `configHome()` (the bug); restoring without verifying (hands over a dead session that looks live); a new `bongos switch` verb (login is the command already reached for, and the public CLI's surface is deliberately small).](<redacted>.md) | cli / auth / distribution |
364
+ | 0270 | [**The rank floor derives from facts, not from a prediction** ([task 1003663](https://cloudbongos.com/builders#/task/1003663) · goal 1000111 — *Working area 7, Government*). [ADR 0084](<redacted>.md) derives every task’s claim-time `requires_rank` floor from `touches[]` + `security_sensitive`; [ADR 0049](<redacted>.md) then retired PREDICTIVE `touches[]`, which is now backfilled from the real committed diff at SHIP — after the claim the floor exists to gate. So on every create vector that omits it the matcher saw an empty array, and because the floor is computed ONCE at creation and never recomputed, it returned `xenos` forever. Nothing threw: an empty `touches[]` is a legal state. **Measured before the fix: 88 tasks declared `needs_migration` and 68 of them (77%) sat at `xenos`** — including [task 1003372](https://cloudbongos.com/builders#/task/1003372), a READY task to hash the `builder_sessions` token store at rest, claimable by any newcomer; goal 1000111 alone held 20 such rows. Two mechanisms hid it: the authoring routes DISAGREE (`POST /goals/:id/tasks` REQUIRES `touches[]` as its scope-wall blast radius, so the floor derives there, while its sibling `POST /tasks` — the route `/goal-create` actually files seed tasks through — treats it as optional), and `goal-create/SKILL.md` told authors in ONE line to *omit `touches[]`* and *never hand-set `requires_rank`*: omit the only input, never set the output. **Decision:** the floor derives from facts a task already carries at create time, never from a prediction — `deriveRequiredRank` gains `opts` with two touches-free inputs, `needs_migration` (the direct successor to the `migrations/` entry `POST /tasks` used to fold into `touches[]`) and `scopeProtected` (the owning goal’s `scope_modules` wall, ALWAYS available since `goal_id` is required, read through the same `scopeIncludesProtected` primitive the pre-push hook, grader and main-audit use, so it cannot drift). Derived at the LOWEST create layer in `createTask` after `goalIdEffective` resolves — the ADR 0096 argument — so every vector inherits it and patching the two routes separately (which is how they came to disagree) is not repeated. `updateTaskRequiresRank` re-derives with the same four inputs, else an Archon setting a migration task to `xenos` would silently undo the create-time floor; raise-only throughout; `archon` still never auto-assigned; a strict `=== true` so a stray truthy value cannot wall off the open queue. One-time backfill `core_236` raises still-CLAIMABLE rows only (the floor is a claim-time gate; rewriting shipped rows changes no live decision) and never lowers. **Accepted cost:** a protected goal wall is blunt — any protected module in the wall floors the whole goal, so a `hall-ui` task in a mixed goal floors at Metic too; same semantics the ideas vector has enforced since task 1003066, fails safe, and the remedy is to narrow the wall. Zero claimable `newcomer_friendly` tasks exist today, and no carve-out was added for them on purpose. Rejected: re-requiring predictive `touches[]` (re-litigates ADR 0049); recomputing at ship (too late by construction); deriving from `module_key` (null on most tasks, including the reporter).](<redacted>.md) | permissions / task lifecycle |
@@ -1701,5 +1701,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1701
1701
  landed since 1.19.624 with no explicit bump. run 34328603214. (task 1002620)
1702
1702
  1.19.626 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1703
1703
  landed since 1.19.625 with no explicit bump. run 34363350286. (task 1002620)
1704
+ 1.19.627 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1705
+ landed since 1.19.626 with no explicit bump. run 34390069410. (task 1002620)
1704
1706
  ---------------------------------------------------------------------------
1705
1707
  ```
@@ -0,0 +1,56 @@
1
+ -- core_236_rank_floor_backfill.sql — raise the requires_rank floor on work that
2
+ -- was created while the derivation was inert (task 1003663, goal 1000111,
3
+ -- ADR 0270).
4
+ --
5
+ -- WHY THERE IS ANYTHING TO BACK FILL. deriveRequiredRank has always derived the
6
+ -- per-task floor from touches[] + security_sensitive (task 1498, ADR 0085). ADR
7
+ -- 0049 then retired PREDICTIVE touches[] — it is backfilled from the real
8
+ -- committed diff at SHIP, which is after the claim this floor exists to gate —
9
+ -- so on every create vector that omits it the matcher saw an empty array and the
10
+ -- floor never rose. Measured on this instance before the fix: 88 tasks declared
11
+ -- needs_migration and 68 of them (77%) sat at requires_rank='xenos', including
12
+ -- task 1003372, a READY task to rewrite the auth token store at rest.
13
+ --
14
+ -- ADR 0270 gives the derivation two touches-free inputs at create time
15
+ -- (needs_migration, and whether the owning goal's scope wall reaches protected
16
+ -- territory). That fixes every task created from now on. This migration is the
17
+ -- one-time pass over the rows that already exist.
18
+ --
19
+ -- SCOPED TO STILL-CLAIMABLE WORK, on purpose. Only 'backlog' and 'ready' rows are
20
+ -- touched. The floor is enforced at CLAIM time (the INSUFFICIENT_RANK rung of the
21
+ -- claim refusal ladder), so raising it on an already-claimed, shipped or
22
+ -- abandoned row changes no decision that has not already been made — it would
23
+ -- only rewrite history and dirty updated_at across the ledger.
24
+ --
25
+ -- RAISES ONLY, like every other path that writes this column. The WHERE clause
26
+ -- selects requires_rank='xenos', so a row an Archon has already lifted to 'metic'
27
+ -- or 'archon' is left exactly as it is. There is no down-migration for the same
28
+ -- reason ADR 0085 gave: lowering a safety floor is never automatic.
29
+ --
30
+ -- THE MODULE LIST BELOW IS A POINT-IN-TIME SNAPSHOT, not a second source of
31
+ -- truth. The live authority is src/bongos/module-scope-map.scopeIncludesProtected
32
+ -- over PROTECTED_GLOBS (permission-path-check.js) — the same roster the pre-push
33
+ -- hook, the grader and main-audit read, and what the running code calls. A
34
+ -- migration cannot call into JS, and a historical backfill SHOULD record the
35
+ -- roster as it stood the day it ran rather than drift with a later one, so the
36
+ -- nine protected keys are spelled here once and never read again.
37
+
38
+ UPDATE tasks t
39
+ SET requires_rank = 'metic',
40
+ updated_at = now()
41
+ WHERE t.requires_rank = 'xenos'
42
+ AND t.status IN ('backlog', 'ready')
43
+ AND (
44
+ -- The migration core, by the task's own declaration.
45
+ t.needs_migration = true
46
+ -- Or the owning goal's scope wall reaches protected territory.
47
+ OR EXISTS (
48
+ SELECT 1
49
+ FROM goals g
50
+ WHERE g.id = t.goal_id
51
+ AND g.scope_modules && ARRAY[
52
+ 'kernel', 'lifecycle', 'grading', 'onboarding', 'builder-settings',
53
+ 'autonomy', 'government', 'dev-box', 'devsecops'
54
+ ]::text[]
55
+ )
56
+ );
@@ -146,7 +146,7 @@ function rankAllowsTask(builderRank, requiredRank) {
146
146
  return have >= needed;
147
147
  }
148
148
 
149
- // deriveRequiredRank — task 1498 / ADR 0085. The per-task requires_rank floor
149
+ // deriveRequiredRank — task 1498 / ADR 0084. The per-task requires_rank floor
150
150
  // (V3.R82 #301) shipped INERT: no create/edit path ever set it, so every task
151
151
  // sat at the 'xenos' default and the claim gate never bit. We now DERIVE a floor
152
152
  // from what a task touches + its sensitivity, reusing the SAME protected-path
@@ -157,10 +157,35 @@ function rankAllowsTask(builderRank, requiredRank) {
157
157
  // sub-Metic builder already cannot push to). Everything else stays 'xenos' (the
158
158
  // open general queue). NEVER auto-assigns 'archon': "only the Archon may claim
159
159
  // this" is a deliberate manual override, not something to infer.
160
+ //
161
+ // task 1003663 / ADR 0270 — WHY THERE ARE NOW FOUR INPUTS, NOT TWO. The floor
162
+ // above derived from touches[] alone, and ADR 0049 then retired PREDICTIVE
163
+ // touches[]: it is backfilled from the real committed diff at SHIP, which is
164
+ // after the claim this floor exists to gate. So the derivation quietly went
165
+ // inert on the vectors that omit it — 68 of the 88 tasks declaring
166
+ // needs_migration sat at 'xenos', including a live-DB migration rewriting the
167
+ // auth token store. The fix is NOT to bring prediction back; it is to derive
168
+ // from facts a task already carries at create time. `opts` takes those:
169
+ // { needsMigration, scopeProtected }.
160
170
  // Pure + exported so tests/rank_gate.mjs can exercise it without a DB.
161
171
 
162
- function deriveRequiredRank(touches, securitySensitive) {
172
+ function deriveRequiredRank(touches, securitySensitive, opts = {}) {
163
173
  if (securitySensitive === true) return 'metic';
174
+ // task 1003663 / ADR 0270 — the two touches-FREE inputs. ADR 0049 retired
175
+ // PREDICTIVE touches[]: it is backfilled from the real diff at ship, so at
176
+ // CREATE time the matchProtected() arm below sees an empty array on most
177
+ // vectors and the floor never rises. These arms restore it from facts a task
178
+ // already carries at create time.
179
+ //
180
+ // needsMigration — the direct successor to the 'migrations/' entry POST /tasks
181
+ // used to fold into touches[] before it became a dedicated boolean. A task
182
+ // that alters the LIVE database is the migration core by definition.
183
+ if (opts.needsMigration === true) return 'metic';
184
+ // scopeProtected — the owning goal's scope wall reaches protected territory.
185
+ // Unlike touches[] this is ALWAYS available (goal_id is required on every task
186
+ // since BV1.R60), and it is computed from the SAME protected roster the
187
+ // pre-push hook, the grader and main-audit read, so it cannot drift from them.
188
+ if (opts.scopeProtected === true) return 'metic';
164
189
  const files = Array.isArray(touches) ? touches : [];
165
190
  if (files.length > 0 && matchProtected(files).length > 0) return 'metic';
166
191
  return 'xenos';
@@ -23,6 +23,31 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
23
23
  // -------------------------------------------------------------------------
24
24
 
25
25
 
26
+ // goalScopeProtected — does this task's owning goal reach protected territory
27
+ // (the permission / ship-grade / deploy / migration core)? The touches-free half
28
+ // of the create-time rank floor (task 1003663, ADR 0270).
29
+ //
30
+ // It reads the goal's `scope_modules` wall through the SAME published primitive
31
+ // modules/ideas/inbox.js already uses for routed filings (task 1003066, BV1.R05)
32
+ // and that the pre-push hook, the grader and main-audit read — so this floor
33
+ // cannot drift from the roster they enforce.
34
+ //
35
+ // Runs on the CALLER'S client when there is one, so it sees the same
36
+ // transaction snapshot as the INSERT it feeds — a goal rescoped mid-transaction
37
+ // cannot land between the check and the row. FAILS CLOSED-ish by omission: an
38
+ // absent goal or a non-array wall yields false, which is the honest answer
39
+ // ("no protected scope declared"), and the other three arms of deriveRequiredRank
40
+ // still apply.
41
+ async function goalScopeProtected(goalId, client) {
42
+ if (goalId == null) return false;
43
+ const { rows } = await (client || pool).query(
44
+ 'SELECT scope_modules FROM goals WHERE id = $1',
45
+ [goalId]
46
+ );
47
+ const scope = rows.length ? rows[0].scope_modules : null;
48
+ return Array.isArray(scope) && api.moduleScopeMap.scopeIncludesProtected(scope);
49
+ }
50
+
26
51
  // The version statuses that can still receive new work. 'shipped' and 'frozen' are
27
52
  // closed: a version whose scope is sealed cannot be told about a bug found afterwards.
28
53
  // Mirrors the CHECK constraint on versions.status (migrations/003_pms.sql).
@@ -124,15 +149,6 @@ async function createTask({
124
149
  // to name a goal instead.
125
150
  allowCatchAll = false,
126
151
  }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
127
- // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
128
- // from touches + sensitivity, then let an explicit requiresRank override only
129
- // UPWARD (highestRank) — a requested rank below the derived floor is clamped
130
- // up to the floor (fail-closed; the floor mirrors the trust-boundary paths a
131
- // sub-Metic builder cannot push to anyway).
132
- const requiresRankEffective = highestRank(
133
- deriveRequiredRank(touches, securitySensitive),
134
- requiresRank ?? 'xenos'
135
- );
136
152
  // task 1677 / ADR 0096 (2026-06-28): creation is now the PRIMARY reward-assignment
137
153
  // point — every task is born with a fair drachmae value, not just at promote/claim.
138
154
  // When the caller supplies NO positive credits_reward (null/undefined/0), auto-assign
@@ -181,6 +197,26 @@ async function createTask({
181
197
  }
182
198
  goalIdEffective = await generalGoalIdForVersion(versionId);
183
199
  }
200
+ // task 1498 / ADR 0084: every task carries a deliberate rank floor. Derive it,
201
+ // then let an explicit requiresRank override only UPWARD (highestRank) — a
202
+ // requested rank below the derived floor is clamped up to the floor
203
+ // (fail-closed; the floor mirrors the trust-boundary paths a sub-Metic builder
204
+ // cannot push to anyway).
205
+ //
206
+ // task 1003663 / ADR 0270: derived HERE, at the lowest create layer and AFTER
207
+ // goalIdEffective resolves, rather than in each route — the same argument the
208
+ // reward auto-assignment above makes. Every create vector (POST /tasks,
209
+ // POST /goals/:id/tasks, idea-promotion, capture, newcomer-restock, copy-desk,
210
+ // kickoff-seed, seed scripts) inherits the floor, and a vector added next year
211
+ // cannot forget it. Patching the two authoring routes instead is how
212
+ // POST /tasks and POST /goals/:id/tasks came to disagree in the first place.
213
+ const requiresRankEffective = highestRank(
214
+ deriveRequiredRank(touches, securitySensitive, {
215
+ needsMigration,
216
+ scopeProtected: await goalScopeProtected(goalIdEffective, deps.client),
217
+ }),
218
+ requiresRank ?? 'xenos'
219
+ );
184
220
  const { rows } = await (deps.client || pool).query(
185
221
  `INSERT INTO tasks (version_id, title, description, status, touches,
186
222
  est_minutes, est_cost_usd, manual_degree, priority,
@@ -366,7 +402,7 @@ async function updateTaskNewcomerFriendly(id, newcomerFriendly) { return updateT
366
402
  // (the === true coercion below preserves the boolean-only contract).
367
403
 
368
404
  async function updateTaskSecuritySensitive(id, securitySensitive) {
369
- // task 1498 / ADR 0085: flagging a task security_sensitive RAISES its rank
405
+ // task 1498 / ADR 0084: flagging a task security_sensitive RAISES its rank
370
406
  // floor (a now-sensitive task must require Metic+). Recompute and ratchet
371
407
  // requires_rank UP in the same atomic UPDATE so the two columns can't diverge;
372
408
  // never lower it (an existing 'metic'/'archon' may be a protected-touch floor
@@ -386,7 +422,7 @@ async function updateTaskSecuritySensitive(id, securitySensitive) {
386
422
  return rows[0] ?? null;
387
423
  }
388
424
 
389
- // task 1498 / ADR 0085: set a task's rank floor explicitly (the Archon override).
425
+ // task 1498 / ADR 0084: set a task's rank floor explicitly (the Archon override).
390
426
  // The DERIVED floor (touches + security_sensitive) is a HARD minimum — a request
391
427
  // below it is clamped UP via highestRank, so an override can only RAISE the bar,
392
428
  // never open a sensitive task to a lower rank. `requiresRank` is one live rank
@@ -396,7 +432,15 @@ async function updateTaskSecuritySensitive(id, securitySensitive) {
396
432
  async function updateTaskRequiresRank(id, requiresRank) {
397
433
  const task = await getTask(id);
398
434
  if (!task) return null;
399
- const floor = deriveRequiredRank(task.touches, task.security_sensitive);
435
+ // task 1003663 / ADR 0270: the override re-derives with the SAME four inputs as
436
+ // creation. Without needsMigration/scopeProtected here, an Archon setting a
437
+ // migration task to 'xenos' would succeed — the floor would compute 'xenos'
438
+ // from an empty touches[] and clamp nothing, silently undoing the create-time
439
+ // protection. An override may still only RAISE.
440
+ const floor = deriveRequiredRank(task.touches, task.security_sensitive, {
441
+ needsMigration: task.needs_migration === true,
442
+ scopeProtected: await goalScopeProtected(task.goal_id, null),
443
+ });
400
444
  const effective = highestRank(floor, requiresRank ?? 'xenos');
401
445
  const { rows } = await pool.query(
402
446
  `UPDATE tasks SET requires_rank = $1, updated_at = now() WHERE id = $2 RETURNING *`,
@@ -508,7 +552,32 @@ async function updateTaskAutomationTag(id, automationTag) { return updateTaskCol
508
552
  // task 1002648: correct the create-time migration heuristic's verdict — a false
509
553
  // flag makes claiming reserve a migration number that goes unused (task 1002614
510
554
  // burned 213 that way). Boolean only; the route validates.
511
- async function updateTaskNeedsMigration(id, needsMigration) { return updateTaskColumn(id, 'needs_migration', needsMigration === true); }
555
+ //
556
+ // task 1003663 / ADR 0270: needs_migration is now a rank-floor INPUT, so this
557
+ // stopped being a bare column write and became the exact sibling of
558
+ // updateTaskSecuritySensitive above — same CASE, same ratchet, same reasoning.
559
+ // The correction this setter exists for runs BOTH ways: task 1002648 names the
560
+ // true→false direction, but false→true is the one that matters here, and without
561
+ // the ratchet a Metic could PATCH needs_migration onto an existing xenos task and
562
+ // leave the floor at xenos — reopening on the PATCH path exactly the gap ADR 0270
563
+ // closes on the create path. RAISES ONLY: flipping the flag back off never drops
564
+ // the gate, because an existing 'metic'/'archon' may be a protected-scope floor or
565
+ // a deliberate override. An Archon lowers it explicitly via updateTaskRequiresRank,
566
+ // which re-derives from all four inputs.
567
+ async function updateTaskNeedsMigration(id, needsMigration) {
568
+ const mig = needsMigration === true;
569
+ const { rows } = await pool.query(
570
+ `UPDATE tasks
571
+ SET needs_migration = $1,
572
+ requires_rank = CASE WHEN $1 AND requires_rank IN ('xenos', 'thetes')
573
+ THEN 'metic' ELSE requires_rank END,
574
+ updated_at = now()
575
+ WHERE id = $2
576
+ RETURNING *`,
577
+ [mig, id]
578
+ );
579
+ return rows[0] ?? null;
580
+ }
512
581
 
513
582
  // BV1.R53 (task 1422): assign (or clear) the module_key that declares which
514
583
  // module this task's work belongs to. Drives module-disjointness at claim time
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.626",
3
+ "version": "1.19.627",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.626",
9
+ "version": "1.19.627",
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.626",
3
+ "version": "1.19.627",
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
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.626'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.627'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,135 @@
1
+ // tests/rank_floor_wiring.mjs — the rank floor is WIRED, not merely derivable
2
+ // (task 1003663, goal 1000111, ADR 0270).
3
+ //
4
+ // WHY THIS FILE EXISTS SEPARATELY FROM tests/rank_gate.mjs. That file pins the
5
+ // PURE helpers — `deriveRequiredRank` / `highestRank` — by handing them opts it
6
+ // builds itself. That is necessary and it is not sufficient: the whole defect
7
+ // ADR 0270 repairs was a derivation that was perfectly correct and never
8
+ // REACHED, because the input it needed (`touches[]`) stopped arriving at the
9
+ // call site. A test that constructs the inputs by hand cannot catch that class
10
+ // of bug a second time — it asserts the calculator, not the plumbing.
11
+ //
12
+ // So this file drives the real `createTask` against a SQL-aware client and reads
13
+ // the `requires_rank` actually bound into the INSERT, plus the two PATCH setters
14
+ // that can move a floor input after creation.
15
+ //
16
+ // Run: node --test tests/rank_floor_wiring.mjs
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import { makeSqlAwareClient, lifecycleDbSource } from './helpers.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const { createTask } = require('../modules/lifecycle/db.js');
26
+
27
+ // The INSERT binds ~25 columns positionally, so a hardcoded params[20] would be a
28
+ // silent lie the first time somebody inserts a column. Read the column list out of
29
+ // the statement under test and resolve the index by NAME.
30
+ function boundValue(sql, params, column) {
31
+ const cols = String(sql).slice(sql.indexOf('(') + 1, sql.indexOf(')'))
32
+ .split(',').map((c) => c.trim());
33
+ const i = cols.indexOf(column);
34
+ assert.notEqual(i, -1, `INSERT INTO tasks no longer binds a '${column}' column — update this test`);
35
+ return params[i];
36
+ }
37
+
38
+ // One create against a fake goal row; returns the requires_rank the INSERT bound.
39
+ async function createdFloor({ scopeModules, needsMigration = false, requiresRank = null }) {
40
+ let insert = null;
41
+ const { _client } = makeSqlAwareClient(async (sql, params) => {
42
+ if (/^SELECT scope_modules FROM goals/i.test(sql)) {
43
+ return { rows: [{ scope_modules: scopeModules }] };
44
+ }
45
+ if (/^INSERT INTO tasks/i.test(sql)) {
46
+ insert = { sql, params };
47
+ return { rows: [{ id: '9001' }] };
48
+ }
49
+ return { rows: [] };
50
+ });
51
+
52
+ await createTask({
53
+ versionId: 'BONGOS-V2',
54
+ title: 'a task',
55
+ goalId: '1000111',
56
+ needsMigration,
57
+ requiresRank,
58
+ }, { client: _client });
59
+
60
+ assert.ok(insert, 'createTask never reached its INSERT');
61
+ return boundValue(insert.sql, insert.params, 'requires_rank');
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // createTask — the create path actually reads the goal wall
66
+ // ---------------------------------------------------------------------------
67
+
68
+ test('a goal whose wall reaches protected territory floors the task at metic', async () => {
69
+ // No touches[], not security_sensitive, not a migration — every pre-0270 input
70
+ // is absent, which is precisely the shape that used to come out 'xenos'.
71
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui', 'kernel'] }), 'metic');
72
+ });
73
+
74
+ test('an unprotected wall leaves the task in the open queue', async () => {
75
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'] }), 'xenos');
76
+ });
77
+
78
+ test('needs_migration floors at metic even under an unprotected wall', async () => {
79
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'], needsMigration: true }), 'metic');
80
+ });
81
+
82
+ test('THE REGRESSION: the create path reads the wall, it does not just own a helper', async () => {
83
+ // Mutation guard. If someone drops the goalScopeProtected call from createTask
84
+ // — the exact way this bug happened the first time, by a caller ceasing to
85
+ // supply an input — the pure tests in rank_gate.mjs all still pass and this one
86
+ // fails. That asymmetry is the point of the file.
87
+ assert.equal(await createdFloor({ scopeModules: ['lifecycle'] }), 'metic');
88
+ assert.equal(await createdFloor({ scopeModules: ['government'] }), 'metic');
89
+ });
90
+
91
+ test('an explicit requires_rank still only RAISES, never lowers the derived floor', async () => {
92
+ assert.equal(await createdFloor({ scopeModules: ['kernel'], requiresRank: 'xenos' }), 'metic');
93
+ assert.equal(await createdFloor({ scopeModules: ['hall-ui'], requiresRank: 'archon' }), 'archon');
94
+ });
95
+
96
+ test('a missing goal row is the honest false, not a throw', async () => {
97
+ // scope_modules is NOT NULL DEFAULT '{}', but a goal deleted mid-transaction
98
+ // returns no row at all. That must fall through to the other arms, not crash a
99
+ // create.
100
+ assert.equal(await createdFloor({ scopeModules: undefined }), 'xenos');
101
+ assert.equal(await createdFloor({ scopeModules: undefined, needsMigration: true }), 'metic');
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // The PATCH setters — a floor INPUT may be flipped after creation
106
+ // ---------------------------------------------------------------------------
107
+ // These two write through the module-level pool rather than an injectable client,
108
+ // so they are pinned at the source. The assertion is narrow on purpose: it is the
109
+ // RATCHET that matters (raise on true, never lower), not the surrounding SQL.
110
+
111
+ test('updateTaskNeedsMigration ratchets requires_rank UP, exactly like its security_sensitive sibling', () => {
112
+ const src = lifecycleDbSource();
113
+ const setter = src.slice(src.indexOf('async function updateTaskNeedsMigration'));
114
+ const body = setter.slice(0, setter.indexOf('\n}\n') + 1);
115
+
116
+ assert.match(
117
+ body.replace(/\s+/g, ' '),
118
+ /requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\) THEN 'metic' ELSE requires_rank END/,
119
+ 'PATCHing needs_migration=true must raise the floor — otherwise ADR 0270 is closed on the create path and open on the PATCH path'
120
+ );
121
+ // Raise-only: nothing in the setter may write a rank BACK DOWN when the flag
122
+ // clears. An existing metic may be a protected-scope floor or an Archon override.
123
+ assert.doesNotMatch(body, /ELSE 'xenos'/, 'the setter must never lower an existing floor');
124
+ });
125
+
126
+ test('both floor-input setters share one ratchet — neither may drift from the other', () => {
127
+ const src = lifecycleDbSource();
128
+ const ratchet = /requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\)\s+THEN 'metic' ELSE requires_rank END/g;
129
+ const hits = src.match(ratchet) || [];
130
+ assert.equal(
131
+ hits.length, 2,
132
+ `expected the ratchet in exactly 2 setters (security_sensitive + needs_migration), found ${hits.length} — ` +
133
+ 'a new boolean that feeds deriveRequiredRank needs one too, and a removed one is a reopened gap'
134
+ );
135
+ });
@@ -399,6 +399,51 @@ t('empty / null / undefined touches with no sensitivity => xenos (no throw)', ()
399
399
  assert.equal(db.deriveRequiredRank(undefined, undefined), 'xenos');
400
400
  });
401
401
 
402
+ // ---------------------------------------------------------------------------
403
+ // task 1003663 / ADR 0270 — the two touches-FREE inputs. ADR 0049 retired
404
+ // predictive touches[], so at CREATE time the arms above see an empty array on
405
+ // most vectors and the floor never rose: 68 of 88 needs_migration tasks sat at
406
+ // xenos, one of them a ready task to rewrite the auth token store. These pin the
407
+ // replacement, which derives from facts a task carries WITHOUT a prediction.
408
+ // ---------------------------------------------------------------------------
409
+ console.log('\nderiveRequiredRank (the touches-free floors — task 1003663):');
410
+
411
+ t('needs_migration floors at metic with NO touches at all', () => {
412
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: true }), 'metic');
413
+ assert.equal(db.deriveRequiredRank(null, false, { needsMigration: true }), 'metic');
414
+ });
415
+
416
+ t('a protected goal scope floors at metic with NO touches at all', () => {
417
+ assert.equal(db.deriveRequiredRank([], false, { scopeProtected: true }), 'metic');
418
+ assert.equal(db.deriveRequiredRank(null, false, { scopeProtected: true }), 'metic');
419
+ });
420
+
421
+ t('THE REGRESSION: the exact shape of task 1003372 (ready, migration, no touches)', () => {
422
+ // Before ADR 0270 this returned 'xenos' — a Xenos could claim a task to hash
423
+ // the builder_sessions token store at rest. It must never do so again.
424
+ assert.equal(
425
+ db.deriveRequiredRank([], false, { needsMigration: true, scopeProtected: false }),
426
+ 'metic'
427
+ );
428
+ });
429
+
430
+ t('the flags are opt-in: false / absent / non-boolean never RAISE the floor', () => {
431
+ // Only a strict === true raises, so a stray truthy value cannot quietly wall
432
+ // off the open queue (the fail-closed direction here is "stay open").
433
+ assert.equal(db.deriveRequiredRank([], false, {}), 'xenos');
434
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: false, scopeProtected: false }), 'xenos');
435
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: 'yes' }), 'xenos');
436
+ assert.equal(db.deriveRequiredRank([], false, { needsMigration: 1 }), 'xenos');
437
+ });
438
+
439
+ t('omitting opts entirely is the pre-0270 two-argument behaviour, unchanged', () => {
440
+ // Every existing call site passes two arguments; none may change meaning.
441
+ assert.equal(db.deriveRequiredRank([], false), 'xenos');
442
+ assert.equal(db.deriveRequiredRank(['public/game/main.js'], false), 'xenos');
443
+ assert.equal(db.deriveRequiredRank(['migrations/145_x.sql'], false), 'metic');
444
+ assert.equal(db.deriveRequiredRank([], true), 'metic');
445
+ });
446
+
402
447
  console.log('\nhighestRank (compose floor with an override; raise-only):');
403
448
 
404
449
  t('picks the higher-authority rank either way', () => {