@bongos/core 1.19.790 → 1.19.792
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 +76 -41
- package/.claude/skills/builder-reauth/SKILL.md +5 -3
- package/README.md +1 -1
- package/docs/adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md +2 -0
- package/docs/adr/0294-cli-sessions-do-not-expire.md +56 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +90 -90
- package/docs/copy-registry.json +109 -109
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_243_cli_sessions_never_expire.sql +58 -0
- package/modules/hall-ui/public/hall-kit.js +7 -65
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/hall-visual.js +85 -0
- package/modules/hall-ui/public/index.html +1 -0
- package/modules/hall-ui/public/profile.html +1 -0
- package/modules/hall-ui/public/profile.js +2 -2
- package/modules/hall-ui/public/settings.js +5 -2
- package/modules/hall-ui/public/studio.css +1 -1
- package/modules/hall-ui/public/studio.html +1 -0
- package/modules/hall-ui/public/studio.js +4 -3
- package/modules/hall-ui/public/style.css +0 -16
- package/modules/hall-ui/public/task.html +1 -0
- package/modules/hall-ui/public/task.js +10 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/accessibility-replay.js +195 -0
- package/scripts/gds/box-auth-check.js +1 -1
- package/scripts/gds/cli-lib.js +2 -2
- package/scripts/gds/reauth.js +9 -7
- package/scripts/gds/run-unit-tests.js +7 -0
- package/src/bongos/auth-github.js +2 -0
- package/src/bongos/db-kernel.js +29 -11
- package/src/bongos/db.js +33 -24
- package/src/bongos/routes/auth.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/accessibility_replay.mjs +201 -0
- package/tests/auth_session_expiry_db.mjs +183 -0
- package/tests/auth_session_ttl.mjs +124 -11
- package/tests/fixtures/accessibility-replay.json +93 -0
- package/tests/hall_studio_world.mjs +19 -11
- package/tests/task_visuals.mjs +91 -27
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tests/accessibility_replay.mjs
|
|
2
|
+
//
|
|
3
|
+
// The two-profile replay regression (task 1002517; clauses 5 and 8 of criterion
|
|
4
|
+
// `accessibility-fluency-profile`) — the proof the criterion named for itself
|
|
5
|
+
// and that had never been run.
|
|
6
|
+
//
|
|
7
|
+
// This is NOT a second copy of tests/session_accessibility.mjs. That file pins
|
|
8
|
+
// the scorer's arithmetic against fixtures it builds line by line. This one runs
|
|
9
|
+
// a fixed set of eight beginner questions through two real answer registers and
|
|
10
|
+
// asserts what the criterion actually claims: accessible prose clears a floor,
|
|
11
|
+
// an expert's prose is not scored for being an expert's prose, and the two
|
|
12
|
+
// verdicts come from the same corpus — so neither is a tautology.
|
|
13
|
+
//
|
|
14
|
+
// The assertion that keeps the rest honest is DISCRIMINATION (leg 3). A scorer
|
|
15
|
+
// that had gone blind — three detectors quietly matching nothing — produces a
|
|
16
|
+
// perfect 10 on the non-technical run and passes leg 1 looking exactly like a
|
|
17
|
+
// working one. Leg 3 reads the same engineer prose as a newcomer would and
|
|
18
|
+
// requires every detector to register hits and the score to fall through the
|
|
19
|
+
// floor. Nothing else here can pass while the detectors are dead.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/accessibility_replay.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const H = require('../scripts/gds/accessibility-replay.js');
|
|
28
|
+
const acc = require('../modules/sessions/accessibility.js');
|
|
29
|
+
|
|
30
|
+
const corpus = H.loadCorpus();
|
|
31
|
+
const NEWCOMER = corpus.runs.non_technical.profile;
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
function test(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ok ${name}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
failed++;
|
|
42
|
+
console.log(` FAIL ${name}`);
|
|
43
|
+
console.log(` ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const signalsOf = (r) => Object.fromEntries(r.result.signals.map((s) => [s.type, s]));
|
|
48
|
+
|
|
49
|
+
// ---- the floor is the scorer's own line, not a second number ---------------
|
|
50
|
+
|
|
51
|
+
test('FLOOR is bandFor()\'s workable boundary, so retuning the bands cannot move it silently', () => {
|
|
52
|
+
assert.equal(H.bandFor(H.FLOOR), 'workable', 'FLOOR sits exactly on the workable boundary');
|
|
53
|
+
assert.equal(H.bandFor(H.FLOOR - 0.1), 'dense', 'a tenth below the floor is already the dense band');
|
|
54
|
+
assert.equal(corpus.floor, H.FLOOR, 'the corpus records the same floor the harness enforces');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---- the corpus is a fixed set, and both runs answer all of it -------------
|
|
58
|
+
|
|
59
|
+
test('the prompt set is fixed, non-trivial, and answered in full by both runs', () => {
|
|
60
|
+
assert.ok(corpus.prompts.length >= 8, 'at least eight beginner prompts');
|
|
61
|
+
const ids = corpus.prompts.map((p) => p.id);
|
|
62
|
+
assert.equal(new Set(ids).size, ids.length, 'prompt ids are unique');
|
|
63
|
+
for (const runName of ['non_technical', 'engineer']) {
|
|
64
|
+
const run = corpus.runs[runName];
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
const turns = run.replies[id];
|
|
67
|
+
assert.ok(Array.isArray(turns) && turns.length > 0, `${runName} answers ${id}`);
|
|
68
|
+
for (const t of turns) assert.ok(typeof t === 'string' && t.trim(), `${runName}/${id} turn is prose`);
|
|
69
|
+
}
|
|
70
|
+
for (const [field, value] of Object.entries(run.profile)) {
|
|
71
|
+
assert.ok(acc.PROFILE_VALUES[field]?.includes(value), `${runName} profile ${field}=${value} is a real value`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- leg 1: the dimension recognises accessible prose ----------------------
|
|
77
|
+
|
|
78
|
+
test('leg 1 — the non-technical run clears the floor under the non-technical profile', () => {
|
|
79
|
+
const r = H.replay(corpus, 'non_technical');
|
|
80
|
+
assert.ok(r.result.score != null, 'the non-technical run is scored, not waived away');
|
|
81
|
+
assert.ok(
|
|
82
|
+
r.result.score >= H.FLOOR,
|
|
83
|
+
`non-technical run scored ${r.result.score}, floor is ${H.FLOOR}`
|
|
84
|
+
);
|
|
85
|
+
assert.equal(r.result.confidence, 'normal', 'the sample is big enough for the score to stand behind');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---- leg 2: an expert's prose is not scored for being an expert's prose ----
|
|
89
|
+
|
|
90
|
+
test('leg 2 — the engineer run is not scored at all under the engineer profile', () => {
|
|
91
|
+
const r = H.replay(corpus, 'engineer');
|
|
92
|
+
assert.equal(r.result.score, null, 'an all-waived session scores null, never a number');
|
|
93
|
+
assert.equal(r.result.band, 'n/a');
|
|
94
|
+
assert.equal(r.result.signals.length, 3);
|
|
95
|
+
for (const s of r.result.signals) {
|
|
96
|
+
assert.equal(s.waived, true, `${s.type} is waived for this profile`);
|
|
97
|
+
assert.ok(/technical_level|toolchain_familiarity/.test(s.reason), `${s.type} names the setting that waived it`);
|
|
98
|
+
}
|
|
99
|
+
assert.equal(r.result.confidence, 'normal', 'the prose was read — the null is a waiver, not an empty sample');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('leg 2 holds when the same expert answers even more briefly', () => {
|
|
103
|
+
const full = H.replay(corpus, 'engineer');
|
|
104
|
+
const terse = H.replay(corpus, 'engineer', { variant: 'terse' });
|
|
105
|
+
assert.ok(terse.result.sampled_chars < full.result.sampled_chars, 'the terse variant really is shorter');
|
|
106
|
+
assert.equal(terse.result.score, null, 'still nothing to regress');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- leg 3: the same prose, read as a newcomer would read it ---------------
|
|
110
|
+
|
|
111
|
+
test('leg 3 — the SAME engineer prose falls through the floor under the non-technical profile', () => {
|
|
112
|
+
const r = H.replay(corpus, 'engineer', { profile: NEWCOMER });
|
|
113
|
+
assert.ok(r.result.score != null, 'nothing is waived for a newcomer');
|
|
114
|
+
assert.ok(
|
|
115
|
+
r.result.score < H.FLOOR,
|
|
116
|
+
`engineer prose read as a newcomer scored ${r.result.score}, which should be under ${H.FLOOR}`
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('leg 3 — every detector registers hits, so a scorer that went blind cannot pass', () => {
|
|
121
|
+
const s = signalsOf(H.replay(corpus, 'engineer', { profile: NEWCOMER }));
|
|
122
|
+
for (const type of Object.keys(acc.SIGNAL_SPEC)) {
|
|
123
|
+
assert.ok(s[type], `${type} was scored`);
|
|
124
|
+
assert.ok(s[type].count > 0, `${type} found something in the engineer prose (found ${s[type].count})`);
|
|
125
|
+
assert.ok(s[type].evidence, `${type} can name what it found`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---- the perfect run is forgiveness, not avoidance -------------------------
|
|
130
|
+
|
|
131
|
+
test('the non-technical run scores well WITH a jargon term and implementation framing in it, not by dodging them', () => {
|
|
132
|
+
const prose = Object.values(corpus.runs.non_technical.replies).flat().join('\n');
|
|
133
|
+
const present = acc.JARGON_TERMS.filter((t) => new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(prose));
|
|
134
|
+
assert.ok(present.length > 0, 'the accessible prose does use at least one term from the jargon list');
|
|
135
|
+
assert.ok(/\barchitecture\b/i.test(prose), 'and frames something in architecture terms at least once');
|
|
136
|
+
|
|
137
|
+
const s = signalsOf(H.replay(corpus, 'non_technical'));
|
|
138
|
+
assert.equal(s.unexplained_jargon.count, 0, `"${present.join('", "')}" is used and DEFINED, so it is not a finding`);
|
|
139
|
+
assert.equal(s.implementation_over_outcome.count, 0, 'the architecture turn also names the cost, so it is not a finding');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---- leg 4: the metric reads density, not volume --------------------------
|
|
143
|
+
|
|
144
|
+
test('leg 4 — saying the same thing twice doubles the counts and leaves every rate, and the score, identical', () => {
|
|
145
|
+
const once = H.replay(corpus, 'engineer', { profile: NEWCOMER });
|
|
146
|
+
const twice = H.replay(corpus, 'engineer', { profile: NEWCOMER, variant: 'doubled' });
|
|
147
|
+
assert.equal(twice.result.sampled_chars, once.result.sampled_chars * 2, 'twice the prose');
|
|
148
|
+
|
|
149
|
+
const a = signalsOf(once);
|
|
150
|
+
const b = signalsOf(twice);
|
|
151
|
+
for (const type of Object.keys(acc.SIGNAL_SPEC)) {
|
|
152
|
+
assert.equal(b[type].count, a[type].count * 2, `${type} finds twice as much in twice the prose`);
|
|
153
|
+
assert.equal(b[type].rate, a[type].rate, `${type} rate is unchanged — the metric reads density, not volume`);
|
|
154
|
+
}
|
|
155
|
+
assert.equal(twice.result.score, once.result.score, 'so the score does not move with length');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('answering more briefly never costs points', () => {
|
|
159
|
+
for (const runName of ['non_technical', 'engineer']) {
|
|
160
|
+
const full = H.replay(corpus, runName);
|
|
161
|
+
const terse = H.replay(corpus, runName, { variant: 'terse' });
|
|
162
|
+
assert.ok(terse.result.sampled_chars < full.result.sampled_chars, `${runName} terse variant is shorter`);
|
|
163
|
+
if (full.result.score == null) {
|
|
164
|
+
assert.equal(terse.result.score, null, `${runName} stays unscored`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
assert.ok(
|
|
168
|
+
terse.result.score >= full.result.score,
|
|
169
|
+
`${runName} terse scored ${terse.result.score} against ${full.result.score} full — brevity must not cost`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---- the two waivers are independent settings ------------------------------
|
|
175
|
+
|
|
176
|
+
test('an engineer who is new to the terminal still gets the click-path signal', () => {
|
|
177
|
+
const r = H.replay(corpus, 'engineer', { profile: H.ENGINEER_NEW_TO_TOOLCHAIN });
|
|
178
|
+
const s = signalsOf(r);
|
|
179
|
+
assert.equal(r.result.signals.filter((x) => x.waived).length, 2, 'technical_level waives two, not three');
|
|
180
|
+
assert.equal(s.terminal_where_ui_exists.waived, false, 'the click-path signal is waived by TOOLCHAIN familiarity, not by technical level');
|
|
181
|
+
assert.ok(s.terminal_where_ui_exists.count > 0, 'and it still finds the commands handed over');
|
|
182
|
+
assert.ok(r.result.score != null, 'so this profile still gets a number');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- the harness is runnable, which is half of what the task asked for -----
|
|
186
|
+
|
|
187
|
+
test('the report renders and names every run', () => {
|
|
188
|
+
const out = H.formatReport(corpus);
|
|
189
|
+
assert.ok(out.includes(`floor ${H.FLOOR.toFixed(1)}`), 'the report states the floor it enforces');
|
|
190
|
+
assert.ok(out.includes('non-technical profile'), 'and both profiles appear');
|
|
191
|
+
assert.ok(out.includes('engineer profile'));
|
|
192
|
+
|
|
193
|
+
let json = '';
|
|
194
|
+
assert.equal(H.main(['--json'], (s) => { json += s; }), 0, 'the JSON mode exits clean');
|
|
195
|
+
const parsed = JSON.parse(json);
|
|
196
|
+
assert.equal(parsed.floor, H.FLOOR, 'and carries the floor a caller would check against');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(`Accessibility two-profile replay (clauses 5 + 8): ${passed} passed, ${failed} failed`);
|
|
201
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/auth_session_expiry_db.mjs — ADR 0294, task 1003956 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// The DB-free sibling (auth_session_ttl.mjs) pins the CONSTANT, createSession's
|
|
4
|
+
// choice of value against a stub pool, and the TEXT of the refresh statement and
|
|
5
|
+
// the migration. That last part is the weak half, and the grader on this task was
|
|
6
|
+
// right to say so — though not for the reason first assumed. Most edits to the
|
|
7
|
+
// guard's WORDING do break its regex, so the sibling is tighter than it looks.
|
|
8
|
+
// What a regex cannot see is whether Postgres ACCEPTS the statement at all.
|
|
9
|
+
//
|
|
10
|
+
// Measured, by deleting the CASE's `END` and running both files: the sibling
|
|
11
|
+
// reported 20 passed, 0 failed on SQL that fails to parse — SQL that would throw
|
|
12
|
+
// on every authenticated request in production. This file caught it.
|
|
13
|
+
//
|
|
14
|
+
// Worth knowing WHICH assertions caught it, because it is not the obvious one.
|
|
15
|
+
// The refresh is fire-and-forget with a `.catch()`, so a broken UPDATE fails
|
|
16
|
+
// SILENTLY: expires_at stays NULL and the never-expires assertion still passes.
|
|
17
|
+
// What failed was `last_used_at` never moving and the web row never rolling. Those
|
|
18
|
+
// two are not padding — on this statement they are the only live smoke alarm.
|
|
19
|
+
//
|
|
20
|
+
// So this file proves what only a real transaction engine can:
|
|
21
|
+
//
|
|
22
|
+
// 1. A CLI session is CREATED with no expiry, and a web session's explicit
|
|
23
|
+
// 30 days survives untouched — the two halves of the default change.
|
|
24
|
+
// 2. THE REGRESSION GUARD EXECUTES — after a real lookupSession, a CLI row's
|
|
25
|
+
// expires_at is still NULL. Without the CASE this is where the timeout
|
|
26
|
+
// comes back, on the first request after sign-in.
|
|
27
|
+
// 3. A web row's expiry STILL rolls forward, so the guard narrowed the write
|
|
28
|
+
// to NULL rows rather than disabling refresh-on-use for everyone.
|
|
29
|
+
// 4. Expiry is still HONOURED where present, and revocation still wins — the
|
|
30
|
+
// two things a "sessions never expire" change could plausibly have broken.
|
|
31
|
+
// 5. THE MIGRATION, run for real: it clears a pre-existing CLI expiry, leaves
|
|
32
|
+
// web and revoked rows alone, and is idempotent.
|
|
33
|
+
// 6. The point of the whole task — a session idle far past 24h authenticates.
|
|
34
|
+
//
|
|
35
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in
|
|
36
|
+
// run-unit-tests.js's INTEGRATION set so the default DB-free lane never runs it.
|
|
37
|
+
//
|
|
38
|
+
// Run: DATABASE_URL=... node tests/auth_session_expiry_db.mjs
|
|
39
|
+
|
|
40
|
+
import { createRequire } from 'node:module';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
44
|
+
import { strict as assert } from 'node:assert';
|
|
45
|
+
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const { Pool } = require('pg');
|
|
48
|
+
|
|
49
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const MIGRATION = join(here, '..', 'migrations', 'core_243_cli_sessions_never_expire.sql');
|
|
51
|
+
|
|
52
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
53
|
+
try {
|
|
54
|
+
await pool.query('SELECT 1');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.log(`auth_session_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
57
|
+
await pool.end().catch(() => {});
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An isolated schema, so this never touches a real builders/builder_sessions.
|
|
62
|
+
const SCHEMA = `ttl_probe_${process.pid}`;
|
|
63
|
+
await pool.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
64
|
+
await pool.query(`SET search_path TO ${SCHEMA}`);
|
|
65
|
+
await pool.query(`
|
|
66
|
+
CREATE TABLE builders (id bigserial PRIMARY KEY, github_id text, github_login text,
|
|
67
|
+
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
68
|
+
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
69
|
+
CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
|
|
70
|
+
token text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
|
|
71
|
+
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
72
|
+
INSERT INTO builders (github_login) VALUES ('probe');
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
// Run the REAL kernel against this schema: stub ./pool in the require cache so
|
|
76
|
+
// db-kernel binds our client, exactly as the production code binds the singleton.
|
|
77
|
+
const client = await pool.connect();
|
|
78
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
79
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
80
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
81
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
82
|
+
|
|
83
|
+
let passed = 0, failed = 0;
|
|
84
|
+
const t = async (name, fn) => {
|
|
85
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
86
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
87
|
+
};
|
|
88
|
+
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
89
|
+
// refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
|
|
90
|
+
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
91
|
+
|
|
92
|
+
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
93
|
+
|
|
94
|
+
await t('a CLI session is created with NO expiry', async () => {
|
|
95
|
+
const s = await kernel.createSession({ builderId: '1', token: 'cli-tok', source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
96
|
+
assert.equal(s.expires_at, null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await t("a web session's explicit 30-day expiry is stored as given", async () => {
|
|
100
|
+
const s = await kernel.createSession({ builderId: '1', token: 'web-tok', source: 'web', userAgent: 'probe', expiresAt: thirtyDays });
|
|
101
|
+
assert.equal(s.expires_at.getTime(), thirtyDays.getTime());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('lookupSession resolves a CLI bearer to its builder', async () => {
|
|
105
|
+
const got = await kernel.lookupSession('cli-tok');
|
|
106
|
+
assert.equal(got && got.github_login, 'probe');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// THE GUARD. This is the assertion the source-matching sibling cannot make.
|
|
110
|
+
await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
|
|
111
|
+
await kernel.lookupSession('cli-tok');
|
|
112
|
+
await settle();
|
|
113
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='cli-tok'`)).expires_at, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('refresh-on-use still bumps last_used_at on that same row', async () => {
|
|
117
|
+
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
|
|
118
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token='cli-tok'`);
|
|
119
|
+
await kernel.lookupSession('cli-tok');
|
|
120
|
+
await settle();
|
|
121
|
+
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token='cli-tok'`)).last_used_at;
|
|
122
|
+
assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
|
|
126
|
+
await kernel.lookupSession('web-tok');
|
|
127
|
+
await settle();
|
|
128
|
+
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token='web-tok'`)).expires_at;
|
|
129
|
+
assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
|
|
133
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
|
|
134
|
+
VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
|
|
135
|
+
assert.equal(await kernel.lookupSession('dead-tok'), null);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await t('a revoked CLI session is still REFUSED', async () => {
|
|
139
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, revoked_at)
|
|
140
|
+
VALUES (1,'revoked-tok','cli', now())`);
|
|
141
|
+
assert.equal(await kernel.lookupSession('revoked-tok'), null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The migration, against rows shaped like the ones live instances actually hold.
|
|
145
|
+
const migrationSql = readFileSync(MIGRATION, 'utf8');
|
|
146
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token, source, expires_at) VALUES
|
|
147
|
+
(1,'old-cli','cli', now() + interval '24 hours'),
|
|
148
|
+
(1,'old-web','web', now() + interval '30 days'),
|
|
149
|
+
(1,'old-revoked','cli', now() + interval '24 hours')`);
|
|
150
|
+
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token='old-revoked'`);
|
|
151
|
+
await client.query(migrationSql);
|
|
152
|
+
|
|
153
|
+
await t('core_243 clears a pre-existing CLI expiry', async () => {
|
|
154
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
|
|
158
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
|
|
162
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-revoked'`)).expires_at, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t('core_243 is idempotent — a re-run changes nothing', async () => {
|
|
166
|
+
await client.query(migrationSql);
|
|
167
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-cli'`)).expires_at, null);
|
|
168
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token='old-web'`)).expires_at, null);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The whole point of the task, stated as a test.
|
|
172
|
+
await t('a CLI session idle for 21 DAYS still authenticates', async () => {
|
|
173
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token='cli-tok'`);
|
|
174
|
+
assert.notEqual(await kernel.lookupSession('cli-tok'), null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await client.query(`SET search_path TO public`);
|
|
178
|
+
client.release();
|
|
179
|
+
await pool.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
|
|
180
|
+
await pool.end();
|
|
181
|
+
|
|
182
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
183
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -19,15 +19,30 @@
|
|
|
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 db = require('../src/bongos/db.js');
|
|
25
26
|
|
|
26
27
|
let passed = 0;
|
|
27
28
|
let failed = 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Cases are queued rather than run inline so an ASYNC one (the createSession
|
|
30
|
+
// checks below hit a stubbed pool through an async function) is actually awaited.
|
|
31
|
+
// Running those inline would report PASS before the assertion resolved, and a
|
|
32
|
+
// rejection would surface as an unhandled rejection instead of a FAIL.
|
|
33
|
+
const queue = [];
|
|
34
|
+
function t(name, fn) { queue.push({ name, fn }); }
|
|
35
|
+
// Section headers queue too, so they still print ABOVE the cases they introduce
|
|
36
|
+
// instead of all bunching at the top once execution is deferred.
|
|
37
|
+
function section(label) { queue.push({ label }); }
|
|
38
|
+
async function runQueue() {
|
|
39
|
+
for (const item of queue) {
|
|
40
|
+
if (item.label) { console.log(item.label); continue; }
|
|
41
|
+
const { name, fn } = item;
|
|
42
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
queue.length = 0;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
// ---------------------------------------------------------------------------
|
|
@@ -103,7 +118,7 @@ function isDemotion(oldRank, newRank) {
|
|
|
103
118
|
return newIdx > oldIdx;
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
|
|
121
|
+
section('\ndemotion-detection (revoke-all hook in setBuilderRank):');
|
|
107
122
|
|
|
108
123
|
t('archon → metic is a demotion (revoke triggers)', () => {
|
|
109
124
|
assert.equal(isDemotion('archon', 'metic'), true);
|
|
@@ -135,21 +150,119 @@ t('an unknown rank string does not crash and returns false', () => {
|
|
|
135
150
|
});
|
|
136
151
|
|
|
137
152
|
// ---------------------------------------------------------------------------
|
|
138
|
-
// 3.
|
|
153
|
+
// 3. No expiry on a CLI session (ADR 0294)
|
|
139
154
|
// ---------------------------------------------------------------------------
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
155
|
+
// The TTL was argued three times — 1h (V3.R38/#259), 24h (V3.R90/#400), then
|
|
156
|
+
// gone (ADR 0294 / task 1003956). The first two rounds moved the number; the
|
|
157
|
+
// third removed the mechanism, because refresh-on-use meant the timer only ever
|
|
158
|
+
// fired on a session gone QUIET — the legitimate holder stepping away — while a
|
|
159
|
+
// leaked token's own traffic kept it alive. What is asserted here is the shape
|
|
160
|
+
// that replaced it, and the two ways it could silently come back.
|
|
161
|
+
//
|
|
162
|
+
// createSession is exercised against a STUB POOL rather than a live Postgres, in
|
|
163
|
+
// keeping with this file's no-real-PG stance: the claim under test is which
|
|
164
|
+
// value reaches the INSERT, which is a pure function of the argument. The pool
|
|
165
|
+
// module is stubbed in the require cache before db-kernel is loaded fresh, since
|
|
166
|
+
// db-kernel binds `pool` once at module load.
|
|
145
167
|
|
|
146
|
-
|
|
168
|
+
section('\nSESSION_TTL_MS constant:');
|
|
147
169
|
|
|
148
170
|
t('SESSION_TTL_MS is exactly 24 hours (86,400,000 ms)', () => {
|
|
149
171
|
assert.equal(db.SESSION_TTL_MS, 24 * 60 * 60 * 1000);
|
|
150
172
|
});
|
|
151
173
|
|
|
174
|
+
section('\ncreateSession expiry default (ADR 0294):');
|
|
175
|
+
|
|
176
|
+
// Load db-kernel over a stub pool that records the parameters it is handed.
|
|
177
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
178
|
+
const kernelPath = require.resolve('../src/bongos/db-kernel.js');
|
|
179
|
+
const realPool = require.cache[poolPath];
|
|
180
|
+
const realKernel = require.cache[kernelPath];
|
|
181
|
+
const calls = [];
|
|
182
|
+
require.cache[poolPath] = {
|
|
183
|
+
id: poolPath, filename: poolPath, loaded: true, exports: {
|
|
184
|
+
pool: { query: async (text, params) => { calls.push({ text, params }); return { rows: [{ id: '1' }] }; } },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
delete require.cache[kernelPath];
|
|
188
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
189
|
+
|
|
190
|
+
// expires_at is the 5th INSERT parameter — see createSession's VALUES list.
|
|
191
|
+
async function expiryFor(expiresAt) {
|
|
192
|
+
calls.length = 0;
|
|
193
|
+
await kernel.createSession({ builderId: '1', token: 't', source: 'cli', userAgent: null, expiresAt });
|
|
194
|
+
return calls[0].params[4];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
t('a CLI session named with NO expiry is stored with expires_at NULL', async () => {
|
|
198
|
+
assert.equal(await expiryFor(null), null);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
t('an OMITTED expiresAt is also stored as NULL, not a 24h default', async () => {
|
|
202
|
+
assert.equal(await expiryFor(undefined), null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
t('an EXPLICIT expiry is preserved verbatim — the web flow keeps its 30 days', async () => {
|
|
206
|
+
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
207
|
+
assert.equal((await expiryFor(thirtyDays)).getTime(), thirtyDays.getTime());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
section('\nrefresh-on-use must not re-mint an expiry (ADR 0294):');
|
|
211
|
+
|
|
212
|
+
// THE REGRESSION THIS GUARDS. lookupSession rolls expires_at forward on every
|
|
213
|
+
// authenticated request. Without the NULL guard, the FIRST request after sign-in
|
|
214
|
+
// would write now()+24h onto a never-expires row and restore the whole timeout —
|
|
215
|
+
// silently, with createSession still looking correct. Asserted over the source
|
|
216
|
+
// because the behaviour lives in SQL the stub pool never executes.
|
|
217
|
+
const kernelSrc = readFileSync(new URL('../src/bongos/db-kernel.js', import.meta.url), 'utf8');
|
|
218
|
+
const refreshSql = kernelSrc.slice(
|
|
219
|
+
kernelSrc.indexOf('UPDATE builder_sessions'),
|
|
220
|
+
kernelSrc.indexOf('[row.session_id, SESSION_TTL_MS]')
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
t("refresh-on-use leaves a NULL expires_at NULL", () => {
|
|
224
|
+
assert.ok(refreshSql.includes('UPDATE builder_sessions'), 'refresh statement not found');
|
|
225
|
+
assert.match(refreshSql, /CASE\s*\n?\s*WHEN expires_at IS NULL THEN NULL/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
t('refresh-on-use still bumps last_used_at (the own-sessions list reads it)', () => {
|
|
229
|
+
assert.match(refreshSql, /last_used_at = now\(\)/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
section('\nmigration core_243 scope:');
|
|
233
|
+
|
|
234
|
+
// The migration clears expires_at on rows that already exist. Its source filter
|
|
235
|
+
// is the entire safety property: lookupSession only rolls an expiry it can
|
|
236
|
+
// already SEE, so a web row cleared here would stay cleared forever — an
|
|
237
|
+
// immortality nobody decided on, and unrecoverable by normal use.
|
|
238
|
+
//
|
|
239
|
+
// Comment lines are STRIPPED before matching. This file's header explains the
|
|
240
|
+
// source filter at length, so asserting over the raw text would let the prose
|
|
241
|
+
// satisfy a check the statement no longer meets — verified by deleting the WHERE
|
|
242
|
+
// clause and watching the revoked_at assertion still pass.
|
|
243
|
+
const migrationSql = readFileSync(
|
|
244
|
+
new URL('../migrations/core_243_cli_sessions_never_expire.sql', import.meta.url), 'utf8'
|
|
245
|
+
)
|
|
246
|
+
.split('\n')
|
|
247
|
+
.filter((line) => !line.trim().startsWith('--'))
|
|
248
|
+
.join('\n');
|
|
249
|
+
|
|
250
|
+
t("core_243 only clears CLI rows", () => {
|
|
251
|
+
assert.match(migrationSql, /SET expires_at = NULL/);
|
|
252
|
+
assert.match(migrationSql, /WHERE source = 'cli'/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
t('core_243 leaves revoked rows alone (they are the audit trail)', () => {
|
|
256
|
+
assert.match(migrationSql, /revoked_at IS NULL/);
|
|
257
|
+
});
|
|
258
|
+
|
|
152
259
|
// ---------------------------------------------------------------------------
|
|
153
260
|
|
|
261
|
+
await runQueue();
|
|
262
|
+
|
|
263
|
+
// Put the real modules back so anything importing this file after it is unaffected.
|
|
264
|
+
if (realPool) require.cache[poolPath] = realPool; else delete require.cache[poolPath];
|
|
265
|
+
if (realKernel) require.cache[kernelPath] = realKernel; else delete require.cache[kernelPath];
|
|
266
|
+
|
|
154
267
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
155
268
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The fixed beginner-prompt set for the accessibility replay regression (task 1002517, clauses 5 and 8 of criterion accessibility-fluency-profile). EIGHT prompts a newcomer to this instance actually asks; each one is the documented trigger of a real surface, so the click-paths named in the non-technical replies are paths that EXIST (Settings -> API keys, Settings -> Session, Settings -> Interaction, the hall task list, the hall blockers page, the hall leaderboard, the hall Deploy page). Prompt 'make-it-live' is the owner's own words, verbatim, 2026-08-22 (task 1003159). Each prompt carries TWO answer runs: the same question answered in the register each profile asks for. The runs are the CONTROLLED variable and the prompt set is fixed - that is what makes this a replay rather than a fixture parade. Nothing here is tuned to a target score: the replies are written in each register and the harness measures what falls out, so a detector that goes blind moves these numbers.",
|
|
3
|
+
"floor": 6.0,
|
|
4
|
+
"_floor_comment": "6.0 is not a new number. It is bandFor()'s 'workable' boundary in modules/sessions/accessibility.js - the point the scorer itself calls the difference between prose a newcomer can work with and prose that is merely dense. accessibility_replay.mjs asserts the two stay tied, so retuning the bands cannot silently move the floor.",
|
|
5
|
+
"prompts": [
|
|
6
|
+
{ "id": "what-to-work-on", "prompt": "What can I work on?" },
|
|
7
|
+
{ "id": "make-it-live", "prompt": "I don't think I can make anything live." },
|
|
8
|
+
{ "id": "art-key", "prompt": "My image generation stopped working." },
|
|
9
|
+
{ "id": "session-expired", "prompt": "It says my session expired." },
|
|
10
|
+
{ "id": "how-you-talk", "prompt": "Can you stop using so much jargon with me?" },
|
|
11
|
+
{ "id": "credits", "prompt": "How do credits work here?" },
|
|
12
|
+
{ "id": "stuck", "prompt": "What's stuck waiting on me?" },
|
|
13
|
+
{ "id": "why-slow", "prompt": "Why is that page so slow?" }
|
|
14
|
+
],
|
|
15
|
+
"runs": {
|
|
16
|
+
"non_technical": {
|
|
17
|
+
"_comment": "The register the non-technical profile asks for: lead with the answer, name the click-path before the terminal, define a term the first time it is needed, and frame the trade-off as cost, time and what the reader will see. Two turns deliberately exercise the scorer's FORGIVING paths rather than dodging them - 'why-slow' turn 1 uses the word migration and defines it in the same breath, and turn 2 names the architecture AND what it costs - because a run that scores well only by avoiding every trigger proves nothing about the detectors.",
|
|
18
|
+
"profile": { "technical_level": "non_technical", "toolchain_familiarity": "new" },
|
|
19
|
+
"replies": {
|
|
20
|
+
"what-to-work-on": [
|
|
21
|
+
"Four jobs are open to you right now. They are on the hall task list, newest first, and each row shows what it pays and roughly how long it takes.",
|
|
22
|
+
"The second one is the quickest - about twenty minutes, and it pays thirty credits. Tell me its number and I will take it on for you."
|
|
23
|
+
],
|
|
24
|
+
"make-it-live": [
|
|
25
|
+
"You can do this yourself now, and it is two clicks. In the hall, open Deploy: the page shows the version your site is actually serving and the newer one it would move to.",
|
|
26
|
+
"The first click previews the move and the second makes it. If the new version does not answer within a minute it puts the old one back on its own, so a bad release cannot leave your site down. It costs nothing and takes about two minutes."
|
|
27
|
+
],
|
|
28
|
+
"art-key": [
|
|
29
|
+
"Your free art coverage has ended, so pictures now come out of an art key of your own - think of a key as a password that lets us make images on your account, and you pay only for what you make.",
|
|
30
|
+
"Add it in Settings, under API keys. Do not paste it to me in chat; the Settings page is the only place it should ever go. Most people spend a few dollars per month on this."
|
|
31
|
+
],
|
|
32
|
+
"session-expired": [
|
|
33
|
+
"That message means your sign-in has timed out. Nothing is lost, and it happens every few weeks on purpose.",
|
|
34
|
+
"Open Settings, then Session, and choose Re-issue. That hands you a fresh code and I take it from there. It takes under a minute."
|
|
35
|
+
],
|
|
36
|
+
"how-you-talk": [
|
|
37
|
+
"Yes - and it is a setting, so you will not have to keep asking. Open Settings, then Interaction, and set your technical level to non-technical.",
|
|
38
|
+
"Every session after that starts already knowing to define a term the first time it comes up, and to talk in cost, time and what you will see on screen. You will notice the difference in the very next reply."
|
|
39
|
+
],
|
|
40
|
+
"credits": [
|
|
41
|
+
"Credits are the record of what building here actually cost. When a piece of work ships you earn what the session really cost plus twenty per cent, and one credit is one dollar.",
|
|
42
|
+
"The figure on a task row is only a guide - you are paid on the real cost, never the estimate. Your running total sits on the hall leaderboard."
|
|
43
|
+
],
|
|
44
|
+
"stuck": [
|
|
45
|
+
"Four things are waiting on you rather than on me. Two need a yes or no and two need a number I cannot guess for you.",
|
|
46
|
+
"They are all on the hall blockers page, oldest first. The one at the top has been sitting eleven days and is holding up two other jobs, so it is the one worth five minutes."
|
|
47
|
+
],
|
|
48
|
+
"why-slow": [
|
|
49
|
+
"The page re-reads the whole list every time someone opens it. The fix is a migration - that is a one-off change to the way the information is kept - and it would take about an hour.",
|
|
50
|
+
"The architecture underneath is fine; the cost is that repeated reading. After the change the page should open in under a second instead of four."
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"engineer": {
|
|
55
|
+
"_comment": "The same eight questions answered the way an engineer profile asks to be answered: terse, command-first, and in the vocabulary of the trade with nothing defined. This run is DELIBERATELY dense in all three signals - that is the point. Under the engineer profile it must not be scored for any of it; under the non-technical profile the very same prose must fall through the floor. One run, two profiles, opposite verdicts is what proves the dimension is profile-relative rather than a taste test.",
|
|
56
|
+
"profile": { "technical_level": "engineer", "toolchain_familiarity": "comfortable" },
|
|
57
|
+
"replies": {
|
|
58
|
+
"what-to-work-on": [
|
|
59
|
+
"Four ready. Run `node scripts/gds/start.js`, then claim a task by id.",
|
|
60
|
+
"Second row is a refactor behind the module boundary. The claim is idempotent, so re-running it is a no-op."
|
|
61
|
+
],
|
|
62
|
+
"make-it-live": [
|
|
63
|
+
"Pin move is gate 3. `bongos upgrade --registry --to <ver> --commit-pin` on the control plane, or the Deploy page enqueues the same intent.",
|
|
64
|
+
"The control flow is enqueue, drain, health-gate, rollback. SIGTERM on a failed probe and the symlink goes back."
|
|
65
|
+
],
|
|
66
|
+
"art-key": [
|
|
67
|
+
"Art key is unset. Run `node scripts/gds/api.js` against the settings route, or set it in Settings - either path ends up in the same env file.",
|
|
68
|
+
"The upstream call is async and the error surfaces on stderr, not in the hall."
|
|
69
|
+
],
|
|
70
|
+
"session-expired": [
|
|
71
|
+
"Session token expired. Run `bongos auth --reissue`.",
|
|
72
|
+
"The JWT is short-lived by design and the refresh is idempotent, so a double call is harmless."
|
|
73
|
+
],
|
|
74
|
+
"how-you-talk": [
|
|
75
|
+
"That is the interaction profile. Run `node scripts/gds/api.js PATCH /settings`, or set it in Settings.",
|
|
76
|
+
"It is injected per session by the Conductor hook and never memoized, so the change takes on the next turn."
|
|
77
|
+
],
|
|
78
|
+
"credits": [
|
|
79
|
+
"Cost-plus. Run `node scripts/gds/api.js GET /builders/me` for your balance, or read the leaderboard.",
|
|
80
|
+
"The write is an upsert keyed on the session, so a replayed ship cannot double-pay."
|
|
81
|
+
],
|
|
82
|
+
"stuck": [
|
|
83
|
+
"Four open blockers. Run `node scripts/gds/blocker-review.js` to walk them.",
|
|
84
|
+
"Resolving one will upsert the linked rows to ready inside the same transaction."
|
|
85
|
+
],
|
|
86
|
+
"why-slow": [
|
|
87
|
+
"N+1 on the list read.",
|
|
88
|
+
"The ORM will hydrate each row separately. Fold it into one query against the schema with a jsonb aggregate."
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|