@bongos/core 1.19.29 → 1.19.31

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.29",
6
- "core_contract": "1.19.29",
7
- "source_commit": "653afa8efb0b5167d51ed1510c45977a56a575dd",
5
+ "core_version": "1.19.31",
6
+ "core_contract": "1.19.31",
7
+ "source_commit": "2e9b2abd8b182334b2eeb52e9815f499f340a3e3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T14:19:03.503Z",
9
+ "built_at": "2026-08-11T14:47:58.633Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 285,
12
+ "docs_redacted": 286,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1205,
14
+ "functional_verbatim": 1207,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1511,
20
- "tree_sha256": "0076ae91d3bb7f715ca250d05367d1eae9a660b5c015b88c0a6687d7a01a9794",
19
+ "file_count": 1514,
20
+ "tree_sha256": "46bd790b34d0d3e1c2884f43eecf20fa747ddd57fb76ad0331f24dfb362fc6a2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1269,10 +1269,15 @@
1269
1269
  "mode": "0000644",
1270
1270
  "sha256": "f6176799c7731da3d4970b75e882e0ab88e31b1a412c87957b9a5085bfc799c7"
1271
1271
  },
1272
+ {
1273
+ "path": "docs/adr/0163-legacy-display-name-resolution.md",
1274
+ "mode": "0000644",
1275
+ "sha256": "f1d673faaffd4d8a92709bafdc0d5c33b715131beac8f12c02dfa59880242a64"
1276
+ },
1272
1277
  {
1273
1278
  "path": "docs/adr/README.md",
1274
1279
  "mode": "0000644",
1275
- "sha256": "b3ed6866c5042d2ee2f1c5680af8d6ef85d36c70a4f9e208eac2e5922461f6dc"
1280
+ "sha256": "acaedbcf1451bdd23461b4d2e2e567678d1d5e4574ad615042a6c3d3db845fcc"
1276
1281
  },
1277
1282
  {
1278
1283
  "path": "docs/api-reference.md",
@@ -2194,6 +2199,11 @@
2194
2199
  "mode": "0000644",
2195
2200
  "sha256": "cfc4208327550710a64e44a401f552d78ab28814f033627f5fccc174f1c15b9d"
2196
2201
  },
2202
+ {
2203
+ "path": "migrations/core_214_display_name_source_backfill.sql",
2204
+ "mode": "0000644",
2205
+ "sha256": "5d970696a87e6f663f9fda83a268dbe99ced00ac02fb805d91114f54d4a8fece"
2206
+ },
2197
2207
  {
2198
2208
  "path": "modules/autonomy/CLAUDE.md",
2199
2209
  "mode": "0000644",
@@ -2827,12 +2837,12 @@
2827
2837
  {
2828
2838
  "path": "modules/grading/grader-rubric.json",
2829
2839
  "mode": "0000644",
2830
- "sha256": "52dfa2cde50170d982cc816da444fe1d1cf79814104cc9452aee1d658474556e"
2840
+ "sha256": "6917875f2be5c39248b44caf7bf8d50f68cbe8effbc2bfdef3ba11e5ac17fc6c"
2831
2841
  },
2832
2842
  {
2833
2843
  "path": "modules/grading/grader-workers/efficiency.js",
2834
2844
  "mode": "0000644",
2835
- "sha256": "6163aa89c558269267decf1ed91d6fc65939f27a34baf67364af01f8e6204cc1"
2845
+ "sha256": "9b8bbb51c5d8641d1c145d90ad8d0d96d19466abd8777bd10d5819630a6b7066"
2836
2846
  },
2837
2847
  {
2838
2848
  "path": "modules/grading/grader-workers/extract-json.js",
@@ -2847,7 +2857,7 @@
2847
2857
  {
2848
2858
  "path": "modules/grading/grader-workers/hacker.js",
2849
2859
  "mode": "0000644",
2850
- "sha256": "be6e82997286141f1c561c496ce1a717591d2b2bed9d5cee1f7af73770bc526c"
2860
+ "sha256": "99f64187efc56c2edf5a41626f87ee4083e83da43f1a00628d3506929d42e3c2"
2851
2861
  },
2852
2862
  {
2853
2863
  "path": "modules/grading/grader-workers/index.js",
@@ -2857,12 +2867,12 @@
2857
2867
  {
2858
2868
  "path": "modules/grading/grader-workers/narc.js",
2859
2869
  "mode": "0000644",
2860
- "sha256": "c034baa7e0973cbbf09f54305ed1ee8cb90c9642088edd6b45b63504ba267435"
2870
+ "sha256": "4e881e6d04bafcf6093be0e1c653979d00c174b6f7966fb9cd3d833a994f48d3"
2861
2871
  },
2862
2872
  {
2863
2873
  "path": "modules/grading/grader-workers/quality.js",
2864
2874
  "mode": "0000644",
2865
- "sha256": "7a64a9c4bb97b791be3e7ca8fe17164b80ae30aca968ac05a0229a6e4926b05e"
2875
+ "sha256": "de6003eb2af2abca2e6d68ebab729c8a3e18f0b8514d9f14e65b9442c21df253"
2866
2876
  },
2867
2877
  {
2868
2878
  "path": "modules/grading/grader-workers/rubric.js",
@@ -2872,12 +2882,12 @@
2872
2882
  {
2873
2883
  "path": "modules/grading/grader-workers/worker-prompt.js",
2874
2884
  "mode": "0000644",
2875
- "sha256": "017536ca8d35b1bb9b893a4fa7402d1f13f36c2e4c12585b9f296971080fa19c"
2885
+ "sha256": "f691b0a42984a9e768477ab694bf7d7bc92264d4bb6d5f50ea52fc0e79abbfb1"
2876
2886
  },
2877
2887
  {
2878
2888
  "path": "modules/grading/grader-workers/worker-verdict.js",
2879
2889
  "mode": "0000644",
2880
- "sha256": "8185aa753112fe66a13c2ce650ef21b9a832413e70fbebc8ff048aa8e7aae5d1"
2890
+ "sha256": "9c3abb069cfdee124b4fd5c521fe1c11b44177f7af941c9ab7bbb2a4a3718bab"
2881
2891
  },
2882
2892
  {
2883
2893
  "path": "modules/grading/grader.js",
@@ -4002,12 +4012,12 @@
4002
4012
  {
4003
4013
  "path": "package-lock.json",
4004
4014
  "mode": "0000644",
4005
- "sha256": "7a14171b353dbfb6da83df462c031ecce9d4c97b085504845035072e5e97bbfa"
4015
+ "sha256": "bb741853a2f1a9a62c27bfa769492a653a975103458dd872c4e51f7a7e4e6b9a"
4006
4016
  },
4007
4017
  {
4008
4018
  "path": "package.json",
4009
4019
  "mode": "0000644",
4010
- "sha256": "3ff6057a4a6120421ce79d9593bdf54e424fdd143bd0a20fc616ba92582ee910"
4020
+ "sha256": "1d0695ec7d2636aab3ca123aff5dfb89e5ccc6ddfd3cb3692b4570fd114e4f97"
4011
4021
  },
4012
4022
  {
4013
4023
  "path": "public-docs/index.html",
@@ -5402,7 +5412,7 @@
5402
5412
  {
5403
5413
  "path": "src/bongos/db-kernel.js",
5404
5414
  "mode": "0000644",
5405
- "sha256": "7951e23e770dc001f6893d42db1cdb6dbb91cf8b084f0651972c75bc8463083d"
5415
+ "sha256": "fa80ba2c0bb742b83d637280e65965f450bc0387063e65dd06f6bd9171283f23"
5406
5416
  },
5407
5417
  {
5408
5418
  "path": "src/bongos/db.js",
@@ -5627,7 +5637,7 @@
5627
5637
  {
5628
5638
  "path": "src/module-api.js",
5629
5639
  "mode": "0000644",
5630
- "sha256": "576d6afdcdf097366f36831d4b56ba300f71beefdc29c853605f9c0788cd260c"
5640
+ "sha256": "1b4208d337a8dd0b88ba82cb42f5685537be1e4d12824c4972564cecbf84ce32"
5631
5641
  },
5632
5642
  {
5633
5643
  "path": "src/module-loader/loader.js",
@@ -6287,7 +6297,7 @@
6287
6297
  {
6288
6298
  "path": "tests/display_name.mjs",
6289
6299
  "mode": "0000644",
6290
- "sha256": "4eac4b50dc7e3b9797f7d7633eb480b04dd5ebde4fc867ec19765177ff22d8b4"
6300
+ "sha256": "f5074a25ef511149149a6d708561ac639eff333519f703f015b6e72dc6adb56d"
6291
6301
  },
6292
6302
  {
6293
6303
  "path": "tests/dns_preflight.mjs",
@@ -6597,7 +6607,7 @@
6597
6607
  {
6598
6608
  "path": "tests/grader.mjs",
6599
6609
  "mode": "0000644",
6600
- "sha256": "4832bcffc7385cdd8deb0c1ff9bb44542190a3da106d2f00ccacd823c90103ec"
6610
+ "sha256": "9c8525b85be898be30d6034902b34fcdedb16cf4b37fa8d79fbd5d1fb89898cb"
6601
6611
  },
6602
6612
  {
6603
6613
  "path": "tests/grader_bypass.mjs",
@@ -6614,6 +6624,11 @@
6614
6624
  "mode": "0000644",
6615
6625
  "sha256": "fa155f3c5faa3763d1a51d56b7746ad870e826e3249f5e9b08108a3cfbfcbd72"
6616
6626
  },
6627
+ {
6628
+ "path": "tests/grader_severity_gate.mjs",
6629
+ "mode": "0000644",
6630
+ "sha256": "956384c2334742858dba987fe042b6c26581875ed5981a7565d43e46b55dfc49"
6631
+ },
6617
6632
  {
6618
6633
  "path": "tests/hall_audit.mjs",
6619
6634
  "mode": "0000644",
@@ -0,0 +1,44 @@
1
+ # 0163 — Pre-197 display names: a transitional 'legacy' source, resolved at first sign-in
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-11
5
+ - **Deciders:** Claude (jaxri session), under the bug-wave mandate
6
+
7
+ **Track:** `internal` (GDS / builder experience). Task [1002643](https://cloudbongos.com/builders#/task/1002643), from idea 1000659. Extends [ADR 0156](<redacted>.md).
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ ADR 0156 (migration `core_197`) made a builder's self-chosen display name durable by recording *who last set it* (`builders.display_name_source`: `'github' | 'user'`) and guarding the sign-in upsert. It claimed `'github'` was "the correct default for every pre-existing row: whatever is stored survived the last sign-in, so it *is* the GitHub-derived value."
14
+
15
+ That claim has a hole: a builder who renamed via `PATCH /me/display-name` **after their last sign-in but before 197 landed** still held their builder-set name in the row — 197 stamped it `'github'` anyway, so their next sign-in clobbers it. The fix shipped forward-only; the bug it fixed kept firing for exactly the people who had already hit it.
16
+
17
+ A precise backfill is impossible: the rename route writes no `audit_log` entry, and nothing stores what GitHub reported per row. "Was this stored name builder-set?" has no answer in the data.
18
+
19
+ ## Decision
20
+
21
+ **A third, transitional `display_name_source` value — `'legacy'` — for every row that predates 197 on the instance, resolved definitively by the upsert at each row's first subsequent sign-in.**
22
+
23
+ - Migration `core_214` extends the CHECK constraint and marks `'legacy'` every row with `display_name_source = 'github'` created before `core_197`'s `applied_at` **on that instance** (read from `schema_migrations`, so the scoping is portable to self-hosted instances regardless of when they upgraded). A replay guard ensures resolved rows are never re-marked.
24
+ - `upsertBuilder`'s conflict branch resolves each `'legacy'` row exactly once, using the one comparison that is only available at sign-in time — the incoming GitHub-derived name:
25
+ - stored name **equals** incoming → provably GitHub-derived → `'github'`; re-sync resumes as if 197's default had been right.
26
+ - stored name **differs** → treated as builder-set → kept, stamped `'user'`; durable from then on.
27
+ - `'user'` and `'github'` remain terminal; post-197 rows are untouched.
28
+
29
+ ### The accepted misclassification
30
+
31
+ A `'legacy'` row whose *GitHub-side* name changed between its last pre-214 sync and its first post-214 sign-in also "differs" — the stale GitHub name gets frozen as `'user'`. That trade is deliberate: the wrong outcome there is a stale *label*, visible and self-fixable in Settings; the wrong outcome under the status quo is the silent destruction of a deliberate rename — the reported bug. Preserve wins over sync when the evidence is ambiguous.
32
+
33
+ ### Rejected alternatives
34
+
35
+ - **Backfill `'user'` where `display_name <> github_login`** — overwhelmingly wrong: most GitHub accounts have a profile name that differs from the login, so this would freeze GitHub-derived names for nearly everyone, silently reintroducing the frozen-name bug ADR 0156 rejected.
36
+ - **Backfill nothing, compare names for ALL `'github'` rows forever** — turns every future GitHub rename into a frozen name; `'legacy'` scopes the ambiguity to the rows that actually carry it and expires it one sign-in later.
37
+ - **Restore lost names from an audit trail** — there is no audit trail; rows already clobbered between 197 and 214 are unrecoverable, and this ADR does not pretend otherwise.
38
+
39
+ ## Consequences
40
+
41
+ - `builders_display_name_source_check` now allows `'legacy'`; the value disappears from a row at its first post-214 sign-in and is never written by any other path.
42
+ - `tests/display_name.mjs` evaluates the real conflict-branch SQL for all three source states and pins the migration's contract (only-`'github'` rows marked, `applied_at` scoping, replay guard).
43
+ - Not surfaced in the API — same posture as ADR 0156; no UI copy branches on it.
44
+ - Builders whose names were already re-clobbered between 197 and 214 must re-save once in Settings — with 214 in place, it now sticks.
@@ -235,4 +235,6 @@ This keeps the decision history honest and traceable.
235
235
  | 0161 | [**Publish on merge: every merge to core `main` auto-publishes `@bongos/core`** (task 1002620). Supersedes ADR 0137's manual owner-gated publish after its own "when to revisit" clause fired twice in three weeks: 171 commits/10 days, then 104 commits/5 days, sat on `main` reachable by no pin while every layer reported success. A `publish` workflow rides behind the required `unit` check (`workflow_run`, success-gated): when HEAD's `CORE_VERSION` is already on npm, CI bumps a PATCH via the shape-preserving `bump-version.js` (+ changelog line; pinned by `tests/bump_version.mjs`) and pushes the carrier commit; `package-core --self-verify --publish --live` builds through the fail-closed no-leak gate; the release is tagged `core-v<v>`. A MINOR/MAJOR stays a human in-task edit — it decides what patch-channel subscribers receive unattended (ADR 0136), a call CI must not make. Token custody knowingly reverses (owner-accepted): `NPM_TOKEN` write + `RELEASE_PUSH_TOKEN` PAT as Actions secrets, mitigated by the lane shipping INERT behind the `PUBLISH_ON_MERGE=1` repo variable — an owner kill-switch symmetrical to `gating.js`. Idempotent by version-compare; npm duplicate rejection is the backstop; queued no-cancel concurrency collapses merge races.](<redacted>.md) | build pipeline / distribution |
236
236
  | 0162 | [**Review gates retired: rank-consistent CI checks only on `main`** ([task 1002635](https://cloudbongos.com/builders#/task/1002635); owner directive, idea 1000657). Amends ADR 0159. Within a day of task 1002619 arming the merge gates, the code-owner review requirement had stranded two graded-PASS wave-4 ships (PR 28 sat BLOCKED behind a review only the repo-owner account could give) and then blocked the fixes for the strands it caused — structurally, not incidentally: GitHub can only express the gate as *accounts* (no rank concept, no self-approval), so on a solo-archon instance it degenerates into "one named login must click every gate-surface PR" — identity-pinned authority, against ADR 0016's rank principle and the remediation's own goal. Decision: branch protection carries required status checks ONLY; `gating.js` `DEFAULT_PROTECTION` sets `required_pull_request_reviews: null` and `cmdOn` normalizes BOTH restore paths so a pre-retirement snapshot cannot resurrect the gate (`tests/gating_review_retired.mjs` pins it with a counter-example); the CODEOWNERS *file* is deleted while its *glob* stays in the hard-floor lists so reintroducing it is itself gate-flagged. Accepted trade, chosen explicitly by the owner: no pre-merge human approval on safety-machinery changes — the unit check, the fail-closed gate-review sandbox, the grader panel, and the every-push main-audit are the net, catching a compromised session's merge after the fact rather than before.](<redacted>.md) | governance / build pipeline |
237
237
 
238
+ | 0163 | [**Pre-197 display names: a transitional 'legacy' source, resolved at first sign-in** ([task 1002643](https://cloudbongos.com/builders#/task/1002643); idea 1000659). Amends [ADR 0156](<redacted>.md), whose migration defaulted every pre-existing row to `'github'` — false for builders who renamed after their last sign-in but before 197 landed, so their names kept reverting. No audit trail exists to backfill precisely, so `core_214` marks every pre-197 `'github'` row `'legacy'` (scoped by 197's `applied_at` on the instance — portable) and the upsert resolves each at its first sign-in: stored = incoming GitHub name → provably GitHub-derived → `'github'`; differs → treated as builder-set → kept, stamped `'user'`. Accepted misclassification: a GitHub-side rename in the window freezes a stale label (visible, self-fixable in Settings) — preferred over silently destroying deliberate renames. Rejected: `display_name <> github_login` heuristic (freezes nearly everyone), comparing forever on all `'github'` rows (turns every future GitHub rename into a frozen name).](<redacted>.md) | GDS / builder experience |
239
+
238
240
  > ⚠️ **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`.)
@@ -0,0 +1,55 @@
1
+ -- core_214_display_name_source_backfill.sql — heal pre-197 user-set names (task 1002643).
2
+ --
3
+ -- THE DEFECT. core_197 added display_name_source with DEFAULT 'github' for every
4
+ -- existing row, reasoning that "whatever is stored survived the last sign-in, so
5
+ -- it IS the GitHub-derived value" (ADR 0156). That is false for a builder who
6
+ -- renamed via PATCH /me/display-name AFTER their last sign-in but BEFORE 197
7
+ -- landed: their stored name is builder-set, the row says 'github', and their
8
+ -- next sign-in still clobbers it — the exact bug 197 shipped to fix. The fix was
9
+ -- forward-only; this migration is the missing backfill.
10
+ --
11
+ -- WHY NOT A PRECISE BACKFILL. The rename route writes no audit_log entry and
12
+ -- nothing records what GitHub reported per row, so "was this name builder-set?"
13
+ -- is unanswerable from stored data. Instead: a transitional third source value,
14
+ -- 'legacy', for every row that predates 197 on THIS instance. The upsert guard
15
+ -- (src/bongos/db-kernel.js) resolves each 'legacy' row definitively at its next
16
+ -- sign-in: stored name equals what GitHub sends, so it is provably GitHub-derived,
17
+ -- back to 'github' (re-sync resumes); differs, so treated as builder-set: kept, 'user'.
18
+ --
19
+ -- ACCEPTED TRADE (ADR 0163): a legacy row whose GitHub-side name changed before
20
+ -- its first post-214 sign-in freezes the stale GitHub name as 'user' — a
21
+ -- visible label, self-fixable in Settings. The status quo instead silently
22
+ -- destroys deliberate renames. Preserve wins.
23
+ --
24
+ -- Scoped by created_at against 197's applied_at ON THIS INSTANCE — portable:
25
+ -- every instance applied 197 at its own time. When 197 and 214 apply in the
26
+ -- same run (fresh install / big upgrade / restore-replay), applied_at is ~now()
27
+ -- so every pre-existing row is covered, which is then also correct: none of
28
+ -- those rows ever had the guard protecting them.
29
+ --
30
+ -- Idempotent: constraint statements re-run cleanly, and the NOT EXISTS guard
31
+ -- makes the UPDATE a no-op on replay against an up-to-date DB — rows resolved
32
+ -- after the first run are never re-marked 'legacy'.
33
+
34
+ BEGIN;
35
+
36
+ ALTER TABLE builders DROP CONSTRAINT IF EXISTS builders_display_name_source_check;
37
+ ALTER TABLE builders
38
+ ADD CONSTRAINT builders_display_name_source_check
39
+ CHECK (display_name_source IN ('github', 'user', 'legacy'));
40
+
41
+ UPDATE builders
42
+ SET display_name_source = 'legacy'
43
+ WHERE display_name_source = 'github'
44
+ AND created_at < COALESCE(
45
+ (SELECT applied_at FROM schema_migrations
46
+ WHERE version = 'core_197_display_name_source'),
47
+ now())
48
+ AND NOT EXISTS (
49
+ SELECT 1 FROM schema_migrations
50
+ WHERE version = 'core_214_display_name_source_backfill');
51
+
52
+ INSERT INTO schema_migrations (version) VALUES ('core_214_display_name_source_backfill')
53
+ ON CONFLICT DO NOTHING;
54
+
55
+ COMMIT;
@@ -32,17 +32,18 @@
32
32
  "concerns": "ANY worker.verdict='concerns' (and no fails) → overall passed=false, forwarded confidence = fallthrough_confidence (manual-confirm available).",
33
33
  "pass": "ALL workers.verdict='pass' → overall passed=true, advance to confirmed."
34
34
  },
35
+ "severity_gate_policy": "Task 1002663: style findings (comment style/length/placement, naming conventions, doc placement, test-name conventions) are ADVISORY — severity nit, recorded in issues[] for the audit trail, never a verdict driver. Enforced deterministically in grader-workers/worker-verdict.js applySeverityGatePolicy for narc/quality/efficiency: a worker 'fail' whose only basis is a sub-threshold score with NO major/blocker finding is an inconsistent self-report and downgrades to 'concerns' (manual-confirm pause) — a worker whose findings are all nit/minor can never verdict fail. The HACKER is deliberately EXEMPT: the security lens stays fail-closed on an unbacked sub-threshold score (a real finding mislabeled minor must still hard-fail). Untouched everywhere: blocker-driven fails, non-score fail triggers (Narc missing-AC), error short-circuits. Every worker prompt carries the matching STYLE_ADVISORY paragraph (worker-prompt.js).",
35
36
  "active_workers": {
36
37
  "narc": {
37
38
  "specialty": "semantic completion (description vs diff, line-level)",
38
39
  "scores_reported": ["functional_fidelity"],
39
- "verdict_rules": "fail if any blocker issue, any AC marked 'missing', or ff < 6; concerns if any AC 'partial' OR confidence < 0.4; pass otherwise",
40
+ "verdict_rules": "fail if any blocker issue, any AC marked 'missing', or ff < 6; concerns if any AC 'partial' OR confidence < 0.4; pass otherwise; severity_gate_policy: an ff < 6 with no major/blocker finding downgrades fail → concerns (style findings are advisory)",
40
41
  "prompt_lives_in": "modules/grading/grader-workers/narc.js"
41
42
  },
42
43
  "quality": {
43
44
  "specialty": "code quality (idiom, dead code, tests, invariants) + economy (advisory)",
44
45
  "scores_reported": ["code_quality", "economy"],
45
- "verdict_rules": "fail if any blocker issue or cq < 6; concerns if confidence < 0.4, economy < 6 (advisory nudge), or any major issue; pass otherwise",
46
+ "verdict_rules": "fail if any blocker issue or cq < 6; concerns if confidence < 0.4, economy < 6 (advisory nudge), or any major issue; pass otherwise; severity_gate_policy: a cq < 6 with no major/blocker finding downgrades fail → concerns (style findings are advisory)",
46
47
  "prompt_lives_in": "modules/grading/grader-workers/quality.js"
47
48
  },
48
49
  "hacker": {
@@ -55,7 +56,7 @@
55
56
  "efficiency": {
56
57
  "specialty": "runtime cost / hot-path performance (N+1, O(n²), sync I/O, unbounded queries)",
57
58
  "scores_reported": ["efficiency_score"],
58
- "verdict_rules": "fail if any blocker (critical/high) runtime-cost issue or efficiency_score < 6; concerns if confidence < 0.4 or any major (medium-severity) finding; pass otherwise",
59
+ "verdict_rules": "fail if any blocker (critical/high) runtime-cost issue or efficiency_score < 6; concerns if confidence < 0.4 or any major (medium-severity) finding; pass otherwise; severity_gate_policy: an efficiency_score < 6 with no major/blocker finding downgrades fail → concerns (style findings are advisory)",
59
60
  "distinct_from_economy": "Quality's `economy` axis measures DIFF SIZE (was this change bigger than the task needed?). Efficiency Monger's `efficiency_score` measures RUNTIME COST (will this code be expensive when it runs?). Both axes can be high (small AND fast); either can be low (small but slow, or large but fast). The panel records both separately.",
60
61
  "prompt_lives_in": "modules/grading/grader-workers/efficiency.js"
61
62
  }
@@ -17,7 +17,7 @@ const { THRESHOLD, LOW_CONF } = require('./rubric');
17
17
  // SR-15 / G1 (#995): scrub + fence all builder-controlled inputs. The shared
18
18
  // scaffold (worker-prompt) owns fenceBlock/scrubUntrusted/NOFOLLOW_PREAMBLE.
19
19
  const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
20
- const { failResult, parseCommon, finalizeVerdict } = require('./worker-verdict');
20
+ const { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy } = require('./worker-verdict');
21
21
 
22
22
  const KIND = 'efficiency';
23
23
  const NAME = 'Efficiency Monger';
@@ -191,7 +191,7 @@ function computeVerdict(parsed, meta = {}) {
191
191
  notesPrefix = `Efficiency Monger — PASS (eff=${effR}/10, ${patternsChecked.length} pattern${patternsChecked.length === 1 ? '' : 's'} checked, conf=${conf ?? '?'})`;
192
192
  }
193
193
 
194
- return finalizeVerdict({
194
+ return applySeverityGatePolicy(finalizeVerdict({
195
195
  verdict,
196
196
  scores: { efficiency_score: effR },
197
197
  issues,
@@ -199,7 +199,7 @@ function computeVerdict(parsed, meta = {}) {
199
199
  notesPrefix,
200
200
  notesFromSubagent,
201
201
  confidence: conf,
202
- });
202
+ }), { threshold: THRESHOLD });
203
203
  }
204
204
 
205
205
  module.exports = {
@@ -217,6 +217,11 @@ function computeVerdict(parsed, meta = {}) {
217
217
  notesPrefix = `Hacker — PASS (sec=${secR}/10, ${surfacesChecked.length} surface${surfacesChecked.length === 1 ? '' : 's'} checked, conf=${conf ?? '?'})`;
218
218
  }
219
219
 
220
+ // #1002663: the Hacker is deliberately EXEMPT from applySeverityGatePolicy.
221
+ // The security lens stays fail-closed on an unbacked sub-threshold score —
222
+ // a real finding the model mislabels as minor must still hard-fail, and the
223
+ // nit-churn evidence that motivated the policy was style/Quality-side, never
224
+ // security-score-driven.
220
225
  return finalizeVerdict({
221
226
  verdict,
222
227
  scores: { security_score: secR },
@@ -16,7 +16,7 @@ const { THRESHOLD, LOW_CONF } = require('./rubric');
16
16
  // block the model is told to treat as DATA, never instructions. The shared
17
17
  // scaffold (worker-prompt) owns fenceBlock/scrubUntrusted/NOFOLLOW_PREAMBLE.
18
18
  const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
19
- const { failResult, parseCommon, finalizeVerdict } = require('./worker-verdict');
19
+ const { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy } = require('./worker-verdict');
20
20
 
21
21
  const KIND = 'narc';
22
22
  const NAME = 'Narc';
@@ -185,7 +185,7 @@ function computeVerdict(parsed, meta = {}) {
185
185
  notesPrefix = `Narc — PASS (ff=${ffR}/10, ${verifiedStr}, conf=${conf ?? '?'})`;
186
186
  }
187
187
 
188
- return finalizeVerdict({
188
+ return applySeverityGatePolicy(finalizeVerdict({
189
189
  verdict,
190
190
  scores: { functional_fidelity: ffR },
191
191
  issues,
@@ -193,7 +193,9 @@ function computeVerdict(parsed, meta = {}) {
193
193
  notesPrefix,
194
194
  notesFromSubagent,
195
195
  confidence: conf,
196
- });
196
+ // A missing AC is a substantive non-score fail basis — it routinely
197
+ // co-occurs with a sub-threshold ff and must never be downgraded.
198
+ }), { threshold: THRESHOLD, substantiveFail: missingCount > 0 });
197
199
  }
198
200
 
199
201
  module.exports = {
@@ -14,7 +14,7 @@ const { THRESHOLD, LOW_CONF } = require('./rubric');
14
14
  // SR-15 / G1 (#995): scrub + fence all builder-controlled inputs. The shared
15
15
  // scaffold (worker-prompt) owns fenceBlock/scrubUntrusted/NOFOLLOW_PREAMBLE.
16
16
  const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
17
- const { failResult, parseCommon, finalizeVerdict } = require('./worker-verdict');
17
+ const { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy } = require('./worker-verdict');
18
18
 
19
19
  const KIND = 'quality';
20
20
  const NAME = 'Quality';
@@ -174,7 +174,7 @@ function computeVerdict(parsed, meta = {}) {
174
174
  + `, conf=${conf ?? '?'})`;
175
175
  }
176
176
 
177
- return finalizeVerdict({
177
+ return applySeverityGatePolicy(finalizeVerdict({
178
178
  verdict,
179
179
  scores: {
180
180
  code_quality: cqR,
@@ -184,7 +184,7 @@ function computeVerdict(parsed, meta = {}) {
184
184
  notesPrefix,
185
185
  notesFromSubagent,
186
186
  confidence: conf,
187
- });
187
+ }), { threshold: THRESHOLD });
188
188
  }
189
189
 
190
190
  module.exports = {
@@ -29,6 +29,18 @@ const LLM_PROJECT = (() => {
29
29
  catch { return 'this project'; }
30
30
  })();
31
31
 
32
+ // #1002663 severity-gate policy, stated to every worker: style findings are
33
+ // advisory and must never drive the verdict. Enforced deterministically in
34
+ // worker-verdict.js applySeverityGatePolicy — this paragraph keeps the model's
35
+ // self-reports consistent with that gate so downgrades stay rare.
36
+ const STYLE_ADVISORY = [
37
+ '## Severity policy — style findings are ADVISORY',
38
+ '',
39
+ 'Comment style/length/placement, naming conventions, documentation placement, and test-name conventions are severity `nit`, always. Record them in `issues[]` for the audit trail, but:',
40
+ '- never report a style finding at `major` or `blocker`;',
41
+ '- never let style concerns depress a numeric score below the pass threshold — a sub-threshold score MUST be backed by at least one `major` or `blocker` finding, or the verdict machinery downgrades your fail to concerns as an inconsistent self-report.',
42
+ ].join('\n');
43
+
32
44
  function buildWorkerPrompt({
33
45
  header, task, valueSummary, diff, changedFiles,
34
46
  taskHeader, descLabel, emptyFilesText, readFilesHint, finalInstruction,
@@ -82,6 +94,8 @@ function buildWorkerPrompt({
82
94
  lines.push('');
83
95
  lines.push(...fenceBlock('UNIFIED DIFF', diffText));
84
96
  lines.push('');
97
+ lines.push(STYLE_ADVISORY);
98
+ lines.push('');
85
99
  lines.push(finalInstruction);
86
100
  return lines.join('\n');
87
101
  }
@@ -47,4 +47,33 @@ function finalizeVerdict({ verdict, scores, issues, extra = {}, notesPrefix, not
47
47
  };
48
48
  }
49
49
 
50
- module.exports = { failResult, parseCommon, finalizeVerdict };
50
+ // #1002663 severity-gate policy: style findings are advisory, and a
51
+ // sub-threshold score must be BACKED by a substantive finding. Every worker
52
+ // prompt already demands "a score below threshold must be accompanied by a
53
+ // major-or-blocker issue"; this makes that contract deterministic instead of
54
+ // advisory prose the model can ignore. A 'fail' whose only basis is a
55
+ // sub-threshold score with NO major/blocker finding is an inconsistent
56
+ // self-report (style gripes leaking into the score channel — the task-1002653
57
+ // regrade-churn class) and downgrades to 'concerns', which routes to the
58
+ // manual-confirm pause rather than a hard panel FAIL. Deliberately untouched:
59
+ // blocker-driven fails, non-score fail triggers (e.g. Narc's missing-AC),
60
+ // and the error short-circuits — no per-worker security trigger is flattened.
61
+ // `substantiveFail`: a worker whose fail has a NON-SCORE basis the policy
62
+ // cannot see in issues[] (Narc's missing-AC — which routinely CO-OCCURS with a
63
+ // sub-threshold ff) passes true so the downgrade never touches it. Without
64
+ // this, missing-AC + low ff + no major issue read as an unbacked score fail.
65
+ function applySeverityGatePolicy(result, { threshold, substantiveFail = false }) {
66
+ if (!result || result.error || result.verdict !== 'fail' || substantiveFail) return result;
67
+ const issues = Array.isArray(result.issues) ? result.issues : [];
68
+ if (issues.some((i) => i && (i.severity === 'blocker' || i.severity === 'major'))) return result;
69
+ const reported = Object.values(result.scores || {}).filter((v) => Number.isFinite(v));
70
+ if (!reported.some((v) => v < threshold)) return result;
71
+ return {
72
+ ...result,
73
+ verdict: 'concerns',
74
+ policy_downgrade: 'subthreshold_score_without_substantive_finding',
75
+ notes_md: `${result.notes_md}\n[severity-gate] sub-threshold score with no major/blocker finding — fail downgraded to concerns (style findings are advisory)`.trim(),
76
+ };
77
+ }
78
+
79
+ module.exports = { failResult, parseCommon, finalizeVerdict, applySeverityGatePolicy };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.29",
3
+ "version": "1.19.31",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.29",
9
+ "version": "1.19.31",
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.29",
3
+ "version": "1.19.31",
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",
@@ -154,6 +154,10 @@ const RANKS = [
154
154
  // seeding and re-syncing the name for anyone who never renamed
155
155
  // (display_name_source='github'), and never overwrites one the builder set
156
156
  // themselves ('user', stamped by setBuilderDisplayName). See ADR 0156.
157
+ // 'legacy' rows (pre-197 accounts, backfilled by core_214) are ambiguous — no
158
+ // record says whether their stored name was builder-set. This sign-in resolves
159
+ // each one: stored = incoming proves GitHub-derived → 'github'; differing is
160
+ // treated as builder-set → kept and stamped 'user' (ADR 0163).
157
161
  async function upsertBuilder({ githubId, githubLogin, displayName, avatarUrl }) {
158
162
  const { rows } = await pool.query(
159
163
  `INSERT INTO builders (github_id, github_login, display_name, avatar_url)
@@ -162,7 +166,16 @@ async function upsertBuilder({ githubId, githubLogin, displayName, avatarUrl })
162
166
  SET github_login = EXCLUDED.github_login,
163
167
  display_name = CASE WHEN builders.display_name_source = 'user'
164
168
  THEN builders.display_name
169
+ WHEN builders.display_name_source = 'legacy'
170
+ AND builders.display_name IS DISTINCT FROM EXCLUDED.display_name
171
+ THEN builders.display_name
165
172
  ELSE EXCLUDED.display_name END,
173
+ display_name_source = CASE WHEN builders.display_name_source = 'user'
174
+ THEN 'user'
175
+ WHEN builders.display_name_source = 'legacy'
176
+ AND builders.display_name IS DISTINCT FROM EXCLUDED.display_name
177
+ THEN 'user'
178
+ ELSE 'github' END,
166
179
  avatar_url = EXCLUDED.avatar_url
167
180
  RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, created_at, rank, preferred_disciplines`,
168
181
  [String(githubId), githubLogin, displayName, avatarUrl ?? null]
package/src/module-api.js CHANGED
@@ -485,8 +485,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
485
485
  // landed since 1.19.27 with no explicit bump. run 31499228797. (task 1002620)
486
486
  // 1.19.29 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
487
487
  // landed since 1.19.28 with no explicit bump. run 31500810303. (task 1002620)
488
+ // 1.19.30 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
489
+ // landed since 1.19.29 with no explicit bump. run 31502880105. (task 1002620)
490
+ // 1.19.31 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
491
+ // landed since 1.19.30 with no explicit bump. run 31503461332. (task 1002620)
488
492
  // ---------------------------------------------------------------------------
489
- const CORE_VERSION = '1.19.29'; // CI auto-patch carrier (ADR 0161); see changelog above
493
+ const CORE_VERSION = '1.19.31'; // CI auto-patch carrier (ADR 0161); see changelog above
490
494
 
491
495
  // A namespaced logger so a module's log lines are attributable + consistent.
492
496
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { strict as assert } from 'node:assert';
21
21
  import { createRequire } from 'node:module';
22
+ import { readFileSync } from 'node:fs';
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
  const d = require('../modules/builder-settings/display-name.js');
@@ -151,20 +152,38 @@ function conflictAssignment(sql, column) {
151
152
  throw new Error(`no "${column} = …" assignment in the conflict branch`);
152
153
  }
153
154
 
154
- // Evaluate the display_name assignment for one row, so the tests below read as
155
- // behaviour ("whose name ends up stored?") rather than string matching. An
155
+ // Evaluate a conflict-branch assignment for one row, so the tests below read as
156
+ // behaviour ("whose name ends up stored?") rather than string matching. Handles
157
+ // the guard's exact grammar: multi-arm CASE, AND-joined conditions on
158
+ // display_name_source and IS DISTINCT FROM, string-literal results. An
156
159
  // unguarded assignment — the shape that shipped the bug — is a bare column
157
160
  // reference and evaluates to whatever GitHub just sent.
158
- function resolvedDisplayName(expr, { source, stored, incoming }) {
159
- const ref = (token) => {
160
- if (/^EXCLUDED\.display_name$/i.test(token)) return incoming;
161
- if (/^builders\.display_name$/i.test(token)) return stored;
162
- throw new Error(`unrecognised display_name reference: ${token}`);
161
+ function resolvedAssignment(expr, { source, stored, incoming }) {
162
+ const val = (token) => {
163
+ const t = token.trim();
164
+ if (/^EXCLUDED\.display_name$/i.test(t)) return incoming;
165
+ if (/^builders\.display_name$/i.test(t)) return stored;
166
+ const lit = /^'(\w+)'$/.exec(t);
167
+ if (lit) return lit[1];
168
+ throw new Error(`unrecognised token: ${t}`);
163
169
  };
170
+ const cond = (c) => c.split(/\s+AND\s+/i).every((one) => {
171
+ const t = one.trim();
172
+ let m = /^builders\.display_name_source = '(\w+)'$/i.exec(t);
173
+ if (m) return source === m[1];
174
+ m = /^builders\.display_name IS DISTINCT FROM EXCLUDED\.display_name$/i.exec(t);
175
+ if (m) return stored !== incoming;
176
+ throw new Error(`unrecognised condition: ${t}`);
177
+ });
164
178
  const norm = expr.replace(/\s+/g, ' ').trim();
165
- const m = /^CASE WHEN builders\.display_name_source = '(\w+)' THEN (\S+) ELSE (\S+) END$/i.exec(norm);
166
- if (!m) return ref(norm);
167
- return source === m[1] ? ref(m[2]) : ref(m[3]);
179
+ if (!/^CASE WHEN /i.test(norm)) return val(norm);
180
+ const inner = norm.replace(/^CASE\s+/i, '').replace(/\s+END$/i, '');
181
+ const elseM = /\sELSE\s+(.+)$/i.exec(inner);
182
+ if (!elseM) throw new Error(`CASE without ELSE: ${norm}`);
183
+ for (const [, c, result] of inner.slice(0, elseM.index).matchAll(/WHEN\s+(.+?)\s+THEN\s+(\S+)/gi)) {
184
+ if (cond(c)) return val(result);
185
+ }
186
+ return val(elseM[1]);
168
187
  }
169
188
 
170
189
  // Drive both halves once (they're async), then assert on the captured SQL with
@@ -173,6 +192,7 @@ await kernel.upsertBuilder({
173
192
  githubId: 64553469, githubLogin: 'jaxri', displayName: 'GitHub Name', avatarUrl: null,
174
193
  });
175
194
  const upsertExpr = conflictAssignment(lastSql, 'display_name');
195
+ const sourceExpr = conflictAssignment(lastSql, 'display_name_source');
176
196
 
177
197
  await settingsDb.setBuilderDisplayName(1000095, 'Renée the Builder');
178
198
  const setNameSql = lastSql.replace(/\s+/g, ' ');
@@ -187,14 +207,14 @@ t('the conflict branch keys display_name off display_name_source', () => {
187
207
  });
188
208
 
189
209
  t('a builder who set their own name keeps it when GitHub sends a different one', () => {
190
- const kept = resolvedDisplayName(upsertExpr, {
210
+ const kept = resolvedAssignment(upsertExpr, {
191
211
  source: 'user', stored: 'Renée the Builder', incoming: 'renee-gh',
192
212
  });
193
213
  assert.equal(kept, 'Renée the Builder');
194
214
  });
195
215
 
196
216
  t('a builder who never renamed still picks up a GitHub rename', () => {
197
- const synced = resolvedDisplayName(upsertExpr, {
217
+ const synced = resolvedAssignment(upsertExpr, {
198
218
  source: 'github', stored: 'Old GitHub Name', incoming: 'New GitHub Name',
199
219
  });
200
220
  assert.equal(synced, 'New GitHub Name');
@@ -208,5 +228,49 @@ t('setBuilderDisplayName stamps the name as user-set', () => {
208
228
  );
209
229
  });
210
230
 
231
+ // --- pre-197 rows: 'legacy' resolves at first sign-in (task 1002643, ADR 0163)
232
+
233
+ console.log('\nlegacy resolution (pre-197 rows backfilled by core_214):');
234
+
235
+ t('a legacy row whose name differs from GitHub keeps it — and becomes user-set', () => {
236
+ const row = { source: 'legacy', stored: 'Percy Jackson', incoming: 'GitHub Name' };
237
+ assert.equal(resolvedAssignment(upsertExpr, row), 'Percy Jackson');
238
+ assert.equal(resolvedAssignment(sourceExpr, row), 'user');
239
+ });
240
+
241
+ t('a legacy row whose name matches GitHub resolves to github and resumes syncing', () => {
242
+ const row = { source: 'legacy', stored: 'Same Name', incoming: 'Same Name' };
243
+ assert.equal(resolvedAssignment(upsertExpr, row), 'Same Name');
244
+ assert.equal(resolvedAssignment(sourceExpr, row), 'github');
245
+ });
246
+
247
+ t('resolved sources are terminal: user stays user, github stays github', () => {
248
+ assert.equal(resolvedAssignment(sourceExpr, { source: 'user', stored: 'A', incoming: 'B' }), 'user');
249
+ assert.equal(resolvedAssignment(sourceExpr, { source: 'github', stored: 'A', incoming: 'B' }), 'github');
250
+ });
251
+
252
+ // The backfill migration is load-bearing for the guard above: without it no row
253
+ // is ever 'legacy'. Pin its contract so a hand-edit can't silently unwind it.
254
+ const migration = readFileSync(
255
+ new URL('../migrations/core_214_display_name_source_backfill.sql', import.meta.url),
256
+ 'utf8'
257
+ );
258
+
259
+ t("core_214 extends the CHECK constraint to allow 'legacy'", () => {
260
+ assert.match(migration, /CHECK \(display_name_source IN \('github', 'user', 'legacy'\)\)/);
261
+ });
262
+
263
+ t("core_214 marks only 'github' rows — a user-set stamp is never touched", () => {
264
+ assert.match(migration, /SET display_name_source = 'legacy'\s+WHERE display_name_source = 'github'/);
265
+ });
266
+
267
+ t('core_214 scopes the backfill to rows created before 197 applied on this instance', () => {
268
+ assert.match(migration, /created_at < COALESCE\(\s*\(SELECT applied_at FROM schema_migrations\s+WHERE version = 'core_197_display_name_source'\)/);
269
+ });
270
+
271
+ t('core_214 is replay-safe: resolved rows are never re-marked legacy', () => {
272
+ assert.match(migration, /NOT EXISTS \(\s*SELECT 1 FROM schema_migrations\s+WHERE version = 'core_214_display_name_source_backfill'\)/);
273
+ });
274
+
211
275
  console.log(`\n${passed} passed, ${failed} failed`);
212
276
  process.exit(failed === 0 ? 0 : 1);
package/tests/grader.mjs CHANGED
@@ -1267,12 +1267,15 @@ test('v4 narc: any partial AC → concerns', () => {
1267
1267
  assert.equal(v.verdict, 'concerns');
1268
1268
  });
1269
1269
 
1270
- test('v4 narc: ff < threshold → fail', () => {
1270
+ // #1002663 severity-gate policy: a sub-threshold score must be BACKED by a
1271
+ // major/blocker finding to hard-fail; unbacked it downgrades to concerns
1272
+ // (tests/grader_severity_gate.mjs covers the downgrade shapes).
1273
+ test('v4 narc: ff < threshold backed by a major finding → fail', () => {
1271
1274
  const v = grader.WORKERS.narc.computeVerdict({
1272
1275
  functional_fidelity: 4,
1273
1276
  confidence: 0.9,
1274
1277
  ac_checklist: [{ ac: 'A', status: 'satisfied' }],
1275
- issues: [],
1278
+ issues: [{ severity: 'major', file: 'x.js', summary: 'scope gap' }],
1276
1279
  notes_md: 'low score',
1277
1280
  }, {});
1278
1281
  assert.equal(v.verdict, 'fail');
@@ -1316,9 +1319,10 @@ test('v4 quality: blocker issue → fail', () => {
1316
1319
  assert.equal(v.verdict, 'fail');
1317
1320
  });
1318
1321
 
1319
- test('v4 quality: cq < threshold → fail', () => {
1322
+ test('v4 quality: cq < threshold backed by a major issue → fail (#1002663 policy)', () => {
1320
1323
  const v = grader.WORKERS.quality.computeVerdict({
1321
- code_quality: 4, economy: 7, confidence: 0.9, issues: [], notes_md: '',
1324
+ code_quality: 4, economy: 7, confidence: 0.9,
1325
+ issues: [{ severity: 'major', file: 'x.js', summary: 'dead code path' }], notes_md: '',
1322
1326
  }, {});
1323
1327
  assert.equal(v.verdict, 'fail');
1324
1328
  });
@@ -1685,7 +1689,7 @@ test('v4 hacker: blocker (critical/high) issue → fail', () => {
1685
1689
  assert.equal(v.verdict, 'fail');
1686
1690
  });
1687
1691
 
1688
- test('v4 hacker: security_score below threshold → fail', () => {
1692
+ test('v4 hacker: security_score below threshold → fail (EXEMPT from the #1002663 downgrade — fail-closed)', () => {
1689
1693
  const v = grader.WORKERS.hacker.computeVerdict({
1690
1694
  security_score: 4, confidence: 0.9, surfaces_checked: [], issues: [], notes_md: '',
1691
1695
  }, {});
@@ -1821,9 +1825,10 @@ test('v4 efficiency: blocker (N+1, O(n²), sync-I/O-on-hot-path) → fail', () =
1821
1825
  assert.equal(v.verdict, 'fail');
1822
1826
  });
1823
1827
 
1824
- test('v4 efficiency: efficiency_score below threshold → fail', () => {
1828
+ test('v4 efficiency: efficiency_score below threshold backed by a major finding → fail (#1002663 policy)', () => {
1825
1829
  const v = grader.WORKERS.efficiency.computeVerdict({
1826
- efficiency_score: 4, confidence: 0.9, expensive_patterns_checked: [], issues: [], notes_md: '',
1830
+ efficiency_score: 4, confidence: 0.9, expensive_patterns_checked: [],
1831
+ issues: [{ severity: 'major', file: 'x.js', summary: 'N+1 in list view' }], notes_md: '',
1827
1832
  }, {});
1828
1833
  assert.equal(v.verdict, 'fail');
1829
1834
  });
@@ -0,0 +1,156 @@
1
+ // tests/grader_severity_gate.mjs
2
+ //
3
+ // Unit tests for task 1002663 — the severity-gate policy: style findings are
4
+ // ADVISORY, and a sub-threshold score must be BACKED by a major/blocker
5
+ // finding. Evidence class (task 1002653, 2026-08-11): panel rounds hung on
6
+ // comment-style nits, and the prompt line "a score below 6 must be accompanied
7
+ // by a major-or-blocker issue" was advisory prose the model could ignore.
8
+ // applySeverityGatePolicy (grader-workers/worker-verdict.js) makes it
9
+ // deterministic: an unbacked sub-threshold 'fail' downgrades to 'concerns'
10
+ // (manual-confirm pause), never a hard panel FAIL — while blocker-driven
11
+ // fails, non-score triggers (Narc's missing-AC), and error short-circuits
12
+ // stay exactly as they were.
13
+ //
14
+ // Run: node tests/grader_severity_gate.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { applySeverityGatePolicy, failResult } = require('../modules/grading/grader-workers/worker-verdict.js');
21
+ const narc = require('../modules/grading/grader-workers/narc.js');
22
+ const quality = require('../modules/grading/grader-workers/quality.js');
23
+ const hacker = require('../modules/grading/grader-workers/hacker.js');
24
+ const efficiency = require('../modules/grading/grader-workers/efficiency.js');
25
+
26
+ const THRESHOLD = 6;
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ try { fn(); passed++; console.log(` ok ${name}`); }
32
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
33
+ }
34
+
35
+ const nit = { severity: 'nit', file: 'x.js', summary: 'comment style' };
36
+ const minor = { severity: 'minor', file: 'x.js', summary: 'small thing' };
37
+ const major = { severity: 'major', file: 'x.js', summary: 'real problem' };
38
+ const blocker = { severity: 'blocker', file: 'x.js', summary: 'must fix' };
39
+
40
+ // ---- applySeverityGatePolicy unit ----------------------------------------
41
+
42
+ test('unbacked sub-threshold fail (only nits/minors) → concerns with policy_downgrade', () => {
43
+ const r = applySeverityGatePolicy(
44
+ { verdict: 'fail', scores: { code_quality: 5 }, issues: [nit, minor], notes_md: 'n', confidence: 0.9, error: null },
45
+ { threshold: THRESHOLD }
46
+ );
47
+ assert.equal(r.verdict, 'concerns');
48
+ assert.equal(r.policy_downgrade, 'subthreshold_score_without_substantive_finding');
49
+ assert.match(r.notes_md, /severity-gate/);
50
+ });
51
+
52
+ test('sub-threshold fail BACKED by a major stays fail', () => {
53
+ const r = applySeverityGatePolicy(
54
+ { verdict: 'fail', scores: { code_quality: 5 }, issues: [major], notes_md: 'n', confidence: 0.9, error: null },
55
+ { threshold: THRESHOLD }
56
+ );
57
+ assert.equal(r.verdict, 'fail');
58
+ assert.equal(r.policy_downgrade, undefined);
59
+ });
60
+
61
+ test('blocker-driven fail with passing scores stays fail (no security trigger flattened)', () => {
62
+ const r = applySeverityGatePolicy(
63
+ { verdict: 'fail', scores: { security_score: 9 }, issues: [blocker], notes_md: 'n', confidence: 0.9, error: null },
64
+ { threshold: THRESHOLD }
65
+ );
66
+ assert.equal(r.verdict, 'fail');
67
+ });
68
+
69
+ test('non-score fail trigger (no sub-threshold score, no issues) stays fail — the Narc missing-AC shape', () => {
70
+ const r = applySeverityGatePolicy(
71
+ { verdict: 'fail', scores: { functional_fidelity: 7 }, issues: [], notes_md: 'n', confidence: 0.9, error: null },
72
+ { threshold: THRESHOLD }
73
+ );
74
+ assert.equal(r.verdict, 'fail');
75
+ });
76
+
77
+ test('error short-circuits are untouched', () => {
78
+ const r = applySeverityGatePolicy(failResult({ notesMd: 'boom', error: 'subprocess_error' }), { threshold: THRESHOLD });
79
+ assert.equal(r.verdict, 'fail');
80
+ assert.equal(r.error, 'subprocess_error');
81
+ });
82
+
83
+ test('pass and concerns verdicts are untouched', () => {
84
+ for (const verdict of ['pass', 'concerns']) {
85
+ const r = applySeverityGatePolicy(
86
+ { verdict, scores: { code_quality: 5 }, issues: [nit], notes_md: 'n', confidence: 0.9, error: null },
87
+ { threshold: THRESHOLD }
88
+ );
89
+ assert.equal(r.verdict, verdict);
90
+ }
91
+ });
92
+
93
+ // ---- worker integration ---------------------------------------------------
94
+
95
+ test('quality: cq=5 with only nits → concerns (not fail)', () => {
96
+ const r = quality.computeVerdict({ code_quality: 5, economy: 8, confidence: 0.9, issues: [nit], notes_md: '' });
97
+ assert.equal(r.verdict, 'concerns');
98
+ assert.equal(r.policy_downgrade, 'subthreshold_score_without_substantive_finding');
99
+ });
100
+
101
+ test('quality: cq=5 with a major → fail stays', () => {
102
+ const r = quality.computeVerdict({ code_quality: 5, economy: 8, confidence: 0.9, issues: [major], notes_md: '' });
103
+ assert.equal(r.verdict, 'fail');
104
+ });
105
+
106
+ test('quality: blocker with cq=9 → fail stays', () => {
107
+ const r = quality.computeVerdict({ code_quality: 9, economy: 8, confidence: 0.9, issues: [blocker], notes_md: '' });
108
+ assert.equal(r.verdict, 'fail');
109
+ });
110
+
111
+ test('hacker is EXEMPT: security_score=5 with only a nit stays a hard fail (security lens fail-closed)', () => {
112
+ const r = hacker.computeVerdict({ security_score: 5, confidence: 0.9, issues: [nit], notes_md: '', surfaces_checked: [] });
113
+ assert.equal(r.verdict, 'fail');
114
+ assert.equal(r.policy_downgrade, undefined);
115
+ });
116
+
117
+ test('efficiency: eff=5 with only a minor → concerns (not fail)', () => {
118
+ const r = efficiency.computeVerdict({ efficiency_score: 5, confidence: 0.9, issues: [minor], notes_md: '', expensive_patterns_checked: [] });
119
+ assert.equal(r.verdict, 'concerns');
120
+ });
121
+
122
+ test('narc: missing AC with ff=7 and no issues → fail stays (non-score trigger untouched)', () => {
123
+ const r = narc.computeVerdict({
124
+ functional_fidelity: 7, confidence: 0.9, issues: [], notes_md: '',
125
+ ac_checklist: [{ ac: 'does the thing', status: 'missing' }],
126
+ });
127
+ assert.equal(r.verdict, 'fail');
128
+ });
129
+
130
+ test('narc: missing AC CO-OCCURRING with sub-threshold ff and no issues → fail stays (the common shape)', () => {
131
+ const r = narc.computeVerdict({
132
+ functional_fidelity: 4, confidence: 0.9, issues: [], notes_md: '',
133
+ ac_checklist: [{ ac: 'does the thing', status: 'missing' }],
134
+ });
135
+ assert.equal(r.verdict, 'fail');
136
+ assert.equal(r.policy_downgrade, undefined);
137
+ });
138
+
139
+ test('narc: ff=5 with only nits → concerns (not fail)', () => {
140
+ const r = narc.computeVerdict({ functional_fidelity: 5, confidence: 0.9, issues: [nit], notes_md: '', ac_checklist: [] });
141
+ assert.equal(r.verdict, 'concerns');
142
+ });
143
+
144
+ // ---- prompt carries the policy -------------------------------------------
145
+
146
+ test('every worker prompt carries the STYLE_ADVISORY paragraph', () => {
147
+ const args = { task: { id: 1, title: 't', description: 'd', kind: 'bug', discipline: 'engineer' }, valueSummary: 'v', diff: '+ x', changedFiles: ['x.js'] };
148
+ for (const w of [narc, quality, hacker, efficiency]) {
149
+ const p = w.buildPrompt(args);
150
+ assert.match(p, /Severity policy — style findings are ADVISORY/, `${w.KIND || 'worker'} prompt must carry the policy`);
151
+ assert.match(p, /never report a style finding at `major`/);
152
+ }
153
+ });
154
+
155
+ console.log(`\n${passed} passed, ${failed} failed`);
156
+ process.exit(failed === 0 ? 0 : 1);