@principles/pd-cli 1.152.5 → 1.152.6
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/dist/commands/build-trajectory-evidence.d.ts +10 -0
- package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
- package/dist/commands/build-trajectory-evidence.js +12 -0
- package/dist/commands/build-trajectory-evidence.js.map +1 -1
- package/dist/commands/pain-record.d.ts.map +1 -1
- package/dist/commands/pain-record.js +63 -29
- package/dist/commands/pain-record.js.map +1 -1
- package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-rulehost.js +21 -1
- package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
- package/dist/services/demo-rule-compiler.d.ts +9 -11
- package/dist/services/demo-rule-compiler.d.ts.map +1 -1
- package/dist/services/demo-rule-compiler.js +11 -37
- package/dist/services/demo-rule-compiler.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts +6 -9
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +6 -9
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/build-trajectory-evidence.ts +22 -0
- package/src/commands/pain-record.ts +64 -29
- package/src/commands/runtime-internalization-run-rulehost.ts +22 -1
- package/src/services/demo-rule-compiler.ts +12 -45
- package/src/services/rulehost-pipeline-runner.ts +6 -9
- package/tests/commands/build-trajectory-evidence.test.ts +106 -0
- package/tests/commands/pain-record-session-parser.test.ts +62 -12
- package/tests/commands/pain-record.test.ts +59 -19
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
* - The artificer↔evaluator stage delegates to `runAdversarialLoop` (PRI-428),
|
|
18
18
|
* which never throws and degrades to { decision: 'rejected' } with a reason.
|
|
19
19
|
* - gateDeps (the sandbox adapter) is built from `compileDemoRule` +
|
|
20
|
-
* `evaluateInRefinerSandbox`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* openclaw-plugin), not a capability gap.
|
|
20
|
+
* `evaluateInRefinerSandbox`. `compileDemoRule` delegates to the canonical
|
|
21
|
+
* hardened replay evaluator in @principles/core (PRI-809), so pre-activation
|
|
22
|
+
* replay crosses the same vm trust boundary as the production gate.
|
|
24
23
|
*
|
|
25
24
|
* @see docs/plans/rulehost-mvp-activation.md
|
|
26
25
|
* @see runAdversarialLoop in @principles/core/runtime-v2
|
|
@@ -226,11 +225,9 @@ export interface RuleHostPipelineResult {
|
|
|
226
225
|
/**
|
|
227
226
|
* Build the sandbox gate deps for adversarial replay.
|
|
228
227
|
*
|
|
229
|
-
* `compileDemoRule`
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* boundary), so this duplicate is intentional and capability-identical, not a
|
|
233
|
-
* stripped demo. See demo-rule-compiler.ts header comment.
|
|
228
|
+
* `compileDemoRule` delegates to the canonical hardened replay evaluator in
|
|
229
|
+
* @principles/core (PRI-809) — capability-identical across packages without a
|
|
230
|
+
* duplicate vm implementation. See demo-rule-compiler.ts header comment.
|
|
234
231
|
*/
|
|
235
232
|
export function createSandboxGateDeps(): RefinerRuleHostGateDeps {
|
|
236
233
|
return {
|
|
@@ -400,3 +400,109 @@ describe('acquireTrajectoryEvidenceFromDb — typed CLI acquisition (PRI-642 Sco
|
|
|
400
400
|
expect(unreadable.reasonCode).toBe('evidence_read_failed');
|
|
401
401
|
});
|
|
402
402
|
});
|
|
403
|
+
|
|
404
|
+
// ── PRI-783 review P1: the binding fact gates degrade vs refuse ─────────────
|
|
405
|
+
// 'verified' = the sessions table was queried and DID contain the id;
|
|
406
|
+
// 'unverified' = DB missing / unopenable / id absent / sessions table
|
|
407
|
+
// unreadable. Only verified bindings may degrade to a bound submission.
|
|
408
|
+
|
|
409
|
+
describe('acquisition binding fact (PRI-783 review P1)', () => {
|
|
410
|
+
beforeEach(() => {
|
|
411
|
+
createStateDir();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
afterEach(() => {
|
|
415
|
+
try {
|
|
416
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
417
|
+
} catch {
|
|
418
|
+
// ignore cleanup errors
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('session present with zero evidence → empty_trajectory, binding verified (degrade precondition)', async () => {
|
|
423
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
424
|
+
const db = createTrajectoryDb();
|
|
425
|
+
db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
|
|
426
|
+
.run('quiet-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
|
|
427
|
+
insertUserTurn(db, 'quiet-session', 'ordinary message', false, '2026-01-01T09:01:00Z');
|
|
428
|
+
db.close();
|
|
429
|
+
|
|
430
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'quiet-session', tmpDir);
|
|
431
|
+
|
|
432
|
+
expect(result.status).toBe('unavailable');
|
|
433
|
+
if (result.status !== 'unavailable') return;
|
|
434
|
+
expect(result.reasonCode).toBe('empty_trajectory');
|
|
435
|
+
expect(result.binding).toBe('verified');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('id absent from sessions table → session_not_found, binding unverified', async () => {
|
|
439
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
440
|
+
const db = createTrajectoryDb();
|
|
441
|
+
db.close();
|
|
442
|
+
|
|
443
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'no-such-session', tmpDir);
|
|
444
|
+
|
|
445
|
+
expect(result.status).toBe('unavailable');
|
|
446
|
+
if (result.status !== 'unavailable') return;
|
|
447
|
+
expect(result.reasonCode).toBe('session_not_found');
|
|
448
|
+
expect(result.binding).toBe('unverified');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('missing trajectory.db → trajectory_unavailable, binding unverified', async () => {
|
|
452
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
453
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
|
|
454
|
+
|
|
455
|
+
expect(result.status).toBe('unavailable');
|
|
456
|
+
if (result.status !== 'unavailable') return;
|
|
457
|
+
expect(result.reasonCode).toBe('trajectory_unavailable');
|
|
458
|
+
expect(result.binding).toBe('unverified');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('corrupt DB (open fails) → evidence_read_failed, binding unverified', async () => {
|
|
462
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
463
|
+
const dbPath = path.join(stateDir, 'trajectory.db');
|
|
464
|
+
fs.writeFileSync(dbPath, Buffer.from('this is not a sqlite database at all'));
|
|
465
|
+
|
|
466
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
|
|
467
|
+
|
|
468
|
+
expect(result.status).toBe('unavailable');
|
|
469
|
+
if (result.status !== 'unavailable') return;
|
|
470
|
+
expect(result.reasonCode).toBe('evidence_read_failed');
|
|
471
|
+
expect(result.binding).toBe('unverified');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('session verified but evidence tables unreadable → evidence_read_failed, binding verified', async () => {
|
|
475
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
476
|
+
const db = createTrajectoryDb();
|
|
477
|
+
db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
|
|
478
|
+
.run('readable-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
|
|
479
|
+
db.close();
|
|
480
|
+
// Drop the evidence tables after the session row exists: the sessions
|
|
481
|
+
// check passed, the evidence read cannot.
|
|
482
|
+
const raw = new Database(path.join(stateDir, 'trajectory.db'));
|
|
483
|
+
raw.exec('DROP TABLE user_turns');
|
|
484
|
+
raw.close();
|
|
485
|
+
|
|
486
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'readable-session', tmpDir);
|
|
487
|
+
|
|
488
|
+
expect(result.status).toBe('unavailable');
|
|
489
|
+
if (result.status !== 'unavailable') return;
|
|
490
|
+
expect(result.reasonCode).toBe('evidence_read_failed');
|
|
491
|
+
expect(result.binding).toBe('verified');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('sessions table missing entirely → nothing is verified (unverified even when the reason is evidence_read_failed)', async () => {
|
|
495
|
+
const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
|
|
496
|
+
const db = new Database(path.join(stateDir, 'trajectory.db'));
|
|
497
|
+
db.exec('CREATE TABLE user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, correction_cue TEXT, created_at TEXT NOT NULL)');
|
|
498
|
+
db.close();
|
|
499
|
+
|
|
500
|
+
const result = acquireTrajectoryEvidenceFromDb(stateDir, 'never-seen', tmpDir);
|
|
501
|
+
|
|
502
|
+
expect(result.status).toBe('unavailable');
|
|
503
|
+
if (result.status !== 'unavailable') return;
|
|
504
|
+
// assistant_turns/tool_calls tables missing → readFailed → evidence_read_failed
|
|
505
|
+
expect(result.reasonCode).toBe('evidence_read_failed');
|
|
506
|
+
expect(result.binding).toBe('unverified');
|
|
507
|
+
});
|
|
508
|
+
});
|
|
@@ -13,6 +13,7 @@ import Database from 'better-sqlite3';
|
|
|
13
13
|
import * as fs from 'node:fs';
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
import * as os from 'node:os';
|
|
16
|
+
import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
|
|
16
17
|
|
|
17
18
|
let tmpDir: string;
|
|
18
19
|
|
|
@@ -24,7 +25,12 @@ function createWorkspace(): void {
|
|
|
24
25
|
const db = new Database(path.join(stateDir, 'trajectory.db'));
|
|
25
26
|
db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
|
|
26
27
|
db.exec("CREATE TABLE IF NOT EXISTS assistant_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sanitized_text TEXT, stop_reason TEXT, created_at TEXT NOT NULL)");
|
|
27
|
-
|
|
28
|
+
// PRI-783 lesson recorded on the fixture: user_turns MUST carry
|
|
29
|
+
// correction_cue — collectEvidenceFromDb SELECTs it, and a missing column
|
|
30
|
+
// silently degraded the user-turns read into evidence_read_failed, so this
|
|
31
|
+
// file's 'accepts --session' case never actually exercised a real evidence
|
|
32
|
+
// read (it vacuously asserted on a refusal).
|
|
33
|
+
db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, correction_cue TEXT, created_at TEXT NOT NULL)");
|
|
28
34
|
db.exec("CREATE TABLE IF NOT EXISTS tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, outcome TEXT NOT NULL, error_type TEXT, exit_code INTEGER, params_json TEXT NOT NULL DEFAULT '{}', result_preview TEXT, created_at TEXT NOT NULL)");
|
|
29
35
|
db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
|
|
30
36
|
.run('real-session-1', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
|
|
@@ -157,22 +163,66 @@ describe('pd pain record --session (real Commander + real trajectory.db)', () =>
|
|
|
157
163
|
expect(typeof parsed.nextAction).toBe('string');
|
|
158
164
|
}, 15_000);
|
|
159
165
|
|
|
160
|
-
it('accepts --session for a session that exists in the trajectory (
|
|
166
|
+
it('accepts --session for a session that exists in the trajectory (real acquisition returns available evidence)', async () => {
|
|
167
|
+
// The spawned-CLI route is used above for the refusal contracts. The
|
|
168
|
+
// acceptance route ends in the diagnostician pipeline (out of this file's
|
|
169
|
+
// scope — the service has its own suite), so the acceptance boundary is
|
|
170
|
+
// asserted against the REAL acquisition function the CLI delegates to,
|
|
171
|
+
// over this file's real trajectory.db fixture (cli-7: real parser wiring
|
|
172
|
+
// is covered by the refusal spawns; real DB semantics here — PRI-783).
|
|
173
|
+
const stateDir = path.join(tmpDir, '.state');
|
|
174
|
+
const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, 'real-session-1', tmpDir);
|
|
175
|
+
|
|
176
|
+
expect(acquisition.status).toBe('available');
|
|
177
|
+
if (acquisition.status !== 'available') return;
|
|
178
|
+
expect(acquisition.entries.length).toBeGreaterThanOrEqual(1);
|
|
179
|
+
expect(acquisition.entries.some(e => e.sourceRef.startsWith('owner_message:'))).toBe(true);
|
|
180
|
+
}, 15_000);
|
|
181
|
+
|
|
182
|
+
// ── PRI-783 review P1: unverified bindings must refuse, never persist ──────
|
|
183
|
+
|
|
184
|
+
it('locks the review P1: --session with a missing trajectory.db refuses and NEVER creates a db containing the fake id', async () => {
|
|
185
|
+
// Remove the fixture db so the id cannot be verified at all (the shared
|
|
186
|
+
// createWorkspace always seeds one). The run must fail loud
|
|
187
|
+
// (trajectory_unavailable) BEFORE any mutation — the observability writer
|
|
188
|
+
// must not create a trajectory.db and upsert the unverified id into
|
|
189
|
+
// sessions (self-proving loop).
|
|
190
|
+
fs.rmSync(path.join(tmpDir, '.state', 'trajectory.db'), { force: true });
|
|
161
191
|
const result = await runBuiltCli([
|
|
162
192
|
'pain', 'record',
|
|
163
|
-
'--reason', '
|
|
164
|
-
'--session', '
|
|
193
|
+
'--reason', 'PRI-783 review P1 lock',
|
|
194
|
+
'--session', 'fake-unverified-id',
|
|
165
195
|
'--workspace', tmpDir,
|
|
166
196
|
'--json',
|
|
167
197
|
]);
|
|
168
198
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
expect(
|
|
199
|
+
expect(result.status).not.toBe(0);
|
|
200
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
201
|
+
expect(parsed.status).toBe('failed');
|
|
202
|
+
expect(parsed.reason).toBe('trajectory_unavailable');
|
|
203
|
+
expect(parsed.nextAction).not.toMatch(/pd pain record --session/);
|
|
204
|
+
// The lock: no trajectory.db was created by this run — the fake id can
|
|
205
|
+
// never appear in a sessions table.
|
|
206
|
+
expect(fs.existsSync(path.join(tmpDir, '.state', 'trajectory.db'))).toBe(false);
|
|
207
|
+
}, 15_000);
|
|
208
|
+
|
|
209
|
+
it('real empty session reports empty_trajectory with a VERIFIED binding (the degrade precondition)', async () => {
|
|
210
|
+
// Gate the degrade path on the real acquisition semantics: a session
|
|
211
|
+
// row that exists with zero evidence rows is the only unavailable shape
|
|
212
|
+
// allowed to degrade to a bound submission.
|
|
213
|
+
const stateDir = path.join(tmpDir, '.state');
|
|
214
|
+
const db = new Database(path.join(stateDir, 'trajectory.db'));
|
|
215
|
+
db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
|
|
216
|
+
.run('real-empty-session', '2026-01-01T08:00:00Z', '2026-01-01T08:00:00Z');
|
|
217
|
+
db.prepare('INSERT INTO user_turns (session_id, raw_excerpt, correction_detected, correction_cue, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
218
|
+
.run('real-empty-session', 'ordinary message, not a correction', 0, null, '2026-01-01T08:01:00Z');
|
|
219
|
+
db.close();
|
|
220
|
+
|
|
221
|
+
const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, 'real-empty-session', tmpDir);
|
|
222
|
+
|
|
223
|
+
expect(acquisition.status).toBe('unavailable');
|
|
224
|
+
if (acquisition.status !== 'unavailable') return;
|
|
225
|
+
expect(acquisition.reasonCode).toBe('empty_trajectory');
|
|
226
|
+
expect(acquisition.binding).toBe('verified');
|
|
177
227
|
}, 15_000);
|
|
178
228
|
});
|
|
@@ -566,6 +566,7 @@ describe('pd pain record', () => {
|
|
|
566
566
|
status: 'unavailable',
|
|
567
567
|
reasonCode: 'session_not_found',
|
|
568
568
|
detail: 'session not present in trajectory.db sessions table',
|
|
569
|
+
binding: 'unverified',
|
|
569
570
|
} as any);
|
|
570
571
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
571
572
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -579,6 +580,9 @@ describe('pd pain record', () => {
|
|
|
579
580
|
expect(jsonOutput.status).toBe('failed');
|
|
580
581
|
expect(jsonOutput.reason).toBe('session_not_found');
|
|
581
582
|
expect(jsonOutput.nextAction).toBeTruthy();
|
|
583
|
+
// PRI-783: the next action must not recommend retrying the exact path
|
|
584
|
+
// that just failed (`pd pain record --session`).
|
|
585
|
+
expect(jsonOutput.nextAction).not.toMatch(/pd pain record --session/);
|
|
582
586
|
// cli-2/cli-5: execution stopped — no service mutation, exit 1
|
|
583
587
|
expect(lastRecordPainInput).toBeNull();
|
|
584
588
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
@@ -593,6 +597,7 @@ describe('pd pain record', () => {
|
|
|
593
597
|
status: 'unavailable',
|
|
594
598
|
reasonCode: 'session_not_found',
|
|
595
599
|
detail: 'session not present',
|
|
600
|
+
binding: 'unverified',
|
|
596
601
|
} as any);
|
|
597
602
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
598
603
|
const exitSpy = mockProcessExit();
|
|
@@ -607,70 +612,105 @@ describe('pd pain record', () => {
|
|
|
607
612
|
exitSpy.mockRestore();
|
|
608
613
|
});
|
|
609
614
|
|
|
610
|
-
it('
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
// (
|
|
614
|
-
//
|
|
615
|
+
it('degrades to a bound, honest empty-evidence submission on empty_trajectory (PRI-783: a verified session must not be refused)', async () => {
|
|
616
|
+
// SPEC §8.2: binding failure and evidence failure are separate axes — a
|
|
617
|
+
// real session with no usable evidence rows is bound + unavailable
|
|
618
|
+
// (row 4, degrade), not unbound (row 5, refuse). PRI-783 Owner
|
|
619
|
+
// directive: a correct current session id is sufficient; empty evidence
|
|
620
|
+
// must not hard-refuse the report.
|
|
615
621
|
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
616
622
|
status: 'unavailable',
|
|
617
623
|
reasonCode: 'empty_trajectory',
|
|
618
624
|
detail: 'session exists but no usable evidence',
|
|
625
|
+
binding: 'verified',
|
|
619
626
|
} as any);
|
|
620
627
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
621
628
|
const exitSpy = mockProcessExit();
|
|
622
629
|
|
|
623
630
|
await handlePainRecord({ reason: 'test pain', session: 'quiet-session', json: true });
|
|
624
631
|
|
|
625
|
-
|
|
632
|
+
// Bound correlation with honest empty evidence — nothing fabricated.
|
|
633
|
+
expect(lastRecordPainInput).not.toBeNull();
|
|
634
|
+
expect(lastRecordPainInput!.sessionId).toBe('quiet-session');
|
|
635
|
+
expect(lastRecordPainInput!.hostKind).toBe('openclaw');
|
|
636
|
+
expect(lastRecordPainInput!.evidence).toEqual([]);
|
|
637
|
+
expect(lastRecordPainInput!.painIngress.correlation.status).toBe('bound');
|
|
638
|
+
expect(lastRecordPainInput!.painIngress.evidenceClass).toEqual({
|
|
639
|
+
status: 'unavailable',
|
|
640
|
+
reason: 'empty_trajectory',
|
|
641
|
+
});
|
|
642
|
+
// Degradation is disclosed, and the report is NOT refused.
|
|
626
643
|
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
627
|
-
expect(jsonOutput.status).toBe('
|
|
628
|
-
expect(jsonOutput.
|
|
629
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
644
|
+
expect(jsonOutput.status).toBe('succeeded');
|
|
645
|
+
expect(String(jsonOutput.warning ?? '')).toMatch(/empty_trajectory/);
|
|
646
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
630
647
|
|
|
631
648
|
logSpy.mockRestore();
|
|
632
649
|
exitSpy.mockRestore();
|
|
633
650
|
});
|
|
634
651
|
|
|
635
|
-
it('
|
|
652
|
+
it('degrades on evidence_read_failed when the binding was verified (tables unreadable after the sessions check)', async () => {
|
|
653
|
+
// SPEC §8.2 note: a real session with an unreadable trajectory is
|
|
654
|
+
// bound + unavailable — the sessions row was found, only the evidence
|
|
655
|
+
// read failed.
|
|
636
656
|
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
637
657
|
status: 'unavailable',
|
|
638
658
|
reasonCode: 'evidence_read_failed',
|
|
639
|
-
detail: '
|
|
659
|
+
detail: 'trajectory_tables_unreadable',
|
|
660
|
+
binding: 'verified',
|
|
640
661
|
} as any);
|
|
641
662
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
642
663
|
const exitSpy = mockProcessExit();
|
|
643
664
|
|
|
644
665
|
await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
|
|
645
666
|
|
|
646
|
-
expect(lastRecordPainInput).toBeNull();
|
|
667
|
+
expect(lastRecordPainInput).not.toBeNull();
|
|
668
|
+
expect(lastRecordPainInput!.sessionId).toBe('sess-x');
|
|
669
|
+
expect(lastRecordPainInput!.evidence).toEqual([]);
|
|
670
|
+
expect(lastRecordPainInput!.painIngress.correlation.status).toBe('bound');
|
|
671
|
+
expect(lastRecordPainInput!.painIngress.evidenceClass).toEqual({
|
|
672
|
+
status: 'unavailable',
|
|
673
|
+
reason: 'evidence_read_failed',
|
|
674
|
+
});
|
|
647
675
|
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
648
|
-
expect(jsonOutput.status).toBe('
|
|
649
|
-
expect(jsonOutput.
|
|
650
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
676
|
+
expect(jsonOutput.status).toBe('succeeded');
|
|
677
|
+
expect(String(jsonOutput.warning ?? '')).toMatch(/evidence_read_failed/);
|
|
678
|
+
expect(exitSpy).not.toHaveBeenCalledWith(1);
|
|
651
679
|
|
|
652
680
|
logSpy.mockRestore();
|
|
653
681
|
exitSpy.mockRestore();
|
|
654
682
|
});
|
|
655
683
|
|
|
656
|
-
it(
|
|
684
|
+
it.each([
|
|
685
|
+
['trajectory_unavailable', 'no trajectory.db at workspace .state'],
|
|
686
|
+
['evidence_read_failed', 'trajectory_db_unreadable: file is not a database'],
|
|
687
|
+
] as const)('refuses %s when the binding is UNVERIFIED (PRI-783 review P1: no verified session, no bound claim)', async (reasonCode, detail) => {
|
|
688
|
+
// PRI-783 review P1: when trajectory.db is missing or unopenable the id
|
|
689
|
+
// could not be verified. Claiming bound here would let the observability
|
|
690
|
+
// writer persist the unverified id into a freshly created trajectory.db —
|
|
691
|
+
// a self-proving loop. It must refuse instead.
|
|
657
692
|
vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
|
|
658
693
|
status: 'unavailable',
|
|
659
|
-
reasonCode
|
|
660
|
-
detail
|
|
694
|
+
reasonCode,
|
|
695
|
+
detail,
|
|
696
|
+
binding: 'unverified',
|
|
661
697
|
} as any);
|
|
662
698
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
699
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
663
700
|
const exitSpy = mockProcessExit();
|
|
664
701
|
|
|
665
702
|
await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
|
|
666
703
|
|
|
704
|
+
// Refused before any mutation, with honest next guidance.
|
|
667
705
|
expect(lastRecordPainInput).toBeNull();
|
|
668
706
|
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
|
|
669
707
|
expect(jsonOutput.status).toBe('failed');
|
|
670
|
-
expect(jsonOutput.reason).toBe(
|
|
708
|
+
expect(jsonOutput.reason).toBe(reasonCode);
|
|
709
|
+
expect(jsonOutput.nextAction).not.toMatch(/pd pain record --session/);
|
|
671
710
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
672
711
|
|
|
673
712
|
logSpy.mockRestore();
|
|
713
|
+
errorSpy.mockRestore();
|
|
674
714
|
exitSpy.mockRestore();
|
|
675
715
|
});
|
|
676
716
|
|