@evoclock/pi-agentic-driver 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +116 -32
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +1 -1
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +1 -1
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +33 -2
  11. package/extensions/task-board.ts +1 -1
  12. package/janus/README.draft.md +210 -0
  13. package/janus/README.md +196 -0
  14. package/janus/janus/audit.ts +213 -0
  15. package/janus/janus/budget.ts +101 -0
  16. package/janus/janus/budget_breaker.ts +105 -0
  17. package/janus/janus/config.ts +332 -0
  18. package/janus/janus/errors.ts +90 -0
  19. package/janus/janus/evaluate.ts +230 -0
  20. package/janus/janus/keychain.ts +119 -0
  21. package/janus/janus/providers.ts +544 -0
  22. package/janus/janus/schema.ts +344 -0
  23. package/janus/janus/server.ts +267 -0
  24. package/janus/janus/single_flight.ts +99 -0
  25. package/janus/janus/verdict.ts +192 -0
  26. package/janus/package-lock.json +616 -0
  27. package/janus/package.json +29 -0
  28. package/janus/tests/accounting.test.ts +229 -0
  29. package/janus/tests/allowlist.test.ts +97 -0
  30. package/janus/tests/config.test.ts +151 -0
  31. package/janus/tests/errors.test.ts +147 -0
  32. package/janus/tests/evaluate_pipeline.test.ts +358 -0
  33. package/janus/tests/helpers.ts +217 -0
  34. package/janus/tests/providers.test.ts +292 -0
  35. package/janus/tests/redaction_budget.test.ts +227 -0
  36. package/janus/tests/schema.test.ts +356 -0
  37. package/janus/tests/server.test.ts +288 -0
  38. package/janus/tests/single_flight.test.ts +84 -0
  39. package/janus/tsconfig.json +19 -0
  40. package/janus/user.janus.plist +51 -0
  41. package/lib/adapters/diff-scope.mjs +1 -1
  42. package/lib/adapters/evidence.mjs +1 -1
  43. package/lib/adapters/narrative.mjs +1 -1
  44. package/lib/adapters/review-feedback.mjs +1 -1
  45. package/lib/adapters/visualization.mjs +1 -1
  46. package/lib/code-phage-core.mjs +1 -1
  47. package/lib/python_ast_metrics.py +1 -1
  48. package/lib/typescript_ast_metrics.mjs +1 -1
  49. package/package.json +14 -3
  50. package/scripts/aidr_writing_review.js +1 -1
  51. package/scripts/enforcement/attended_authority_guard.js +1 -1
  52. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  53. package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
  54. package/scripts/enforcement/herdr_communication_pi.js +13 -8
  55. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  56. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  57. package/scripts/enforcement/native_tui_context.js +1 -1
  58. package/scripts/enforcement/pulse_core_pi.js +38 -14
  59. package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
  60. package/scripts/enforcement/router_engine_pi.js +483 -0
  61. package/scripts/enforcement/router_ranking_pi.js +146 -0
  62. package/scripts/enforcement/router_replacement_pi.js +98 -0
  63. package/scripts/enforcement/router_runtime_pi.js +299 -0
  64. package/scripts/enforcement/router_schemas_pi.js +654 -0
  65. package/scripts/enforcement/router_store_pi.js +438 -0
  66. package/scripts/enforcement/task_board_core_pi.js +255 -44
  67. package/skills/pulse/SKILL.md +1 -1
@@ -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,7 +1410,33 @@ 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",
@@ -1390,17 +1444,27 @@ const CONSUMED_REASONS = Object.freeze([
1390
1444
  ]);
1391
1445
  const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
1392
1446
  const REPLACE_TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase", "oldEnvelopeId", "oldReason"]);
1393
- const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
1394
-
1395
- function wellFormedClaim(claim) {
1396
- return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CLAIM_FIELDS)
1397
- && CARD_ID_RE.test(claim.cardId)
1398
- && ISO_TS_RE.test(claim.claimedAt)
1399
- && ROLE_NAME_RE.test(claim.role)
1400
- && /^[0-9a-f]{32}$/.test(claim.envelopeId)
1401
- && wellFormedEnvelope(claim.envelope)
1402
- && claim.envelope.envelopeId === claim.envelopeId
1403
- && claim.envelope.cardId === claim.cardId;
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;
1404
1468
  }
1405
1469
 
1406
1470
  function wellFormedConsumedClaim(claim) {
@@ -1450,16 +1514,33 @@ export function readClaimsState(boardPath) {
1450
1514
  if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
1451
1515
  return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
1452
1516
  }
1453
- 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: {} } };
1454
1518
  }
1455
1519
  const value = readJsonFile(path);
1456
- if (value === null || typeof value !== "object" || Array.isArray(value) || !exactKeys(value, CLAIMS_STATE_FIELDS)) {
1520
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1457
1521
  return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1458
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)) {
1535
+ return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1536
+ }
1537
+ if (!isPlainEvidenceMap(evidence)) {
1538
+ return { ok: false, reason: "the claims evidence map is malformed (fails closed)" };
1539
+ }
1459
1540
  if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
1460
1541
  || !Number.isInteger(value.generation) || value.generation < 0
1461
1542
  || !wellFormedTransaction(value.transaction)
1462
- || !value.claims.every(wellFormedClaim)
1543
+ || !value.claims.every((claim) => wellFormedClaim(claim, { legacy }))
1463
1544
  || !value.consumedClaims.every(wellFormedConsumedClaim)) {
1464
1545
  return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
1465
1546
  }
@@ -1472,7 +1553,8 @@ export function readClaimsState(boardPath) {
1472
1553
  if (writerState.secret === null) {
1473
1554
  return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
1474
1555
  }
1475
- 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 })) {
1476
1558
  return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
1477
1559
  }
1478
1560
  // Deletion/rollback guard: the generation must be exactly the anchored
@@ -1490,6 +1572,7 @@ export function readClaimsState(boardPath) {
1490
1572
  transaction: value.transaction,
1491
1573
  claims: value.claims,
1492
1574
  consumedClaims: value.consumedClaims,
1575
+ evidence,
1493
1576
  },
1494
1577
  };
1495
1578
  }
@@ -1510,6 +1593,24 @@ function claimsAnchorDigest(value, secret) {
1510
1593
  .digest("hex");
1511
1594
  }
1512
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
+
1513
1614
  // Recover the two-file claims/anchor commit. The complete authenticated next
1514
1615
  // claims state is staged in writer state first, so either crash window rolls
1515
1616
  // forward deterministically rather than accepting an older file.
@@ -1518,9 +1619,15 @@ function recoverClaimsAnchorLocked(boardPath) {
1518
1619
  const writerState = readWriterState(statePath);
1519
1620
  const pending = writerState.pendingClaimsState;
1520
1621
  if (pending === null) return { ok: true, recovered: false };
1521
- if (writerState.secret === null || !pending || typeof pending !== "object"
1522
- || !exactKeys(pending, CLAIMS_STATE_FIELDS)
1523
- || 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 })) {
1524
1631
  return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
1525
1632
  }
1526
1633
  writeJsonFileAtomic(claimsPath(boardPath), pending);
@@ -1544,12 +1651,18 @@ function writeClaims(boardPath, claims, { consumedClaims = null, transaction = n
1544
1651
  writerState = readWriterState(statePath);
1545
1652
  const previous = readClaimsState(boardPath);
1546
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);
1547
1659
  const value = {
1548
1660
  schema: CLAIMS_SCHEMA,
1549
1661
  generation: previous.state.generation + 1,
1550
1662
  transaction,
1551
- claims,
1663
+ claims: upgradedClaims,
1552
1664
  consumedClaims: consumedClaims ?? previous.state.consumedClaims,
1665
+ evidence: previous.state.evidence ?? {},
1553
1666
  };
1554
1667
  value.hmac = claimsFileHmac(value, writerState.secret);
1555
1668
  // Prepare → claims → commit. Because prepare contains the complete signed
@@ -1758,7 +1871,7 @@ export function repositoryAccepted(policy, repository) {
1758
1871
  // in), the card's base revision is bound in when present (branch chaining),
1759
1872
  // and the risk classification comes from the policy (per-card override only
1760
1873
  // when the policy allows).
1761
- 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 }) {
1762
1875
  const at = now ?? new Date().toISOString();
1763
1876
  const envelopeId = randomBytes(16).toString("hex");
1764
1877
  const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
@@ -1775,6 +1888,22 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
1775
1888
  // envelope's starting revision IS the base (branch chaining), not merely a
1776
1889
  // copied field; execution validation enforces HEAD === startingRevision.
1777
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
+ };
1778
1907
  return deepFreeze({
1779
1908
  schema: ENVELOPE_SCHEMA,
1780
1909
  envelopeId,
@@ -1796,6 +1925,7 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
1796
1925
  mode: "automated",
1797
1926
  createdAt: at,
1798
1927
  expiry,
1928
+ ...routeFields,
1799
1929
  });
1800
1930
  }
1801
1931
 
@@ -1865,8 +1995,8 @@ export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId
1865
1995
  // the new complete state (both writes are atomic renames); two concurrent
1866
1996
  // claims can never both win because the entire read-decide-write sequence
1867
1997
  // holds the lock. Expired claims are released first, inside the same lock.
1868
- export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision }) {
1869
- return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: 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 });
1870
2000
  }
1871
2001
 
1872
2002
  // ---------------------------------------------------------------------------
@@ -1920,7 +2050,7 @@ export function claimCardForConfirmedBatch({ boardPath, cardId = null, role, pol
1920
2050
  return result;
1921
2051
  }
1922
2052
 
1923
- function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
2053
+ function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null, route = null }) {
1924
2054
  if (typeof boardPath !== "string" || boardPath === "") {
1925
2055
  throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1926
2056
  }
@@ -1934,7 +2064,7 @@ function claimCardInternal({ boardPath, cardId = null, role, policy = null, conf
1934
2064
  for (let attempt = 0; ; attempt += 1) {
1935
2065
  try {
1936
2066
  return withWriterLock(boardPath, () =>
1937
- claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch }));
2067
+ claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch, route }));
1938
2068
  } catch (error) {
1939
2069
  if (error?.code === "writer-lock-held" && attempt < 20) {
1940
2070
  sleepSync(25);
@@ -1948,7 +2078,27 @@ function claimCardInternal({ boardPath, cardId = null, role, policy = null, conf
1948
2078
  }
1949
2079
  }
1950
2080
 
1951
- function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch = null }) {
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 }) {
1952
2102
  const at = now ?? new Date().toISOString();
1953
2103
  if (!existsSync(boardPath)) {
1954
2104
  return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
@@ -2038,13 +2188,16 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
2038
2188
  : "no dispatchable unclaimed card is available" };
2039
2189
  }
2040
2190
  const card = selected.card;
2041
- 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 });
2042
2193
  const claim = {
2043
2194
  cardId: card.cardId,
2044
2195
  claimedAt: at,
2045
2196
  role,
2046
2197
  envelopeId: envelope.envelopeId,
2047
2198
  envelope,
2199
+ parentClaimId: null,
2200
+ attemptIndex: 0,
2048
2201
  };
2049
2202
  const nextClaims = [...activeClaims, claim];
2050
2203
  // F3: the transaction record goes into the claims file BEFORE the
@@ -2473,13 +2626,71 @@ function replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replaceme
2473
2626
  // carries the complete signed next value, so either crash window rolls
2474
2627
  // forward deterministically (the same prepare→claims→commit scheme as
2475
2628
  // claimCard, extended with the replace op and phase).
2476
- const newEnvelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: replacementRepository, startingRevision: replacement.startingRevision ?? undefined });
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 });
2477
2686
  const newClaim = {
2478
2687
  cardId,
2479
2688
  claimedAt: at,
2480
2689
  role: replacementRole,
2481
2690
  envelopeId: newEnvelope.envelopeId,
2482
2691
  envelope: newEnvelope,
2692
+ parentClaimId: envelopeId,
2693
+ attemptIndex: (oldClaim.attemptIndex ?? 0) + 1,
2483
2694
  };
2484
2695
  const keptClaims = activeClaims.filter((claim) => claim.envelopeId !== envelopeId);
2485
2696
  const nextClaims = [...keptClaims, newClaim];
@@ -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
  # Pulse
5
5