@bongos/core 1.19.320 → 1.19.322

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.320",
6
- "core_contract": "1.19.320",
7
- "source_commit": "add665ea5d3565d67ff3ef951b724cae1b7e2ca7",
5
+ "core_version": "1.19.322",
6
+ "core_contract": "1.19.322",
7
+ "source_commit": "2fd56f3db9adacbb215ffdc56bba734266ef8e89",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-24T00:55:40.560Z",
9
+ "built_at": "2026-08-24T01:23:28.434Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 323,
12
+ "docs_redacted": 324,
13
13
  "agent_docs_stubbed": 22,
14
14
  "functional_verbatim": 1455,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1800,
20
- "tree_sha256": "e31137b82bd472bdedaa8baed29373670556670e80ed40ff0c4b841142b5259f",
19
+ "file_count": 1801,
20
+ "tree_sha256": "6cd259fb90abf1a7df74cc5e7dffead0cfa9c5d0f636032c51b94e715d21b31d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1409,10 +1409,15 @@
1409
1409
  "mode": "0000644",
1410
1410
  "sha256": "51931fe5ae89b14b6c8955e6d6cd43d88a7a6ed05f8c862cdfe56a14302cc990"
1411
1411
  },
1412
+ {
1413
+ "path": "docs/adr/0186-per-project-idea-templates.md",
1414
+ "mode": "0000644",
1415
+ "sha256": "749040f16b59cc1cd1c49ca40453fed838f31b36af8c8eed2b5de7db00ddb0d0"
1416
+ },
1412
1417
  {
1413
1418
  "path": "docs/adr/README.md",
1414
1419
  "mode": "0000644",
1415
- "sha256": "96f12718ea595a8fff3d3aeafc27013b4ef251724e80d077607addc167533639"
1420
+ "sha256": "c5af7723dbcc0e4a62a897153b1fa1cf34962a1f8ed72fc7bf9681ba074dab6a"
1416
1421
  },
1417
1422
  {
1418
1423
  "path": "docs/api-reference.md",
@@ -2827,7 +2832,7 @@
2827
2832
  {
2828
2833
  "path": "modules/dev-box/box-access.js",
2829
2834
  "mode": "0000644",
2830
- "sha256": "870976966de7c48be4172390dc8e4f7d2eb6ac53e20dfb3fd5d40b08cfcd7850"
2835
+ "sha256": "a51e16aba67547c0692d9422587f83d8ce40f0e45830463b80bfd188401eb6c0"
2831
2836
  },
2832
2837
  {
2833
2838
  "path": "modules/dev-box/box-credential.js",
@@ -4642,12 +4647,12 @@
4642
4647
  {
4643
4648
  "path": "package-lock.json",
4644
4649
  "mode": "0000644",
4645
- "sha256": "cc6846ec9d50fa1d8e41c960ff14171e4033faf435fe54fdfe60b38ec69968a3"
4650
+ "sha256": "c9390686abc47c1954f212e7e4ebbce0320cfd21d9acc1fd2fe79d4f9667ab79"
4646
4651
  },
4647
4652
  {
4648
4653
  "path": "package.json",
4649
4654
  "mode": "0000644",
4650
- "sha256": "5b964977f97fcd0749db36916838c51f0ffbea4a6e2eba5af2538472b519758d"
4655
+ "sha256": "290a425b20d5c5f87bc9a1eaeb154d02f8ca95e8147dd7a6f5835ea443d25ec4"
4651
4656
  },
4652
4657
  {
4653
4658
  "path": "public-docs/index.html",
@@ -5292,7 +5297,7 @@
5292
5297
  {
5293
5298
  "path": "scripts/gds/fitness-ratchets.js",
5294
5299
  "mode": "0000644",
5295
- "sha256": "a27a3873f516abdc52f7f482603779010bbc1af4d6e73c0b86dd34292017bc9a"
5300
+ "sha256": "3f32080357abdecb0f0860c5c9313f12283f01774c8367ac22c1c12d0d176411"
5296
5301
  },
5297
5302
  {
5298
5303
  "path": "scripts/gds/fitness.js",
@@ -6417,7 +6422,7 @@
6417
6422
  {
6418
6423
  "path": "src/module-api.js",
6419
6424
  "mode": "0000644",
6420
- "sha256": "8cf8e0f172ad38bdf3871c06b99170adf366d2b2b5f1b0e993ecbc0877003fd7"
6425
+ "sha256": "d808c8f826309876d6ce3bf99e47ca874b5afa0efe108fcbda629328c45d9b00"
6421
6426
  },
6422
6427
  {
6423
6428
  "path": "src/module-loader/catalog.js",
@@ -6732,7 +6737,7 @@
6732
6737
  {
6733
6738
  "path": "tests/box_access.mjs",
6734
6739
  "mode": "0000644",
6735
- "sha256": "1e4db680c5925e473fff9ad6247e797bd9089fdf3696c3ffb161903bda41dcda"
6740
+ "sha256": "fcad6c578c2d8698d6ad93fe04f500734d589013590f006910f1bc33fc908642"
6736
6741
  },
6737
6742
  {
6738
6743
  "path": "tests/box_auth_check.mjs",
@@ -7272,7 +7277,7 @@
7272
7277
  {
7273
7278
  "path": "tests/fitness_ratchets.mjs",
7274
7279
  "mode": "0000644",
7275
- "sha256": "781bdee0778645704b5179bf99507e839743158714f799e45d6cc64f8fc30477"
7280
+ "sha256": "e33edfa4925694d824c042f1fd06a977f1ac14b5c1cb24b5326014ca9f86ece0"
7276
7281
  },
7277
7282
  {
7278
7283
  "path": "tests/fitness_two_root.mjs",
@@ -0,0 +1,146 @@
1
+ # 0186 — Per-project idea templates: the seam is already built; the economics are the open problem
2
+
3
+ - **Status:** Accepted (spike outcome — decisions below; no implementation in this task)
4
+ - **Date:** 2026-08-23
5
+ - **Tasks:** [#1002932](https://cloudbongos.com/builders#/task/1002932) (BV1.R156, goal 1000062 — three grades of idea). Spike output: this ADR plus the downstream task list in §7. Gates criterion `abi-full-idea-base-template`.
6
+ - **Builds on:** [ADR 0172](<redacted>.md) (the ideator credit lane), [ADR 0185](<redacted>.md) (the 70/30 split), [ADR 0083](<redacted>.md) (module boundary + module-owned migrations)
7
+ - **Related:** R141 (`modules/ideas/templates.js` — the template declaration), R142 (`modules/ideas/completeness.js` — the deterministic scorer)
8
+
9
+ ## 1. Context
10
+
11
+ The owner's philosophy for this platform, recorded on goal 1000062: **the best
12
+ possible simple base, already designed to be altered.** R141 shipped `base-v1` —
13
+ five fields, declared as data — and R142 shipped a scorer that walks that
14
+ declaration rather than hard-coding five names. This spike asks the question those
15
+ two deliberately deferred: *how does a project founder define their own idea
16
+ shape — different fields, different weights — without a schema change and without
17
+ forking the scorer?*
18
+
19
+ ## 2. The finding that matters: the seam is already there
20
+
21
+ The answer to the literal question is short, because R141 built for it:
22
+
23
+ - **Every template function already takes an optional `registry`**
24
+ (`templateFor`, `validateFieldValues`, `fieldOrder`, `scoreFieldValues`),
25
+ defaulting to `BUILTIN_TEMPLATES`. Validation and scoring both read the
26
+ declaration as data. Swapping the registry changes what both accept with no
27
+ code change in either consumer, and `tests/idea_templates.mjs` already proves
28
+ it by injecting a different registry.
29
+ - **The store is already project-ready.** `ideas_grades.template_key` is per-row
30
+ with `CHECK (template_key ~ '^[a-z][a-z0-9-]*$' AND length <= 64)` — its own
31
+ migration comment says a project-defined key "stays addressable".
32
+ - **A per-project settings home already exists**: `provisioning_instances.settings
33
+ jsonb NOT NULL DEFAULT '{}'` (provisioning_009).
34
+
35
+ So: **no schema change is required, and the scorer does not need forking.** That
36
+ part of the task's question is answered by work already shipped. What the spike
37
+ actually found is a different problem, in §4.
38
+
39
+ ## 3. Decision A — where a project template comes from
40
+
41
+ A new **`project-settings` kernel port**, the sibling of the existing
42
+ `builder-settings` port, reading `provisioning_instances.settings` under a
43
+ namespaced key (`settings.ideas.templates`).
44
+
45
+ - The `ideas` module resolves it with `resolveOptional`, so a vanilla instance
46
+ with no `provisioning` module **degrades to the built-in registry** rather than
47
+ failing — the same graceful posture `lifecycle` takes toward `reward` and
48
+ `onboarding` (ADR 0093 §2).
49
+ - `base-v1` remains the default and the **fallback for any row whose
50
+ `template_key` does not resolve**. An unresolvable key already renders raw
51
+ rather than throwing (`templateFor` returns null by design, after the R143
52
+ prototype-pollution fix); that behaviour is kept.
53
+
54
+ **Rejected:** a new `ideas_templates` table (a second home for a thing that
55
+ already has one, and a module-owned table for data the project owner edits in
56
+ project settings); a config file (`config/idea-templates.json` is per-*instance*,
57
+ and one instance hosts many projects — wrong granularity).
58
+
59
+ **Boundary note, load-bearing for whoever implements it:** goal 1000062's
60
+ `scope_modules` is `['ideas','hall-ui']`. The port lives in `provisioning` +
61
+ kernel, which is **outside this goal's scope wall**. The port task therefore
62
+ cannot be claimed under goal 1000062 — see §7 T1.
63
+
64
+ ## 4. Decision B — the economic lever (the real finding)
65
+
66
+ Completeness is normalised **0–100 within its own template**: the scorer takes the
67
+ mean of per-field ratios and multiplies by 100. Karma reads that relative number
68
+ against an absolute bar — `IDEA_FULL_PASS_BAR = 60`, `IDEA_FULL_PASS_CREDIT = 5`.
69
+
70
+ Those two facts together mean **template choice becomes a credit-minting lever**:
71
+
72
+ | Template | Required text for a passing score | Credits |
73
+ |---|---|---|
74
+ | `base-v1` (5 fields × substanceMin 40) | ~200 characters of real content | 5 |
75
+ | a project template: 1 field, substanceMin 10 | ~10 characters | 5 |
76
+
77
+ A ~20× arbitrage, available to any project that can define its own shape — and
78
+ per-project templates are exactly what this ADR enables. Shipping Decision A
79
+ without addressing this would make the feature a way to print credits.
80
+
81
+ **Decision:** separate the two questions the single number is currently answering.
82
+
83
+ - **Completeness stays relative** and keeps its present meaning: *did you fill in
84
+ YOUR project's form.* That is the right signal for triage ordering (R148) and
85
+ the hall's live meter (R152), and it must stay comparable within a project.
86
+ - **Karma stops reading it.** Credit eligibility reads an **absolute substance
87
+ total** instead — which the scorer already computes per field (`fields[].substance`)
88
+ and currently discards after averaging. No new measurement is needed; the number
89
+ is there.
90
+
91
+ **Rejected:** making completeness itself absolute (it would break the meter and
92
+ the ordering, which want "how complete is this form"); leaving karma relative and
93
+ policing templates by review (unenforceable, and the platform should not depend on
94
+ someone noticing).
95
+
96
+ **Interim guard**, cheap and shippable before the karma change: a project template
97
+ is karma-eligible only if it declares **≥3 required fields and ≥120 total
98
+ `substanceMin`**. This does not fix the arbitrage, it bounds it — and it is worth
99
+ having on day one because it is a data check, not an economics change.
100
+
101
+ ## 5. Decision C — per-field weights
102
+
103
+ The task asks for "different fields, different weights". Today the scorer is an
104
+ **unweighted** mean (`fields.reduce((s,f) => s + f.ratio, 0) / fields.length`).
105
+
106
+ **Decision:** an optional `weight` on the field declaration, default `1`, and a
107
+ weighted mean in the scorer. Today's behaviour is the special case where every
108
+ weight is 1, so this is a pure data addition with one arithmetic line changed and
109
+ no migration. Weights are declared alongside `substanceMin`, which keeps the whole
110
+ shape of a template in one declaration.
111
+
112
+ ## 6. Decision D — do the five base fields stay mandatory?
113
+
114
+ **No.** A project may define any shape; forcing the five would make "define your
115
+ own" cosmetic, and the base fields are the owner's wording for *this* project's
116
+ concerns, not universal truths.
117
+
118
+ Two constraints stand in their place:
119
+
120
+ 1. **A template must declare at least one required field.** A template with none
121
+ makes completeness vacuous (an empty submission scores 100).
122
+ 2. **A stored template is validated at write time**, so a malformed registry is
123
+ refused when the project saves it — not discovered later inside the capture
124
+ path, which must never throw (R142's hard requirement).
125
+
126
+ ## 7. Downstream tasks (the spike's other output)
127
+
128
+ | # | Task | Scope / goal note |
129
+ |---|---|---|
130
+ | T1 | `project-settings` kernel port, sibling of `builder-settings`, reading `provisioning_instances.settings` | `provisioning` + kernel — **outside goal 1000062's wall**; needs a goal whose scope covers it |
131
+ | T2 | `ideas` resolves that port and merges the project registry over the built-in; `resolveOptional`, degrading to built-in | `ideas` — in this goal's wall |
132
+ | T3 | Optional per-field `weight` (default 1) + weighted mean in the scorer | `ideas` |
133
+ | T4 | Karma eligibility reads an absolute substance total, not the relative completeness score | `economy` — outside this goal's wall |
134
+ | T5 | Interim guard: a project template is karma-eligible only at ≥3 required fields and ≥120 total `substanceMin` | `ideas` |
135
+ | T6 | Write-time validation of a stored project template | `ideas` (+ wherever project settings are saved) |
136
+
137
+ T1 → T2 is the ordering dependency; T3, T5, T6 are independent. **T4 is the one
138
+ that must not be skipped** — T5 bounds the arbitrage, only T4 removes it.
139
+
140
+ ## 8. What this ADR deliberately does not do
141
+
142
+ No customization UI, per the task's own instruction ("Do not build the
143
+ customization UI from this task"). No implementation of any row above. The spike's
144
+ deliverable is this decision record plus §7, and the reason the platform builds
145
+ the seam before the surface is ADR 0062 §7's core↔host split: the core ships the
146
+ mechanism, the host supplies the content.
@@ -259,5 +259,6 @@ This keeps the decision history honest and traceable.
259
259
  | 0183 | [**A done-when criterion closes itself; the review queue becomes a place to look** ([task 1002069](https://cloudbongos.com/builders#/task/1002069), goal 1000072, owner request 2026-07-05). ADR 0086 §6 made `satisfied` reachable exactly one way — a Metic+ calling `POST /done-when/:id/satisfy` — even though the signal it gated on is unambiguous (**every** gating task shipped) and a human confirming it adds no information. When nobody runs `/goal-review` the result is not a careful pause but a **lying board**: this session measured goal 1000072 at `6/9 shipped, 0/5 criteria`. Now one helper, `doneWhen.autoSatisfyShippedCriteria(exec, {taskId})`, runs from **two** places for two different reasons — inside `shipTask`'s txn (scoped, **immediate**, on the txn client so a rolled-back ship cannot leave a goal achieved) and unscoped on every `publish-reconciler` tick (**backlog drain** for criteria whose tasks shipped before this existed, since nothing will ship against them again, plus **self-heal**). Idempotent by construction — it reads only `satisfied=false` rows — which is what makes an every-tick sweep safe. **The load-bearing subtlety is the delivery guard.** Auto-close needs BOTH "nothing still pending" AND **≥ 1 task actually `shipped`**; the second is not redundant, because since [task 1002651](https://cloudbongos.com/builders#/task/1002651) made `abandoned` terminal-not-pending, a criterion whose tasks were **all cut** satisfies the first clause *vacuously* and would auto-close on ZERO delivered work, cascading its goal to `achieved` (measured: `buildCriterionProgress` returns `pending_review:true` for exactly that shape). Yet `pending_review` **keeps** that wider meaning on purpose — it asks "should a human LOOK at this", and 1002651 chose that an all-abandoned criterion surfaces so someone decides whether it still means anything. So: everything that auto-closes was flagged; not everything flagged auto-closes. An earlier draft "aligned" the two predicates and broke that intent — `tests/criterion_progress.mjs` caught it; **do not re-align them**, the gap is the design. `/goal-review` is kept, demoted from gate to viewing surface; manual `POST /satisfy` stays for what automation cannot judge (a criterion with no linked tasks never auto-closes). Honest limits recorded: auto-satisfy cannot help a criterion nothing is LINKED to, and the in-txn close logs before commit (accepted — threading the summary out through `withTx` would cost ~5 lines in the over-threshold `db.js` under shrink-only ratchet policy). Rejected: sweep-only (up to 5 min to see your own goal close), hook-only (never drains the backlog), a stored `pending_review` column (second source of truth), deleting `/goal-review`.](<redacted>.md) | lifecycle / goal tier |
260
260
  | 0184 | [**A ship must account for itself: `shipTask` requires a land proof or a declared no-artifact** ([task 1002483](https://cloudbongos.com/builders#/task/1002483), goal 1000024). `db.shipTask` is the confirmed→shipped flip and, since [ADR 0120](<redacted>.md), where the CREDIT is booked — yet its only precondition was `WHERE status = 'confirmed'`, nothing about whether code reached main. The two AUTOMATED paths each proved the land first; the MANUAL route passed nothing, so a task confirmed with no pushed code could be shipped by its claim holder **or by any Metic+ sweeping `/merge-mode`**, booking its `task.shipped` credit. It stayed untracked for two months because its tracking task was closed in June asserting task 1001547 had delivered the gate — it had not, blocker 1000047's own final update says so, and the code agreed with the blocker rather than the closure note: **a hole hidden by a record nobody checked**, which is the same failure the gate is about. Decision: the gate lives at the WRITE, not the route (a route gate leaves the invariant unenforced at the layer that moves the ledger, so the next caller reopens it silently), and `shipTask` is FAIL-CLOSED on a required `landAssurance`. TWO kinds, and the second is why this does not make things worse: `proof` (this task's commit is on main) and **`no_artifact`** — a deliberate DB-only ship, an explicitly supported shape (CLAUDE.md "Direct DB writes are work too") that [task 1002478](https://cloudbongos.com/builders#/task/1002478) documents as a dead end below Archon. A naive proof-only gate would have made 1002478 strictly worse; naming no-artifact as a KIND turns it from an absence the gate must be talked out of into a recorded answer. Both carry a reason, audited post-commit and best-effort (an audit miss must never roll back a landed ship). `ship.js` now declares its own `--db-only`/`--allow-empty` lands, without which this fix would have regressed the very flow CLAUDE.md calls supported. Rejected: (b) gate only the route — leaves the write unguarded; (c) record the residual as tolerable — defensible on blast radius and genuinely close, but it would have been the FOURTH record asserting this was handled with nothing checkable behind it. Does NOT gate confirm: a codeless confirm is still accepted, it simply cannot reach `shipped` or pay.](<redacted>.md) | lifecycle / integrity |
261
261
  | 0185 | [**The spark-handoff credit split: 70% developer, 30% spark author** ([task 1002923](https://cloudbongos.com/builders#/task/1002923), BV1.R147, goal 1000062 — completing the chain [task 1002920](https://cloudbongos.com/builders#/task/1002920) mark → [task 1002921](https://cloudbongos.com/builders#/task/1002921) queue → [task 1002922](https://cloudbongos.com/builders#/task/1002922) pickup). [ADR 0172](<redacted>.md) built the ideator streams and paid `captured_by`, the only person an idea then had. The spark handoff created a second: R146 develops a spark IN PLACE, preserving the author while recording a `developed_by`. **R146 therefore shipped paying NOTHING on purpose** — not an oversight but a refusal to guess, because `awardIdeaFullPassCredit`'s idempotency predicate is `(builder_id, reason)`, so paying the DEVELOPER would not have blocked a later payment to the AUTHOR and one idea would have paid twice; the cost of that caution was that a developed Full Idea received stream 2 **never** unless its author resubmitted. Owner decision (2026-08-22): **70/30 favouring the developer**, on BOTH existing streams, one ratio so there is one rule to explain. The reasoning is behavioural, not sentimental — developing someone else's spark is the scarce act the queue depends on, and at an even split it pays HALF what authoring your own Full Idea pays for the same writing work, so the rational move is always to write your own and the R145 queue stays a list nobody reads. The author keeps 30% plus their share of stream 1 on every task shipped under the idea, forever. `IDEA_DEVELOPER_SHARE = 0.70` is a **policy constant in `credits.js`** beside the pass bar and quick factor (ADR 0172's own consequence, the ADR 0023 precedent): a share of someone's earnings deserves a review and a commit, not a runtime edit. **The structural half is what retires R146's caution:** the economy module now derives the payees itself (`ideaCreditParties` reads `captured_by` + `developed_by`), so the caller can no longer choose a recipient — the develop route hands over an idea and a score, never a payee, and books the split like any other path (fail-open; the development has already committed). Three arithmetic properties, each a real bug if missed: the total is **conserved** (developer rounded, author gets the REMAINDER — rounding both pays 6 out of a 5-credit payout); a **self-developed** idea collapses to ONE payee, because two rows for one builder would silently UNDER-pay under the per-builder idempotency predicate, not double-pay (the comparison normalises via `String()` since `bigint` arrives as a string, so a type mismatch cannot become a phantom payee); and **zero-value rows are dropped**. Ids otherwise pass through unconverted — `Number()`-ing a `bigint` risks precision. `builder_id` in the idempotency predicate, which entered as an index optimisation (the R143/IC2 grader finding), is now **load-bearing for correctness**: two payees share one `reason`, so a reason-only predicate would refuse the second, and a partially-booked split completes on the next call. **Not decided here:** stream 3's split (ships with the Board Room, task 1002999), and whether `origin='develop'` in `ideas_task_lineage` belongs to whoever promotes a DEVELOPED idea into a task — ADR 0172 §5 says the row is "set at promotion and at develop (R146)", but R146 creates no task, so that origin stays deliberately unwired rather than invented; it changes who stream 1 pays.](<redacted>.md) | economy / ideator lane |
262
+ | 0186 | [**Per-project idea templates: the seam is already built; the economics are the open problem** ([task 1002932](https://cloudbongos.com/builders#/task/1002932), BV1.R156, goal 1000062 — a SPIKE, output is this ADR plus a task list, no implementation). The literal question — how does a founder define their own idea shape without a schema change or forking the scorer — is **already answered by shipped work**: R141 gave every template function an optional `registry` defaulting to the built-in one, so validation and scoring both read the declaration as data; `ideas_grades.template_key` is per-row with a format CHECK whose own comment says a project key "stays addressable"; and `provisioning_instances.settings jsonb` is an existing per-project home. **No schema change, no fork.** Decision A: a **`project-settings` kernel port**, sibling of `builder-settings`, read via `resolveOptional` so a vanilla instance with no provisioning module degrades to the built-in registry; `base-v1` stays the default and the fallback for an unresolvable key. Rejected a new `ideas_templates` table (second home for data that has one) and a config file (per-instance, but one instance hosts many projects). **Decision B is the finding that matters.** Completeness is normalised 0–100 WITHIN its template (mean of per-field ratios), while karma reads it against an absolute bar (`IDEA_FULL_PASS_BAR = 60`, `IDEA_FULL_PASS_CREDIT = 5`) — so template choice becomes a credit-minting lever: `base-v1` needs ~200 characters for 5 credits, a 1-field template with `substanceMin` 10 needs ~10, a ~20× arbitrage handed to exactly the projects this ADR empowers. Shipping A without B would make the feature a way to print credits. The fix separates the two questions one number is answering: **completeness stays relative** (it is the right signal for triage ordering and the hall meter) and **karma stops reading it**, switching to an absolute substance total the scorer already computes per field and currently discards after averaging. An interim guard (≥3 required fields, ≥120 total `substanceMin`) bounds the arbitrage without fixing it. Decision C: an optional per-field `weight` (default 1) turning the unweighted mean into a weighted one — today's behaviour is the all-weights-1 case, so it is a data addition and one arithmetic line. Decision D: the five base fields are **not** mandatory (forcing them would make "define your own" cosmetic), replaced by two constraints — a template must declare ≥1 required field, and a stored template is validated at WRITE time so a malformed one is refused there rather than discovered inside the capture path, which must never throw. **Boundary note:** goal 1000062's wall is `[ideas, hall-ui]`, so the port task (provisioning + kernel) and the karma task (economy) cannot be claimed under it.](<redacted>.md) | ideas / templates |
262
263
 
263
264
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -158,6 +158,26 @@ const BASE_SPARSE_PATHS = [
158
158
  // and box-source-fetch re-applies this server-computed list every tick
159
159
  // (task 1002745). Generated ESM only, no product runtime.
160
160
  'clients',
161
+ // config/ — task 1003227. Without it a box CANNOT RUN ITS OWN QUALITY GATE:
162
+ // `scripts/gds/fitness.js` compares against config/fitness-baselines.json, and
163
+ // with the file absent the ratchet used to report PASS (it read the baselines
164
+ // through a try/catch that returned {}, so every metric had no floor to breach).
165
+ // A planted 5,000-line + 94-cycle regression sailed straight through. So a boxed
166
+ // builder shipped believing they were clean and learned otherwise from red CI,
167
+ // with nothing on the box to reproduce it. The fail-closed half is fixed in
168
+ // fitness-ratchets.js; this is the half that lets the gate actually run.
169
+ //
170
+ // Precedent, and why the "a module glob will pull it" argument does not hold:
171
+ // ADR 0174 put config/government.json in the `government` key's globs and the
172
+ // parent-directory emission below exists precisely because `config` was not in
173
+ // BASE — task 1003018 was RELEASED because its box could never see the file that
174
+ // was its entire deliverable. BASE is the right home, not a per-module accident.
175
+ //
176
+ // Secret posture unchanged: a cone-mode directory of JSON config. The NEUTRAL
177
+ // packs are already public, and an instance pack (config/branding.json,
178
+ // config/government.json) is host content the box's own builder authors — no
179
+ // credentials live here (ADR 0022); those are in /etc and the box env.
180
+ 'config',
161
181
  ];
162
182
 
163
183
  // sparsePathsForScopeKeys — PURE. Given the module KEYS a builder's active claims
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.320",
3
+ "version": "1.19.322",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.320",
9
+ "version": "1.19.322",
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.320",
3
+ "version": "1.19.322",
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",
@@ -215,12 +215,52 @@ function knipIssueCount() {
215
215
 
216
216
  // ---------- baseline + ratchet ----------
217
217
 
218
+ // readBaselines — task 1003227. Returns { metrics, error }: `error` is a reason
219
+ // string when the file cannot be trusted, null when it read cleanly.
220
+ //
221
+ // This used to `catch { return {}; }`, which made the gate FAIL OPEN. An empty
222
+ // baseline map leaves every `floor === undefined`, and compareToBaselines classifies
223
+ // an absent floor as a WARNING — so a checkout with no config/ reported the ratchet
224
+ // as PASSED. Measured: with the baselines gone, a planted 5,000-line and 94-cycle
225
+ // regression produced 0 violations. That is not a hypothetical checkout — `config/`
226
+ // is absent from every dev box's sparse slice, so the builders who most needed the
227
+ // gate were the ones silently exempt from it, learning from red CI instead.
228
+ //
229
+ // Distinguish the two absences, because they are not the same fact:
230
+ // - the FILE is missing/malformed -> cannot verify anything -> hard fail (here);
231
+ // - a KEY is missing for one metric -> a genuinely new metric -> stays a warning
232
+ // in compareToBaselines, so adding a metric does not break the build.
233
+ //
234
+ // The file's own header says raising a baseline to pass CI is out of policy;
235
+ // deleting the file must not be the loophole that raising it is not.
218
236
  function readBaselines() {
237
+ let raw;
219
238
  try {
220
- return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')).metrics || {};
221
- } catch {
222
- return {};
239
+ raw = fs.readFileSync(BASELINE_PATH, 'utf8');
240
+ } catch (e) {
241
+ const missing = e && e.code === 'ENOENT';
242
+ return {
243
+ metrics: {},
244
+ error: missing
245
+ ? `config/fitness-baselines.json is MISSING — the quality ratchet cannot verify anything, so it fails CLOSED rather than reporting a pass it did not check. `
246
+ + `If this is a dev box, config/ was historically outside the sparse checkout (task 1003227); re-sync the box (\`node scripts/gds/box-sync.js\`). `
247
+ + `To bootstrap a fresh baseline deliberately: node scripts/gds/fitness-ratchets.js --update-baselines`
248
+ : `config/fitness-baselines.json is unreadable (${e && e.message}) — failing CLOSED.`,
249
+ };
250
+ }
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(raw);
254
+ } catch (e) {
255
+ return { metrics: {}, error: `config/fitness-baselines.json is not valid JSON (${e && e.message}) — failing CLOSED.` };
256
+ }
257
+ const metrics = parsed && typeof parsed.metrics === 'object' && parsed.metrics !== null
258
+ ? parsed.metrics
259
+ : null;
260
+ if (!metrics) {
261
+ return { metrics: {}, error: 'config/fitness-baselines.json has no "metrics" object — failing CLOSED.' };
223
262
  }
263
+ return { metrics, error: null };
224
264
  }
225
265
 
226
266
  // Pure ratchet verdict — exported for the hermetic test.
@@ -256,7 +296,11 @@ function computeMetrics({ withDocs = true } = {}) {
256
296
  // The fitness.js check (in-suite, hermetic — everything except knip).
257
297
  function checkQualityRatchets() {
258
298
  const metrics = computeMetrics();
259
- const { violations, warnings } = compareToBaselines(metrics, readBaselines());
299
+ const { metrics: baselines, error: baselineError } = readBaselines();
300
+ const { violations, warnings } = compareToBaselines(metrics, baselines);
301
+ // task 1003227: an unreadable baseline file is a hard failure, not silence. Put it
302
+ // FIRST so it is the headline rather than buried under per-metric noise.
303
+ if (baselineError) violations.unshift(baselineError);
260
304
  return {
261
305
  name: 'quality ratchets — size, duplication, cycles, console, doc entropy only tighten (task 1003129)',
262
306
  hardFail: violations.length > 0,
@@ -274,7 +318,9 @@ function main() {
274
318
  const metrics = computeMetrics();
275
319
  const knip = knipIssueCount();
276
320
  if (knip !== null) metrics.knip_issue_count = knip;
277
- const existing = readBaselines();
321
+ // The bootstrap path deliberately tolerates an absent file — writing it is the
322
+ // whole point — so it reads the metrics and ignores any error (task 1003227).
323
+ const { metrics: existing } = readBaselines();
278
324
  if (knip === null && existing.knip_issue_count !== undefined) metrics.knip_issue_count = existing.knip_issue_count;
279
325
  fs.writeFileSync(
280
326
  BASELINE_PATH,
@@ -290,7 +336,9 @@ function main() {
290
336
  console.warn('fitness-ratchets: WARN — knip not runnable here (no node_modules / npx failure); failing OPEN.');
291
337
  process.exit(0);
292
338
  }
293
- const { violations, warnings } = compareToBaselines({ knip_issue_count: value }, readBaselines());
339
+ const { metrics: knipBaselines, error: knipBaselineError } = readBaselines();
340
+ const { violations, warnings } = compareToBaselines({ knip_issue_count: value }, knipBaselines);
341
+ if (knipBaselineError) violations.unshift(knipBaselineError);
294
342
  for (const w of warnings) console.warn(`fitness-ratchets: ${w}`);
295
343
  if (violations.length) {
296
344
  for (const v of violations) console.error(`fitness-ratchets: FAIL — ${v}`);
package/src/module-api.js CHANGED
@@ -1067,8 +1067,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1067
1067
  // landed since 1.19.318 with no explicit bump. run 32670195088. (task 1002620)
1068
1068
  // 1.19.320 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1069
1069
  // landed since 1.19.319 with no explicit bump. run 32678147546. (task 1002620)
1070
+ // 1.19.321 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1071
+ // landed since 1.19.320 with no explicit bump. run 32679324013. (task 1002620)
1072
+ // 1.19.322 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1073
+ // landed since 1.19.321 with no explicit bump. run 32679604360. (task 1002620)
1070
1074
  // ---------------------------------------------------------------------------
1071
- const CORE_VERSION = '1.19.320'; // CI auto-patch carrier (ADR 0161); see changelog above
1075
+ const CORE_VERSION = '1.19.322'; // CI auto-patch carrier (ADR 0161); see changelog above
1072
1076
 
1073
1077
  // A namespaced logger so a module's log lines are attributable + consistent.
1074
1078
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -368,5 +368,51 @@ t('a DENIED decision projects to null (the route returns 403, never a scope body
368
368
  assert.equal(access.scopeMetadata(), null, 'no args → null');
369
369
  });
370
370
 
371
+ // ---------- task 1003227: config/ must be in the box's BASE slice ----------
372
+ //
373
+ // Without it a box cannot run its own quality gate: fitness.js compares against
374
+ // config/fitness-baselines.json, and with the file absent the ratchet used to report
375
+ // PASS (a planted 5,000-line + 94-cycle regression sailed through). So a boxed builder
376
+ // shipped believing they were clean and learned otherwise from red CI, with nothing on
377
+ // the box to reproduce it. Precedent for the same class: ADR 0174 put
378
+ // config/government.json in the `government` globs, config/ was not in BASE, and task
379
+ // 1003018 was RELEASED because its box could never see its own deliverable.
380
+
381
+ t('1003227 BASE includes config/ so a box can read its own fitness baselines', () => {
382
+ const spec = access.decideSourceAccess({
383
+ rank: 'archon', status: 'active', scopeKeys: [], hasActiveClaims: false, moduleScopeMap: {},
384
+ });
385
+ assert.equal(spec.scope, 'base');
386
+ assert.ok(spec.spec.sparsePaths.includes('config'),
387
+ `base scope must carry config/; got ${spec.spec.sparsePaths.join(',')}`);
388
+ });
389
+
390
+ t('1003227 a TASK-scoped box carries config/ too (the common case — a claim is held)', () => {
391
+ // The REAL scope map, not a hand-rolled object — the shape matters here.
392
+ const scopeMap = require('../src/bongos/module-scope-map.js');
393
+ const spec = access.decideSourceAccess({
394
+ rank: 'xenos', status: 'active', scopeKeys: ['economy'], hasActiveClaims: true,
395
+ moduleScopeMap: scopeMap,
396
+ });
397
+ assert.equal(spec.scope, 'task');
398
+ assert.ok(spec.spec.sparsePaths.includes('config'), 'task scope must carry config/ as well');
399
+ assert.ok(spec.spec.sparsePaths.includes('modules/economy'),
400
+ `and still layer the claimed module on top; got ${spec.spec.sparsePaths.join(',')}`);
401
+ });
402
+
403
+ t('1003227 config/ is a cone-mode DIRECTORY entry (a file path aborts the whole set)', () => {
404
+ // ADR 0148: `git sparse-checkout set` rejects a file path, which kills the entire
405
+ // checkout — so every BASE entry must be a bare directory, no trailing slash.
406
+ for (const p of access.BASE_SPARSE_PATHS) {
407
+ assert.ok(!p.endsWith('/'), `${p} must not carry a trailing slash`);
408
+ // A dotted DIRECTORY (.claude, .devcontainer) is fine; what cone mode rejects is
409
+ // a file, so only test for an extension on the FINAL segment of a non-dotfile.
410
+ const last = p.split('/').pop();
411
+ if (!last.startsWith('.')) {
412
+ assert.ok(!/\.[a-z0-9]+$/i.test(last), `${p} looks like a FILE — cone mode would abort the set`);
413
+ }
414
+ }
415
+ });
416
+
371
417
  console.log(`\nbox_access.mjs: ${passed} passed, ${failed} failed`);
372
418
  process.exit(failed === 0 ? 0 : 1);
@@ -89,3 +89,85 @@ test('the committed baseline file parses and covers every hermetic metric', () =
89
89
  assert.ok(Number.isInteger(b.metrics[key]), `baseline missing ${key}`);
90
90
  }
91
91
  });
92
+
93
+ // ---------- task 1003227: the gate must FAIL CLOSED on an unusable baseline ----------
94
+ //
95
+ // readBaselines used to `catch { return {} }`. An empty baseline map leaves every
96
+ // `floor === undefined`, which compareToBaselines classifies as a WARNING — so a
97
+ // checkout with no config/ reported the ratchet PASSED. Measured before the fix: a
98
+ // planted 5,000-line + 94-cycle regression produced ZERO violations. That was not a
99
+ // hypothetical checkout — config/ is absent from every dev box's sparse slice, so the
100
+ // builders who most needed the gate were the ones silently exempt from it.
101
+ //
102
+ // checkQualityRatchets() reads the real repo, so these tests drive compareToBaselines
103
+ // (pure) for the metric-level rules and exercise the file-level rule by pointing at a
104
+ // baseline that does not exist.
105
+
106
+ test('1003227 an ABSENT baseline file makes the gate hard-fail, not pass', async () => {
107
+ const fs = require('node:fs');
108
+ const real = rat.BASELINE_PATH;
109
+ const backup = `${real}.1003227-test-backup`;
110
+ const had = fs.existsSync(real);
111
+ if (had) fs.renameSync(real, backup);
112
+ try {
113
+ const r = rat.checkQualityRatchets();
114
+ assert.equal(r.hardFail, true, 'a gate that cannot read its floors must not report a pass');
115
+ assert.ok(r.violations.length > 0, 'and it must say so as a VIOLATION, not a warning');
116
+ assert.match(r.violations[0], /MISSING/, 'the first violation names the real cause');
117
+ assert.match(r.violations[0], /fails CLOSED/i, 'and states the posture explicitly');
118
+ } finally {
119
+ if (had) fs.renameSync(backup, real);
120
+ }
121
+ });
122
+
123
+ test('1003227 a MALFORMED baseline file also hard-fails', async () => {
124
+ const fs = require('node:fs');
125
+ const real = rat.BASELINE_PATH;
126
+ const backup = `${real}.1003227-test-backup2`;
127
+ const had = fs.existsSync(real);
128
+ if (had) fs.copyFileSync(real, backup);
129
+ try {
130
+ fs.writeFileSync(real, '{ this is not json');
131
+ const r = rat.checkQualityRatchets();
132
+ assert.equal(r.hardFail, true, 'unparseable is no more verifiable than absent');
133
+ assert.match(r.violations[0], /not valid JSON/);
134
+ } finally {
135
+ if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
136
+ }
137
+ });
138
+
139
+ test('1003227 a baseline file with no "metrics" object hard-fails', async () => {
140
+ const fs = require('node:fs');
141
+ const real = rat.BASELINE_PATH;
142
+ const backup = `${real}.1003227-test-backup3`;
143
+ const had = fs.existsSync(real);
144
+ if (had) fs.copyFileSync(real, backup);
145
+ try {
146
+ fs.writeFileSync(real, JSON.stringify({ _comment: 'no metrics key' }));
147
+ const r = rat.checkQualityRatchets();
148
+ assert.equal(r.hardFail, true);
149
+ assert.match(r.violations[0], /no "metrics" object/);
150
+ } finally {
151
+ if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
152
+ }
153
+ });
154
+
155
+ test('1003227 a missing KEY for a NEW metric stays a warning (adding a metric must not brick CI)', () => {
156
+ // The two absences are different facts and must not collapse: no FILE = cannot
157
+ // verify = fail; no KEY = a genuinely new metric = warn.
158
+ const { violations, warnings } = rat.compareToBaselines(
159
+ { brand_new_metric: 7 },
160
+ { excess_lines_total: 100 },
161
+ );
162
+ assert.deepEqual(violations, [], 'a new metric with no floor yet must not fail the build');
163
+ assert.match(warnings.join(' '), /no baseline for "brand_new_metric"/);
164
+ });
165
+
166
+ test('1003227 a real regression is still caught when the baselines ARE readable', () => {
167
+ const { violations } = rat.compareToBaselines(
168
+ { excess_lines_total: 5100, require_cycle_count: 99 },
169
+ { excess_lines_total: 100, require_cycle_count: 5 },
170
+ );
171
+ assert.equal(violations.length, 2, 'both regressions reported');
172
+ assert.match(violations.join(' '), /never raise the baseline to pass/);
173
+ });