@principles/pd-cli 1.152.7 → 1.152.9

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 (72) hide show
  1. package/dist/commands/__tests__/runtime-probe-config.test.js +2 -1
  2. package/dist/commands/__tests__/runtime-probe-config.test.js.map +1 -1
  3. package/dist/commands/__tests__/version.test.js +104 -0
  4. package/dist/commands/__tests__/version.test.js.map +1 -1
  5. package/dist/commands/diagnose.d.ts.map +1 -1
  6. package/dist/commands/diagnose.js +3 -6
  7. package/dist/commands/diagnose.js.map +1 -1
  8. package/dist/commands/pain-retry.d.ts.map +1 -1
  9. package/dist/commands/pain-retry.js +3 -6
  10. package/dist/commands/pain-retry.js.map +1 -1
  11. package/dist/commands/runtime-activation.d.ts.map +1 -1
  12. package/dist/commands/runtime-activation.js +11 -5
  13. package/dist/commands/runtime-activation.js.map +1 -1
  14. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  15. package/dist/commands/runtime-internalization-run-once.js +8 -13
  16. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  17. package/dist/commands/runtime-internalization-run-rulehost.d.ts +14 -0
  18. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  19. package/dist/commands/runtime-internalization-run-rulehost.js +67 -15
  20. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  21. package/dist/index.js +0 -12
  22. package/dist/index.js.map +1 -1
  23. package/dist/services/__tests__/rulehost-readiness.test.js +0 -3
  24. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -1
  25. package/dist/services/demo-story-a-runner.d.ts.map +1 -1
  26. package/dist/services/demo-story-a-runner.js +32 -41
  27. package/dist/services/demo-story-a-runner.js.map +1 -1
  28. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -1
  29. package/dist/services/pain-flood-simulation-runner.js +0 -4
  30. package/dist/services/pain-flood-simulation-runner.js.map +1 -1
  31. package/dist/services/rulehost-pipeline-runner.d.ts +90 -21
  32. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  33. package/dist/services/rulehost-pipeline-runner.js +328 -24
  34. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  35. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -1
  36. package/dist/services/synthetic-baseline-runner.js +0 -4
  37. package/dist/services/synthetic-baseline-runner.js.map +1 -1
  38. package/dist/services/version-report.d.ts +16 -0
  39. package/dist/services/version-report.d.ts.map +1 -1
  40. package/dist/services/version-report.js +37 -2
  41. package/dist/services/version-report.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/commands/__tests__/runtime-probe-config.test.ts +2 -1
  44. package/src/commands/__tests__/version.test.ts +110 -0
  45. package/src/commands/diagnose.ts +3 -6
  46. package/src/commands/pain-retry.ts +3 -6
  47. package/src/commands/runtime-activation.ts +11 -5
  48. package/src/commands/runtime-internalization-run-once.ts +8 -13
  49. package/src/commands/runtime-internalization-run-rulehost.ts +69 -15
  50. package/src/index.ts +0 -13
  51. package/src/services/__tests__/rulehost-readiness.test.ts +0 -3
  52. package/src/services/demo-story-a-runner.ts +35 -45
  53. package/src/services/pain-flood-simulation-runner.ts +0 -4
  54. package/src/services/rulehost-pipeline-runner.ts +368 -35
  55. package/src/services/synthetic-baseline-runner.ts +0 -4
  56. package/src/services/version-report.ts +52 -2
  57. package/tests/commands/config-doctor.test.ts +0 -2
  58. package/tests/commands/pain-evidence.test.ts +1 -1
  59. package/tests/commands/run-rulehost-handler.test.ts +43 -0
  60. package/tests/commands/runtime-features.test.ts +0 -1
  61. package/tests/commands/runtime-internalization-run-once-language.test.ts +0 -1
  62. package/tests/commands/runtime-internalization-run-once-rollout-parity.test.ts +37 -11
  63. package/tests/e2e/cross-package-acceptance.test.ts +13 -3
  64. package/tests/services/pd-config-loader.test.ts +0 -1
  65. package/tests/services/rulehost-pipeline-runner.test.ts +378 -12
  66. package/dist/commands/runtime-internalization-context-trace.d.ts +0 -25
  67. package/dist/commands/runtime-internalization-context-trace.d.ts.map +0 -1
  68. package/dist/commands/runtime-internalization-context-trace.js +0 -374
  69. package/dist/commands/runtime-internalization-context-trace.js.map +0 -1
  70. package/src/commands/runtime-internalization-context-trace.ts +0 -445
  71. package/tests/bdd/context-trace.steps.ts +0 -229
  72. package/tests/commands/runtime-internalization-context-trace.test.ts +0 -110
@@ -711,3 +711,46 @@ describe('handleRunRuleHost — PR #1122 behavior-examples fail-fast (CodeRabbit
711
711
  expect(stdout).toMatch(/Next: fix the code-rule capability issue above/);
712
712
  });
713
713
  });
714
+
715
+ // ── Owner Decision v1 review fix (Codex P1): identity-gated publication ─────
716
+
717
+ describe('describePublicationOutcome — identity-gated refusal reporting (Codex P1)', () => {
718
+ const baseResult = {
719
+ decision: 'candidate_ready_for_owner_review' as const,
720
+ painId: 'pain-1',
721
+ stages: [],
722
+ scribeTaskId: 'scribe-1',
723
+ ruleArtifactId: 'pi-rule-1' as string | null,
724
+ principleArtifactId: null as string | null,
725
+ approvalId: null as string | null,
726
+ degradationReason: null as string | null,
727
+ };
728
+
729
+ it('flags the identity-blocked state and provides a repair nextAction', async () => {
730
+ const { describePublicationOutcome } = await import('../../src/commands/runtime-internalization-run-rulehost.js');
731
+ const outcome = describePublicationOutcome({
732
+ ...baseResult,
733
+ degradationReason: 'identity_binding_unverified: ledger_missing. Rule artifact NOT enqueued for Owner review (governance publication boundary).',
734
+ });
735
+ expect(outcome.blocked).toBe(true);
736
+ expect(outcome.nextAction).toContain('NOT enqueued');
737
+ expect(outcome.nextAction).toContain('re-run');
738
+ });
739
+
740
+ it('does not flag a healthy ready result with a real approval id', async () => {
741
+ const { describePublicationOutcome } = await import('../../src/commands/runtime-internalization-run-rulehost.js');
742
+ const outcome = describePublicationOutcome({ ...baseResult, approvalId: 'apr-1' });
743
+ expect(outcome.blocked).toBe(false);
744
+ });
745
+
746
+ it('text output for the identity-blocked result never claims WAITING for owner review', async () => {
747
+ const { formatTextOutput } = await import('../../src/commands/runtime-internalization-run-rulehost.js');
748
+ const text = formatTextOutput({
749
+ ...baseResult,
750
+ degradationReason: 'identity_binding_unverified: ledger_missing. Rule artifact NOT enqueued for Owner review (governance publication boundary).',
751
+ });
752
+ expect(text).toContain('PUBLICATION REFUSED');
753
+ expect(text).not.toContain('WAITING for owner review');
754
+ expect(text).toContain('NOT enqueued for Owner review');
755
+ });
756
+ });
@@ -59,7 +59,6 @@ function makeValidConfigYaml(): string {
59
59
  evaluator: { enabled: false },
60
60
  rolloutReviewer: { enabled: false },
61
61
  correctionObserver: { enabled: false },
62
- empathyObserver: { enabled: false },
63
62
  },
64
63
  },
65
64
  ui: { diagnostics: { mode: 'simple' } },
@@ -72,7 +72,6 @@ function makeWorkspace(outputLanguage?: 'en'): string {
72
72
  evaluator: { enabled: false },
73
73
  rolloutReviewer: { enabled: false },
74
74
  correctionObserver: { enabled: false },
75
- empathyObserver: { enabled: false },
76
75
  },
77
76
  },
78
77
  ui: { diagnostics: { mode: 'simple' } },
@@ -598,6 +598,31 @@ async function readDispatchOutcome(sm: RuntimeStateManager, workspaceDir: string
598
598
  }
599
599
  }
600
600
 
601
+ /** PRI-811 Phase B: read the pending approval row a rollout recommendation must produce. */
602
+ async function readPendingApproval(
603
+ workspaceDir: string,
604
+ artifactId: string,
605
+ channel: 'prompt' | 'code_tool_hook' | 'defer_archive',
606
+ ): Promise<{ artifactId: string; channel: string; status: string } | null> {
607
+ const connection = new SqliteConnection(workspaceDir);
608
+ try {
609
+ const rows = connection.getDb().prepare(
610
+ 'SELECT artifact_id, channel, status FROM approvals WHERE artifact_id = ? AND channel = ? AND status = \'pending\''
611
+ ).all(artifactId, channel) as unknown;
612
+ if (!Array.isArray(rows) || rows.length === 0) return null;
613
+ const row: unknown = rows[0];
614
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) return null;
615
+ const record = row as Record<string, unknown>;
616
+ return {
617
+ artifactId: String(record['artifact_id']),
618
+ channel: String(record['channel']),
619
+ status: String(record['status']),
620
+ };
621
+ } finally {
622
+ try { connection.close(); } catch { /* best-effort */ }
623
+ }
624
+ }
625
+
601
626
  describe('PRI-713 dispatch parity: approve_rollout through the run-once entry', () => {
602
627
  const dirs: string[] = [];
603
628
  const states: RuntimeStateManager[] = [];
@@ -631,13 +656,13 @@ describe('PRI-713 dispatch parity: approve_rollout through the run-once entry',
631
656
  // rollout 侧: dispatch 完成治理迁移 — 无 NHR、任务 succeeded
632
657
  expect(outcome.rolloutStatus).toBe('succeeded');
633
658
  expect(outcome.humanReviewReasonCode).toBeUndefined();
634
- // 激活事实: prompt 渠道低风险 auto_activate — activations 表有持久记录,
635
- // 幂等键 = <scribe artifact>::prompt, activationId 由 PromptWriter 生成
636
- expect(outcome.activation).not.toBeNull();
637
- expect(outcome.activation!.artifactId).toBe(SCRIBE_ART);
638
- expect(outcome.activation!.channel).toBe('prompt');
639
- expect(outcome.activation!.activationId).toBe('act_prompt_parity-p');
640
- expect(outcome.activation!.deactivatedAt).toBeNull();
659
+ // PRI-811 Phase B: prompt 渠道推荐只入 approval 队列, activations 表无行;
660
+ // 幂等键 = <scribe artifact>::prompt 等待 Owner 批准
661
+ expect(outcome.activation).toBeNull();
662
+ const pending = await readPendingApproval(ws, SCRIBE_ART, 'prompt');
663
+ expect(pending).not.toBeNull();
664
+ expect(pending!.artifactId).toBe(SCRIBE_ART);
665
+ expect(pending!.channel).toBe('prompt');
641
666
  });
642
667
 
643
668
  it('EQUIVALENCE: production consumer-cycle assembly on the same durable input yields the same dispatch outcome', async () => {
@@ -672,9 +697,10 @@ describe('PRI-713 dispatch parity: approve_rollout through the run-once entry',
672
697
  const outcome = await readDispatchOutcome(sm, ws, rolloutTaskId);
673
698
  expect(outcome.rolloutStatus).toBe('succeeded');
674
699
  expect(outcome.humanReviewReasonCode).toBeUndefined();
675
- expect(outcome.activation).not.toBeNull();
676
- expect(outcome.activation!.artifactId).toBe(SCRIBE_ART);
677
- expect(outcome.activation!.channel).toBe('prompt');
678
- expect(outcome.activation!.activationId).toBe('act_prompt_parity-p');
700
+ expect(outcome.activation).toBeNull();
701
+ const pending = await readPendingApproval(ws, SCRIBE_ART, 'prompt');
702
+ expect(pending).not.toBeNull();
703
+ expect(pending!.artifactId).toBe(SCRIBE_ART);
704
+ expect(pending!.channel).toBe('prompt');
679
705
  });
680
706
  });
@@ -56,6 +56,7 @@ import {
56
56
  createPITaskDiagnosticJson,
57
57
  } from '@principles/core/runtime-v2';
58
58
  import type { PIArtifactSnapshot, RuleHostInput } from '@principles/core/runtime-v2';
59
+ import { addPrincipleToLedger } from '@principles/core/principle-tree-ledger';
59
60
 
60
61
  // ── Scripted adapter (same pattern as rulehost-pipeline-runner.test.ts) ──────
61
62
 
@@ -236,11 +237,11 @@ function makeTmpDir(): string {
236
237
  return dir;
237
238
  }
238
239
 
239
- async function seedDreamerWithId(sm: RuntimeStateManager, taskId: string, painId: string): Promise<void> {
240
+ async function seedDreamerWithId(sm: RuntimeStateManager, taskId: string, painId: string, candidateId?: string): Promise<void> {
240
241
  const baseMetadata = JSON.parse(createPITaskDiagnosticJson({
241
242
  dependencyTaskIds: [], channel: 'code_tool_hook', timeoutMs: 1000, inputArtifactRefs: [], outputArtifactRefs: [],
242
243
  })) as Record<string, unknown>;
243
- const diagnosticJson = JSON.stringify({ ...baseMetadata, sourcePainId: painId });
244
+ const diagnosticJson = JSON.stringify({ ...baseMetadata, sourcePainId: painId, ...(candidateId ? { candidateId } : {}) });
244
245
  await sm.createTask({ taskId, taskKind: 'dreamer', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson });
245
246
  }
246
247
 
@@ -270,8 +271,17 @@ describe('Cross-Package Acceptance Test (PRI-408 P1/P2 fixes) — unsplippable c
270
271
  tmpDir = makeTmpDir();
271
272
  const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
272
273
  await sm.initialize();
273
- await seedDreamerWithId(sm, 'dreamer-xpkg-001', 'pain-xpkg-001');
274
+ // Owner Decision Experience v1 Phase A: the governance publication boundary
275
+ // requires a verified chain identity (candidateId → unique ledger entry),
276
+ // mirroring what the intake bridge creates in production workspaces.
277
+ await seedDreamerWithId(sm, 'dreamer-xpkg-001', 'pain-xpkg-001', 'cand-xpkg-001');
274
278
  await sm.close();
279
+ addPrincipleToLedger(path.join(tmpDir, '.state'), {
280
+ id: 'ledger-xpkg-001', version: 1, text: 'cross-package ledger principle', triggerPattern: '', action: '',
281
+ status: 'candidate', evaluability: 'weak_heuristic', priority: 'P1', scope: 'general',
282
+ valueScore: 0, adherenceRate: 0, painPreventedCount: 0, derivedFromPainIds: ['cand-xpkg-001'],
283
+ ruleIds: [], conflictsWithPrincipleIds: [], createdAt: '2026-09-15T00:00:00.000Z', updatedAt: '2026-09-15T00:00:00.000Z',
284
+ });
275
285
 
276
286
  // ── Step 2: Run RuleHost pipeline → candidate + auto-enqueue (P1 #1) ───
277
287
  const adapter = makeAdapter();
@@ -76,7 +76,6 @@ function makeValidConfigYaml(): string {
76
76
  evaluator: { enabled: false },
77
77
  rolloutReviewer: { enabled: false },
78
78
  correctionObserver: { enabled: false },
79
- empathyObserver: { enabled: false },
80
79
  },
81
80
  },
82
81
  ui: { diagnostics: { mode: 'simple' } },
@@ -16,10 +16,11 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
16
16
  import * as os from 'node:os';
17
17
  import * as path from 'node:path';
18
18
  import * as fs from 'node:fs';
19
- import { runRuleHostPipeline } from '../../src/services/rulehost-pipeline-runner.js';
19
+ import { runRuleHostPipeline, backfillScribeIdentity } from '../../src/services/rulehost-pipeline-runner.js';
20
20
  import type { CodeRuleCapability } from '../../src/services/rulehost-pipeline-runner.js';
21
21
  import type { PDRuntimeAdapter, RunHandle, RunStatus, PIArtifactStore, RuntimeCapabilities, RuntimeHealth, RuntimeArtifactRef, ContextItem, StructuredRunOutput, StartRunInput } from '@principles/core/runtime-v2';
22
- import { RuntimeStateManager, createPITaskDiagnosticJson } from '@principles/core/runtime-v2';
22
+ import { RuntimeStateManager, createPITaskDiagnosticJson, SqliteApprovalQueueStore } from '@principles/core/runtime-v2';
23
+ import { addPrincipleToLedger, updatePrinciple } from '@principles/core/principle-tree-ledger';
23
24
  import { saveHostToolDeclaration } from '@principles/host-runtime';
24
25
 
25
26
  type StageFactory = (taskId: string, priorArtifactId?: string) => unknown;
@@ -248,11 +249,12 @@ async function seedDreamerWithId(
248
249
  taskId: string,
249
250
  painId: string,
250
251
  channel: 'prompt' | 'code_tool_hook' | 'defer_archive' = 'code_tool_hook',
252
+ candidateId?: string,
251
253
  ): Promise<void> {
252
254
  const baseMetadata = JSON.parse(createPITaskDiagnosticJson({
253
255
  dependencyTaskIds: [], channel, timeoutMs: 1000, inputArtifactRefs: [], outputArtifactRefs: [],
254
256
  })) as Record<string, unknown>;
255
- const diagnosticJson = JSON.stringify({ ...baseMetadata, sourcePainId: painId });
257
+ const diagnosticJson = JSON.stringify({ ...baseMetadata, sourcePainId: painId, ...(candidateId ? { candidateId } : {}) });
256
258
  await sm.createTask({ taskId, taskKind: 'dreamer', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson });
257
259
  }
258
260
 
@@ -270,12 +272,18 @@ describe('runRuleHostPipeline (PRI-429) — atomic capability + exact pain match
270
272
  });
271
273
 
272
274
  // ── Test 1: Capability ON + approved → candidate_ready_for_owner_review ──
273
- it('capability ON + evaluator approved → candidate_ready_for_owner_review', async () => {
275
+ it('capability ON + evaluator approved → candidate_ready_for_owner_review; scribe identity backfilled on the rule path too (EP002-R4 follow-up #2)', async () => {
274
276
  tmpDir = makeTmpDir();
275
277
  const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
276
278
  await sm.initialize();
277
- await seedDreamerWithId(sm, 'dreamer-seeded-001', 'pain-test-001');
279
+ await seedDreamerWithId(sm, 'dreamer-seeded-001', 'pain-test-001', 'code_tool_hook', 'cand-001');
278
280
  await sm.close();
281
+ addPrincipleToLedger(path.join(tmpDir, '.state'), {
282
+ id: 'ledger-principle-001', version: 1, text: 'test ledger principle', triggerPattern: '', action: '',
283
+ status: 'candidate', evaluability: 'weak_heuristic', priority: 'P1', scope: 'general',
284
+ valueScore: 0, adherenceRate: 0, painPreventedCount: 0, derivedFromPainIds: ['cand-001'],
285
+ ruleIds: [], conflictsWithPrincipleIds: [], createdAt: '2026-09-15T00:00:00.000Z', updatedAt: '2026-09-15T00:00:00.000Z',
286
+ });
279
287
 
280
288
  const adapter = makeAdapter();
281
289
  const capability: CodeRuleCapability = { enabled: true, artificerAdapter: adapter };
@@ -292,6 +300,23 @@ describe('runRuleHostPipeline (PRI-429) — atomic capability + exact pain match
292
300
  expect(result.ruleArtifactId).not.toBeNull();
293
301
  // P1 #1 fix: candidate should be auto-enqueued into the ApprovalQueue
294
302
  expect(result.approvalId).not.toBeNull();
303
+
304
+ // EP002-R4 follow-up #2: the scribe artifact carries the LEDGER UUID (not
305
+ // a title) so the evaluator propagates it into the rule artifact and the
306
+ // Console groups the approval by an id the detail page can resolve.
307
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
308
+ await verify.initialize();
309
+ try {
310
+ const scribeArt = await verify.piArtifactStore.getArtifactById(
311
+ (await verify.piArtifactStore.listBySourceTaskId(result.stages.find((s) => s.name === 'scribe')!.taskId!))
312
+ .find((a) => a.artifactKind === 'principle')!.artifactId,
313
+ );
314
+ expect(scribeArt?.sourcePrincipleId).toBe('ledger-principle-001');
315
+ const ruleArt = await verify.piArtifactStore.getArtifactById(result.ruleArtifactId!);
316
+ expect(ruleArt?.sourcePrincipleId).toBe('ledger-principle-001');
317
+ } finally {
318
+ await verify.close();
319
+ }
295
320
  }, 60_000);
296
321
 
297
322
  it('adversarial feedback loop drives a second artificer round before creating a candidate', async () => {
@@ -350,17 +375,29 @@ describe('runRuleHostPipeline (PRI-429) — atomic capability + exact pain match
350
375
  expect(artificerPrompts[1]).toContain('Prior adversarial replay failures');
351
376
  expect(result.decision, JSON.stringify(result)).toBe('candidate_ready_for_owner_review');
352
377
  expect(result.ruleArtifactId).toMatch(/^pi-rule-/);
353
- // P1 #1 fix: candidate should be auto-enqueued into the ApprovalQueue
354
- expect(result.approvalId).not.toBeNull();
378
+ // Owner Decision Experience v1 Phase A: this seed carries no candidateId and
379
+ // no ledger principle, so identity binding is unverified (candidate_unresolved)
380
+ // and the governance publication boundary refuses the approval enqueue — the
381
+ // two-round adversarial loop itself is unaffected.
382
+ expect(result.approvalId).toBeNull();
383
+ expect(result.degradationReason ?? '').toContain('identity_binding_unverified');
355
384
  }, 60_000);
356
385
 
357
386
  // ── Test 2: Capability OFF (explicitly disabled) → text_principle_only ──
358
- it('capability OFF (explicitly disabled) → text_principle_only', async () => {
387
+ it('capability OFF (explicitly disabled) → text_principle_only + prompt approval enqueue + identity backfill (PRI-804)', async () => {
359
388
  tmpDir = makeTmpDir();
360
389
  const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
361
390
  await sm.initialize();
362
- await seedDreamerWithId(sm, 'dreamer-seeded-002', 'pain-test-001');
391
+ await seedDreamerWithId(sm, 'dreamer-seeded-002', 'pain-test-001', 'code_tool_hook', 'cand-002');
363
392
  await sm.close();
393
+ // PRI-804(a): seed the ledger with the principle this chain's candidate maps
394
+ // to, mirroring what the intake bridge creates in production workspaces.
395
+ addPrincipleToLedger(path.join(tmpDir, '.state'), {
396
+ id: 'ledger-principle-002', version: 1, text: 'test ledger principle', triggerPattern: '', action: '',
397
+ status: 'candidate', evaluability: 'weak_heuristic', priority: 'P1', scope: 'general',
398
+ valueScore: 0, adherenceRate: 0, painPreventedCount: 0, derivedFromPainIds: ['cand-002'],
399
+ ruleIds: [], conflictsWithPrincipleIds: [], createdAt: '2026-09-15T00:00:00.000Z', updatedAt: '2026-09-15T00:00:00.000Z',
400
+ });
364
401
 
365
402
  const adapter = makeAdapter();
366
403
  const capability: CodeRuleCapability = { enabled: false, disabledReason: 'artificer agent disabled in config' };
@@ -380,6 +417,29 @@ describe('runRuleHostPipeline (PRI-429) — atomic capability + exact pain match
380
417
  // Adversarial loop stage should be skipped (not present or skipped status).
381
418
  const advStage = result.stages.find((s) => s.name === 'adversarial_loop');
382
419
  expect(advStage?.status).toBe('skipped');
420
+
421
+ // PRI-804: the text principle must NOT dead-end — it is enqueued into the
422
+ // EXISTING approval queue on the prompt channel for Owner Console review.
423
+ expect(result.approvalId).not.toBeNull();
424
+ expect(result.approvalId).toBe(`apr_prompt_${result.principleArtifactId}`);
425
+
426
+ // Verify the persisted approval row, the PRI-804(a) identity backfill, and
427
+ // the terminal validation marking (real store, real SQLite, real ledger).
428
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
429
+ await verify.initialize();
430
+ try {
431
+ const approvalStore = new SqliteApprovalQueueStore(verify.connection);
432
+ const record = await approvalStore.getById(result.approvalId!);
433
+ expect(record).not.toBeNull();
434
+ expect(record!.status).toBe('pending');
435
+ expect(record!.channel).toBe('prompt');
436
+ expect(record!.artifactId).toBe(result.principleArtifactId);
437
+ const artifact = await verify.piArtifactStore.getArtifactById(result.principleArtifactId!);
438
+ expect(artifact?.sourcePrincipleId).toBe('ledger-principle-002');
439
+ expect(artifact?.validationStatus).toBe('validated');
440
+ } finally {
441
+ await verify.close();
442
+ }
383
443
  }, 60_000);
384
444
 
385
445
  // ── Test 3: Capability ON + evaluator rejected → generation_rejected ──
@@ -626,8 +686,14 @@ describe('runRuleHostPipeline (PRI-429) — atomic capability + exact pain match
626
686
  expect(dreamerCallCount).toBe(2);
627
687
  expect(dreamerStage?.status).toBe('succeeded');
628
688
  expect(result.decision).toBe('candidate_ready_for_owner_review');
629
- // P1 #1 fix: candidate should be auto-enqueued into the ApprovalQueue
630
- expect(result.approvalId).not.toBeNull();
689
+ // Owner Decision Experience v1 Phase A: this seed carries NO candidateId,
690
+ // so the chain cannot bind a ledger principle (candidate_unresolved). The
691
+ // rule artifact is produced and preserved, but the governance publication
692
+ // boundary REFUSES to create an Owner approval subject for it.
693
+ expect(result.approvalId).toBeNull();
694
+ expect(result.degradationReason ?? '').toContain('identity_binding_unverified');
695
+ expect(result.degradationReason ?? '').toContain('candidate_unresolved');
696
+ expect(result.ruleArtifactId).not.toBeNull();
631
697
  }, 60_000);
632
698
 
633
699
  // ── Test 9 (E fix): retried status exhausted → stage marked 'degraded' ──
@@ -705,7 +771,6 @@ function writeLanguageConfig(dir: string, outputLanguage?: 'en'): void {
705
771
  evaluator: { enabled: false },
706
772
  rolloutReviewer: { enabled: false },
707
773
  correctionObserver: { enabled: false },
708
- empathyObserver: { enabled: false },
709
774
  },
710
775
  },
711
776
  ui: { diagnostics: { mode: 'simple' } },
@@ -807,3 +872,304 @@ describe('runRuleHostPipeline — outputLanguage reaches adapter.startRun messag
807
872
  expect(dreamer?.payload).toContain('dreamer-lang-zh-001');
808
873
  }, 60_000);
809
874
  });
875
+
876
+ // ── Owner Decision Experience v1 Phase A — identity fix-forward (SPEC §9) ────
877
+ //
878
+ // §9.4 deterministic acceptance slice, exercised against the REAL production
879
+ // path (real RuntimeStateManager, real SQLite, real ledger adapter):
880
+ // success / ledger missing / ambiguous / conflict / write failure / replay.
881
+ // Failure slices must prove: NO approval enqueue, NO activation, NO duplicate
882
+ // ledger — while the generated artifacts stay preserved for later replay.
883
+
884
+ describe('runRuleHostPipeline — Phase A governance publication boundary (identity fix-forward)', () => {
885
+ afterEach(() => {
886
+ vi.restoreAllMocks();
887
+ if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } tmpDir = ''; }
888
+ });
889
+
890
+ function seedLedger(dir: string, id: string, candidateId: string): void {
891
+ addPrincipleToLedger(path.join(dir, '.state'), {
892
+ id, version: 1, text: `principle ${id}`, triggerPattern: '', action: '',
893
+ status: 'candidate', evaluability: 'weak_heuristic', priority: 'P1', scope: 'general',
894
+ valueScore: 0, adherenceRate: 0, painPreventedCount: 0, derivedFromPainIds: [candidateId],
895
+ ruleIds: [], conflictsWithPrincipleIds: [], createdAt: '2026-09-15T00:00:00.000Z', updatedAt: '2026-09-15T00:00:00.000Z',
896
+ });
897
+ }
898
+
899
+ async function countApprovalsForArtifact(dir: string, artifactId: string): Promise<number> {
900
+ const verify = new RuntimeStateManager({ workspaceDir: dir });
901
+ await verify.initialize();
902
+ try {
903
+ const rows = verify.connection.getDb()
904
+ .prepare('SELECT COUNT(*) AS n FROM approvals WHERE artifact_id = ?').all(artifactId);
905
+ const first = rows[0] as { n: number } | undefined;
906
+ return first?.n ?? 0;
907
+ } finally {
908
+ await verify.close();
909
+ }
910
+ }
911
+
912
+ async function countActivations(dir: string): Promise<number> {
913
+ const verify = new RuntimeStateManager({ workspaceDir: dir });
914
+ await verify.initialize();
915
+ try {
916
+ const rows = verify.connection.getDb().prepare('SELECT COUNT(*) AS n FROM activations').all();
917
+ const first = rows[0] as { n: number } | undefined;
918
+ return first?.n ?? 0;
919
+ } finally {
920
+ await verify.close();
921
+ }
922
+ }
923
+
924
+ async function ledgerPrincipleCount(dir: string): Promise<number> {
925
+ const ledgerPath = path.join(dir, '.state', 'principle_training_state.json');
926
+ if (!fs.existsSync(ledgerPath)) return 0; // no ledger file yet = zero principles
927
+ const parsed = JSON.parse(fs.readFileSync(ledgerPath, 'utf8')) as { _tree?: { principles?: Record<string, unknown> } };
928
+ return Object.keys(parsed._tree?.principles ?? {}).length;
929
+ }
930
+
931
+ it('ledger_missing: rule artifact preserved, but NO approval subject, NO activation, ledger untouched', async () => {
932
+ tmpDir = makeTmpDir();
933
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
934
+ await sm.initialize();
935
+ await seedDreamerWithId(sm, 'dreamer-id-missing', 'pain-id-missing', 'code_tool_hook', 'cand-no-ledger');
936
+ await sm.close();
937
+ // Deliberately NO ledger principle for cand-no-ledger.
938
+
939
+ const adapter = makeAdapter();
940
+ const result = await runRuleHostPipeline({
941
+ workspaceDir: tmpDir, painId: 'pain-id-missing', runtimeAdapter: adapter,
942
+ channel: 'code_tool_hook', pollIntervalMs: 5, timeoutMs: 1000,
943
+ codeRuleCapability: { enabled: true, artificerAdapter: adapter },
944
+ behaviorExamplePack: PIPE_PACK,
945
+ onStoreReady: (store) => { adapter.artifactStore = store; },
946
+ });
947
+
948
+ expect(result.decision, JSON.stringify(result)).toBe('candidate_ready_for_owner_review');
949
+ // The generation chain produced and PRESERVED its artifacts…
950
+ expect(result.ruleArtifactId).not.toBeNull();
951
+ // …but the publication boundary refused to create an Owner approval subject.
952
+ expect(result.approvalId).toBeNull();
953
+ expect(result.degradationReason ?? '').toContain('identity_binding_unverified');
954
+ expect(result.degradationReason ?? '').toContain('ledger_missing');
955
+ expect(await countApprovalsForArtifact(tmpDir, result.ruleArtifactId!)).toBe(0);
956
+ expect(await countActivations(tmpDir)).toBe(0);
957
+ expect(await ledgerPrincipleCount(tmpDir)).toBe(0);
958
+ }, 60_000);
959
+
960
+ it('ledger_ambiguous: two ledger targets for one candidate refuse publication and refuse a silent first-match binding', async () => {
961
+ tmpDir = makeTmpDir();
962
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
963
+ await sm.initialize();
964
+ await seedDreamerWithId(sm, 'dreamer-id-ambiguous', 'pain-id-ambiguous', 'code_tool_hook', 'cand-ambiguous');
965
+ await sm.close();
966
+ seedLedger(tmpDir, 'ledger-A', 'cand-ambiguous');
967
+ seedLedger(tmpDir, 'ledger-B', 'cand-ambiguous');
968
+
969
+ const adapter = makeAdapter();
970
+ const result = await runRuleHostPipeline({
971
+ workspaceDir: tmpDir, painId: 'pain-id-ambiguous', runtimeAdapter: adapter,
972
+ channel: 'code_tool_hook', pollIntervalMs: 5, timeoutMs: 1000,
973
+ codeRuleCapability: { enabled: true, artificerAdapter: adapter },
974
+ behaviorExamplePack: PIPE_PACK,
975
+ onStoreReady: (store) => { adapter.artifactStore = store; },
976
+ });
977
+
978
+ expect(result.decision).toBe('candidate_ready_for_owner_review');
979
+ expect(result.approvalId).toBeNull();
980
+ expect(result.degradationReason ?? '').toContain('ledger_ambiguous');
981
+ expect(await countApprovalsForArtifact(tmpDir, result.ruleArtifactId!)).toBe(0);
982
+ // The scribe artifact must NOT be silently bound to the first match.
983
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
984
+ await verify.initialize();
985
+ try {
986
+ const scribeTaskId = result.stages.find((s) => s.name === 'scribe')!.taskId!;
987
+ const scribeArt = (await verify.piArtifactStore.listBySourceTaskId(scribeTaskId)).find((a) => a.artifactKind === 'principle');
988
+ expect(scribeArt?.sourcePrincipleId).toBeUndefined();
989
+ } finally {
990
+ await verify.close();
991
+ }
992
+ }, 60_000);
993
+
994
+ it('identity_conflict: an existing binding to a different ledger target is never overwritten (§9.1.7)', async () => {
995
+ tmpDir = makeTmpDir();
996
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
997
+ await sm.initialize();
998
+ await seedDreamerWithId(sm, 'dreamer-id-conflict', 'pain-id-conflict', 'code_tool_hook', 'cand-conflict');
999
+ await sm.close();
1000
+ seedLedger(tmpDir, 'ledger-original', 'cand-conflict');
1001
+
1002
+ const adapter = makeAdapter();
1003
+ const first = await runRuleHostPipeline({
1004
+ workspaceDir: tmpDir, painId: 'pain-id-conflict', runtimeAdapter: adapter,
1005
+ channel: 'code_tool_hook', pollIntervalMs: 5, timeoutMs: 1000,
1006
+ codeRuleCapability: { enabled: true, artificerAdapter: adapter },
1007
+ behaviorExamplePack: PIPE_PACK,
1008
+ onStoreReady: (store) => { adapter.artifactStore = store; },
1009
+ });
1010
+ expect(first.approvalId).not.toBeNull();
1011
+
1012
+ // Repoint the candidate: detach it from the original principle so the
1013
+ // chain now resolves to a DIFFERENT (still unique) target. Otherwise the
1014
+ // plural lookup would correctly report ledger_ambiguous, not a conflict.
1015
+ updatePrinciple(path.join(tmpDir, '.state'), 'ledger-original', { derivedFromPainIds: [] });
1016
+ seedLedger(tmpDir, 'ledger-replacement', 'cand-conflict');
1017
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
1018
+ await verify.initialize();
1019
+ try {
1020
+ const scribeTaskId = first.stages.find((s) => s.name === 'scribe')!.taskId!;
1021
+ const binding = await backfillScribeIdentity({
1022
+ stateManager: verify,
1023
+ artifactStore: verify.piArtifactStore,
1024
+ scribeTaskId,
1025
+ dreamerTaskId: 'dreamer-id-conflict',
1026
+ workspaceDir: tmpDir,
1027
+ now: new Date().toISOString(),
1028
+ });
1029
+ expect(binding).toEqual({ status: 'identity_conflict', existingPrincipleId: 'ledger-original', resolvedPrincipleId: 'ledger-replacement' });
1030
+ const scribeArt = (await verify.piArtifactStore.listBySourceTaskId(scribeTaskId)).find((a) => a.artifactKind === 'principle');
1031
+ expect(scribeArt?.sourcePrincipleId).toBe('ledger-original');
1032
+ } finally {
1033
+ await verify.close();
1034
+ }
1035
+ }, 60_000);
1036
+
1037
+ it('binding_write_failed / binding_readback_failed: store failures are structured, never silently accepted', async () => {
1038
+ tmpDir = makeTmpDir();
1039
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
1040
+ await sm.initialize();
1041
+ await seedDreamerWithId(sm, 'dreamer-id-wf', 'pain-id-wf', 'code_tool_hook', 'cand-wf');
1042
+ await sm.close();
1043
+ seedLedger(tmpDir, 'ledger-wf', 'cand-wf');
1044
+
1045
+ // Run once through the real path so a REAL scribe artifact exists.
1046
+ const adapter = makeAdapter();
1047
+ const run = await runRuleHostPipeline({
1048
+ workspaceDir: tmpDir, painId: 'pain-id-wf', runtimeAdapter: adapter,
1049
+ channel: 'prompt', pollIntervalMs: 5, timeoutMs: 1000,
1050
+ onStoreReady: (store) => { adapter.artifactStore = store; },
1051
+ });
1052
+ expect(run.decision).toBe('text_principle_only');
1053
+
1054
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
1055
+ await verify.initialize();
1056
+ const scribeTaskId = run.stages.find((s) => s.name === 'scribe')!.taskId!;
1057
+ try {
1058
+ // The real run already bound + verified — replay must be idempotent.
1059
+ const idempotent = await backfillScribeIdentity({
1060
+ stateManager: verify, artifactStore: verify.piArtifactStore, scribeTaskId,
1061
+ dreamerTaskId: 'dreamer-id-wf', workspaceDir: tmpDir, now: new Date().toISOString(),
1062
+ });
1063
+ expect(idempotent).toEqual({ status: 'already_bound', principleId: 'ledger-wf' });
1064
+
1065
+ // Write failure: upsert throws.
1066
+ const throwingStore = new Proxy(verify.piArtifactStore, {
1067
+ get(target, prop, receiver) {
1068
+ if (prop === 'upsertArtifact') {
1069
+ return async () => { throw new Error('sqlite locked (simulated)'); };
1070
+ }
1071
+ return Reflect.get(target, prop, receiver);
1072
+ },
1073
+ });
1074
+ // For the write-failure slice we need an UNBOUND artifact: use a second,
1075
+ // unbound scribe-shaped artifact created directly in the store.
1076
+ const taskId2 = 'scribe-unbound-task';
1077
+ await verify.createTask({ taskId: taskId2, taskKind: 'scribe', status: 'succeeded', attemptCount: 0, maxAttempts: 3, diagnosticJson: JSON.stringify({ pi_metadata: { channel: 'prompt', dependencyTaskIds: [], timeoutMs: 1000 } }) });
1078
+ await verify.piArtifactStore.createArtifact({
1079
+ artifactId: 'pi-art-unbound-1', artifactKind: 'principle', sourceTaskId: taskId2,
1080
+ lineageArtifactIds: [], validationStatus: 'pending', contentJson: '{}',
1081
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
1082
+ });
1083
+ const failed = await backfillScribeIdentity({
1084
+ stateManager: verify, artifactStore: throwingStore, scribeTaskId: taskId2,
1085
+ dreamerTaskId: 'dreamer-id-wf', workspaceDir: tmpDir, now: new Date().toISOString(),
1086
+ });
1087
+ expect(failed.status).toBe('binding_write_failed');
1088
+
1089
+ // Read-back failure: upsert "succeeds" but the store returns a row without
1090
+ // the binding (write lost / wrong row).
1091
+ const staleStore = new Proxy(verify.piArtifactStore, {
1092
+ get(target, prop, receiver) {
1093
+ if (prop === 'getArtifactById') {
1094
+ return async () => null;
1095
+ }
1096
+ return Reflect.get(target, prop, receiver);
1097
+ },
1098
+ });
1099
+ const stale = await backfillScribeIdentity({
1100
+ stateManager: verify, artifactStore: staleStore, scribeTaskId: taskId2,
1101
+ dreamerTaskId: 'dreamer-id-wf', workspaceDir: tmpDir, now: new Date().toISOString(),
1102
+ });
1103
+ expect(stale.status).toBe('binding_readback_failed');
1104
+ } finally {
1105
+ await verify.close();
1106
+ }
1107
+ }, 60_000);
1108
+
1109
+ it('text path: unverified binding preserves the principle artifact but creates NO approval (candidate_unresolved)', async () => {
1110
+ tmpDir = makeTmpDir();
1111
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
1112
+ await sm.initialize();
1113
+ // No candidateId on the seed — candidate_unresolved.
1114
+ await seedDreamerWithId(sm, 'dreamer-text-noid', 'pain-text-noid', 'prompt');
1115
+ await sm.close();
1116
+
1117
+ const adapter = makeAdapter();
1118
+ const result = await runRuleHostPipeline({
1119
+ workspaceDir: tmpDir, painId: 'pain-text-noid', runtimeAdapter: adapter,
1120
+ channel: 'prompt', pollIntervalMs: 5, timeoutMs: 1000,
1121
+ onStoreReady: (store) => { adapter.artifactStore = store; },
1122
+ });
1123
+
1124
+ expect(result.decision).toBe('text_principle_only');
1125
+ expect(result.principleArtifactId).not.toBeNull();
1126
+ expect(result.approvalId).toBeNull();
1127
+ expect(result.degradationReason ?? '').toContain('identity_binding_unverified');
1128
+ expect(result.degradationReason ?? '').toContain('candidate_unresolved');
1129
+ expect(await countApprovalsForArtifact(tmpDir, result.principleArtifactId!)).toBe(0);
1130
+ // The artifact is still marked validated — preserved, publishable later.
1131
+ const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
1132
+ await verify.initialize();
1133
+ try {
1134
+ const artifact = await verify.piArtifactStore.getArtifactById(result.principleArtifactId!);
1135
+ expect(artifact?.validationStatus).toBe('validated');
1136
+ expect(artifact?.sourcePrincipleId).toBeUndefined();
1137
+ } finally {
1138
+ await verify.close();
1139
+ }
1140
+ }, 60_000);
1141
+
1142
+ it('replay after identity repair publishes: seed ledger mid-flight is unnecessary — repair-then-rerun enqueues (§9.1.8)', async () => {
1143
+ tmpDir = makeTmpDir();
1144
+ const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
1145
+ await sm.initialize();
1146
+ await seedDreamerWithId(sm, 'dreamer-repair', 'pain-repair', 'prompt', 'cand-repair');
1147
+ await sm.close();
1148
+
1149
+ const adapter1 = makeAdapter();
1150
+ const refused = await runRuleHostPipeline({
1151
+ workspaceDir: tmpDir, painId: 'pain-repair', runtimeAdapter: adapter1,
1152
+ channel: 'prompt', pollIntervalMs: 5, timeoutMs: 1000,
1153
+ onStoreReady: (store) => { adapter1.artifactStore = store; },
1154
+ });
1155
+ expect(refused.approvalId).toBeNull();
1156
+
1157
+ // Identity repair: the ledger entry for the candidate appears (e.g. intake
1158
+ // completed or the ledger was restored). Replay publishes.
1159
+ seedLedger(tmpDir, 'ledger-repaired', 'cand-repair');
1160
+ const adapter2 = makeAdapter();
1161
+ // Run 2 RESUMES run 1's succeeded dreamer seed (no new dreamer startRun) —
1162
+ // pre-register that call so the scripted philosopher can resolve its
1163
+ // sourceDreamerArtifactId lineage from the shared store.
1164
+ adapter2.startRunCalls.push({ taskId: 'dreamer-repair' });
1165
+ const published = await runRuleHostPipeline({
1166
+ workspaceDir: tmpDir, painId: 'pain-repair', runtimeAdapter: adapter2,
1167
+ channel: 'prompt', pollIntervalMs: 5, timeoutMs: 1000,
1168
+ onStoreReady: (store) => { adapter2.artifactStore = store; },
1169
+ });
1170
+ expect(published.approvalId, JSON.stringify(published, null, 1)).not.toBeNull();
1171
+ expect(published.principleArtifactId).not.toBe(refused.principleArtifactId);
1172
+ // Exactly one ledger principle exists — the pipeline never creates one.
1173
+ expect(await ledgerPrincipleCount(tmpDir)).toBe(1);
1174
+ }, 60_000);
1175
+ });