@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.
- package/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +94 -29
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +9 -2
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +5 -4
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +105 -0
- package/extensions/task-board.ts +1 -1
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +17 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +66 -4
- package/scripts/enforcement/herdr_communication_pi.js +55 -19
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +292 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +604 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +597 -48
- 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-
|
|
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.
|
|
1292
|
-
export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.
|
|
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,
|
|
1328
|
-
// transaction record — every authority-bearing
|
|
1329
|
-
|
|
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
|
-
|
|
1360
|
-
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env,
|
|
1361
|
-
if (env.schema !==
|
|
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
|
-
|
|
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
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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)
|
|
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"
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
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:
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
|
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
|