@maintainabilityai/research-runner 0.1.35 → 0.1.41

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 +283 -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`);
1503
1556
  }
1504
1557
  /**
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.
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 }.
1507
1572
  */
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');
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 };
1602
+ }
1603
+ /**
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).
1616
+ */
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,13 @@ 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
1685
1884
  let prev = null;
1686
1885
  for (let i = 0; i < lines.length; i++) {
1687
1886
  let event;
@@ -1712,25 +1911,54 @@ const handleAuditVerifyChain = async (input) => {
1712
1911
  if (recordedHash !== recomputed) {
1713
1912
  return { ok: false, reason: `forged-hash-line-${i + 1}: recorded=${recordedHash.slice(0, 16)}… recomputed=${recomputed.slice(0, 16)}…` };
1714
1913
  }
1715
- if (recordedSignature !== null) {
1914
+ // Bug K + N (cert-run-5): post-agent events (payload.emitted_by in
1915
+ // {'workflow', 'revise-agent'}) carry signature: '' by design — the
1916
+ // post-agent context can't sign because the ephemeral private key is
1917
+ // gone. Count them separately so they don't trip partial-tampering
1918
+ // detection or signature verification. Both attributions are
1919
+ // legitimate-unsigned.
1920
+ const eventPayload = event.payload;
1921
+ const emittedBy = eventPayload?.emitted_by;
1922
+ const isUnsignedByDesign = emittedBy === 'workflow' || emittedBy === 'revise-agent';
1923
+ if (recordedSignature !== null && recordedSignature !== '') {
1716
1924
  signedCount++;
1717
1925
  }
1926
+ else if (isUnsignedByDesign) {
1927
+ workflowUnsignedCount++;
1928
+ }
1718
1929
  prev = recordedHash;
1719
1930
  }
1720
- // Knight's Seal verification: enforce all-or-nothing.
1931
+ // Knight's Seal verification: every AGENT-emitted event must be signed
1932
+ // by its declared signer_epoch's pub key. Workflow-unsigned events are
1933
+ // excluded from the denominator (their emitted_by: 'workflow' marker
1934
+ // proves they're legitimate-unsigned).
1721
1935
  const sealed = signedCount > 0;
1936
+ const agentEventCount = lines.length - workflowUnsignedCount;
1722
1937
  let sealVerified = false;
1723
1938
  if (sealed) {
1724
- if (signedCount !== lines.length) {
1725
- return { ok: false, reason: `partial-signatures: ${signedCount}/${lines.length} events signed (chain tampered)` };
1939
+ if (signedCount !== agentEventCount) {
1940
+ return { ok: false, reason: `partial-signatures: ${signedCount}/${agentEventCount} agent-emitted events signed (chain tampered; ${workflowUnsignedCount} workflow-emitted unsigned by-design)` };
1726
1941
  }
1727
- if (!pubKey) {
1728
- return { ok: false, reason: `public-key-missing: events are signed but no <runId>.pub.pem found in audit/keys/` };
1942
+ if (epochPubKeys.size === 0) {
1943
+ 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
1944
  }
1730
1945
  for (let i = 0; i < lines.length; i++) {
1731
1946
  const event = JSON.parse(lines[i]);
1947
+ const emittedBy = event.payload?.emitted_by;
1948
+ const isLegacyUnsigned = (emittedBy === 'workflow' || emittedBy === 'revise-agent')
1949
+ && (!event.signature || event.signature === '');
1950
+ if (isLegacyUnsigned) {
1951
+ continue;
1952
+ }
1953
+ // Bug O (Task #72) — per-epoch verification. Events default to
1954
+ // epoch 1 if signer_epoch absent (legacy chains).
1955
+ const epoch = typeof event.signer_epoch === 'number' ? event.signer_epoch : 1;
1956
+ const pubKey = epochPubKeys.get(epoch);
1957
+ if (!pubKey) {
1958
+ 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` };
1959
+ }
1732
1960
  if (!verifyEventSignature(pubKey, event.event_hash, event.signature)) {
1733
- return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed` };
1961
+ return { ok: false, reason: `signature-mismatch-line-${i + 1}: Ed25519 verify failed against epoch-${epoch} pub key` };
1734
1962
  }
1735
1963
  }
1736
1964
  sealVerified = true;
@@ -1810,6 +2038,15 @@ async function runSkill(name, input) {
1810
2038
  if (!result.ok) {
1811
2039
  payload.reason = result.reason;
1812
2040
  }
2041
+ // Bug O (Task #72) — per-epoch signing now handles revise-agent
2042
+ // context natively. handleAuditEmitEvent's findActiveEpoch()
2043
+ // detects revise-context from filesystem state and advances to
2044
+ // a fresh epoch with its own keypair. No payload tagging needed
2045
+ // here — every agent-emitted event gets a real signature
2046
+ // attributable to a specific signer_epoch. The legacy
2047
+ // emitted_by:'revise-agent' attribution (Bug N) is still
2048
+ // accepted by chain-verify for backward compat with chains
2049
+ // created before this commit, but new code doesn't emit it.
1813
2050
  // Best-effort: an audit-write failure must not shadow the real
1814
2051
  // skill result. But we MUST surface the failure to stderr — pre-
1815
2052
  // 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.35",
3
+ "version": "0.1.41",
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",