@evoclock/pi-agentic-driver 0.9.0 → 0.9.2

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 (38) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +94 -29
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +9 -2
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +5 -4
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +105 -0
  11. package/extensions/task-board.ts +1 -1
  12. package/lib/adapters/diff-scope.mjs +1 -1
  13. package/lib/adapters/evidence.mjs +1 -1
  14. package/lib/adapters/narrative.mjs +1 -1
  15. package/lib/adapters/review-feedback.mjs +1 -1
  16. package/lib/adapters/visualization.mjs +1 -1
  17. package/lib/code-phage-core.mjs +1 -1
  18. package/lib/python_ast_metrics.py +1 -1
  19. package/lib/typescript_ast_metrics.mjs +1 -1
  20. package/package.json +17 -3
  21. package/scripts/aidr_writing_review.js +1 -1
  22. package/scripts/enforcement/attended_authority_guard.js +1 -1
  23. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  24. package/scripts/enforcement/herdr_async_dispatch_pi.js +66 -4
  25. package/scripts/enforcement/herdr_communication_pi.js +55 -19
  26. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  27. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  28. package/scripts/enforcement/native_tui_context.js +1 -1
  29. package/scripts/enforcement/pulse_core_pi.js +292 -0
  30. package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
  31. package/scripts/enforcement/router_engine_pi.js +483 -0
  32. package/scripts/enforcement/router_ranking_pi.js +146 -0
  33. package/scripts/enforcement/router_replacement_pi.js +98 -0
  34. package/scripts/enforcement/router_runtime_pi.js +299 -0
  35. package/scripts/enforcement/router_schemas_pi.js +604 -0
  36. package/scripts/enforcement/router_store_pi.js +438 -0
  37. package/scripts/enforcement/task_board_core_pi.js +597 -48
  38. package/skills/pulse/SKILL.md +39 -0
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // BOARD-1 semantic model, closed validator, hash canonicalization, both
5
5
  // surface parsers/serializers, the trusted board writer, and the pure
@@ -15,6 +15,11 @@ import { createHash, createHmac, randomBytes } from "node:crypto";
15
15
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { dirname, join } from "node:path";
18
+ // R1/R2 (router design §7.2): the replacement writer derives route trust from
19
+ // the router config and the dead-route rule from observations. These imports
20
+ // are the pure router libraries; the board writer never mutates them.
21
+ import { isDeadRoute } from "./router_replacement_pi.js";
22
+ import { getRouteDecisionIdByDigest } from "./router_store_pi.js";
18
23
 
19
24
  // ---------------------------------------------------------------------------
20
25
  // Closed vocabularies (§1)
@@ -1288,8 +1293,8 @@ export function isDispatchable(card, boardIndex) {
1288
1293
  // Markdown stays canonical for task semantics.
1289
1294
  // ---------------------------------------------------------------------------
1290
1295
 
1291
- export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v2";
1292
- export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v1";
1296
+ export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v3";
1297
+ export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v2";
1293
1298
  export const AUTOMATION_POLICY_SCHEMA = "agentic-driver.automation-policy.v1";
1294
1299
  export const PLACEMENTS = Object.freeze(["container", "host"]);
1295
1300
  export const RISK_LEVELS = Object.freeze(["low", "medium", "high"]);
@@ -1324,17 +1329,21 @@ function writeJsonFileAtomic(path, value) {
1324
1329
  }
1325
1330
 
1326
1331
  // HMAC over the canonical JSON form of the claims content, keyed by the
1327
- // writer-state secret (F1). Covers claims, consumed-claim records, and the
1328
- // transaction record — every authority-bearing field of the file.
1329
- function claimsFileHmac(value, secret) {
1332
+ // writer-state secret (F1). Covers claims, consumed-claim records, the
1333
+ // transaction record, and the verdict-evidence map — every authority-bearing
1334
+ // field of the file. v2→v3 (router design §5.3): `evidence` joins the HMAC
1335
+ // input; v2 files are grandfathered (absent evidence reads as {}).
1336
+ function claimsFileHmac(value, secret, { includeEvidence = true } = {}) {
1337
+ const payload = {
1338
+ schema: value.schema,
1339
+ generation: value.generation,
1340
+ transaction: value.transaction ?? null,
1341
+ claims: value.claims ?? [],
1342
+ consumedClaims: value.consumedClaims ?? [],
1343
+ };
1344
+ if (includeEvidence) payload.evidence = value.evidence ?? {};
1330
1345
  return createHmac("sha256", secret)
1331
- .update(canonicalJsonString({
1332
- schema: value.schema,
1333
- generation: value.generation,
1334
- transaction: value.transaction ?? null,
1335
- claims: value.claims ?? [],
1336
- consumedClaims: value.consumedClaims ?? [],
1337
- }), "utf8")
1346
+ .update(canonicalJsonString(payload), "utf8")
1338
1347
  .digest("hex");
1339
1348
  }
1340
1349
 
@@ -1349,16 +1358,35 @@ function exactKeys(value, keys) {
1349
1358
  return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
1350
1359
  }
1351
1360
 
1361
+ // v1→v2 (router design §5.1): exactly these keys gained — seatId, accountId,
1362
+ // provider, model, role, effort, containmentTier, phase, routeDecisionDigest,
1363
+ // reservationId. v1 claims remain valid and dispatchable under the old
1364
+ // predicate; new claims require the v2 schema.
1352
1365
  const ENVELOPE_FIELDS = Object.freeze([
1353
1366
  "schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
1354
1367
  "baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
1355
1368
  "stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
1356
1369
  "riskCeiling", "mode", "createdAt", "expiry",
1370
+ "seatId", "accountId", "provider", "model", "role", "effort",
1371
+ "containmentTier", "phase", "routeDecisionDigest", "reservationId",
1357
1372
  ]);
1373
+ const V1_ENVELOPE_FIELDS = Object.freeze([
1374
+ "schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
1375
+ "baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
1376
+ "stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
1377
+ "riskCeiling", "mode", "createdAt", "expiry",
1378
+ ]);
1379
+ const EFFORT_LEVELS = Object.freeze(["low", "default", "high"]);
1380
+ const ENVELOPE_PHASES = Object.freeze(["plan", "implement", "review", "escalate"]);
1381
+ const CONTAINMENT_TIERS = Object.freeze(["testudo", "docker-policy", "none"]);
1358
1382
 
1359
- export function wellFormedEnvelope(env) {
1360
- if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
1361
- if (env.schema !== ENVELOPE_SCHEMA) return false;
1383
+ function wellFormedV1Envelope(env) {
1384
+ if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, V1_ENVELOPE_FIELDS)) return false;
1385
+ if (env.schema !== "agentic-driver.assignment-envelope.v1") return false;
1386
+ return v1EnvelopeCoreValid(env);
1387
+ }
1388
+
1389
+ function v1EnvelopeCoreValid(env) {
1362
1390
  if (typeof env.envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(env.envelopeId)) return false;
1363
1391
  if (typeof env.cardId !== "string" || !CARD_ID_RE.test(env.cardId)) return false;
1364
1392
  if (env.cardHash !== null && !/^[0-9a-f]{64}$/.test(env.cardHash)) return false;
@@ -1382,24 +1410,61 @@ export function wellFormedEnvelope(env) {
1382
1410
  return true;
1383
1411
  }
1384
1412
 
1385
- const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
1413
+ export function wellFormedEnvelope(env) {
1414
+ // Migration (§5.1): v1 claims remain valid and dispatchable under the old
1415
+ // predicate; new claims require the v2 schema.
1416
+ if (env !== null && typeof env === "object" && !Array.isArray(env) && env.schema === "agentic-driver.assignment-envelope.v1") {
1417
+ return wellFormedV1Envelope(env);
1418
+ }
1419
+ if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
1420
+ if (env.schema !== ENVELOPE_SCHEMA) return false;
1421
+ if (!v1EnvelopeCoreValid(env)) return false;
1422
+ // Route fields (§5.1): seatId/provider/model/role non-empty strings;
1423
+ // accountId string or null; effort/containmentTier/phase closed enums;
1424
+ // routeDecisionDigest 64-hex; reservationId string or null.
1425
+ for (const field of ["seatId", "provider", "model", "role"]) {
1426
+ if (typeof env[field] !== "string" || env[field] === "") return false;
1427
+ }
1428
+ if (env.accountId !== null && (typeof env.accountId !== "string" || env.accountId === "")) return false;
1429
+ if (!EFFORT_LEVELS.includes(env.effort)) return false;
1430
+ if (!CONTAINMENT_TIERS.includes(env.containmentTier)) return false;
1431
+ if (!ENVELOPE_PHASES.includes(env.phase)) return false;
1432
+ if (!/^[0-9a-f]{64}$/.test(env.routeDecisionDigest ?? "")) return false;
1433
+ if (env.reservationId !== null && (typeof env.reservationId !== "string" || env.reservationId === "")) return false;
1434
+ return true;
1435
+ }
1436
+
1437
+ const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope", "parentClaimId", "attemptIndex"]);
1438
+ // v2 grandfathering: claims in v2 files carry exactly the five legacy fields.
1439
+ const V2_CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
1386
1440
  const CONSUMED_FIELDS = Object.freeze(["cardId", "envelopeId", "consumedAt", "reason"]);
1387
1441
  const CONSUMED_REASONS = Object.freeze([
1388
1442
  "expired", "reclaimed", "completed", "exhausted", "cancelled", "failed",
1389
1443
  "waiting-approval", "role-blocked", "worker-unresponsive",
1390
1444
  ]);
1391
1445
  const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
1392
- const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
1393
-
1394
- function wellFormedClaim(claim) {
1395
- return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CLAIM_FIELDS)
1396
- && CARD_ID_RE.test(claim.cardId)
1397
- && ISO_TS_RE.test(claim.claimedAt)
1398
- && ROLE_NAME_RE.test(claim.role)
1399
- && /^[0-9a-f]{32}$/.test(claim.envelopeId)
1400
- && wellFormedEnvelope(claim.envelope)
1401
- && claim.envelope.envelopeId === claim.envelopeId
1402
- && claim.envelope.cardId === claim.cardId;
1446
+ const REPLACE_TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase", "oldEnvelopeId", "oldReason"]);
1447
+ // v2→v3: verdict evidence is closed and HMAC-authenticated; claims also gain lineage.
1448
+ const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "evidence", "hmac"]);
1449
+ const V2_CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
1450
+ const EVIDENCE_KEY_RE = /^[0-9a-f]{64}$/;
1451
+
1452
+ function wellFormedClaim(claim, { legacy = false } = {}) {
1453
+ if (claim === null || typeof claim !== "object" || Array.isArray(claim)) return false;
1454
+ if (!exactKeys(claim, legacy ? V2_CLAIM_FIELDS : CLAIM_FIELDS)) return false;
1455
+ if (!CARD_ID_RE.test(claim.cardId)) return false;
1456
+ if (!ISO_TS_RE.test(claim.claimedAt)) return false;
1457
+ if (!ROLE_NAME_RE.test(claim.role)) return false;
1458
+ if (!/^[0-9a-f]{32}$/.test(claim.envelopeId)) return false;
1459
+ if (!wellFormedEnvelope(claim.envelope)) return false;
1460
+ if (claim.envelope.envelopeId !== claim.envelopeId) return false;
1461
+ if (claim.envelope.cardId !== claim.cardId) return false;
1462
+ if (legacy) return true;
1463
+ // Lineage (§7.3): parentClaimId is a claim identity string or null
1464
+ // (initial dispatch); attemptIndex is a non-negative integer starting
1465
+ // at 0 for the initial attempt.
1466
+ return (claim.parentClaimId === null || (typeof claim.parentClaimId === "string" && claim.parentClaimId !== ""))
1467
+ && Number.isInteger(claim.attemptIndex) && claim.attemptIndex >= 0;
1403
1468
  }
1404
1469
 
1405
1470
  function wellFormedConsumedClaim(claim) {
@@ -1411,11 +1476,25 @@ function wellFormedConsumedClaim(claim) {
1411
1476
  }
1412
1477
 
1413
1478
  function wellFormedTransaction(tx) {
1414
- return tx === null
1415
- || (typeof tx === "object" && !Array.isArray(tx) && exactKeys(tx, TRANSACTION_FIELDS)
1416
- && tx.op === "claim" && CARD_ID_RE.test(tx.cardId)
1479
+ if (tx === null) return true;
1480
+ if (typeof tx !== "object" || Array.isArray(tx)) return false;
1481
+ if (tx.op === "claim") {
1482
+ return exactKeys(tx, TRANSACTION_FIELDS)
1483
+ && CARD_ID_RE.test(tx.cardId)
1417
1484
  && /^[0-9a-f]{32}$/.test(tx.envelopeId) && ISO_TS_RE.test(tx.at)
1418
- && tx.phase === "claims-written");
1485
+ && tx.phase === "claims-written";
1486
+ }
1487
+ // §10.4 replace-attempt transaction: the pre-recorded replacement identity
1488
+ // plus the old envelope ID and terminal reason it replaces.
1489
+ if (tx.op === "replace-attempt") {
1490
+ return exactKeys(tx, REPLACE_TRANSACTION_FIELDS)
1491
+ && CARD_ID_RE.test(tx.cardId)
1492
+ && /^[0-9a-f]{32}$/.test(tx.envelopeId) && /^[0-9a-f]{32}$/.test(tx.oldEnvelopeId)
1493
+ && ISO_TS_RE.test(tx.at)
1494
+ && tx.phase === "claims-written"
1495
+ && CONSUMED_REASONS.includes(tx.oldReason);
1496
+ }
1497
+ return false;
1419
1498
  }
1420
1499
 
1421
1500
  // Read and VERIFY the claims file. Missing file → fresh ONLY while the
@@ -1435,16 +1514,33 @@ export function readClaimsState(boardPath) {
1435
1514
  if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
1436
1515
  return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
1437
1516
  }
1438
- return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [] } };
1517
+ return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [], evidence: {} } };
1439
1518
  }
1440
1519
  const value = readJsonFile(path);
1441
- if (value === null || typeof value !== "object" || Array.isArray(value) || !exactKeys(value, CLAIMS_STATE_FIELDS)) {
1520
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1521
+ return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1522
+ }
1523
+ // Schema migration (§5.3): v2 files are grandfathered — an absent evidence
1524
+ // map reads as {}, claims keep the legacy five-field shape, and the HMAC is
1525
+ // verified over the legacy input. v3 requires the evidence key, the lineage
1526
+ // fields, and the extended HMAC.
1527
+ const legacy = value.schema === "agentic-driver.board-claims.v2";
1528
+ let evidence = value.evidence;
1529
+ if (legacy) {
1530
+ if (!exactKeys(value, V2_CLAIMS_STATE_FIELDS)) {
1531
+ return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1532
+ }
1533
+ evidence = {};
1534
+ } else if (!exactKeys(value, CLAIMS_STATE_FIELDS)) {
1442
1535
  return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1443
1536
  }
1537
+ if (!isPlainEvidenceMap(evidence)) {
1538
+ return { ok: false, reason: "the claims evidence map is malformed (fails closed)" };
1539
+ }
1444
1540
  if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
1445
1541
  || !Number.isInteger(value.generation) || value.generation < 0
1446
1542
  || !wellFormedTransaction(value.transaction)
1447
- || !value.claims.every(wellFormedClaim)
1543
+ || !value.claims.every((claim) => wellFormedClaim(claim, { legacy }))
1448
1544
  || !value.consumedClaims.every(wellFormedConsumedClaim)) {
1449
1545
  return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
1450
1546
  }
@@ -1457,7 +1553,8 @@ export function readClaimsState(boardPath) {
1457
1553
  if (writerState.secret === null) {
1458
1554
  return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
1459
1555
  }
1460
- if (typeof value.hmac !== "string" || value.hmac !== claimsFileHmac(value, writerState.secret)) {
1556
+ if (typeof value.hmac !== "string"
1557
+ || value.hmac !== claimsFileHmac({ ...value, evidence }, writerState.secret, { includeEvidence: !legacy })) {
1461
1558
  return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
1462
1559
  }
1463
1560
  // Deletion/rollback guard: the generation must be exactly the anchored
@@ -1475,6 +1572,7 @@ export function readClaimsState(boardPath) {
1475
1572
  transaction: value.transaction,
1476
1573
  claims: value.claims,
1477
1574
  consumedClaims: value.consumedClaims,
1575
+ evidence,
1478
1576
  },
1479
1577
  };
1480
1578
  }
@@ -1495,6 +1593,24 @@ function claimsAnchorDigest(value, secret) {
1495
1593
  .digest("hex");
1496
1594
  }
1497
1595
 
1596
+ const EVIDENCE_FIELDS = Object.freeze(["evalInputDigest", "answers", "probabilities", "thresholdPolicy", "versions", "requestId", "timestamp"]);
1597
+ function isPlainEvidenceMap(evidence) {
1598
+ if (evidence === null || typeof evidence !== "object" || Array.isArray(evidence)) return false;
1599
+ const keys = Object.keys(evidence);
1600
+ if (keys.length > 64) return false;
1601
+ for (const key of keys) {
1602
+ const r = evidence[key];
1603
+ if (!EVIDENCE_KEY_RE.test(key) || r === null || typeof r !== "object" || Array.isArray(r) || !exactKeys(r, EVIDENCE_FIELDS)) return false;
1604
+ if (r.evalInputDigest !== key || !EVIDENCE_KEY_RE.test(r.evalInputDigest)) return false;
1605
+ if (r.answers === null || typeof r.answers !== "object" || Array.isArray(r.answers)
1606
+ || r.probabilities === null || typeof r.probabilities !== "object" || Array.isArray(r.probabilities)
1607
+ || r.thresholdPolicy === null || typeof r.thresholdPolicy !== "object" || Array.isArray(r.thresholdPolicy)
1608
+ || r.versions === null || typeof r.versions !== "object" || Array.isArray(r.versions)) return false;
1609
+ if (typeof r.requestId !== "string" || r.requestId === "" || !ISO_TS_RE.test(r.timestamp) || JSON.stringify(r).length > 8192) return false;
1610
+ }
1611
+ return true;
1612
+ }
1613
+
1498
1614
  // Recover the two-file claims/anchor commit. The complete authenticated next
1499
1615
  // claims state is staged in writer state first, so either crash window rolls
1500
1616
  // forward deterministically rather than accepting an older file.
@@ -1503,9 +1619,15 @@ function recoverClaimsAnchorLocked(boardPath) {
1503
1619
  const writerState = readWriterState(statePath);
1504
1620
  const pending = writerState.pendingClaimsState;
1505
1621
  if (pending === null) return { ok: true, recovered: false };
1506
- if (writerState.secret === null || !pending || typeof pending !== "object"
1507
- || !exactKeys(pending, CLAIMS_STATE_FIELDS)
1508
- || pending.hmac !== claimsFileHmac(pending, writerState.secret)) {
1622
+ if (writerState.secret === null || !pending || typeof pending !== "object") {
1623
+ return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
1624
+ }
1625
+ const pendingLegacy = pending.schema === "agentic-driver.board-claims.v2";
1626
+ if (pendingLegacy ? !exactKeys(pending, V2_CLAIMS_STATE_FIELDS) : !exactKeys(pending, CLAIMS_STATE_FIELDS)
1627
+ || !Array.isArray(pending.claims) || !pending.claims.every((claim) => wellFormedClaim(claim, { legacy: pendingLegacy }))) {
1628
+ return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
1629
+ }
1630
+ if (pending.hmac !== claimsFileHmac(pending, writerState.secret, { includeEvidence: !pendingLegacy })) {
1509
1631
  return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
1510
1632
  }
1511
1633
  writeJsonFileAtomic(claimsPath(boardPath), pending);
@@ -1529,12 +1651,18 @@ function writeClaims(boardPath, claims, { consumedClaims = null, transaction = n
1529
1651
  writerState = readWriterState(statePath);
1530
1652
  const previous = readClaimsState(boardPath);
1531
1653
  if (!previous.ok) throw Object.assign(new Error(previous.reason), { code: "claims-corrupt" });
1654
+ // v2→v3 migration: legacy claims are upgraded in place on the next write —
1655
+ // absent lineage fields take their initial-dispatch defaults (§7.3).
1656
+ const upgradedClaims = claims.map((claim) => claim.parentClaimId === undefined && claim.attemptIndex === undefined
1657
+ ? { ...claim, parentClaimId: null, attemptIndex: 0 }
1658
+ : claim);
1532
1659
  const value = {
1533
1660
  schema: CLAIMS_SCHEMA,
1534
1661
  generation: previous.state.generation + 1,
1535
1662
  transaction,
1536
- claims,
1663
+ claims: upgradedClaims,
1537
1664
  consumedClaims: consumedClaims ?? previous.state.consumedClaims,
1665
+ evidence: previous.state.evidence ?? {},
1538
1666
  };
1539
1667
  value.hmac = claimsFileHmac(value, writerState.secret);
1540
1668
  // Prepare → claims → commit. Because prepare contains the complete signed
@@ -1567,7 +1695,103 @@ export function readAutomationPolicy(boardPath, { configPath = null } = {}) {
1567
1695
  const POLICY_FIELDS = Object.freeze([
1568
1696
  "roles", "placement", "maxConcurrent", "expiry", "envelopeExpiryHours",
1569
1697
  "board", "riskCeiling", "allowPerCardRiskOverride", "acceptedRepositories",
1698
+ "pulse",
1699
+ ]);
1700
+
1701
+ // Pulse policy v1 (PULSE_DESIGN_v3 §3): one optional closed `pulse` object
1702
+ // inside the existing board-bound automation policy. No second policy store:
1703
+ // the pulse record is validated here, beside the shipped policy fields, and
1704
+ // a policy without `pulse` behaves exactly as before (Pulse disabled).
1705
+ const PULSE_FIELDS = Object.freeze([
1706
+ "enabled", "mode", "intervalSeconds", "fillOnStart", "routing",
1707
+ "stallTimeoutSeconds", "unattendedHostRiskAccepted",
1570
1708
  ]);
1709
+ const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
1710
+ const PULSE_MODES = Object.freeze(["interactive", "automated"]);
1711
+ const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
1712
+
1713
+ function pulseCapacityOk(value) {
1714
+ return Number.isInteger(value) && value >= 1 && value <= 32;
1715
+ }
1716
+
1717
+ function checkPulseRouteEntry(entry, { seen }) {
1718
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)
1719
+ || !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
1720
+ return "each routing entry must have exactly {model, maxConcurrent}";
1721
+ }
1722
+ if (typeof entry.model !== "string" || !PULSE_MODEL_RE.test(entry.model)) {
1723
+ return `route model "${entry.model}" is not an exact provider/model identifier`;
1724
+ }
1725
+ if (seen.has(entry.model)) {
1726
+ return `model "${entry.model}" appears more than once for this role`;
1727
+ }
1728
+ seen.add(entry.model);
1729
+ if (!pulseCapacityOk(entry.maxConcurrent)) {
1730
+ return `route maxConcurrent for "${entry.model}" must be an integer from 1 through 32`;
1731
+ }
1732
+ return null;
1733
+ }
1734
+
1735
+ // Validate the optional `pulse` object. Returns {ok, pulse, reason}. Fails
1736
+ // closed on unknown fields, duplicate models, routing roles absent from the
1737
+ // policy's declared roles, and invalid capacities.
1738
+ export function checkPulsePolicy(pulse, { roles = null } = {}) {
1739
+ if (pulse === null || pulse === undefined) return { ok: true, pulse: null, reason: null };
1740
+ if (typeof pulse !== "object" || Array.isArray(pulse) || !exactKeys(pulse, PULSE_FIELDS)) {
1741
+ return { ok: false, pulse: null, reason: "the automation policy pulse object has unknown or missing fields — the pulse shape is closed (fails closed)" };
1742
+ }
1743
+ if (typeof pulse.enabled !== "boolean") {
1744
+ return { ok: false, pulse: null, reason: "pulse.enabled is required and must be a boolean (fails closed)" };
1745
+ }
1746
+ if (!PULSE_MODES.includes(pulse.mode)) {
1747
+ return { ok: false, pulse: null, reason: `pulse.mode must be one of ${PULSE_MODES.join(", ")} (fails closed)` };
1748
+ }
1749
+ if (!Number.isInteger(pulse.intervalSeconds) || pulse.intervalSeconds < 10 || pulse.intervalSeconds > 86400) {
1750
+ return { ok: false, pulse: null, reason: "pulse.intervalSeconds must be an integer from 10 through 86400 (fails closed)" };
1751
+ }
1752
+ if (typeof pulse.fillOnStart !== "boolean") {
1753
+ return { ok: false, pulse: null, reason: "pulse.fillOnStart must be a boolean (fails closed)" };
1754
+ }
1755
+ if (pulse.routing === null || typeof pulse.routing !== "object" || Array.isArray(pulse.routing)) {
1756
+ return { ok: false, pulse: null, reason: "pulse.routing must be an object keyed by declared policy role (fails closed)" };
1757
+ }
1758
+ const declared = Array.isArray(roles) ? roles : [];
1759
+ for (const role of Object.keys(pulse.routing)) {
1760
+ if (!declared.includes(role)) {
1761
+ return { ok: false, pulse: null, reason: `pulse.routing declares role "${role}", which is absent from the policy roles (fails closed)` };
1762
+ }
1763
+ const entry = pulse.routing[role];
1764
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)
1765
+ || !exactKeys(entry, ["preferred", "fallback", "maxConcurrent"])) {
1766
+ return { ok: false, pulse: null, reason: `pulse.routing["${role}"] must have exactly {preferred, fallback, maxConcurrent} (fails closed)` };
1767
+ }
1768
+ if (!Array.isArray(entry.preferred) || entry.preferred.length === 0) {
1769
+ return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred must list at least one route (fails closed)` };
1770
+ }
1771
+ if (!Array.isArray(entry.fallback)) {
1772
+ return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback must be a list (fails closed)` };
1773
+ }
1774
+ const seen = new Set();
1775
+ for (const route of entry.preferred) {
1776
+ const reason = checkPulseRouteEntry(route, { seen });
1777
+ if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].preferred: ${reason} (fails closed)` };
1778
+ }
1779
+ for (const route of entry.fallback) {
1780
+ const reason = checkPulseRouteEntry(route, { seen });
1781
+ if (reason) return { ok: false, pulse: null, reason: `pulse.routing["${role}"].fallback: ${reason} (fails closed)` };
1782
+ }
1783
+ if (!pulseCapacityOk(entry.maxConcurrent)) {
1784
+ return { ok: false, pulse: null, reason: `pulse.routing["${role}"].maxConcurrent must be an integer from 1 through 32 (fails closed)` };
1785
+ }
1786
+ }
1787
+ if (!Number.isInteger(pulse.stallTimeoutSeconds) || pulse.stallTimeoutSeconds < 30 || pulse.stallTimeoutSeconds > 86400) {
1788
+ return { ok: false, pulse: null, reason: "pulse.stallTimeoutSeconds must be an integer from 30 through 86400 (fails closed)" };
1789
+ }
1790
+ if (typeof pulse.unattendedHostRiskAccepted !== "boolean") {
1791
+ return { ok: false, pulse: null, reason: "pulse.unattendedHostRiskAccepted must be a boolean (fails closed)" };
1792
+ }
1793
+ return { ok: true, pulse, reason: null };
1794
+ }
1571
1795
 
1572
1796
  export function checkAutomationPolicy(policy, { now = null, boardPath = null } = {}) {
1573
1797
  const at = now ?? new Date().toISOString();
@@ -1623,6 +1847,14 @@ export function checkAutomationPolicy(policy, { now = null, boardPath = null } =
1623
1847
  || !policy.acceptedRepositories.every((repo) => typeof repo === "string" && repo !== "" && !repo.includes(".."))) {
1624
1848
  return { ok: false, reason: "the automation policy acceptedRepositories must be a non-empty closed list of repository paths (fails closed)" };
1625
1849
  }
1850
+
1851
+ // Pulse (§3): the optional closed pulse object is validated in place. A
1852
+ // policy without `pulse` is unchanged and means Pulse is disabled.
1853
+ if (policy.pulse !== undefined) {
1854
+ const pulse = checkPulsePolicy(policy.pulse, { roles: policy.roles });
1855
+ if (!pulse.ok) return { ok: false, reason: pulse.reason };
1856
+ }
1857
+
1626
1858
  return { ok: true, policy, reason: null };
1627
1859
  }
1628
1860
 
@@ -1639,7 +1871,7 @@ export function repositoryAccepted(policy, repository) {
1639
1871
  // in), the card's base revision is bound in when present (branch chaining),
1640
1872
  // and the risk classification comes from the policy (per-card override only
1641
1873
  // when the policy allows).
1642
- export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null }) {
1874
+ export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null, route = null }) {
1643
1875
  const at = now ?? new Date().toISOString();
1644
1876
  const envelopeId = randomBytes(16).toString("hex");
1645
1877
  const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
@@ -1656,6 +1888,22 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
1656
1888
  // envelope's starting revision IS the base (branch chaining), not merely a
1657
1889
  // copied field; execution validation enforces HEAD === startingRevision.
1658
1890
  const startRev = startingRevision ?? card.base ?? gitHead(repo);
1891
+ const r = route;
1892
+ if (r === null || typeof r !== "object"
1893
+ || typeof r.seatId !== "string" || r.seatId === ""
1894
+ || typeof r.provider !== "string" || r.provider === ""
1895
+ || typeof r.model !== "string" || r.model === ""
1896
+ || typeof r.role !== "string" || r.role === ""
1897
+ || !EFFORT_LEVELS.includes(r.effort) || !CONTAINMENT_TIERS.includes(r.containmentTier)
1898
+ || !ENVELOPE_PHASES.includes(r.phase) || !/^[0-9a-f]{64}$/.test(r.routeDecisionDigest ?? "")) {
1899
+ throw Object.assign(new Error("new claims require a complete authenticated route decision"), { code: "route-required" });
1900
+ }
1901
+ const routeFields = {
1902
+ seatId: r.seatId, accountId: r.accountId ?? null, provider: r.provider,
1903
+ model: r.model, role: r.role, effort: r.effort, containmentTier: r.containmentTier,
1904
+ phase: r.phase, routeDecisionDigest: r.routeDecisionDigest,
1905
+ reservationId: typeof r.reservationId === "string" && r.reservationId !== "" ? r.reservationId : null,
1906
+ };
1659
1907
  return deepFreeze({
1660
1908
  schema: ENVELOPE_SCHEMA,
1661
1909
  envelopeId,
@@ -1677,6 +1925,7 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
1677
1925
  mode: "automated",
1678
1926
  createdAt: at,
1679
1927
  expiry,
1928
+ ...routeFields,
1680
1929
  });
1681
1930
  }
1682
1931
 
@@ -1746,7 +1995,62 @@ export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId
1746
1995
  // the new complete state (both writes are atomic renames); two concurrent
1747
1996
  // claims can never both win because the entire read-decide-write sequence
1748
1997
  // holds the lock. Expired claims are released first, inside the same lock.
1749
- export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null }) {
1998
+ export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision, route = null }) {
1999
+ return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: null, route });
2000
+ }
2001
+
2002
+ // ---------------------------------------------------------------------------
2003
+ // Trusted Pulse-internal confirmed-batch claim path. Host placement is
2004
+ // claimable ONLY here, and only for a batch context object this module has
2005
+ // registered after one native confirmation (registerConfirmedBatchForClaims),
2006
+ // matched by object identity — never by batchId string, user/model field, or
2007
+ // durable state. Each confirmed entry is single-use: one claim consumes it,
2008
+ // and a claim for a card outside the confirmed set is refused.
2009
+ // ---------------------------------------------------------------------------
2010
+
2011
+ // batch context object (WeakMap key) -> Set of cardIds still claimable from it.
2012
+ const confirmedBatchClaims = new WeakMap();
2013
+
2014
+ export function registerConfirmedBatchForClaims(batch) {
2015
+ if (batch?.schema !== "agentic-driver.pulse-batch.v1" || typeof batch.batchId !== "string" || !Array.isArray(batch.entries)) {
2016
+ throw new Error("registerConfirmedBatchForClaims requires a minted pulse batch context");
2017
+ }
2018
+ confirmedBatchClaims.set(batch, new Set(batch.entries.map((entry) => entry.cardId)));
2019
+ return batch.batchId;
2020
+ }
2021
+
2022
+ export function claimCardForConfirmedBatch({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
2023
+ if (confirmedBatch === null || !confirmedBatchClaims.has(confirmedBatch)) {
2024
+ return { ok: false, code: "confirmed-batch-required", reason: "host placement is claimable only through the Pulse confirmed-batch path with a batch registered after native confirmation (fails closed)" };
2025
+ }
2026
+ if (typeof cardId !== "string" || cardId === "") {
2027
+ return { ok: false, code: "confirmed-batch-card-required", reason: "a confirmed-batch claim requires an explicit cardId from the confirmed batch (fails closed)" };
2028
+ }
2029
+ const expiresMs = Date.parse(confirmedBatch.expiresAt);
2030
+ const nowMs = Date.parse(now ?? new Date().toISOString());
2031
+ if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
2032
+ return { ok: false, code: "confirmed-batch-time-invalid", reason: "the confirmed batch expiry or current time is invalid (fails closed)" };
2033
+ }
2034
+ if (nowMs >= expiresMs) {
2035
+ return { ok: false, code: "confirmed-batch-expired", reason: "the confirmed batch context has expired; a fresh preview and confirmation is required" };
2036
+ }
2037
+ const entry = confirmedBatch.entries.find((e) => e.cardId === cardId);
2038
+ if (!entry || entry.placement !== "host") {
2039
+ return { ok: false, code: "confirmed-batch-card-mismatch", reason: "the requested card is not a host entry in the confirmed batch; confirmations cannot be crossed between batch entries" };
2040
+ }
2041
+ if (entry.state !== "reserved") {
2042
+ return { ok: false, code: "confirmed-batch-entry-not-reserved", reason: `confirmed batch entry is ${entry.state}, not reserved; it cannot be claimed again (single-use)` };
2043
+ }
2044
+ const claimable = confirmedBatchClaims.get(confirmedBatch);
2045
+ if (!claimable.has(cardId)) {
2046
+ return { ok: false, code: "confirmed-batch-entry-consumed", reason: "the confirmed batch entry was already claimed; confirmation is single-use" };
2047
+ }
2048
+ const result = claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch });
2049
+ if (result.ok) claimable.delete(cardId); // consume the single-use confirmation
2050
+ return result;
2051
+ }
2052
+
2053
+ function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null, route = null }) {
1750
2054
  if (typeof boardPath !== "string" || boardPath === "") {
1751
2055
  throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1752
2056
  }
@@ -1760,7 +2064,7 @@ export function claimCard({ boardPath, cardId = null, role, policy = null, confi
1760
2064
  for (let attempt = 0; ; attempt += 1) {
1761
2065
  try {
1762
2066
  return withWriterLock(boardPath, () =>
1763
- claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }));
2067
+ claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch, route }));
1764
2068
  } catch (error) {
1765
2069
  if (error?.code === "writer-lock-held" && attempt < 20) {
1766
2070
  sleepSync(25);
@@ -1774,7 +2078,27 @@ export function claimCard({ boardPath, cardId = null, role, policy = null, confi
1774
2078
  }
1775
2079
  }
1776
2080
 
1777
- function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }) {
2081
+ // Route fields for confirmed-batch claims: the batch entry carries the
2082
+ // route decision's identity (seatId/provider/model/digest/reservationId)
2083
+ // minted by the scheduler from the route decision — never recomputed here.
2084
+ function confirmedBatchRouteFor(cardId, confirmedBatch) {
2085
+ const entry = (confirmedBatch?.entries ?? []).find((e) => e.cardId === cardId);
2086
+ if (!entry) return null;
2087
+ return {
2088
+ seatId: entry.seatId,
2089
+ accountId: entry.accountId,
2090
+ provider: entry.provider,
2091
+ model: entry.model,
2092
+ role: entry.role,
2093
+ effort: entry.effort,
2094
+ containmentTier: entry.containmentTier,
2095
+ phase: entry.phase,
2096
+ routeDecisionDigest: entry.routeDecisionDigest,
2097
+ reservationId: entry.reservationId,
2098
+ };
2099
+ }
2100
+
2101
+ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch = null, route = null }) {
1778
2102
  const at = now ?? new Date().toISOString();
1779
2103
  if (!existsSync(boardPath)) {
1780
2104
  return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
@@ -1813,9 +2137,17 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
1813
2137
  if (!policyCheck.policy.roles.includes(role)) {
1814
2138
  return { ok: false, code: "policy-role-refused", reason: `role "${role}" is not declared in the automation policy (fails closed)` };
1815
2139
  }
1816
- // §3.4 mode table: automated placement is container/microVM, always.
1817
- if (policyCheck.policy.placement !== "container") {
1818
- return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
2140
+ // §3.4 mode table: ordinary claims are contained-only (container/microVM),
2141
+ // always. Host placement is claimable ONLY through the trusted Pulse-internal
2142
+ // confirmed-batch path (claimCardForConfirmedBatch), which requires a batch
2143
+ // context registered after one native confirmation. Static pulse.mode=
2144
+ // "interactive" plus host placement is never enough on its own; automated
2145
+ // dispatch may never use host placement.
2146
+ if (confirmedBatch === null && policyCheck.policy.placement !== "container") {
2147
+ return { ok: false, code: "policy-placement-refused", reason: "ordinary board dispatch requires container placement per the automation policy (§3.4); host placement is claimable only through the Pulse confirmed-batch path" };
2148
+ }
2149
+ if (confirmedBatch !== null && policyCheck.policy.placement !== "host") {
2150
+ return { ok: false, code: "policy-placement-refused", reason: "the Pulse confirmed-batch claim path requires host placement (fails closed)" };
1819
2151
  }
1820
2152
  // F4: the starting revision comes from the card's repository — the
1821
2153
  // workspace the board lives in, not process.cwd(). A caller-supplied
@@ -1856,13 +2188,16 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
1856
2188
  : "no dispatchable unclaimed card is available" };
1857
2189
  }
1858
2190
  const card = selected.card;
1859
- const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision });
2191
+ const selectedRoute = confirmedBatch !== null ? confirmedBatchRouteFor(card.cardId, confirmedBatch) : route;
2192
+ const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision, route: selectedRoute });
1860
2193
  const claim = {
1861
2194
  cardId: card.cardId,
1862
2195
  claimedAt: at,
1863
2196
  role,
1864
2197
  envelopeId: envelope.envelopeId,
1865
2198
  envelope,
2199
+ parentClaimId: null,
2200
+ attemptIndex: 0,
1866
2201
  };
1867
2202
  const nextClaims = [...activeClaims, claim];
1868
2203
  // F3: the transaction record goes into the claims file BEFORE the
@@ -2186,6 +2521,220 @@ export function reclaimClaim({ boardPath, cardId = null, envelopeId = null } = {
2186
2521
  });
2187
2522
  }
2188
2523
 
2524
+ // §10.4 dispatcher-owned recoverable replaceAttempt: one transition under the
2525
+ // writer lock that replaces a failed/unresponsive attempt with a new attempt
2526
+ // (new envelope, new claim) for the same card. Trusted input: the expected
2527
+ // active card ID, old envelope ID, terminal reason, and replacement route
2528
+ // (role/repository/starting revision). Not a public model action.
2529
+ //
2530
+ // Transaction shape: a prepared transaction is durably staged (HMAC-covered
2531
+ // via the claims file's own authentication) containing the old terminal
2532
+ // evidence and the pre-recorded new attempt identity. Only then is the old
2533
+ // envelope consumed and the new claim published; the transaction record
2534
+ // clears once the projection is republished. Recovery is idempotent and runs
2535
+ // first: if only the old consumption landed, the replacement rolls forward
2536
+ // only while all bound identities still match; otherwise it returns
2537
+ // REVIEW_REQUIRED. Recovery never creates another envelope.
2538
+ const REPLACE_TRANSACTION_OPS = Object.freeze(["replace-attempt"]);
2539
+ const REPLACE_TRANSACTION_PHASES = Object.freeze(["prepared", "committed"]);
2540
+
2541
+ export function replaceAttempt({ boardPath, cardId, envelopeId, reason, replacement = {}, now = null } = {}) {
2542
+ if (typeof boardPath !== "string" || boardPath === "") {
2543
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
2544
+ }
2545
+ if (typeof cardId !== "string" || !CARD_ID_RE.test(cardId)) {
2546
+ throw Object.assign(new Error("cardId must be a well-formed card ID"), { code: "invalid-input" });
2547
+ }
2548
+ if (typeof envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(envelopeId)) {
2549
+ throw Object.assign(new Error("envelopeId must be a 32-hex envelope ID"), { code: "invalid-input" });
2550
+ }
2551
+ if (!CONSUMED_REASONS.includes(reason)) {
2552
+ throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
2553
+ }
2554
+ const at = now ?? new Date().toISOString();
2555
+ return withWriterLock(boardPath, () => replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }));
2556
+ }
2557
+
2558
+ function replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replacement, at }) {
2559
+ // Recovery first (idempotent): roll any interrupted prior transaction
2560
+ // forward before observing state, never creating another envelope.
2561
+ const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
2562
+ if (!anchorRecovery.ok) {
2563
+ return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
2564
+ }
2565
+ const read = readClaimsState(boardPath);
2566
+ if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
2567
+ const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
2568
+ if (reconciliation?.failed) {
2569
+ return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
2570
+ }
2571
+
2572
+ // The old attempt must be the exact active authenticated claim for this
2573
+ // card. Drift, reuse, or a consumed old envelope fails closed.
2574
+ const oldClaim = read.state.claims.find((claim) => claim.envelopeId === envelopeId);
2575
+ if (!oldClaim) {
2576
+ return { ok: false, code: "envelope-not-active",
2577
+ reason: read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId)
2578
+ ? "the old envelope attempt was already consumed — a replacement requires a fresh dispatch (single-attempt lifecycle)"
2579
+ : `no active claim for envelope ${envelopeId} (fails closed)` };
2580
+ }
2581
+ if (oldClaim.cardId !== cardId) {
2582
+ return { ok: false, code: "card-drift", reason: `envelope ${envelopeId} is bound to card ${oldClaim.cardId}, not ${cardId} (fails closed)` };
2583
+ }
2584
+
2585
+ // Re-evaluate current observations under the lock: card identity/hash,
2586
+ // policy validity, and capacity for the replacement route.
2587
+ if (!existsSync(boardPath)) {
2588
+ return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
2589
+ }
2590
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
2591
+ if (!validatedBoard.ok) return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
2592
+ const card = validatedBoard.cards.find((entry) => entry.cardId === cardId);
2593
+ if (!card) return { ok: false, code: "card-not-found", reason: `card ${cardId} no longer exists on the board (drift — REVIEW_REQUIRED)` };
2594
+ const currentHash = card.hash ?? computeCardHash(card);
2595
+ if (currentHash !== oldClaim.envelope.cardHash) {
2596
+ return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the old envelope binding — semantic edits require human review (REVIEW_REQUIRED)" };
2597
+ }
2598
+
2599
+ const resolvedPolicy = readAutomationPolicy(boardPath);
2600
+ const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
2601
+ if (!policyCheck.ok) {
2602
+ return { ok: false, code: "policy-refused", reason: policyCheck.reason };
2603
+ }
2604
+ const replacementRole = typeof replacement.role === "string" && replacement.role !== "" ? replacement.role : oldClaim.role;
2605
+ if (!policyCheck.policy.roles.includes(replacementRole)) {
2606
+ return { ok: false, code: "policy-role-refused", reason: `replacement role "${replacementRole}" is not declared in the automation policy (fails closed)` };
2607
+ }
2608
+ if (policyCheck.policy.placement !== "container") {
2609
+ return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
2610
+ }
2611
+ const replacementRepository = typeof replacement.repository === "string" && replacement.repository !== ""
2612
+ ? replacement.repository
2613
+ : dirname(boardPath);
2614
+ if (!repositoryAccepted(policyCheck.policy, replacementRepository)) {
2615
+ return { ok: false, code: "policy-repository-refused", reason: `repository "${replacementRepository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
2616
+ }
2617
+ const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: read.state });
2618
+ const concurrency = Number(policyCheck.policy.maxConcurrent);
2619
+ // Capacity: the old claim is being replaced, so it frees exactly one slot.
2620
+ if (activeClaims.filter((claim) => claim.envelopeId !== envelopeId).length >= concurrency) {
2621
+ return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); no free slot for the replacement` };
2622
+ }
2623
+
2624
+ // Pre-record the replacement identity, then durably stage the prepared
2625
+ // transaction BEFORE consuming the old envelope. The staged claims state
2626
+ // carries the complete signed next value, so either crash window rolls
2627
+ // forward deterministically (the same prepare→claims→commit scheme as
2628
+ // claimCard, extended with the replace op and phase).
2629
+ const oldRoute = {
2630
+ seatId: oldClaim.envelope.seatId, accountId: oldClaim.envelope.accountId,
2631
+ provider: oldClaim.envelope.provider, model: oldClaim.envelope.model, role: replacementRole,
2632
+ effort: oldClaim.envelope.effort, containmentTier: oldClaim.envelope.containmentTier,
2633
+ phase: oldClaim.envelope.phase, routeDecisionDigest: oldClaim.envelope.routeDecisionDigest,
2634
+ reservationId: oldClaim.envelope.reservationId,
2635
+ };
2636
+ const requestedRoute = replacement.route ?? oldRoute;
2637
+ if (requestedRoute.seatId === oldRoute.seatId
2638
+ && canonicalJsonString(requestedRoute) !== canonicalJsonString(oldRoute)) {
2639
+ return { ok: false, code: "replacement-route-invalid", reason: "same-seat replacement is pinned to every field of the prior authenticated envelope (fails closed)" };
2640
+ }
2641
+ if (requestedRoute.seatId !== oldRoute.seatId) {
2642
+ // R1: different-seat replacement route fidelity. The caller-supplied
2643
+ // requestedRoute is NEVER the route authority. The seat is resolved from
2644
+ // the trusted router config for decision.selectedSeatId; the route fields
2645
+ // must then equal that seat's fields (effort follows the card), and the
2646
+ // decision's parentDecisionId must equal the prior decision id retrieved
2647
+ // from the store — never a merely non-null value.
2648
+ const decision = replacement.routeDecision;
2649
+ const routerConfig = replacement.routerConfig ?? null;
2650
+ const seat = decision !== null && typeof decision === "object" && Array.isArray(routerConfig?.seats)
2651
+ ? routerConfig.seats.find((candidate) => candidate?.seatId === decision.selectedSeatId) ?? null
2652
+ : null;
2653
+ const digest = decision !== null && typeof decision === "object" ? sha256Hex(canonicalJsonString(decision)) : null;
2654
+ // R2: deadness is computed from supplied observations (reusing the pure
2655
+ // §7.2 rule), never accepted as a caller-supplied boolean.
2656
+ const dead = isDeadRoute({ seat, healthObservations: replacement.healthObservations ?? {},
2657
+ quotaObservations: replacement.quotaObservations ?? [], now: at });
2658
+ const priorDecisionId = replacement.routerStore !== null && replacement.routerStore !== undefined
2659
+ ? getRouteDecisionIdByDigest(replacement.routerStore, oldClaim.envelope.routeDecisionDigest)
2660
+ : typeof replacement.priorDecisionId === "string" && replacement.priorDecisionId !== ""
2661
+ ? replacement.priorDecisionId
2662
+ : null;
2663
+ const cardEffort = EFFORT_LEVELS.includes(card?.effort) ? card.effort : "default";
2664
+ const seatRoute = seat === null ? null : {
2665
+ seatId: seat.seatId, accountId: seat.accountId ?? null, provider: seat.provider,
2666
+ model: seat.model, containmentTier: seat.containmentTier, effort: cardEffort,
2667
+ };
2668
+ const routeMatchesSeat = seatRoute !== null
2669
+ && requestedRoute.seatId === seatRoute.seatId
2670
+ && requestedRoute.accountId === seatRoute.accountId
2671
+ && requestedRoute.provider === seatRoute.provider
2672
+ && requestedRoute.model === seatRoute.model
2673
+ && requestedRoute.containmentTier === seatRoute.containmentTier
2674
+ && requestedRoute.effort === seatRoute.effort
2675
+ && requestedRoute.role === replacementRole;
2676
+ if (!decision || decision.schema !== "agentic-driver.route-decision.v1"
2677
+ || seat === null || !routeMatchesSeat
2678
+ || typeof priorDecisionId !== "string" || priorDecisionId === "" || decision.parentDecisionId !== priorDecisionId
2679
+ || digest !== requestedRoute.routeDecisionDigest
2680
+ || dead.dead
2681
+ || (requestedRoute.reservationId !== null && replacement.reservationCreated !== true)) {
2682
+ return { ok: false, code: "replacement-route-invalid", reason: "different-seat replacement requires the config seat for the decision's selectedSeatId, an exact seat-derived route, the prior store decision id as parentDecisionId, a live (non-dead) route, a matching digest, and a reservation (fails closed)" };
2683
+ }
2684
+ }
2685
+ const newEnvelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: replacementRepository, startingRevision: replacement.startingRevision ?? undefined, route: requestedRoute });
2686
+ const newClaim = {
2687
+ cardId,
2688
+ claimedAt: at,
2689
+ role: replacementRole,
2690
+ envelopeId: newEnvelope.envelopeId,
2691
+ envelope: newEnvelope,
2692
+ parentClaimId: envelopeId,
2693
+ attemptIndex: (oldClaim.attemptIndex ?? 0) + 1,
2694
+ };
2695
+ const keptClaims = activeClaims.filter((claim) => claim.envelopeId !== envelopeId);
2696
+ const nextClaims = [...keptClaims, newClaim];
2697
+ const consumedClaims = [...read.state.consumedClaims, {
2698
+ cardId, envelopeId, consumedAt: at, reason,
2699
+ }];
2700
+ const transaction = {
2701
+ op: "replace-attempt",
2702
+ cardId,
2703
+ envelopeId: newEnvelope.envelopeId,
2704
+ at,
2705
+ phase: "claims-written",
2706
+ oldEnvelopeId: envelopeId,
2707
+ oldReason: reason,
2708
+ };
2709
+ writeClaims(boardPath, nextClaims, { consumedClaims, transaction });
2710
+ const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
2711
+ if (!projection.written) {
2712
+ return {
2713
+ ok: false,
2714
+ replaced: true,
2715
+ code: "replace-recoverable",
2716
+ recoverable: true,
2717
+ reason: `projection publication failed: ${projection.error ?? "unknown"} (replacement committed; transaction retained for roll-forward)`,
2718
+ cardId,
2719
+ oldEnvelopeId: envelopeId,
2720
+ claim: newClaim,
2721
+ envelope: newEnvelope,
2722
+ projection,
2723
+ };
2724
+ }
2725
+ finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
2726
+ return {
2727
+ ok: true,
2728
+ replaced: true,
2729
+ cardId,
2730
+ oldEnvelopeId: envelopeId,
2731
+ oldReason: reason,
2732
+ claim: newClaim,
2733
+ envelope: newEnvelope,
2734
+ projection,
2735
+ };
2736
+ }
2737
+
2189
2738
  // ---------------------------------------------------------------------------
2190
2739
  // Provider observation (§5 reversibility): everything registers behind the
2191
2740
  // observation that a board file exists. No board file, no behavior change and