@bongos/core 1.19.888 → 1.19.890

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.888",
6
- "core_contract": "1.19.888",
7
- "source_commit": "7ccf0b2f4cc7f7bafb93762cbd5cfb9ed37cf546",
5
+ "core_version": "1.19.890",
6
+ "core_contract": "1.19.890",
7
+ "source_commit": "fb73c80a33c2f4e3aaf8d9ae3bb0295b6ec73e3b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T20:57:53.725Z",
9
+ "built_at": "2026-09-19T21:31:12.716Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 497,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2266,
14
+ "functional_verbatim": 2271,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2789,
20
- "tree_sha256": "42c331016e53b14ca5416a325790e2846ea0ee2c6a24177222b823a1d2977b9c",
19
+ "file_count": 2794,
20
+ "tree_sha256": "8e1cc8300c4c29b876e8dc18b16384db7ade73e5948c7e009c0b69f9dadea8a7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "7f60dfa2c479fecbd5a81de040689b9ff6f9d2aa5a907f9a7fcf86b77b476fea"
2610
+ "sha256": "7fb640ef029a52e521d43e2828ca0201a7e53c7a94f23fcebd58aefd6f6820fe"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -2742,7 +2742,7 @@
2742
2742
  {
2743
2743
  "path": "docs/recipes/gds-db-backup.md",
2744
2744
  "mode": "0000644",
2745
- "sha256": "b3d6a3e3a400bf593b0f2d671f35cb1a60279f495c8763d311f8cdb68a6d603a"
2745
+ "sha256": "d3e628bc627cb29874e977bf2e93c6557dc72a210506c22515a983f4f513d627"
2746
2746
  },
2747
2747
  {
2748
2748
  "path": "docs/recipes/instance-deploy.md",
@@ -3699,6 +3699,11 @@
3699
3699
  "mode": "0000644",
3700
3700
  "sha256": "4f35f87050a532f26b7c9cd0a9bc6f298ab5c0e15eb1c36ddcdd4d261201291d"
3701
3701
  },
3702
+ {
3703
+ "path": "migrations/core_249_session_token_hash.sql",
3704
+ "mode": "0000644",
3705
+ "sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
3706
+ },
3702
3707
  {
3703
3708
  "path": "modules/agents/CLAUDE.md",
3704
3709
  "mode": "0000644",
@@ -4437,12 +4442,12 @@
4437
4442
  {
4438
4443
  "path": "modules/grading/grader-rubric.json",
4439
4444
  "mode": "0000644",
4440
- "sha256": "a08fe9f320dab2d3f699700cfb9f78d1604533d930e794d556c8f9ab533cf69b"
4445
+ "sha256": "de0ca17fedb33bd54d73759e14e6813cd9f2c8c85ef2180e49c54794c7a63a3d"
4441
4446
  },
4442
4447
  {
4443
4448
  "path": "modules/grading/grader-score.js",
4444
4449
  "mode": "0000644",
4445
- "sha256": "07559f7c1f53ac8de36e1fd23965a9c29344101468542d6e636a3ce4d8860b4f"
4450
+ "sha256": "e0f665b760e2b299a7aa9098e1ab967aa330ff20f7430e0aa23d9c7c57aa0d70"
4446
4451
  },
4447
4452
  {
4448
4453
  "path": "modules/grading/grader-subagent.js",
@@ -4457,7 +4462,7 @@
4457
4462
  {
4458
4463
  "path": "modules/grading/grader-workers/extract-json.js",
4459
4464
  "mode": "0000644",
4460
- "sha256": "1be95c661d95cfc7f54b9f2776eed6c1cd5b6e2e852b24c80f6b638f4d775751"
4465
+ "sha256": "b359c904c68ae8b36ac6518c79bcebf251abcf7aaef3dae4aff4a6e2175e7914"
4461
4466
  },
4462
4467
  {
4463
4468
  "path": "modules/grading/grader-workers/fence.js",
@@ -4472,7 +4477,7 @@
4472
4477
  {
4473
4478
  "path": "modules/grading/grader-workers/index.js",
4474
4479
  "mode": "0000644",
4475
- "sha256": "c527d3dd996b4064ae14836e305de097edea2b1a3c46776d8ec7da8c2c6afe71"
4480
+ "sha256": "ec8046b24550ef81c26b8a421c684360873f9eaed0ec3a7e95b8546aa1e45d6f"
4476
4481
  },
4477
4482
  {
4478
4483
  "path": "modules/grading/grader-workers/narc.js",
@@ -4484,6 +4489,11 @@
4484
4489
  "mode": "0000644",
4485
4490
  "sha256": "1ad616532dc2c240bf94992db17b12f1334e3e74667c402d997c03c2c8df1566"
4486
4491
  },
4492
+ {
4493
+ "path": "modules/grading/grader-workers/reader.js",
4494
+ "mode": "0000644",
4495
+ "sha256": "b7fdf28a76914c708b628a20ee90278ed29ad1e4c886b4e4f0edb2a2515d4d2c"
4496
+ },
4487
4497
  {
4488
4498
  "path": "modules/grading/grader-workers/rubric.js",
4489
4499
  "mode": "0000644",
@@ -4502,7 +4512,7 @@
4502
4512
  {
4503
4513
  "path": "modules/grading/grader.js",
4504
4514
  "mode": "0000644",
4505
- "sha256": "4c664f5080513169c35369e5406f32efa64ce24259fefc47f527a94ff8001b1f"
4515
+ "sha256": "1234bb3c9938fd4cf71c39e4660d69c56a27de667ee7ff09b0f865374b6beba4"
4506
4516
  },
4507
4517
  {
4508
4518
  "path": "modules/grading/module.json",
@@ -5362,7 +5372,7 @@
5362
5372
  {
5363
5373
  "path": "modules/lifecycle/cascade.js",
5364
5374
  "mode": "0000644",
5365
- "sha256": "acc9cf557aabc3fe10efc8c7272162cf288428e8077a24f5df06648e30881337"
5375
+ "sha256": "b168a58926ee0506d3fba4f448c797b8611bc8ae0dfeca322086f22769e7ae35"
5366
5376
  },
5367
5377
  {
5368
5378
  "path": "modules/lifecycle/check-rollup.js",
@@ -5792,7 +5802,7 @@
5792
5802
  {
5793
5803
  "path": "modules/lifecycle/tests/cascade.mjs",
5794
5804
  "mode": "0000644",
5795
- "sha256": "0734ad734ac5ef05c86fa81493c12fed08ffe421781d9ceeebe413c876bf9cf1"
5805
+ "sha256": "9439accf7648ef22000c75f3e315695c806969eb5a908275d7db33e5491499f8"
5796
5806
  },
5797
5807
  {
5798
5808
  "path": "modules/lifecycle/version-autoclose.js",
@@ -7697,12 +7707,12 @@
7697
7707
  {
7698
7708
  "path": "package-lock.json",
7699
7709
  "mode": "0000644",
7700
- "sha256": "d3b1b9f84e1713ff7815c19a9bd02b6c4de4dd3b44a5ede68ff56fbd5ad0cfcd"
7710
+ "sha256": "f9267b40d96aa3395f93b456da92cb8ff99f579340e1e9ac57e2264764e7b293"
7701
7711
  },
7702
7712
  {
7703
7713
  "path": "package.json",
7704
7714
  "mode": "0000644",
7705
- "sha256": "4cd9682e13a9a2e1e358c7bd500286378e7b5b8ceb1bb284a925b91ab8de2a08"
7715
+ "sha256": "4185cf704dd2b4d4dddd72ad802fbc6861c66181b69bbc66eaafdec7e603da35"
7706
7716
  },
7707
7717
  {
7708
7718
  "path": "public-docs/index.html",
@@ -8132,7 +8142,7 @@
8132
8142
  {
8133
8143
  "path": "scripts/gds/db-backup-offsite.sh",
8134
8144
  "mode": "0000644",
8135
- "sha256": "f455e4a6fdd69f0105104752c86099e3a17dad7b4802fa715191cedab9762cd2"
8145
+ "sha256": "97f52f6aff95e1c81e069c1d4228eecb514af6068c810441465b5f5b7ee0f2ce"
8136
8146
  },
8137
8147
  {
8138
8148
  "path": "scripts/gds/demote.js",
@@ -8807,7 +8817,7 @@
8807
8817
  {
8808
8818
  "path": "scripts/gds/run-unit-tests.js",
8809
8819
  "mode": "0000644",
8810
- "sha256": "8227fe5256b530d22192cd86f3624d52cc1dc34ac82099a8bb9e3d6a28482eb9"
8820
+ "sha256": "a40a90ba6255820130aa6a94ddc314b643177e4a153750dc6290550a75e1a4e8"
8811
8821
  },
8812
8822
  {
8813
8823
  "path": "scripts/gds/runner-drift.js",
@@ -9057,7 +9067,7 @@
9057
9067
  {
9058
9068
  "path": "scripts/gds/ship-grade.js",
9059
9069
  "mode": "0000644",
9060
- "sha256": "79fcf45dd254681e7f91fe83fbb8a208b30c93eebe77df569aae505a025daa68"
9070
+ "sha256": "b1fb38635433b579fbb38d65e92b9db226cccb753866ab7a2a15227796be66cd"
9061
9071
  },
9062
9072
  {
9063
9073
  "path": "scripts/gds/ship-honesty.js",
@@ -9447,12 +9457,12 @@
9447
9457
  {
9448
9458
  "path": "src/bongos/db-kernel.js",
9449
9459
  "mode": "0000644",
9450
- "sha256": "1fffc39fb9b70cd009d7d239729a9ff329cd41ca1f35b50b7c9e6b89aa83624e"
9460
+ "sha256": "dd16328ac569aa8c6af09b2700957634e2c6d368d291b015a74a8dc452dc340a"
9451
9461
  },
9452
9462
  {
9453
9463
  "path": "src/bongos/db.js",
9454
9464
  "mode": "0000644",
9455
- "sha256": "e88c470ed9399be2930ec2d925e0864c30e355bf20d89486fb57dbe7561f6d78"
9465
+ "sha256": "def84475a0470dafb894e7ca3f96f9db27cea78964f90ac3e81ed748fe9efa48"
9456
9466
  },
9457
9467
  {
9458
9468
  "path": "src/bongos/github-repos.js",
@@ -9707,7 +9717,7 @@
9707
9717
  {
9708
9718
  "path": "src/module-api.js",
9709
9719
  "mode": "0000644",
9710
- "sha256": "ae3867dc81f944026ce67bae25ca927232b84accc4ed03d4ef45798526c72c46"
9720
+ "sha256": "7b2ddf6ecca1f92f1ae2ba7a70e0e6aeb9726ec0fa9c4a5375f075755b036796"
9711
9721
  },
9712
9722
  {
9713
9723
  "path": "src/module-loader/catalog.js",
@@ -10092,7 +10102,7 @@
10092
10102
  {
10093
10103
  "path": "tests/auth_session_expiry_db.mjs",
10094
10104
  "mode": "0000644",
10095
- "sha256": "880cbfa0304ab037db980f22471a2b46bcdbad0e7dca57af359402dbb068d6a4"
10105
+ "sha256": "89730864ff5aa36271582c7e2717bf434bc84cbc20b615026005a1ef662ff31c"
10096
10106
  },
10097
10107
  {
10098
10108
  "path": "tests/auth_session_ttl.mjs",
@@ -10957,7 +10967,7 @@
10957
10967
  {
10958
10968
  "path": "tests/extract_json.mjs",
10959
10969
  "mode": "0000644",
10960
- "sha256": "8bdee65e64ba4bce81f2c8aeb3068affe78ec1bc893a8b209eece8fde5b93fa5"
10970
+ "sha256": "74f8d8852b8949dc55ac38dd6f31da85bb55aae1215a146213495db898515086"
10961
10971
  },
10962
10972
  {
10963
10973
  "path": "tests/figma_adapter.mjs",
@@ -11357,7 +11367,7 @@
11357
11367
  {
11358
11368
  "path": "tests/government_live_proof.mjs",
11359
11369
  "mode": "0000644",
11360
- "sha256": "6d829af6ef71bfa744a4ff457a4766b5cce021f317661579b06ecfc14298a523"
11370
+ "sha256": "ca29425b30e726e6a7e939b008f0e5ca71877fa88fdcbfe1bf6356482f47c1e8"
11361
11371
  },
11362
11372
  {
11363
11373
  "path": "tests/government_live_proof_shape.mjs",
@@ -11499,6 +11509,11 @@
11499
11509
  "mode": "0000644",
11500
11510
  "sha256": "cbe81b9176d6ffdd59ea8d6955e30aab9bf6d13bd15144fd79d126455aa0e1e3"
11501
11511
  },
11512
+ {
11513
+ "path": "tests/grader_reader_lens.mjs",
11514
+ "mode": "0000644",
11515
+ "sha256": "93e24012252c08798e3017ce9a7130ce9ece4e5e07456892b3632ab12f44ad47"
11516
+ },
11502
11517
  {
11503
11518
  "path": "tests/grader_regrade_delta.mjs",
11504
11519
  "mode": "0000644",
@@ -12372,7 +12387,7 @@
12372
12387
  {
12373
12388
  "path": "tests/own_session_revoke.mjs",
12374
12389
  "mode": "0000644",
12375
- "sha256": "34ae2846e314ad4ba732a40257fb81f7b4e4be3e81e3b6d1f478c3bb0690f23d"
12390
+ "sha256": "ff24d3753a52a64f426e0602bc2411aad13c1426c2bc0774b7618a1d20cebfad"
12376
12391
  },
12377
12392
  {
12378
12393
  "path": "tests/package_core.mjs",
@@ -13234,6 +13249,16 @@
13234
13249
  "mode": "0000644",
13235
13250
  "sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
13236
13251
  },
13252
+ {
13253
+ "path": "tests/session_token_hash.mjs",
13254
+ "mode": "0000644",
13255
+ "sha256": "ff4cea59acb15ce383d9346793206865f0aababc5e2f4144c957bb93d45f62b4"
13256
+ },
13257
+ {
13258
+ "path": "tests/session_token_hash_db.mjs",
13259
+ "mode": "0000644",
13260
+ "sha256": "4a757c27f11cde314649e46ab24e51600d24c2f503f1aa7f055cdb4fa60ea6ce"
13261
+ },
13237
13262
  {
13238
13263
  "path": "tests/session_type.mjs",
13239
13264
  "mode": "0000644",
@@ -2250,5 +2250,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2250
2250
  landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
2251
2251
  1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2252
2252
  landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
2253
+ 1.19.889 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2254
+ landed since 1.19.888 with no explicit bump. run 35470239834. (task 1002620)
2255
+ 1.19.890 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2256
+ landed since 1.19.889 with no explicit bump. run 35470624284. (task 1002620)
2253
2257
  ---------------------------------------------------------------------------
2254
2258
  ```
@@ -46,8 +46,33 @@ gds-db-offsite.timer ──→ gds-db-offsite.service ──→ db-backup-offsit
46
46
  - **It does not take its own `pg_dump`.** It uploads the newest `/var/backups/gds/<db>-*.sql.gz` that the local nightly already produced. Runs at 05:00 UTC — one hour after the 04:00 local dump — so the copy is same-day-fresh.
47
47
  - **Bucket layout:** objects land under the `gds-offsite/` key prefix in the Space.
48
48
  - **Retention:** 4 weekly copies (`GDS_OFFSITE_KEEP=4` — ~1 month), pruned oldest-first under that prefix. Sub-1 MB each.
49
- - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
- - **Dependency:** `python3` + the `boto3` module on the droplet. The script fails loudly if either is missing.
49
+ - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`, `GDS_OFFSITE_AGE_RECIPIENT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
+ - **Dependency:** `age`, plus `python3` + the `boto3` module on the droplet. The script fails loudly if any is missing.
51
+ - **The uploaded object is encrypted** (`<db>-<date>.sql.gz.age`) and the retention count applies to those. Any legacy plaintext `.sql.gz` object still under the prefix is **deleted on the next run**, outside the retention count — see below.
52
+
53
+ #### Offsite dumps are encrypted (task [#1003372](https://cloudbongos.com/builders#/task/1003372), audit A1)
54
+
55
+ Until this task the weekly job uploaded the raw `pg_dump`. A dump is not merely "a backup": it is every secret the platform holds, in one file, on storage the platform does not own — OAuth credentials, the GitHub App private key, and (before `core_249`) every builder's live session bearer in plaintext. A breach of the Space, a mis-set bucket ACL or a leaked read-only S3 key was a total compromise with no trace in any log we keep.
56
+
57
+ The dump is now encrypted with [`age`](https://github.com/FiloSottile/age) **to a public key**, so the droplet can produce backups it cannot itself read. That asymmetry is the point: an attacker who owns the droplet already has the live database, but they do not also get the archive of every previous state, and they cannot decrypt what is already in the Space. **Keep the private key off the server.**
58
+
59
+ ```bash
60
+ # ONE-TIME, on a machine that is NOT the server:
61
+ age-keygen -o bongos-backup.key # store safe + OFFLINE — this is the only way back
62
+ # it prints: Public key: age1xxxxxxxx...
63
+ ```
64
+
65
+ Add the **public** key to `/etc/example/spaces.env` as `GDS_OFFSITE_AGE_RECIPIENT=age1...`.
66
+
67
+ > **Without a recipient the job refuses to run**, and does not fall back to a plaintext upload — that fallback is the vulnerability, and a silent one, since a backup that "worked" looks identical either way. A failing timer is visible in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is visible to nobody until it matters. **If offsite backups stop after this deploy, this is why** — set the recipient.
68
+ >
69
+ > **The first successful run also PURGES every unencrypted `.sql.gz` already in the Space**, unconditionally and outside the retention count. Encrypting from here on does nothing about the copies already up there, and retaining four of them "for safety" would retain the finding. Local nightly dumps on the droplet are untouched, so this removes copies, never the last copy.
70
+
71
+ To restore an offsite object:
72
+
73
+ ```bash
74
+ age -d -i bongos-backup.key <db>-<date>.sql.gz.age | gunzip | psql <db>
75
+ ```
51
76
 
52
77
  > **History:** an earlier task ([#7](https://example.com/builders#/task/7)) shipped a `scripts/backup.sh` + `/etc/cron.d/otb-backup` daily-to-Spaces cron that was forgotten and never worked (empty credentials, no Space, no log). [#415](https://example.com/builders#/task/415) retired it and replaced it with the systemd unit above. If you find any trace of `otb-backup.cron`, `scripts/backup.sh`, or `~/.config/otb/spaces.env` on a host, it's dead — remove it.
53
78
  >
@@ -65,6 +90,9 @@ SPACES_SECRET=...
65
90
  SPACES_BUCKET=otb-gds-backups
66
91
  SPACES_REGION=nyc3
67
92
  SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
93
+ # The age PUBLIC key from `age-keygen`, generated OFF this box (see above).
94
+ # Without it the job refuses to run rather than upload plaintext.
95
+ GDS_OFFSITE_AGE_RECIPIENT=age1...
68
96
  ENV
69
97
  sudo chmod 600 /etc/example/spaces.env
70
98
 
@@ -0,0 +1,103 @@
1
+ -- core_249 — builder_sessions stores a HASH of the bearer, never the bearer
2
+ -- (task 1003372, audit ref A1 from the 2026-08-29 security audit, goal 1000084).
3
+ --
4
+ -- forward-unsafe: drops builder_sessions.token and makes token_hash NOT NULL.
5
+ -- Both are the point of the migration, not incidental to it. The whole finding is
6
+ -- that the plaintext bearer is READABLE AT REST, so a version of this that kept the
7
+ -- column would not fix anything; and the NOT NULL is what stops a future writer
8
+ -- inserting a session no lookup can ever match. Neither is reversible, and neither
9
+ -- should be: rolling back to code that reads `token` finds it gone, but a variant
10
+ -- that merely emptied the column would leave that code finding NULL and signing
11
+ -- everyone out just the same — so the unsafety is in the rollback direction only,
12
+ -- and is identical either way. Forward, nobody is signed out: the backfill below
13
+ -- derives every existing row's hash from the plaintext before the column goes.
14
+ --
15
+ -- WHAT WAS WRONG. `token` held the live bearer in plaintext, NOT NULL UNIQUE and
16
+ -- INDEXED. Every row was a directly replayable credential for the builder it
17
+ -- belonged to — Archons included — so ANY read of this table was a complete
18
+ -- takeover of every account holding a session. That is the exact credential the
19
+ -- whole ADR 0016 trust boundary rests on: the server re-resolves authority per
20
+ -- request from a token, so a token IS the authority. `token_hash` had zero
21
+ -- occurrences repo-wide before this migration.
22
+ --
23
+ -- A read of the table is not hypothetical. The offsite dump was uploaded
24
+ -- unencrypted (same task, `scripts/gds/db-backup-offsite.sh`), and a co-tenant
25
+ -- Postgres role reached this table across tenants until task 1003369 split the
26
+ -- unix accounts. Hashing at rest is what makes those reads worthless rather than
27
+ -- catastrophic.
28
+ --
29
+ -- WHY A BACKFILL AND NOT A FORCED RE-AUTH. The hash is computable from the
30
+ -- plaintext we already hold, so every live session survives the change: the row
31
+ -- keeps working and its holder notices nothing. The alternative — ship the code
32
+ -- and let sessions re-mint — would sign out every builder and every dev box on
33
+ -- deploy day to reach a state this statement reaches for free. Migration 035 and
34
+ -- core_243 both reasoned this way in their own direction: do not put a policy
35
+ -- change on the backs of the people holding live sessions when it lands.
36
+ --
37
+ -- WHY sha256 WITH NO EXTENSION. Postgres 16 (docs/architecture.md) carries
38
+ -- `sha256(bytea)` as a built-in, so the backfill needs no pgcrypto — one less
39
+ -- extension to have installed on a box before a migration will run. It is the
40
+ -- same digest `db-kernel.js` computes in Node, so a row backfilled here and a row
41
+ -- minted by the code resolve identically.
42
+ --
43
+ -- WHY A FAST HASH IS THE RIGHT ONE. A session token is 256 bits of CSPRNG, not a
44
+ -- password: there is no dictionary to run and nothing to slow an attacker down,
45
+ -- so bcrypt/argon would buy nothing and cost a KDF on every authenticated
46
+ -- request. The same reasoning is written out at
47
+ -- `modules/platform-identity/platform-identity.js` (`sha256hex`), which hashes
48
+ -- its client secrets and authorization codes for exactly this reason.
49
+ --
50
+ -- WHY THE PLAINTEXT COLUMN IS DROPPED, NOT NULLED. Nulling it would leave a dead
51
+ -- column that still has to be kept empty by every future writer, and it buys no
52
+ -- rollback safety: rolling back to code that reads `token` finds it NULL and
53
+ -- signs everyone out either way. Dropping it is the honest end state, and the
54
+ -- unique index moves to `token_hash` so the uniqueness guarantee is unbroken.
55
+ --
56
+ -- WHAT THIS DOES NOT CHANGE. Expiry is untouched. ADR 0294 (CLI sessions do not
57
+ -- expire) and ADR 0206 (no absolute ceiling) both stand exactly as written; this
58
+ -- migration does not read, write or reason about `expires_at`. The audit also
59
+ -- asked for refresh-on-use to stop and an absolute lifetime to be added — both are
60
+ -- decisions those two ADRs have already taken the other way, with the owner as
61
+ -- decider, AFTER this audit was filed. Changing them is not this task's to do.
62
+ --
63
+ -- IDEMPOTENT. The whole body is guarded on the plaintext column still existing,
64
+ -- so a second run is a no-op rather than an error about a column that is gone.
65
+
66
+ BEGIN;
67
+
68
+ DO $$
69
+ BEGIN
70
+ IF EXISTS (
71
+ SELECT 1 FROM information_schema.columns
72
+ WHERE table_name = 'builder_sessions' AND column_name = 'token'
73
+ ) THEN
74
+
75
+ ALTER TABLE builder_sessions ADD COLUMN IF NOT EXISTS token_hash text;
76
+
77
+ -- Every live row keeps working: the hash is derived from the bearer already
78
+ -- stored, so no holder is signed out by this deploy.
79
+ UPDATE builder_sessions
80
+ SET token_hash = encode(sha256(token::bytea), 'hex')
81
+ WHERE token_hash IS NULL;
82
+
83
+ -- The invariant the lookup depends on. Enforced only after the backfill has
84
+ -- given every existing row a value.
85
+ ALTER TABLE builder_sessions ALTER COLUMN token_hash SET NOT NULL;
86
+
87
+ -- Carries over `token`'s UNIQUE: two sessions may not share a bearer, and the
88
+ -- hash is what the lookup now keys on, so it needs the same index the
89
+ -- plaintext had for the same reason.
90
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash
91
+ ON builder_sessions (token_hash);
92
+
93
+ DROP INDEX IF EXISTS idx_builder_sessions_token;
94
+ ALTER TABLE builder_sessions DROP COLUMN token;
95
+
96
+ END IF;
97
+ END
98
+ $$;
99
+
100
+ COMMENT ON COLUMN builder_sessions.token_hash IS
101
+ 'SHA-256 hex of the bearer token. The plaintext is NEVER stored (core_249, audit A1): it exists only in the response that mints it and in the client that holds it. lookupSession hashes the presented bearer and keys on this column, so a read of this table yields nothing replayable.';
102
+
103
+ COMMIT;
@@ -12,16 +12,16 @@
12
12
  "assumed_builder_model": "claude-fable-5",
13
13
  "model_refresh_note": "task 1002659 (2026-08-11): pins refreshed to the Claude 5 family. Assumed builder = claude-fable-5 (the owner-top-model default per CLAUDE.md §2); default grader = claude-sonnet-5 (cross-model per ADR 0024, cost-parity with the prior sonnet-4-6 posture — opus-5 grades the sonnet/haiku builders where the grader should be the stronger model). selectGraderModel no longer throws on an unknown builder id: it warns loudly and falls back to default_model (the #117 throw stranded ships whose builder id lacked an opus/sonnet/haiku substring — exactly what claude-fable-5 is).",
14
14
  "active_panel": ["narc", "quality", "hacker", "efficiency"],
15
- "worker_model_tiers": { "quality": "claude-sonnet-5" },
16
- "worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens ran on the haiku tier. task 1003318: moved to claude-sonnet-5 — haiku 4.5's 200k window overflows on any diff over ~150 KB (`Prompt is too long`, deterministic; tasks 1003297 and 1003298 each lost three ~$5 panel runs to it) and a Quality worker that never starts is missing signal on a GATING lens. For fable/opus builders the panel model is already claude-sonnet-5, so the tier coincides with default_model and acts as a floor — a future default_model bump must not pin Quality back onto a smaller window; it stays a live cost lever only for haiku builders (whose panel runs on opus-5). workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically.",
15
+ "worker_model_tiers": { "quality": "claude-sonnet-5", "reader": "claude-sonnet-5" },
16
+ "worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens ran on the haiku tier. task 1003318: moved to claude-sonnet-5 — haiku 4.5's 200k window overflows on any diff over ~150 KB (`Prompt is too long`, deterministic; tasks 1003297 and 1003298 each lost three ~$5 panel runs to it) and a Quality worker that never starts is missing signal on a GATING lens. For fable/opus builders the panel model is already claude-sonnet-5, so the tier coincides with default_model and acts as a floor — a future default_model bump must not pin Quality back onto a smaller window; it stays a live cost lever only for haiku builders (whose panel runs on opus-5). workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically. task 1003977: the READER is pinned to claude-sonnet-5 for the same task-1003318 reason — it is Read-heavy by construction (the voice guide, then the render paths that decide whether a string is user-facing), and a small window fails deterministically with PROMPT_TOO_LONG. For a NON-GATING worker that means silently reading no copy at all on exactly the biggest copy diffs, with nothing blocking to reveal it.",
17
17
  "fallthrough_confidence": 0.3,
18
18
  "low_confidence_threshold": 0.4,
19
19
  "rationale": "GDS-V3 task #410 (MAS Phase 2.0), 2026-05-29. Supersedes v3 (single-prompt 3-shot generalist). Phase 1 calibration showed minimal between-shot disagreement on the same prompt — the strict gate fired correctly but the diversity signal was weak. v4 trades sampling-variance for lens-specialization: replace 3 identical-prompt shots with N workers (each a distinct prompt focused on one axis). Phase 2.0 ships 2 of the 3 planned workers (narc for completion, quality for cq + advisory economy) — keeps the gate at the same strictness while the framework calibrates. Phase 2.1 adds hacker (security; wraps /builder-redteam as a CI gate). Phase 2.2 adds efficiency_monger.",
20
20
  "worker_contract": {
21
- "kind": "string — worker registry key (narc | quality | hacker | efficiency)",
21
+ "kind": "string — worker registry key (narc | quality | hacker | efficiency | reader)",
22
22
  "name": "string — display name",
23
23
  "specialty": "string — one-line description of what this worker judges",
24
- "verdict": "'pass' | 'concerns' | 'fail' — per-worker verdict, sub-classification of the panel gate",
24
+ "verdict": "'pass' | 'concerns' | 'fail' — per-worker verdict, sub-classification of the panel gate; plus 'advisory', the ONLY verdict a NON-GATING worker emits (task 1003977 — see active_workers.reader and panel_gate.non_gating). Mirrored by WORKER_VERDICTS in grader-score.js, which hasPanelEvidence checks with an `every`.",
25
25
  "scores": "{ functional_fidelity?, code_quality?, economy? } — only the axes this worker reports",
26
26
  "issues": "[{ severity, file, summary }] — concrete problems found",
27
27
  "notes_md": "short markdown summary",
@@ -31,12 +31,22 @@
31
31
  "error": "null | 'parse_error' | 'schema_error' | 'SUBAGENT_*' — set when the subagent comms failed; verdict will be 'fail'"
32
32
  },
33
33
  "panel_gate": {
34
- "fail": "ANY worker.verdict='fail' OR worker.error != null → overall passed=false, no advance to confirmed.",
35
- "concerns": "ANY worker.verdict='concerns' (and no fails) → overall passed=false, forwarded confidence = fallthrough_confidence (manual-confirm available).",
36
- "pass": "ALL workers.verdict='pass' → overall passed=true, advance to confirmed."
34
+ "fail": "ANY GATING worker.verdict='fail' OR worker.error != null → overall passed=false, no advance to confirmed.",
35
+ "concerns": "ANY GATING worker.verdict='concerns' (and no fails) → overall passed=false, forwarded confidence = fallthrough_confidence (manual-confirm available).",
36
+ "pass": "ALL GATING workers.verdict='pass' → overall passed=true, advance to confirmed.",
37
+ "non_gating": "task 1003977: a worker in NON_GATING_WORKERS (today: reader) is partitioned out of workerResults BEFORE any gate arithmetic — the pass/concerns/fail counts, the missing-signal and total-outage detection, the forwarded-confidence median and every verdict branch read the gating workers only. Its scores and issues still land in rubric_scores and signals.workers. Consequence worth stating out loud: a HEALTHY non-gating worker cannot mask a total grader outage by making 'every gating worker is missing signal' false — that is the task-1003574 regression class, where an outage presented as FAIL 0/10 and routed the builder to a re-grade that could not clear it."
37
38
  },
38
39
  "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).",
39
40
  "active_workers": {
41
+ "reader": {
42
+ "specialty": "user-facing copy: truth, clarity, voice, judged against docs/voice.md",
43
+ "scores_reported": ["copy_score"],
44
+ "gating": false,
45
+ "verdict_rules": "NON-GATING (task 1003977, ADR 0298). Emits the single verdict 'advisory' on every path INCLUDING its own errors — computeVerdict has no branch returning pass/concerns/fail, so 'cannot block' is a property of the function, not a promise kept by callers. aggregatePanel additionally partitions it out of the gate (NON_GATING_WORKERS in grader-workers/index.js). Belt and braces: a filter is one refactor from being forgotten, a verdict is not. Stronger than the advisory treatment narc receives, which only downgrades a fail to manual-confirm — the reader cannot reach 'concerns' either. A 'blocker' severity is FLOORED to 'major' rather than dropped: the observation survives, the veto does not. Deliberately NO blocker escape hatch of the kind narc/hacker carry (grader.js isAdvisory, '... && !hasBlocker'), because that hatch is the exact hole the owner's term closes. An errored reader records NO copy_score rather than 0 — a 0 is a real reading, an outage is missing signal (the #943 distinction, per worker).",
46
+ "scope": "copy-bearing diffs ONLY. The caller (scripts/gds/ship-grade.js) asks hasCopyOrVisualWork(task) from modules/lifecycle/cascade.js and passes the answer to WORKERS.panelFor({ copyBearing }); the grading module may not require a sibling module (ADR 0083, CI-enforced), and a second predicate inside grading would let the at-ship lens and the after-the-fact artist review disagree about what they cover. Where false the panel is byte-identical to DEFAULT_PANEL and costs what it costs today, and the skip is recorded as signals.reader_skipped='not_copy_bearing' — never a silent absence, because a missing copy score must not read as a passing one.",
47
+ "standard": "docs/voice.md (task 1003976), cited by rule id. Hard rules V1-V5 are checkable and may be ASSERTED as breaks; judgement rules V6-V11 may only be FLAGGED with the line quoted. A finding that cites no rule id is not a finding — the guide's own rule, carried into the prompt, and what keeps an advisory lens from recording taste as fact.",
48
+ "prompt_lives_in": "modules/grading/grader-workers/reader.js"
49
+ },
40
50
  "narc": {
41
51
  "specialty": "semantic completion (description vs diff, line-level)",
42
52
  "scores_reported": ["functional_fidelity"],
@@ -55,7 +55,13 @@ function isSubstantiveIssue(it) {
55
55
  // grade-time input (the ADR's documented honor-system residual for Metic+);
56
56
  // its job is separating "the panel ran and unanimously found nothing" (the
57
57
  // guard's confirmed false-positive class) from "no evidence at all".
58
- const WORKER_VERDICTS = new Set(['pass', 'concerns', 'fail']);
58
+ // 'advisory' (task 1003977) is the Reader's only verdict — a non-gating lens
59
+ // whose row must still COUNT AS EVIDENCE that the panel ran. Omit it here and
60
+ // hasPanelEvidence returns false for every copy-bearing ship, because the check
61
+ // below is an `every`: one unrecognised verdict makes a fully-healthy five-worker
62
+ // panel look like no evidence at all, and the implausible-pass guard then fires
63
+ // on exactly the ships that carry the most lenses.
64
+ const WORKER_VERDICTS = new Set(['pass', 'concerns', 'fail', 'advisory']);
59
65
  function hasPanelEvidence(signals) {
60
66
  const ws = Array.isArray(signals?.workers) ? signals.workers : null;
61
67
  if (!ws || ws.length < 3) return false;
@@ -17,7 +17,13 @@
17
17
 
18
18
  // Score keys that mark a parsed object as the real grade (vs an incidental
19
19
  // prose object). Module-level so extractJson doesn't re-allocate per call.
20
- const GRADE_SHAPE_KEYS = ['functional_fidelity', 'code_quality', 'security_score', 'efficiency_score'];
20
+ // task 1003977: 'copy_score' joins the list when the Reader joins the panel.
21
+ // reader.js re-exports THIS extractJson, so without its key the disambiguation
22
+ // above silently does not apply to it — an ambiguous multi-object response would
23
+ // fall back to candidates[0] with no score-based preference. It fails safe (a
24
+ // non-gating worker with no signal blocks nothing), but failing safe is not the
25
+ // same as getting the protection every other worker gets.
26
+ const GRADE_SHAPE_KEYS = ['functional_fidelity', 'code_quality', 'security_score', 'efficiency_score', 'copy_score'];
21
27
 
22
28
  function extractJson(raw) {
23
29
  if (typeof raw !== 'string') return null;
@@ -18,18 +18,52 @@ const narc = require('./narc');
18
18
  const quality = require('./quality');
19
19
  const hacker = require('./hacker');
20
20
  const efficiency = require('./efficiency');
21
+ const reader = require('./reader');
21
22
 
22
23
  const REGISTRY = {
23
24
  [narc.KIND]: narc,
24
25
  [quality.KIND]: quality,
25
26
  [hacker.KIND]: hacker,
26
27
  [efficiency.KIND]: efficiency,
28
+ [reader.KIND]: reader,
27
29
  };
28
30
 
29
31
  // The active panel for the ship-time gate. Order is presentation-only —
30
32
  // workers run in parallel inside gradeSubagent.
31
33
  const DEFAULT_PANEL = [narc.KIND, quality.KIND, hacker.KIND, efficiency.KIND];
32
34
 
35
+ // Workers that are NEVER part of the gate — not a hard fail, not a downgrade to
36
+ // concerns, not a missing-signal outage. Distinct from grader.js's
37
+ // ADVISORY_WORKERS ('narc'), which only softens a fail into manual-confirm.
38
+ //
39
+ // The Reader is here because the owner's term for it (2026-09-17, ADR 0298) is
40
+ // absolute: ADR 0241 put the artist's judgement on the DEPLOY and off the ship
41
+ // path, and a copy lens that could hold a ship puts back what ADR 0162 retired.
42
+ // Deliberately NO blocker escape hatch of the kind narc/hacker carry — see the
43
+ // header of reader.js for why that hatch would be the hole the term closes.
44
+ //
45
+ // This set is belt; reader.js's computeVerdict, which cannot return anything but
46
+ // 'advisory', is braces. Either alone would hold; both, because a filter is one
47
+ // refactor from being forgotten and a verdict is not.
48
+ const NON_GATING_WORKERS = new Set([reader.KIND]);
49
+ function isNonGatingKind(kind) { return NON_GATING_WORKERS.has(kind); }
50
+
51
+ // The panel for one ship. `copyBearing` is the CALLER's answer, never computed
52
+ // here: the one definition is hasCopyOrVisualWork() in modules/lifecycle/
53
+ // cascade.js, and a module may require only src/module-api.js (ADR 0083), so
54
+ // grading cannot reach it. Writing a second predicate here is exactly what the
55
+ // task forbids — the at-ship lens and the after-the-fact artist review must
56
+ // never disagree about what they cover, and widening that set must widen both
57
+ // at once. So the caller (scripts/gds/ship-grade.js, which is not a module and
58
+ // may require both) asks lifecycle and passes the answer down.
59
+ //
60
+ // Where it is false the panel is BYTE-IDENTICAL to today's and costs what it
61
+ // costs today. The skip is recorded by the caller as a visible signal, never as
62
+ // a silent absence — a missing copy score must not read as a passing one.
63
+ function panelFor({ copyBearing = false } = {}) {
64
+ return copyBearing ? [...DEFAULT_PANEL, reader.KIND] : [...DEFAULT_PANEL];
65
+ }
66
+
33
67
  // #465: transient subprocess/parse errors are infra noise, not a quality
34
68
  // signal — auto-retry the worker ONCE on these before letting the error stand.
35
69
  // Deliberately EXCLUDES:
@@ -79,6 +113,11 @@ const WORKER_MAX_TIMEOUT_MS = {
79
113
  quality: 360_000, // Read-heavy: reads changed files for cleanliness review
80
114
  hacker: 360_000, // Read-heavy on a security diff: exploit-surface scan of every changed file
81
115
  efficiency: 360_000, // Read-heavy on a large diff: traces perf across every changed file
116
+ // Read-heavy by construction: the Reader's first Read is the voice guide, and
117
+ // the rest confirm that a string really is user-facing. Same heavy tier as the
118
+ // rest — worst-case panel wall time is max(), and the four already sit at 360s,
119
+ // so adding a fifth at the same budget does not move it (task 1003977).
120
+ reader: 360_000,
82
121
  };
83
122
 
84
123
  // task 1003313: the diagnostic a dark panel used to throw away.
@@ -244,6 +283,9 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
244
283
  module.exports = {
245
284
  REGISTRY,
246
285
  DEFAULT_PANEL,
286
+ NON_GATING_WORKERS,
287
+ isNonGatingKind,
288
+ panelFor,
247
289
  resolvePanel,
248
290
  runWorker,
249
291
  timeoutForWorker, // public surface; WORKER_MAX_TIMEOUT_MS stays module-internal
@@ -255,4 +297,5 @@ module.exports = {
255
297
  quality,
256
298
  hacker,
257
299
  efficiency,
300
+ reader,
258
301
  };