@maintainabilityai/research-runner 0.1.36 → 0.1.42

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 (2) hide show
  1. package/dist/runner/skills.js +323 -46
  2. package/package.json +1 -1
@@ -1337,8 +1337,19 @@ const ProviderResultSchema = zod_1.z.object({
1337
1337
  publishedDate: zod_1.z.string().optional(),
1338
1338
  authors: zod_1.z.array(zod_1.z.string()).optional(),
1339
1339
  });
1340
+ // Cert-run-2 bug D fix (Task #56) — schema now accepts BOTH the
1341
+ // canonical grouped shape (`ProviderResult[][]`, one inner array per
1342
+ // provider) AND the agent's intuitive flat shape (`ProviderResult[]`).
1343
+ // Cert run #2 chain showed the agent attempting flat-input dedupe-and-
1344
+ // rank TWICE in a row (events 10+11) before figuring out grouped on
1345
+ // attempt 3 — burning Zod-error feedback to converge. Lenient schema
1346
+ // removes the trial-and-error: either shape works, handler normalizes
1347
+ // internally before calling the pure dedupeAndRank function.
1340
1348
  const DedupeAndRankInput = zod_1.z.object({
1341
- results: zod_1.z.array(zod_1.z.array(ProviderResultSchema)),
1349
+ results: zod_1.z.union([
1350
+ zod_1.z.array(zod_1.z.array(ProviderResultSchema)), // canonical: grouped by provider
1351
+ zod_1.z.array(ProviderResultSchema), // lenient: flat list across providers
1352
+ ]),
1342
1353
  topN: zod_1.z.number().int().positive().optional(),
1343
1354
  });
1344
1355
  const handleDedupeAndRank = async (input) => {
@@ -1346,11 +1357,17 @@ const handleDedupeAndRank = async (input) => {
1346
1357
  if (!parsed.success) {
1347
1358
  return { ok: false, reason: `bad-input: ${parsed.error.message}` };
1348
1359
  }
1349
- const flat = parsed.data.results.flat();
1360
+ // Discriminate via first element: if it's an array → grouped (flatten);
1361
+ // else flat → use directly. Empty array → treat as empty flat (no-op).
1362
+ const r = parsed.data.results;
1363
+ const grouped = r.length > 0 && Array.isArray(r[0]);
1364
+ const flat = grouped
1365
+ ? parsed.data.results.flat()
1366
+ : parsed.data.results;
1350
1367
  const ranked = (0, dedupe_and_rank_1.dedupeAndRank)({ results: flat, topN: parsed.data.topN ?? 50 });
1351
1368
  const providerCounts = {};
1352
- for (const r of ranked) {
1353
- providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
1369
+ for (const result of ranked) {
1370
+ providerCounts[result.provider] = (providerCounts[result.provider] ?? 0) + 1;
1354
1371
  }
1355
1372
  return { ok: true, rankedSources: ranked, providerCounts };
1356
1373
  };
@@ -1430,7 +1447,19 @@ const handleFormatResearchIssueUpdate = async (input) => {
1430
1447
  const AuditEmitInput = zod_1.z.object({
1431
1448
  okrId: zod_1.z.string().min(1),
1432
1449
  runId: zod_1.z.string().min(1),
1433
- eventKind: zod_1.z.enum(['skill_call', 'llm_call', 'artifact_written', 'review_received', 'state_transition', 'human_gate']),
1450
+ // Bug K (cert-run-5) added `self_review` and `self_review_exhausted`
1451
+ // to the enum. The HOW + WHAT workflows' review-emit step calls
1452
+ // `audit-emit-event` with `eventKind: 'self_review'` to write per-
1453
+ // persona-per-round events into the chain (parsed from PR-body /
1454
+ // artifact-md / artifact-frontmatter sources). Previously the enum
1455
+ // rejected the kind with bad-input; the workflow logged a warning
1456
+ // and moved on; the chain never got the synthetic event; the UI
1457
+ // had to rely on its artifact-fallback. Now the emit succeeds.
1458
+ eventKind: zod_1.z.enum([
1459
+ 'skill_call', 'llm_call', 'artifact_written', 'review_received',
1460
+ 'self_review', 'self_review_exhausted',
1461
+ 'state_transition', 'human_gate',
1462
+ ]),
1434
1463
  payload: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
1435
1464
  phase: zod_1.z.enum(['why', 'how', 'what']),
1436
1465
  intentThreadUuid: zod_1.z.string().min(1),
@@ -1493,29 +1522,125 @@ async function sleep(ms) {
1493
1522
  // `sealed: false, sealVerified: false` but still passes if hashes are
1494
1523
  // intact. A chain with PARTIAL signatures is treated as tampering.
1495
1524
  // ─────────────────────────────────────────────────────────────────────
1496
- function knightSealPubKeyPath(okrId, runId) {
1525
+ // ─────────────────────────────────────────────────────────────────────
1526
+ // Bug O (Task #72) — Per-epoch Ed25519 signing.
1527
+ //
1528
+ // Each agent session = one "signer epoch":
1529
+ // - Original agent invocation → epoch 1
1530
+ // - First revise-agent invocation (different runner machine) → epoch 2
1531
+ // - Second revise → epoch 3
1532
+ // - ... etc
1533
+ //
1534
+ // Each epoch persists its OWN keypair:
1535
+ // <runId>.epoch-N.pub.pem (mesh, committed)
1536
+ // <runId>--<runId>.epoch-N.priv.pem (tmpdir, ephemeral)
1537
+ //
1538
+ // Events carry `signer_epoch: N` so chain-verify can look up the right
1539
+ // pub key per event. Workflow-emitted events (`emitted_by: 'workflow'`)
1540
+ // stay unsigned-by-design — those are CI infrastructure, not an agent.
1541
+ //
1542
+ // Backward compat:
1543
+ // - Legacy chains used <runId>.pub.pem with no epoch suffix. Verifiers
1544
+ // treat that as the epoch-1 pub key if no epoch-suffixed files exist.
1545
+ // - Events without `signer_epoch` field default to epoch 1.
1546
+ // ─────────────────────────────────────────────────────────────────────
1547
+ function knightSealLegacyPubKeyPath(okrId, runId) {
1497
1548
  return path.join(meshPath(), 'okrs', okrId, 'audit', 'keys', `${runId}.pub.pem`);
1498
1549
  }
1499
- function knightSealPrivKeyPath(okrId, runId) {
1550
+ function knightSealEpochPubKeyPath(okrId, runId, epoch) {
1551
+ return path.join(meshPath(), 'okrs', okrId, 'audit', 'keys', `${runId}.epoch-${epoch}.pub.pem`);
1552
+ }
1553
+ function knightSealEpochPrivKeyPath(okrId, runId, epoch) {
1500
1554
  // Tmpdir-scoped to avoid any chance of `git add`-ing a private key.
1501
- // Filename collision-resistant via okrId+runId.
1502
- return path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.priv.pem`);
1555
+ return path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.epoch-${epoch}.priv.pem`);
1556
+ }
1557
+ /**
1558
+ * Find the active signer epoch for this run.
1559
+ *
1560
+ * Returns the epoch number AND whether the caller should generate a
1561
+ * fresh keypair (isNewSession=true) or load the existing one (false).
1562
+ *
1563
+ * Logic:
1564
+ * 1. Scan `audit/keys/<runId>.epoch-N.pub.pem` files → find max N.
1565
+ * 2. Legacy compat: if no epoch files but `<runId>.pub.pem` exists,
1566
+ * treat it as the epoch-1 pub (max=1).
1567
+ * 3. If max=0 (no keys at all) → genesis, return { epoch: 1, isNew: true }.
1568
+ * 4. If max>0 and `<okrId>--<runId>.epoch-N.priv.pem` exists in tmp →
1569
+ * same session, return { epoch: max, isNew: false }.
1570
+ * 5. If max>0 and priv missing → new session (revise pass / different
1571
+ * runner machine), return { epoch: max+1, isNew: true }.
1572
+ */
1573
+ function findActiveEpoch(okrId, runId) {
1574
+ const keysDir = path.join(meshPath(), 'okrs', okrId, 'audit', 'keys');
1575
+ let maxEpoch = 0;
1576
+ if (fs.existsSync(keysDir)) {
1577
+ const escaped = runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1578
+ const epochRe = new RegExp(`^${escaped}\\.epoch-(\\d+)\\.pub\\.pem$`);
1579
+ for (const f of fs.readdirSync(keysDir)) {
1580
+ const m = f.match(epochRe);
1581
+ if (m) {
1582
+ maxEpoch = Math.max(maxEpoch, parseInt(m[1], 10));
1583
+ }
1584
+ }
1585
+ // Legacy fallback: bare `<runId>.pub.pem` counts as epoch 1.
1586
+ if (maxEpoch === 0 && fs.existsSync(knightSealLegacyPubKeyPath(okrId, runId))) {
1587
+ maxEpoch = 1;
1588
+ }
1589
+ }
1590
+ if (maxEpoch === 0) {
1591
+ return { epoch: 1, isNewSession: true };
1592
+ }
1593
+ // Check if the max-epoch's private key still exists in tmp.
1594
+ const privPath = knightSealEpochPrivKeyPath(okrId, runId, maxEpoch);
1595
+ // For legacy compat: epoch 1 priv key might be at the legacy path.
1596
+ const legacyPrivPath = path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.priv.pem`);
1597
+ if (fs.existsSync(privPath) || (maxEpoch === 1 && fs.existsSync(legacyPrivPath))) {
1598
+ return { epoch: maxEpoch, isNewSession: false };
1599
+ }
1600
+ // Priv gone → new session, advance to the next epoch.
1601
+ return { epoch: maxEpoch + 1, isNewSession: true };
1503
1602
  }
1504
1603
  /**
1505
- * Load the run's private key from tmp, or generate + persist a fresh
1506
- * keypair if this is the first event for the run. Returns both KeyObjects.
1604
+ * Load OR create the keypair for a specific (run, epoch). When
1605
+ * isNewSession is true, generates a fresh Ed25519 keypair and persists
1606
+ * BOTH the pub key (mesh) and priv key (tmpdir, mode 0600). When
1607
+ * isNewSession is false, loads the existing pair from disk.
1608
+ *
1609
+ * Bug O (Task #72) — replaces the old single-key `loadOrCreateRunKeypair`.
1610
+ * Per-epoch model means every agent session signs with its own identity,
1611
+ * closing the cryptographic gap that revise-agent events previously had.
1612
+ *
1613
+ * Backward compat: for epoch 1 only, if no `<runId>.epoch-1.pub.pem`
1614
+ * exists but the legacy `<runId>.pub.pem` does, load from the legacy
1615
+ * path (existing chains keep verifying without renaming files).
1507
1616
  */
1508
- function loadOrCreateRunKeypair(okrId, runId) {
1509
- const privPath = knightSealPrivKeyPath(okrId, runId);
1510
- const pubPath = knightSealPubKeyPath(okrId, runId);
1511
- if (fs.existsSync(privPath) && fs.existsSync(pubPath)) {
1512
- const privPem = fs.readFileSync(privPath, 'utf8');
1513
- const pubPem = fs.readFileSync(pubPath, 'utf8');
1617
+ function loadOrCreateEpochKeypair(okrId, runId, epoch, isNewSession) {
1618
+ const privPath = knightSealEpochPrivKeyPath(okrId, runId, epoch);
1619
+ const pubPath = knightSealEpochPubKeyPath(okrId, runId, epoch);
1620
+ const legacyPubPath = knightSealLegacyPubKeyPath(okrId, runId);
1621
+ const legacyPrivPath = path.join(os.tmpdir(), '.research-runner-keys', `${okrId.replace(/[^A-Za-z0-9_-]/g, '_')}--${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.priv.pem`);
1622
+ if (!isNewSession) {
1623
+ // Load existing keypair. For epoch 1, try the epoch-suffixed path
1624
+ // first; fall back to legacy paths if those are what's on disk.
1625
+ let privPem;
1626
+ let pubPem;
1627
+ if (fs.existsSync(privPath) && fs.existsSync(pubPath)) {
1628
+ privPem = fs.readFileSync(privPath, 'utf8');
1629
+ pubPem = fs.readFileSync(pubPath, 'utf8');
1630
+ }
1631
+ else if (epoch === 1 && fs.existsSync(legacyPrivPath) && fs.existsSync(legacyPubPath)) {
1632
+ privPem = fs.readFileSync(legacyPrivPath, 'utf8');
1633
+ pubPem = fs.readFileSync(legacyPubPath, 'utf8');
1634
+ }
1635
+ else {
1636
+ throw new Error(`epoch-keypair-load-failed: epoch=${epoch} privPath=${privPath} pubPath=${pubPath}`);
1637
+ }
1514
1638
  return {
1515
1639
  privKey: (0, node_crypto_1.createPrivateKey)({ key: privPem, format: 'pem' }),
1516
1640
  pubKey: (0, node_crypto_1.createPublicKey)({ key: pubPem, format: 'pem' }),
1517
1641
  };
1518
1642
  }
1643
+ // Generate + persist fresh keypair for this epoch.
1519
1644
  const { privateKey, publicKey } = (0, node_crypto_1.generateKeyPairSync)('ed25519');
1520
1645
  const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
1521
1646
  const pubPem = publicKey.export({ type: 'spki', format: 'pem' });
@@ -1525,19 +1650,47 @@ function loadOrCreateRunKeypair(okrId, runId) {
1525
1650
  fs.writeFileSync(pubPath, pubPem, 'utf8');
1526
1651
  return { privKey: privateKey, pubKey: publicKey };
1527
1652
  }
1528
- /** Returns null if no public key has been persisted for this run yet. */
1529
- function tryLoadRunPublicKey(okrId, runId) {
1530
- const pubPath = knightSealPubKeyPath(okrId, runId);
1531
- if (!fs.existsSync(pubPath)) {
1532
- return null;
1653
+ /**
1654
+ * Load every epoch's public key for this run into a Map<epoch, KeyObject>.
1655
+ * Used by audit-verify-chain. Includes the legacy `<runId>.pub.pem` as
1656
+ * epoch 1 when present (so old chains verify without renaming).
1657
+ */
1658
+ function loadAllEpochPubKeys(okrId, runId) {
1659
+ const keysDir = path.join(meshPath(), 'okrs', okrId, 'audit', 'keys');
1660
+ const result = new Map();
1661
+ if (!fs.existsSync(keysDir)) {
1662
+ return result;
1533
1663
  }
1534
- try {
1535
- return (0, node_crypto_1.createPublicKey)({ key: fs.readFileSync(pubPath, 'utf8'), format: 'pem' });
1664
+ const escaped = runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1665
+ const epochRe = new RegExp(`^${escaped}\\.epoch-(\\d+)\\.pub\\.pem$`);
1666
+ for (const f of fs.readdirSync(keysDir)) {
1667
+ const m = f.match(epochRe);
1668
+ if (!m) {
1669
+ continue;
1670
+ }
1671
+ const epoch = parseInt(m[1], 10);
1672
+ try {
1673
+ const pem = fs.readFileSync(path.join(keysDir, f), 'utf8');
1674
+ result.set(epoch, (0, node_crypto_1.createPublicKey)({ key: pem, format: 'pem' }));
1675
+ }
1676
+ catch { /* skip unreadable */ }
1536
1677
  }
1537
- catch {
1538
- return null;
1678
+ // Legacy fallback: bare `<runId>.pub.pem` populates epoch 1 if not
1679
+ // already set by an epoch-suffixed file.
1680
+ if (!result.has(1)) {
1681
+ const legacyPath = knightSealLegacyPubKeyPath(okrId, runId);
1682
+ if (fs.existsSync(legacyPath)) {
1683
+ try {
1684
+ result.set(1, (0, node_crypto_1.createPublicKey)({ key: fs.readFileSync(legacyPath, 'utf8'), format: 'pem' }));
1685
+ }
1686
+ catch { /* skip */ }
1687
+ }
1539
1688
  }
1689
+ return result;
1540
1690
  }
1691
+ // tryLoadRunPublicKey removed in Bug O (Task #72) — the per-epoch
1692
+ // model uses loadAllEpochPubKeys() to load every signer's key.
1693
+ // Legacy callers should switch to the multi-key flow.
1541
1694
  function signEventHash(privKey, eventHashHex) {
1542
1695
  // Ed25519 signs raw bytes — we sign the UTF-8 bytes of the hex digest,
1543
1696
  // which is the canonical chain anchor. Output: 64-byte signature, hex.
@@ -1600,8 +1753,48 @@ const handleAuditEmitEvent = async (input) => {
1600
1753
  nextEventId = last.event_id + 1;
1601
1754
  }
1602
1755
  }
1603
- const { privKey, pubKey } = loadOrCreateRunKeypair(okrId, runId);
1604
- const publicKeyPem = pubKey.export({ type: 'spki', format: 'pem' });
1756
+ // Bug O (Task #72) per-epoch signing.
1757
+ //
1758
+ // Workflow-emitted events stay unsigned-by-design (CI infrastructure,
1759
+ // not an agent). Everything else (original agent + revise agent)
1760
+ // gets a per-epoch signature; each agent session = one signer epoch.
1761
+ //
1762
+ // Backward compat: legacy chains with emitted_by:'revise-agent' +
1763
+ // unsigned events still verify (chain-verify accepts the legacy
1764
+ // attribution). New chains sign all agent events.
1765
+ const emittedBy = payload?.emitted_by;
1766
+ const isWorkflowEmit = emittedBy === 'workflow';
1767
+ let privKey = null;
1768
+ let publicKeyPem = null;
1769
+ let signerEpoch = null;
1770
+ let isFirstEventOfEpoch = false;
1771
+ if (!isWorkflowEmit) {
1772
+ // Agent context (original OR revise). Determine the current
1773
+ // epoch + load-or-generate its keypair.
1774
+ const { epoch, isNewSession } = findActiveEpoch(okrId, runId);
1775
+ signerEpoch = epoch;
1776
+ const keypair = loadOrCreateEpochKeypair(okrId, runId, epoch, isNewSession);
1777
+ privKey = keypair.privKey;
1778
+ publicKeyPem = keypair.pubKey.export({ type: 'spki', format: 'pem' });
1779
+ // First event of each epoch embeds the pub key inline so a
1780
+ // single-line audit excerpt names its signer.
1781
+ if (fs.existsSync(filePath)) {
1782
+ const existing = fs.readFileSync(filePath, 'utf8').split('\n').filter(l => l.trim().length > 0);
1783
+ isFirstEventOfEpoch = !existing.some(line => {
1784
+ try {
1785
+ const e = JSON.parse(line);
1786
+ const eEpoch = typeof e.signer_epoch === 'number' ? e.signer_epoch : 1;
1787
+ return eEpoch === epoch;
1788
+ }
1789
+ catch {
1790
+ return false;
1791
+ }
1792
+ });
1793
+ }
1794
+ else {
1795
+ isFirstEventOfEpoch = true;
1796
+ }
1797
+ }
1605
1798
  const draft = {
1606
1799
  event_id: nextEventId,
1607
1800
  ts: new Date().toISOString(),
@@ -1612,19 +1805,23 @@ const handleAuditEmitEvent = async (input) => {
1612
1805
  event_kind: eventKind,
1613
1806
  payload,
1614
1807
  prev_event_hash: prevHash,
1615
- // Embed public key on event 1 so a single-line audit excerpt
1616
- // still names its signer. Subsequent events reference the same
1617
- // committed key on disk; embedding on every line would balloon
1618
- // the JSONL with no integrity gain.
1619
- public_key: nextEventId === 1 ? publicKeyPem : null,
1808
+ // Embed pub key on first event of each epoch (agent only).
1809
+ // Workflow events carry no public_key they're system-trusted.
1810
+ public_key: isFirstEventOfEpoch ? publicKeyPem : null,
1620
1811
  event_hash: '',
1621
1812
  signature: '',
1622
1813
  };
1814
+ // signer_epoch present on all agent-signed events; absent on
1815
+ // workflow events. Older chains without this field default to
1816
+ // epoch 1 in chain-verify (backward compat).
1817
+ if (signerEpoch !== null) {
1818
+ draft.signer_epoch = signerEpoch;
1819
+ }
1623
1820
  const hash = sha256(canonicalStringify(draft));
1624
- const signature = signEventHash(privKey, hash);
1821
+ const signature = privKey ? signEventHash(privKey, hash) : '';
1625
1822
  const finalEvent = { ...draft, event_hash: hash, signature };
1626
1823
  fs.appendFileSync(filePath, JSON.stringify(finalEvent) + '\n', 'utf8');
1627
- return { ok: true, chainHead: hash, eventId: nextEventId, sealed: true };
1824
+ return { ok: true, chainHead: hash, eventId: nextEventId, sealed: signature !== '' };
1628
1825
  }
1629
1826
  finally {
1630
1827
  if (lockFd !== null) {
@@ -1677,11 +1874,19 @@ const handleAuditVerifyChain = async (input) => {
1677
1874
  catch (err) {
1678
1875
  return { ok: false, reason: `read-failed: ${err.message}` };
1679
1876
  }
1680
- const pubKey = tryLoadRunPublicKey(okrId, runId);
1681
- // Track signature state across the whole chain. v1 contract: either
1682
- // EVERY event is signed (sealed=true) or NO event is signed (legacy
1683
- // pre-B27 chain, sealed=false). Partial signatures = tampering.
1877
+ // Bug O (Task #72) — load ALL epoch pub keys (epoch-1, epoch-2, ...).
1878
+ // Each agent session signs with its own epoch key. Includes legacy
1879
+ // <runId>.pub.pem as epoch-1 if no epoch-suffixed files exist.
1880
+ const epochPubKeys = loadAllEpochPubKeys(okrId, runId);
1881
+ // Track signature state across the whole chain.
1684
1882
  let signedCount = 0;
1883
+ let workflowUnsignedCount = 0; // post-agent workflow-emitted events, unsigned by-design
1884
+ // P9 (Bug-P / Codex audit): revise-agent unsigned events get their own
1885
+ // bucket so we can decide legitimacy chain-by-chain — legacy chains
1886
+ // (no per-epoch signing anywhere) keep the old allowance; per-epoch
1887
+ // chains (any event with `signer_epoch`) require revise-agent to sign.
1888
+ let reviseAgentUnsignedCount = 0;
1889
+ let chainUsesPerEpochSigning = false;
1685
1890
  let prev = null;
1686
1891
  for (let i = 0; i < lines.length; i++) {
1687
1892
  let event;
@@ -1712,25 +1917,88 @@ const handleAuditVerifyChain = async (input) => {
1712
1917
  if (recordedHash !== recomputed) {
1713
1918
  return { ok: false, reason: `forged-hash-line-${i + 1}: recorded=${recordedHash.slice(0, 16)}… recomputed=${recomputed.slice(0, 16)}…` };
1714
1919
  }
1715
- if (recordedSignature !== null) {
1920
+ // Bug K + N (cert-run-5): post-agent events emitted by the workflow
1921
+ // (e.g. the synthetic self_review backfill that runs AFTER the agent
1922
+ // session ended) genuinely cannot sign — the ephemeral private key
1923
+ // is gone by then. `payload.emitted_by: 'workflow'` is the legitimate
1924
+ // unsigned attribution.
1925
+ //
1926
+ // P9 (Bug-P / Codex audit) — `revise-agent` used to share the same
1927
+ // legitimate-unsigned bucket because Bug N landed BEFORE Bug O. With
1928
+ // per-epoch signing (Bug O), a revise-agent session DOES have an
1929
+ // ephemeral key and DOES sign its events. So an unsigned revise-agent
1930
+ // event is now only legitimate on LEGACY chains — chains where no
1931
+ // event carries `signer_epoch`. Tracking that requires a chain-level
1932
+ // verdict, so we count unsigned revise-agent events into a separate
1933
+ // bucket and decide legitimacy after the loop sees whether the chain
1934
+ // uses per-epoch signing at all.
1935
+ const eventPayload = event.payload;
1936
+ const emittedBy = eventPayload?.emitted_by;
1937
+ const isWorkflowUnsigned = emittedBy === 'workflow';
1938
+ const isReviseAgentUnsigned = emittedBy === 'revise-agent';
1939
+ if (typeof event.signer_epoch === 'number') {
1940
+ chainUsesPerEpochSigning = true;
1941
+ }
1942
+ if (recordedSignature !== null && recordedSignature !== '') {
1716
1943
  signedCount++;
1717
1944
  }
1945
+ else if (isWorkflowUnsigned) {
1946
+ workflowUnsignedCount++;
1947
+ }
1948
+ else if (isReviseAgentUnsigned) {
1949
+ reviseAgentUnsignedCount++;
1950
+ }
1718
1951
  prev = recordedHash;
1719
1952
  }
1720
- // Knight's Seal verification: enforce all-or-nothing.
1953
+ // P9: legacy chains (pre-Bug-O — no signer_epoch on any event) keep
1954
+ // the broad allowance. New chains (any event carries signer_epoch)
1955
+ // require revise-agent events to be signed; an unsigned revise-agent
1956
+ // event on a per-epoch chain is now a real chain-integrity failure.
1957
+ if (chainUsesPerEpochSigning && reviseAgentUnsignedCount > 0) {
1958
+ return {
1959
+ ok: false,
1960
+ reason: `revise-agent-unsigned-on-per-epoch-chain: ${reviseAgentUnsignedCount} revise-agent events without signatures; per-epoch chains require revise-agent to sign with its own epoch key (Bug O contract)`,
1961
+ };
1962
+ }
1963
+ // Legacy chains: roll revise-agent unsigned into the workflow-unsigned
1964
+ // bucket so the downstream "agent_event_count" math still excludes them.
1965
+ workflowUnsignedCount += reviseAgentUnsignedCount;
1966
+ // Knight's Seal verification: every AGENT-emitted event must be signed
1967
+ // by its declared signer_epoch's pub key. Workflow-unsigned events are
1968
+ // excluded from the denominator (their emitted_by: 'workflow' marker
1969
+ // proves they're legitimate-unsigned).
1721
1970
  const sealed = signedCount > 0;
1971
+ const agentEventCount = lines.length - workflowUnsignedCount;
1722
1972
  let sealVerified = false;
1723
1973
  if (sealed) {
1724
- if (signedCount !== lines.length) {
1725
- return { ok: false, reason: `partial-signatures: ${signedCount}/${lines.length} events signed (chain tampered)` };
1974
+ if (signedCount !== agentEventCount) {
1975
+ return { ok: false, reason: `partial-signatures: ${signedCount}/${agentEventCount} agent-emitted events signed (chain tampered; ${workflowUnsignedCount} workflow-emitted unsigned by-design)` };
1726
1976
  }
1727
- if (!pubKey) {
1728
- return { ok: false, reason: `public-key-missing: events are signed but no <runId>.pub.pem found in audit/keys/` };
1977
+ if (epochPubKeys.size === 0) {
1978
+ return { ok: false, reason: `public-key-missing: events are signed but no <runId>.epoch-*.pub.pem (or legacy <runId>.pub.pem) found in audit/keys/` };
1729
1979
  }
1730
1980
  for (let i = 0; i < lines.length; i++) {
1731
1981
  const event = JSON.parse(lines[i]);
1982
+ const emittedBy = event.payload?.emitted_by;
1983
+ // P9: workflow-emitted unsigned events are always legitimate
1984
+ // (the post-agent context genuinely has no private key). For
1985
+ // revise-agent unsigned events, the loop above already returned
1986
+ // an error if we're on a per-epoch chain, so reaching this point
1987
+ // means we're on a legacy chain where the looser bucket applies.
1988
+ const isLegitimateUnsigned = (emittedBy === 'workflow' || emittedBy === 'revise-agent')
1989
+ && (!event.signature || event.signature === '');
1990
+ if (isLegitimateUnsigned) {
1991
+ continue;
1992
+ }
1993
+ // Bug O (Task #72) — per-epoch verification. Events default to
1994
+ // epoch 1 if signer_epoch absent (legacy chains).
1995
+ const epoch = typeof event.signer_epoch === 'number' ? event.signer_epoch : 1;
1996
+ const pubKey = epochPubKeys.get(epoch);
1997
+ if (!pubKey) {
1998
+ return { ok: false, reason: `pub-key-missing-for-epoch-${epoch}-line-${i + 1}: chain references epoch ${epoch} but no <runId>.epoch-${epoch}.pub.pem on disk` };
1999
+ }
1732
2000
  if (!verifyEventSignature(pubKey, event.event_hash, event.signature)) {
1733
- return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed` };
2001
+ return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed against epoch-${epoch} pub key` };
1734
2002
  }
1735
2003
  }
1736
2004
  sealVerified = true;
@@ -1810,6 +2078,15 @@ async function runSkill(name, input) {
1810
2078
  if (!result.ok) {
1811
2079
  payload.reason = result.reason;
1812
2080
  }
2081
+ // Bug O (Task #72) — per-epoch signing now handles revise-agent
2082
+ // context natively. handleAuditEmitEvent's findActiveEpoch()
2083
+ // detects revise-context from filesystem state and advances to
2084
+ // a fresh epoch with its own keypair. No payload tagging needed
2085
+ // here — every agent-emitted event gets a real signature
2086
+ // attributable to a specific signer_epoch. The legacy
2087
+ // emitted_by:'revise-agent' attribution (Bug N) is still
2088
+ // accepted by chain-verify for backward compat with chains
2089
+ // created before this commit, but new code doesn't emit it.
1813
2090
  // Best-effort: an audit-write failure must not shadow the real
1814
2091
  // skill result. But we MUST surface the failure to stderr — pre-
1815
2092
  // B28a.v1.1 these were silently swallowed and PR #108 dropped 3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainabilityai/research-runner",
3
- "version": "0.1.36",
3
+ "version": "0.1.42",
4
4
  "description": "Research + PRD agent runner — orchestrates the Archeologist and PRD pipelines for the MaintainabilityAI governance mesh",
5
5
  "license": "MIT",
6
6
  "author": "MaintainabilityAI",