@xaccefy/pi-casefile 0.8.2 → 0.8.3

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/src/ledger.ts CHANGED
@@ -11,15 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createHash, randomUUID } from "node:crypto";
14
- import {
15
- type Dirent,
16
- existsSync,
17
- mkdirSync,
18
- readdirSync,
19
- readFileSync,
20
- statSync,
21
- writeFileSync,
22
- } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
23
15
  import { basename, dirname, join, resolve } from "node:path";
24
16
  import {
25
17
  findWorkspaceRoot,
@@ -27,6 +19,7 @@ import {
27
19
  PHASE_ORDER,
28
20
  scratchpad_read,
29
21
  scratchpad_resume,
22
+ scratchpad_runs,
30
23
  } from "./scratchpad.ts";
31
24
  import { DatabaseSync } from "./sqlite-compat/index.ts";
32
25
 
@@ -186,29 +179,11 @@ export type CaseRecord = {
186
179
  /** Agent's documented attempt to disprove the finding (required before CONFIRMED). */
187
180
  disconfirmation?: string;
188
181
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
189
- pocVerified?: {
190
- path: string;
191
- exitCode: number;
192
- ranAt: string;
193
- output?: string;
194
- sandbox: boolean;
195
- };
182
+ pocVerified?: PocVerificationRecord;
196
183
  /** Verification of a disconfirmation run (set only by promoteFindingResult). */
197
- disconfirmationVerified?: {
198
- path: string;
199
- exitCode: number;
200
- ranAt: string;
201
- output?: string;
202
- sandbox: boolean;
203
- };
184
+ disconfirmationVerified?: PocVerificationRecord;
204
185
  /** Verification of a control-target run (set only by promoteFindingResult; anti-cheat gate). */
205
- controlVerified?: {
206
- path: string;
207
- exitCode: number;
208
- ranAt: string;
209
- output?: string;
210
- sandbox: boolean;
211
- };
186
+ controlVerified?: PocVerificationRecord;
212
187
  /** ISO timestamp when CaseContext first wrote the context bundle. */
213
188
  reportedAt?: string;
214
189
  /** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
@@ -223,6 +198,18 @@ export type CaseRecord = {
223
198
  updatedAt: string;
224
199
  };
225
200
 
201
+ export type PocVerificationRecord = {
202
+ path: string;
203
+ exitCode: number;
204
+ ranAt: string;
205
+ output?: string;
206
+ sandbox: boolean;
207
+ completed?: boolean;
208
+ outputComplete?: boolean;
209
+ mode?: string;
210
+ target?: string;
211
+ };
212
+
226
213
  export type CaseInput = {
227
214
  title: string;
228
215
  status?: CaseStatus;
@@ -367,6 +354,13 @@ function getDb(): DatabaseSync {
367
354
  }
368
355
 
369
356
  const db = new DatabaseSync(dbPath);
357
+ // Give parallel agents a short write wait instead of immediate SQLITE_BUSY.
358
+ db.exec("PRAGMA busy_timeout = 5000");
359
+ try {
360
+ db.exec("PRAGMA journal_mode = WAL");
361
+ } catch {
362
+ // Some filesystems/backends reject WAL; rollback journal still works.
363
+ }
370
364
  // Enable foreign-key enforcement so ON DELETE CASCADE actually fires
371
365
  // (SQLite keeps FK off by default; bun:sqlite in particular defaults it off).
372
366
  db.exec("PRAGMA foreign_keys = ON");
@@ -1243,6 +1237,22 @@ function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
1243
1237
 
1244
1238
  // ── SQLite Mutation Actions ───────────────────────────────────────────
1245
1239
 
1240
+ function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
1241
+ db.exec("BEGIN IMMEDIATE");
1242
+ try {
1243
+ const value = fn();
1244
+ db.exec("COMMIT");
1245
+ return value;
1246
+ } catch (err) {
1247
+ try {
1248
+ db.exec("ROLLBACK");
1249
+ } catch {
1250
+ // ignore rollback errors
1251
+ }
1252
+ throw err;
1253
+ }
1254
+ }
1255
+
1246
1256
  function upsertCase(db: DatabaseSync, record: CaseRecord) {
1247
1257
  // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
1248
1258
  // wipe case_links when updating an existing primary key.
@@ -1554,133 +1564,131 @@ export function coverageSummary(caseId: string): CoverageSummary {
1554
1564
  export function addCaseResult(input: CaseInput): CaseAddResult {
1555
1565
  const db = getDb();
1556
1566
  validateNewCaseInput(input);
1557
- const record = buildRecord(input, undefined);
1558
- validateCase(record);
1559
-
1560
- // Check duplicates
1561
- const duplicate = findDuplicateCaseInDb(db, record);
1562
- if (duplicate) {
1563
- return {
1564
- record: duplicate.record,
1565
- created: false,
1566
- nearDuplicate: duplicate.near,
1567
- reason: duplicate.near
1568
- ? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
1569
- `Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
1570
- `Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
1571
- : `Duplicate case exists: ${duplicate.record.id}`,
1572
- };
1573
- }
1567
+ return withImmediateTransaction(db, () => {
1568
+ const record = buildRecord(input, undefined);
1569
+ validateCase(record);
1570
+
1571
+ const duplicate = findDuplicateCaseInDb(db, record);
1572
+ if (duplicate) {
1573
+ return {
1574
+ record: duplicate.record,
1575
+ created: false,
1576
+ nearDuplicate: duplicate.near,
1577
+ reason: duplicate.near
1578
+ ? `Near-duplicate of existing case ${duplicate.record.id} — "${duplicate.record.title}". ` +
1579
+ `Same target, overlapping title. Your candidate was NOT created — the existing case is returned. ` +
1580
+ `Continue with it via CaseUpdate, or re-file with a clearly distinct title if these are genuinely separate findings.`
1581
+ : `Duplicate case exists: ${duplicate.record.id}`,
1582
+ };
1583
+ }
1574
1584
 
1575
- upsertCase(db, record);
1576
- return { record, created: true };
1585
+ upsertCase(db, record);
1586
+ return { record, created: true };
1587
+ });
1577
1588
  }
1578
1589
 
1579
1590
  export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
1580
1591
  const db = getDb();
1581
- const current = getCaseById(id);
1582
- if (!current) {
1583
- throw new Error(`Case not found: ${id}`);
1584
- }
1592
+ return withImmediateTransaction(db, () => {
1593
+ const current = getCaseById(id);
1594
+ if (!current) {
1595
+ throw new Error(`Case not found: ${id}`);
1596
+ }
1585
1597
 
1586
- // Terminal states: block all mutations (status and field edits). The transition
1587
- // gate only runs on status changes, so without this reported/killed cases could
1588
- // still be rewritten via field-only updates.
1589
- if (current.status === "killed") {
1590
- throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
1591
- }
1592
- if (current.status === "reported") {
1593
- throw new Error("Cannot mutate a reported case; file a follow-up case instead");
1594
- }
1598
+ // Terminal states: block all mutations (status and field edits). The transition
1599
+ // gate only runs on status changes, so without this reported/killed cases could
1600
+ // still be rewritten via field-only updates.
1601
+ if (current.status === "killed") {
1602
+ throw new Error("Cannot mutate a killed case; open a new case if the lead is revived");
1603
+ }
1604
+ if (current.status === "reported") {
1605
+ throw new Error("Cannot mutate a reported case; file a follow-up case instead");
1606
+ }
1595
1607
 
1596
- // buildRecord resolves every field as `update.x ?? current.x`; the patch
1597
- // construction is the update itself.
1598
- let next = buildRecord(update, current);
1608
+ let next = buildRecord(update, current);
1599
1609
 
1600
- if (update.status && update.status !== current.status) {
1601
- validateTransition(current.status, next.status, update, current);
1602
- }
1610
+ if (update.status && update.status !== current.status) {
1611
+ validateTransition(current.status, next.status, update, current);
1612
+ }
1603
1613
 
1604
- // reportedAt is stamped when the confirmed → reported transition COMMITS
1605
- // (not at CaseContext time the bundle may be generated days before the
1606
- // report file is written, and a disclosure timeline must not lie).
1607
- // Reported cases are immutable, so current.status cannot be reported here.
1608
- if (next.status === "reported") {
1609
- next = { ...next, reportedAt: new Date().toISOString() };
1610
- }
1614
+ if (next.status === "reported") {
1615
+ next = { ...next, reportedAt: new Date().toISOString() };
1616
+ }
1611
1617
 
1612
- // Demoting off confirmed invalidates prior PoC + disconfirmation + control
1613
- // verification — re-promote required. All three artifacts must be re-earned
1614
- // together (a stale control run must not survive a demote/re-promote cycle).
1615
- if (current.status === "confirmed" && next.status === "investigating") {
1616
- next = {
1617
- ...next,
1618
- pocVerified: undefined,
1619
- disconfirmationVerified: undefined,
1620
- controlVerified: undefined,
1621
- };
1622
- }
1618
+ if (current.status === "confirmed" && next.status === "investigating") {
1619
+ next = {
1620
+ ...next,
1621
+ pocVerified: undefined,
1622
+ disconfirmationVerified: undefined,
1623
+ controlVerified: undefined,
1624
+ };
1625
+ }
1623
1626
 
1624
- validateCase(next);
1627
+ if (current.status === "confirmed" && next.status === "confirmed") {
1628
+ const proofFields = ["target", "poc", "impact", "severity"] as const;
1629
+ const changed = proofFields.filter((field) => current[field] !== next[field]);
1630
+ if (changed.length > 0) {
1631
+ throw new Error(
1632
+ `Confirmed proof-bound field(s) changed: ${changed.join(", ")}. ` +
1633
+ `Demote the case with status: "investigating" in the same update; that clears stale verification records and requires re-promotion.`,
1634
+ );
1635
+ }
1636
+ }
1625
1637
 
1626
- // Check material equality (we ignore links since links are mutated via CaseLink).
1627
- // Keys are sorted before stringify because mapRow (DB read) and buildRecord
1628
- // (write) emit CaseRecord keys in different orders — a plain JSON.stringify({...r})
1629
- // would report false "changed" on no-op updates whenever a field is undefined on
1630
- // one side and absent on the other. Sorting makes the comparison order-independent.
1631
- // Do NOT simplify back to JSON.stringify({...r}) — it reintroduces the bug.
1632
- const norm = (r: CaseRecord) =>
1633
- JSON.stringify(
1634
- Object.keys(r)
1635
- .sort()
1636
- .reduce<Record<string, unknown>>((acc, k) => {
1637
- if (
1638
- k === "updatedAt" ||
1639
- k === "createdAt" ||
1640
- k === "linkedCases" ||
1641
- k === "evidenceItems" ||
1642
- k === "coverageItems"
1643
- ) {
1644
- acc[k] = "";
1645
- } else {
1646
- acc[k] = (r as Record<string, unknown>)[k];
1647
- }
1648
- return acc;
1649
- }, {}),
1650
- );
1651
- if (norm(current) === norm(next)) {
1652
- const reason =
1653
- update.status && update.status === current.status
1654
- ? `Case is already ${current.status}; no material fields changed.`
1655
- : "No material fields changed.";
1656
- return { record: current, changed: false, reason };
1657
- }
1658
-
1659
- const duplicate = findDuplicateCaseInDb(db, next, id);
1660
- if (duplicate) {
1661
- return {
1662
- record: current,
1663
- changed: false,
1664
- reason: duplicate.near
1665
- ? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
1666
- `(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
1667
- `clearly distinct title if these are genuinely separate findings.`
1668
- : `Update would create a duplicate of case ${duplicate.record.id}`,
1669
- };
1670
- }
1638
+ validateCase(next);
1639
+
1640
+ const norm = (r: CaseRecord) =>
1641
+ JSON.stringify(
1642
+ Object.keys(r)
1643
+ .sort()
1644
+ .reduce<Record<string, unknown>>((acc, k) => {
1645
+ if (
1646
+ k === "updatedAt" ||
1647
+ k === "createdAt" ||
1648
+ k === "linkedCases" ||
1649
+ k === "evidenceItems" ||
1650
+ k === "coverageItems"
1651
+ ) {
1652
+ acc[k] = "";
1653
+ } else {
1654
+ acc[k] = (r as Record<string, unknown>)[k];
1655
+ }
1656
+ return acc;
1657
+ }, {}),
1658
+ );
1659
+ if (norm(current) === norm(next)) {
1660
+ const reason =
1661
+ update.status && update.status === current.status
1662
+ ? `Case is already ${current.status}; no material fields changed.`
1663
+ : "No material fields changed.";
1664
+ return { record: current, changed: false, reason };
1665
+ }
1671
1666
 
1672
- upsertCase(db, next);
1673
- return { record: next, changed: true };
1667
+ const duplicate = findDuplicateCaseInDb(db, next, id);
1668
+ if (duplicate) {
1669
+ return {
1670
+ record: current,
1671
+ changed: false,
1672
+ reason: duplicate.near
1673
+ ? `Update would near-duplicate case ${duplicate.record.id} — "${duplicate.record.title}" ` +
1674
+ `(same target, overlapping title). Not applied — continue with the existing case, or pick a ` +
1675
+ `clearly distinct title if these are genuinely separate findings.`
1676
+ : `Update would create a duplicate of case ${duplicate.record.id}`,
1677
+ };
1678
+ }
1679
+
1680
+ upsertCase(db, next);
1681
+ return { record: next, changed: true };
1682
+ });
1674
1683
  }
1675
1684
 
1676
- export type PocVerification = {
1677
- path: string;
1678
- exitCode: number;
1679
- ranAt: string;
1680
- output?: string;
1681
- sandbox: boolean;
1682
- /** True iff the script ran to completion (not a spawn error / signal kill / timeout). */
1683
- completed?: boolean;
1685
+ export type PocVerification = PocVerificationRecord & {
1686
+ /** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
1687
+ outputComplete?: boolean;
1688
+ /** Harness mode used for the run: poc, control, or disconfirmation. */
1689
+ mode?: string;
1690
+ /** Target passed to the PoC through PI_POC_TARGET. */
1691
+ target?: string;
1684
1692
  /**
1685
1693
  * Sanitized but UNTRUNCATED output, used for marker presence/absence
1686
1694
  * checks. Never persisted to the ledger (stripRaw drops it) — a cheating
@@ -1691,11 +1699,45 @@ export type PocVerification = {
1691
1699
  };
1692
1700
 
1693
1701
  /** Drop the transient rawOutput before persisting a verification record. */
1694
- function stripRaw(v: PocVerification): PocVerification {
1702
+ function stripRaw(v: PocVerification): PocVerificationRecord {
1695
1703
  const { rawOutput: _raw, ...rest } = v;
1696
1704
  return rest;
1697
1705
  }
1698
1706
 
1707
+ function assertVerificationRecord(
1708
+ label: string,
1709
+ v: PocVerification | undefined,
1710
+ expectedMode: string,
1711
+ expectedTarget: string,
1712
+ ): asserts v is PocVerification {
1713
+ if (!v) throw new Error(`${label} verification is required`);
1714
+ if (!v.path || typeof v.path !== "string") throw new Error(`${label} verification path missing`);
1715
+ if (!Number.isInteger(v.exitCode)) throw new Error(`${label} verification exitCode invalid`);
1716
+ if (!v.ranAt || Number.isNaN(Date.parse(v.ranAt))) {
1717
+ throw new Error(`${label} verification ranAt must be an ISO timestamp`);
1718
+ }
1719
+ if (typeof v.sandbox !== "boolean") throw new Error(`${label} verification sandbox flag missing`);
1720
+ if (v.completed !== true) throw new Error(`${label} verification did not complete`);
1721
+ if (v.outputComplete !== true) {
1722
+ throw new Error(
1723
+ `${label} verification output capture was incomplete; marker checks are unsafe`,
1724
+ );
1725
+ }
1726
+ if (typeof v.rawOutput !== "string") {
1727
+ throw new Error(`${label} verification rawOutput is required for full-output marker checks`);
1728
+ }
1729
+ if (v.mode !== expectedMode) {
1730
+ throw new Error(
1731
+ `${label} verification mode mismatch: expected ${expectedMode}, got ${v.mode ?? "unset"}`,
1732
+ );
1733
+ }
1734
+ if (v.target !== expectedTarget) {
1735
+ throw new Error(
1736
+ `${label} verification target mismatch: expected ${expectedTarget}, got ${v.target ?? "unset"}`,
1737
+ );
1738
+ }
1739
+ }
1740
+
1699
1741
  /**
1700
1742
  * Gate for promotion to confirmed: case must exist, be investigating, and have
1701
1743
  * poc/evidence/impact/severity. Returns the record when promotable, throws
@@ -1755,194 +1797,167 @@ export function promoteFindingResult(
1755
1797
  controlLivenessMarker?: string,
1756
1798
  ): CaseUpdateResult {
1757
1799
  const db = getDb();
1758
- const current = assertPromotable(id);
1759
- if (verification.exitCode !== 0) {
1760
- throw new Error(
1761
- `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
1762
- );
1763
- }
1764
-
1765
- // Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
1766
- // promotion — sandboxed and live alike. The control-target run is the only
1767
- // deterministic proof that the verification marker is target-dependent: an
1768
- // unconditional-marker PoC prints it in the control too. The control must
1769
- // have COMPLETED (a crash proves nothing), its output must NOT contain the
1770
- // verification marker (when known), AND must contain the control liveness
1771
- // Liveness is mandatory at the ledger too: omitting it is not
1772
- // a bypass for direct promoteFindingResult callers.
1773
- const liveness = controlLivenessMarker?.trim();
1774
- if (!liveness) {
1775
- throw new Error(
1776
- "Every promotion requires controlLivenessMarker: a non-empty string the control run must print " +
1777
- "after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
1778
- );
1779
- }
1780
- // Verification marker is MANDATORY at the ledger too — no `!marker` branch.
1781
- // The tool always passes it; a future direct caller that omits it must fail
1782
- // closed, not get a weakened control gate.
1783
- const verificationMarker = marker?.trim();
1784
- if (!verificationMarker) {
1785
- throw new Error(
1786
- "Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
1787
- "promoteFindingResult refuses to promote on exit 0 alone.",
1788
- );
1789
- }
1790
-
1791
- // Same-file contract: the control must be the SAME script as the PoC
1792
- // (differing only via PI_POC_MODE). The tool enforces this before running;
1793
- // the ledger re-checks so a direct caller cannot bypass it. A separately
1794
- // written control file is meaningless — the same actor writes both.
1795
- let pocHash: string | undefined;
1796
- let controlHash: string | undefined;
1797
- try {
1798
- pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1799
- controlHash = createHash("sha256")
1800
- .update(readFileSync(controlVerification?.path ?? ""))
1801
- .digest("hex");
1802
- } catch {
1803
- pocHash = undefined;
1804
- controlHash = undefined;
1805
- }
1806
- if (!pocHash || !controlHash || pocHash !== controlHash) {
1807
- throw new Error(
1808
- "Every promotion requires controlVerification from the SAME script as the PoC " +
1809
- "(sha256 of controlVerification.path must equal sha256 of verification.path). " +
1810
- "A separately written control file proves nothing.",
1811
- );
1812
- }
1800
+ return withImmediateTransaction(db, () => {
1801
+ const current = assertPromotable(id);
1802
+ const caseTarget = current.target ?? "";
1803
+
1804
+ // Anti-cheat, enforced at the ledger level (not just the tool) for EVERY
1805
+ // promotion — sandboxed and live alike. The control run must be the same
1806
+ // script against a DISTINCT baseline target, with complete captured output.
1807
+ const liveness = controlLivenessMarker?.trim();
1808
+ if (!liveness) {
1809
+ throw new Error(
1810
+ "Every promotion requires controlLivenessMarker: a non-empty string the control run must print " +
1811
+ "after reaching its target. PromoteFinding requires control_path + control_liveness_marker.",
1812
+ );
1813
+ }
1814
+ const verificationMarker = marker?.trim();
1815
+ if (!verificationMarker) {
1816
+ throw new Error(
1817
+ "Every promotion requires verificationMarker: the marker the PoC must print after exploitation. " +
1818
+ "promoteFindingResult refuses to promote on exit 0 alone.",
1819
+ );
1820
+ }
1813
1821
 
1814
- // Marker-absence + liveness checks run on the UNTRUNCATED output
1815
- // (rawOutput) — a script printing its marker past the 4000-char display
1816
- // window must not hide it from the control check.
1817
- const controlOutput = controlVerification?.rawOutput ?? controlVerification?.output ?? "";
1818
- const controlOk =
1819
- controlVerification?.completed === true &&
1820
- !controlOutput.includes(verificationMarker) &&
1821
- controlOutput.includes(liveness);
1822
- if (!controlOk) {
1823
- throw new Error(
1824
- "Every promotion requires a valid controlVerification: a control-target run of the same " +
1825
- `PoC that COMPLETED (completed: true) and whose output does not contain the marker "${verificationMarker}"` +
1826
- ` and whose output DOES contain the control liveness marker "${liveness}"` +
1827
- " (the control must actually reach its target — a failed/early control is not a clean verdict)" +
1828
- ". PromoteFinding requires control_path + control_liveness_marker.",
1822
+ assertVerificationRecord("PoC", verification, "poc", caseTarget);
1823
+ if (!controlVerification?.target?.trim()) {
1824
+ throw new Error("Every promotion requires a controlVerification target");
1825
+ }
1826
+ if (controlVerification.target === caseTarget) {
1827
+ throw new Error(
1828
+ "Every promotion requires a distinct control target; the control run cannot use the case target",
1829
+ );
1830
+ }
1831
+ assertVerificationRecord("Control", controlVerification, "control", controlVerification.target);
1832
+ assertVerificationRecord(
1833
+ "Disconfirmation",
1834
+ disconfirmationVerification,
1835
+ "disconfirmation",
1836
+ caseTarget,
1829
1837
  );
1830
- }
1831
1838
 
1832
- // The verification marker must ALSO be present in the (untruncated) PoC
1833
- // output at the ledger level — defense in depth against direct callers
1834
- // skipping the tool's check (exit 0 alone is not a verdict).
1835
- const pocOutput = verification.rawOutput ?? verification.output ?? "";
1836
- if (!pocOutput.includes(verificationMarker)) {
1837
- throw new Error(
1838
- `PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
1839
- "exit 0 alone cannot promote to confirmed",
1840
- );
1841
- }
1839
+ if (verification.exitCode !== 0) {
1840
+ throw new Error(
1841
+ `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
1842
+ );
1843
+ }
1842
1844
 
1843
- // EVERY promotion must survive an EXECUTED disconfirmation run: completed
1844
- // (no crash a crash is not a survived disproof) and non-zero exit (the
1845
- // finding was NOT disproven). Unconditional (not severity-keyed): a case
1846
- // filed at low/medium must not skip the run and be re-raised afterwards,
1847
- // and the prose `disconfirmation` field cannot carry the burden alone.
1848
- const disconfirmationOk =
1849
- disconfirmationVerification?.completed === true && disconfirmationVerification.exitCode !== 0;
1850
- if (!disconfirmationOk) {
1851
- throw new Error(
1852
- "Every promotion requires an executed disconfirmation run that COMPLETED " +
1853
- "and exited non-zero (the finding survived the attempt to disprove it). PromoteFinding " +
1854
- "requires disconfirmation_path for every promotion.",
1855
- );
1856
- }
1845
+ // Same-file contract: the control must be the SAME script as the PoC
1846
+ // (differing only via PI_POC_MODE / PI_POC_TARGET). The tool enforces this
1847
+ // before running; the ledger re-checks so a direct caller cannot bypass it.
1848
+ let pocHash: string | undefined;
1849
+ let controlHash: string | undefined;
1850
+ try {
1851
+ pocHash = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1852
+ controlHash = createHash("sha256")
1853
+ .update(readFileSync(controlVerification.path))
1854
+ .digest("hex");
1855
+ } catch {
1856
+ pocHash = undefined;
1857
+ controlHash = undefined;
1858
+ }
1859
+ if (!pocHash || !controlHash || pocHash !== controlHash) {
1860
+ throw new Error(
1861
+ "Every promotion requires controlVerification from the SAME script as the PoC " +
1862
+ "(sha256 of controlVerification.path must equal sha256 of verification.path). " +
1863
+ "A separately written control file proves nothing.",
1864
+ );
1865
+ }
1857
1866
 
1858
- // Machine-recorded reproduction evidence: the PoC gate itself writes the
1859
- // artifact-backed evidence item — confirmation is anchored to a real file
1860
- // with its SHA-256, not to agent prose in the evidence field.
1861
- let pocSha256: string | undefined;
1862
- try {
1863
- pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1864
- } catch {
1865
- pocSha256 = undefined;
1866
- }
1867
-
1868
- // Cheap provenance guards on the observation item: it must be a DIFFERENT
1869
- // file than the PoC (same hash = the model re-used its PoC as "the initial
1870
- // signal"), a different basename, and it must predate the PoC run (an
1871
- // observation recorded after the fact is not the initial signal). The
1872
- // irreducible residual — a model writing a fake capture file seconds before
1873
- // EvidenceAdd — is documented, not machine-checkable.
1874
- const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
1875
- if (observation) {
1876
- if (pocSha256 && observation.sha256 === pocSha256) {
1867
+ const controlOutput = controlVerification.rawOutput ?? "";
1868
+ if (controlOutput.includes(verificationMarker) || !controlOutput.includes(liveness)) {
1877
1869
  throw new Error(
1878
- "Evidence chain invalid: the observation artifact is the same file as the PoC " +
1879
- "(identical sha256). The initial signal must be a separate captured artifact.",
1870
+ "Every promotion requires a valid controlVerification: a control-target run of the same " +
1871
+ `PoC whose output does not contain the marker "${verificationMarker}"` +
1872
+ ` and whose output DOES contain the control liveness marker "${liveness}"` +
1873
+ " (the control must actually reach its target — a failed/early control is not a clean verdict)" +
1874
+ ". PromoteFinding requires control_path + control_liveness_marker.",
1880
1875
  );
1881
1876
  }
1882
- if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
1877
+
1878
+ const pocOutput = verification.rawOutput ?? "";
1879
+ if (!pocOutput.includes(verificationMarker)) {
1883
1880
  throw new Error(
1884
- "Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
1885
- "The initial signal must be a separate captured artifact.",
1881
+ `PoC verification output does not contain the verification marker "${verificationMarker}"; ` +
1882
+ "exit 0 alone cannot promote to confirmed",
1886
1883
  );
1887
1884
  }
1888
- if (observation.createdAt > verification.ranAt) {
1885
+
1886
+ if (disconfirmationVerification.exitCode === 0) {
1889
1887
  throw new Error(
1890
- "Evidence chain invalid: the observation item was recorded after the PoC ran " +
1891
- `(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
1888
+ "Every promotion requires an executed disconfirmation run that completed and exited non-zero " +
1889
+ "(the finding survived the attempt to disprove it). PromoteFinding requires disconfirmation_path for every promotion.",
1892
1890
  );
1893
1891
  }
1894
- }
1895
- const reproductionItem: EvidenceItem = {
1896
- id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
1897
- caseId: id,
1898
- role: "reproduction",
1899
- artifactPath: basename(verification.path),
1900
- sha256: pocSha256,
1901
- summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
1902
- createdAt: verification.ranAt,
1903
- };
1904
1892
 
1905
- const newEvidence =
1906
- (current.evidence ? `${current.evidence}\n\n` : "") +
1907
- `### PoC Execution Capture (${verification.ranAt})\n` +
1908
- `- **Exit Code:** ${verification.exitCode}\n` +
1909
- `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
1910
- `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
1911
-
1912
- const update: NormalizedCaseInput = {
1913
- status: "confirmed",
1914
- pocVerified: stripRaw(verification),
1915
- evidence: newEvidence,
1916
- };
1917
- if (disconfirmationVerification) {
1918
- update.disconfirmationVerified = stripRaw(disconfirmationVerification);
1919
- }
1920
- if (controlVerification) {
1921
- update.controlVerified = stripRaw(controlVerification);
1922
- }
1893
+ // Machine-recorded reproduction evidence: the PoC gate itself writes the
1894
+ // artifact-backed evidence item confirmation is anchored to a real file
1895
+ // with its SHA-256, not to agent prose in the evidence field.
1896
+ let pocSha256: string | undefined;
1897
+ try {
1898
+ pocSha256 = createHash("sha256").update(readFileSync(verification.path)).digest("hex");
1899
+ } catch {
1900
+ pocSha256 = undefined;
1901
+ }
1923
1902
 
1924
- const next = buildRecord(update, current);
1925
- validateCase(next);
1903
+ // Cheap provenance guards on the observation item: it must be a DIFFERENT
1904
+ // file than the PoC (same hash = the model re-used its PoC as "the initial
1905
+ // signal"), a different basename, and it must predate the PoC run.
1906
+ const observation = current.evidenceItems.find((e) => e.role === "observation" && e.sha256);
1907
+ if (observation) {
1908
+ if (pocSha256 && observation.sha256 === pocSha256) {
1909
+ throw new Error(
1910
+ "Evidence chain invalid: the observation artifact is the same file as the PoC " +
1911
+ "(identical sha256). The initial signal must be a separate captured artifact.",
1912
+ );
1913
+ }
1914
+ if (observation.artifactPath && observation.artifactPath === basename(verification.path)) {
1915
+ throw new Error(
1916
+ "Evidence chain invalid: the observation artifact has the same basename as the PoC file. " +
1917
+ "The initial signal must be a separate captured artifact.",
1918
+ );
1919
+ }
1920
+ if (observation.createdAt > verification.ranAt) {
1921
+ throw new Error(
1922
+ "Evidence chain invalid: the observation item was recorded after the PoC ran " +
1923
+ `(${observation.createdAt} > ${verification.ranAt}). The observation must predate the repro.`,
1924
+ );
1925
+ }
1926
+ }
1927
+ const reproductionItem: EvidenceItem = {
1928
+ id: `ev_${stableShortId(`${id}\nreproduction\n${verification.ranAt}`)}`,
1929
+ caseId: id,
1930
+ role: "reproduction",
1931
+ artifactPath: basename(verification.path),
1932
+ sha256: pocSha256,
1933
+ summary: `PoC run exit ${verification.exitCode} (sandbox: ${verification.sandbox}) — verification marker present in output`,
1934
+ createdAt: verification.ranAt,
1935
+ };
1936
+
1937
+ const newEvidence =
1938
+ (current.evidence ? `${current.evidence}\n\n` : "") +
1939
+ `### PoC Execution Capture (${verification.ranAt})\n` +
1940
+ `- **Exit Code:** ${verification.exitCode}\n` +
1941
+ `- **Sandbox:** ${verification.sandbox ? "yes" : "no"}\n` +
1942
+ `- **Target:** ${verification.target}\n` +
1943
+ `#### Execution Output\n\`\`\`\n${verification.output ?? ""}\n\`\`\``;
1944
+
1945
+ const update: NormalizedCaseInput = {
1946
+ status: "confirmed",
1947
+ pocVerified: stripRaw(verification),
1948
+ disconfirmationVerified: stripRaw(disconfirmationVerification),
1949
+ controlVerified: stripRaw(controlVerification),
1950
+ evidence: newEvidence,
1951
+ };
1952
+
1953
+ const next = buildRecord(update, current);
1954
+ validateCase(next);
1926
1955
 
1927
- // Evidence insert + case upsert are one atomic step: a failure between them
1928
- // would orphan a reproduction item on an investigating case (a promotion
1929
- // that never happened must leave no trace).
1930
- db.exec("BEGIN");
1931
- try {
1932
1956
  insertEvidenceItem(db, reproductionItem);
1933
1957
  upsertCase(db, next);
1934
- db.exec("COMMIT");
1935
- } catch (err) {
1936
- try {
1937
- db.exec("ROLLBACK");
1938
- } catch {
1939
- // ignore
1940
- }
1941
- throw err;
1942
- }
1943
- // Attach to the record being returned (current was fetched pre-insert).
1944
- next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
1945
- return { record: next, changed: true };
1958
+ next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
1959
+ return { record: next, changed: true };
1960
+ });
1946
1961
  }
1947
1962
 
1948
1963
  // ── Chain suggestions ───────────────────────────────────────────────
@@ -2566,19 +2581,11 @@ function buildCaseLinks(db: DatabaseSync, id: string): string {
2566
2581
  function buildScratchpadSection(caseId: string): string {
2567
2582
  const root = getScratchpadRoot();
2568
2583
  if (!existsSync(root)) return "No scratchpad found (no pipeline run artifacts recorded).";
2569
- let entries: Dirent[] = [];
2570
- try {
2571
- entries = readdirSync(root, { withFileTypes: true });
2572
- } catch {
2573
- return "Scratchpad root unreadable.";
2574
- }
2575
-
2576
2584
  const sections: string[] = [];
2577
2585
  let totalChars = 0;
2578
2586
  let totalCapped = false;
2579
- outer: for (const entry of entries) {
2580
- if (!entry.isDirectory()) continue;
2581
- const resume = scratchpad_resume(entry.name);
2587
+ outer: for (const runId of scratchpad_runs()) {
2588
+ const resume = scratchpad_resume(runId);
2582
2589
  if (!resume) continue;
2583
2590
  const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
2584
2591
  // Gate on the case id appearing in phase_ids OR in any artifact filename —
@@ -2589,7 +2596,7 @@ function buildScratchpadSection(caseId: string): string {
2589
2596
  .some((n) => n.includes(caseId));
2590
2597
  if (!allIds.includes(caseId) && !namedInArtifact) continue;
2591
2598
 
2592
- sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
2599
+ sections.push(`### Run: ${runId} (project root: ${resume.checkpoint.project_root})`);
2593
2600
  for (const phase of PHASE_ORDER) {
2594
2601
  const names = resume.artifacts[phase];
2595
2602
  if (!names?.length) continue;
@@ -2599,7 +2606,7 @@ function buildScratchpadSection(caseId: string): string {
2599
2606
  totalCapped = true;
2600
2607
  break outer;
2601
2608
  }
2602
- const content = scratchpad_read(entry.name, phase, name) ?? "(unreadable)";
2609
+ const content = scratchpad_read(runId, phase, name) ?? "(unreadable)";
2603
2610
  const clipped =
2604
2611
  content.length > MAX_ARTIFACT_CHARS
2605
2612
  ? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`