@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.
Files changed (27) hide show
  1. package/dist/commands/build-trajectory-evidence.d.ts +10 -0
  2. package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
  3. package/dist/commands/build-trajectory-evidence.js +12 -0
  4. package/dist/commands/build-trajectory-evidence.js.map +1 -1
  5. package/dist/commands/pain-record.d.ts.map +1 -1
  6. package/dist/commands/pain-record.js +63 -29
  7. package/dist/commands/pain-record.js.map +1 -1
  8. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  9. package/dist/commands/runtime-internalization-run-rulehost.js +21 -1
  10. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  11. package/dist/services/demo-rule-compiler.d.ts +9 -11
  12. package/dist/services/demo-rule-compiler.d.ts.map +1 -1
  13. package/dist/services/demo-rule-compiler.js +11 -37
  14. package/dist/services/demo-rule-compiler.js.map +1 -1
  15. package/dist/services/rulehost-pipeline-runner.d.ts +6 -9
  16. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  17. package/dist/services/rulehost-pipeline-runner.js +6 -9
  18. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/commands/build-trajectory-evidence.ts +22 -0
  21. package/src/commands/pain-record.ts +64 -29
  22. package/src/commands/runtime-internalization-run-rulehost.ts +22 -1
  23. package/src/services/demo-rule-compiler.ts +12 -45
  24. package/src/services/rulehost-pipeline-runner.ts +6 -9
  25. package/tests/commands/build-trajectory-evidence.test.ts +106 -0
  26. package/tests/commands/pain-record-session-parser.test.ts +62 -12
  27. 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`. Per the Explore finding, `compileDemoRule` is
21
- * byte-equivalent to openclaw-plugin's `loadRuleImplementationModule`; the
22
- * duplication is a package-boundary necessity (pd-cli cannot import
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` is byte-equivalent to openclaw-plugin's
230
- * `loadRuleImplementationModule` (same vm.createContext, same normalize, same
231
- * evaluate-shape check). pd-cli cannot import openclaw-plugin (package
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
- 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, created_at TEXT NOT NULL)");
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 (validation passes, submission proceeds)', async () => {
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', 'parser test pain with real session',
164
- '--session', 'real-session-1',
193
+ '--reason', 'PRI-783 review P1 lock',
194
+ '--session', 'fake-unverified-id',
165
195
  '--workspace', tmpDir,
166
196
  '--json',
167
197
  ]);
168
198
 
169
- // The session exists, so session validation must NOT reject it. The run
170
- // may still fail later (no LLM runtime configured in this temp
171
- // workspace) — the assertion is only about the validation stage.
172
- const trimmed = result.stdout.trim();
173
- expect(trimmed.length).toBeGreaterThan(0);
174
- const parsed = JSON.parse(trimmed) as Record<string, unknown>;
175
- expect(parsed.reason).not.toBe('session_not_found');
176
- expect(parsed.reason).not.toBe('empty_trajectory');
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('fails before mutation on empty_trajectory (CLI never claims host_context_bound without a verified session)', async () => {
611
- // Per Evidence Over Assumption: the CLI does not own session identity
612
- // the way the OpenClaw host command context does. Unverified sessions
613
- // (any acquisition that does not yield 'available') must refuse before
614
- // any LLM/task/candidate mutation.
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
- expect(lastRecordPainInput).toBeNull();
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('failed');
628
- expect(jsonOutput.reason).toBe('empty_trajectory');
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('fails before mutation on evidence_read_failed (no LLM/no task when the CLI cannot verify binding)', async () => {
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: 'trajectory.db unreadable',
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('failed');
649
- expect(jsonOutput.reason).toBe('evidence_read_failed');
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('fails before mutation on trajectory_unavailable (CLI cannot fabricate a session)', async () => {
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: 'trajectory_unavailable',
660
- detail: 'no trajectory.db at workspace .state',
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('trajectory_unavailable');
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