@kontourai/flow-agents 3.2.0 → 3.3.0
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/.github/workflows/ci.yml +12 -0
- package/CHANGELOG.md +13 -0
- package/build/src/cli/assignment-provider.js +10 -1
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +224 -0
- package/build/src/cli/workflow-sidecar.js +775 -4
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/scripts/hooks/workflow-steering.js +73 -1
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/index.md +2 -2
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/fixture-ownership.md +3 -2
- package/docs/index.md +4 -0
- package/docs/integrations/flow-agents-console.md +108 -0
- package/docs/integrations/index.md +4 -0
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +6 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/integration/test_checkpoint_signing.sh +10 -2
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_publish_delivery.sh +59 -2
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +91 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/run.sh +8 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +73 -0
- package/kits/builder/skills/pull-work/SKILL.md +12 -2
- package/kits/veritas-governance/docs/README.md +81 -3
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +5 -0
- package/package.json +1 -1
- package/scripts/ci/trust-reconcile.js +78 -253
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +12 -1
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +866 -4
- package/src/tools/validate-source-tree.ts +3 -2
|
@@ -230,9 +230,17 @@ function resolveEnsureSessionActor(p) {
|
|
|
230
230
|
// the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
|
|
231
231
|
return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
|
|
232
232
|
}
|
|
233
|
+
// #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
|
|
234
|
+
// `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
|
|
235
|
+
// an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
|
|
236
|
+
// actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
|
|
237
|
+
// bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
|
|
238
|
+
const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
|
|
233
239
|
const actorStruct = resolved.source === "explicit-override"
|
|
234
240
|
? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
|
|
235
|
-
:
|
|
241
|
+
: ciActor && ciActor.session_id
|
|
242
|
+
? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
|
|
243
|
+
: { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
|
|
236
244
|
const actorKey = helper.serializeActor(actorStruct);
|
|
237
245
|
return { actorStruct, actorKey, branchActorKey, unresolved: false };
|
|
238
246
|
}
|
|
@@ -1418,6 +1426,11 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1418
1426
|
// assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
|
|
1419
1427
|
// into a die() message, so it has no free-text field requiring the 240 tier today.
|
|
1420
1428
|
const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
|
|
1429
|
+
// #294: the 240-char free-text tier (repo convention: 64 for id-like, 240 for free text — cf.
|
|
1430
|
+
// assignment-provider.ts sanitizeDisplayField(record.branch/reason, 240)). Used for the takeover
|
|
1431
|
+
// `resumed_branch` (a realistic agent/<actor>/<slug> branch exceeds 64 — truncation would produce a
|
|
1432
|
+
// bad `git checkout` target) and the audit `reason` (else "resuming from trust bundle" is cut off).
|
|
1433
|
+
const sanitizeWide = (value) => stripControlCharsForDisplay(value).slice(0, 240);
|
|
1421
1434
|
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
|
|
1422
1435
|
const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
|
|
1423
1436
|
let effective;
|
|
@@ -1496,15 +1509,32 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
|
|
|
1496
1509
|
const fromActor = assignment.record?.actor;
|
|
1497
1510
|
if (!fromActor)
|
|
1498
1511
|
die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
|
|
1512
|
+
// #294 (ADR 0021 §5): takeover is resumption — the successor CONTINUES the incumbent's branch,
|
|
1513
|
+
// never a parallel one. Capture the incumbent's branch (from its pre-supersede record) and
|
|
1514
|
+
// surface it as `resumed_branch` so the skill can `git checkout` it; default the audit reason to
|
|
1515
|
+
// the ADR §5 wording (superseded actor X, last seen T, resuming from trust bundle).
|
|
1516
|
+
const incumbentBranch = assignment.record?.branch;
|
|
1517
|
+
const incumbentLastSeen = effective.holder?.last_at ?? assignment.record?.claimed_at;
|
|
1518
|
+
const takeoverReason = opt(p, "reason") || `takeover: superseded ${holderActor}${incumbentLastSeen ? `, last seen ${sanitize(incumbentLastSeen)}` : ""}, resuming from trust bundle`;
|
|
1499
1519
|
performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
|
|
1500
|
-
|
|
1520
|
+
// #294 (ADR 0021 §5): takeover is RESUMPTION — the successor continues the incumbent's branch,
|
|
1521
|
+
// so the record must keep pointing at that branch, NOT be overwritten with the successor's
|
|
1522
|
+
// current branch (`resolveBranchForClaim()`). At supersede time the successor has not yet
|
|
1523
|
+
// `git checkout`ed the resume branch (the skill claims ownership first, then checks out), so
|
|
1524
|
+
// resolveBranchForClaim() would otherwise clobber the record with a parallel branch and make a
|
|
1525
|
+
// later reader (resume surface / verify-hold guidance) see the wrong branch. Preserve the
|
|
1526
|
+
// incumbent's branch; fall back to the successor's only if the incumbent record had none.
|
|
1527
|
+
branch: incumbentBranch || resolveBranchForClaim(),
|
|
1501
1528
|
artifactDir: path.relative(root, dir) || ".",
|
|
1502
|
-
reason:
|
|
1529
|
+
reason: takeoverReason,
|
|
1503
1530
|
// F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
|
|
1504
1531
|
// computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
|
|
1505
1532
|
// branchActorKey string on the next status/guard check, cross-tool.
|
|
1506
1533
|
actorKey: resolution.branchActorKey,
|
|
1507
1534
|
});
|
|
1535
|
+
// Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
|
|
1536
|
+
// branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
|
|
1537
|
+
printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
|
|
1508
1538
|
return;
|
|
1509
1539
|
}
|
|
1510
1540
|
case "free": {
|
|
@@ -1930,6 +1960,14 @@ function checksFromBundle(dir) {
|
|
|
1930
1960
|
const seen = new Set();
|
|
1931
1961
|
const checks = [];
|
|
1932
1962
|
const kindOf = (claim) => String(claim.metadata.check_kind);
|
|
1963
|
+
// Read side of the buildTrustBundle waiver round-trip (write side: buildTrustBundle reads
|
|
1964
|
+
// check._waiver at line ~689 and stamps it onto claimMetadata.waiver at line ~705). Without
|
|
1965
|
+
// this, any caller that rebuilds checks via checksFromBundle() (recordCritique/recordLearning)
|
|
1966
|
+
// silently drops a previously-recorded waiver on the next bundle write.
|
|
1967
|
+
const waiverOf = (claim) => {
|
|
1968
|
+
const md = claim.metadata;
|
|
1969
|
+
return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver : undefined;
|
|
1970
|
+
};
|
|
1933
1971
|
for (const ev of bundle.evidence) {
|
|
1934
1972
|
if (!ev || !ev.claimId)
|
|
1935
1973
|
continue;
|
|
@@ -1948,6 +1986,9 @@ function checksFromBundle(dir) {
|
|
|
1948
1986
|
check.command = ev.execution.label;
|
|
1949
1987
|
if (ev.evidenceType)
|
|
1950
1988
|
check.evidenceType = ev.evidenceType;
|
|
1989
|
+
const waiver = waiverOf(claim);
|
|
1990
|
+
if (waiver)
|
|
1991
|
+
check._waiver = waiver;
|
|
1951
1992
|
checks.push(check);
|
|
1952
1993
|
}
|
|
1953
1994
|
// Also include check claims that have no evidence item (surface_trust_refs style).
|
|
@@ -1960,7 +2001,11 @@ function checksFromBundle(dir) {
|
|
|
1960
2001
|
continue;
|
|
1961
2002
|
seen.add(claim.id);
|
|
1962
2003
|
const kind = kindOf(claim);
|
|
1963
|
-
|
|
2004
|
+
const check = { id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" };
|
|
2005
|
+
const waiver = waiverOf(claim);
|
|
2006
|
+
if (waiver)
|
|
2007
|
+
check._waiver = waiver;
|
|
2008
|
+
checks.push(check);
|
|
1964
2009
|
}
|
|
1965
2010
|
return checks;
|
|
1966
2011
|
}
|
|
@@ -2224,7 +2269,15 @@ async function promote(p) {
|
|
|
2224
2269
|
// Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
|
|
2225
2270
|
if (p.flags.has("publish")) {
|
|
2226
2271
|
const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
|
|
2272
|
+
// #356 AC6: an InvalidBundleShapeError refusal must be LOUD (rethrown, so `promote`
|
|
2273
|
+
// itself fails/exits non-zero) — it is NOT one of the best-effort failure modes (missing
|
|
2274
|
+
// kits/ ancestor, I/O) this catch otherwise tolerates. A `.catch(() => {})`-style swallow
|
|
2275
|
+
// here would silently defeat the whole preflight for the --publish path.
|
|
2227
2276
|
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
2277
|
+
if (err instanceof InvalidBundleShapeError)
|
|
2278
|
+
throw err;
|
|
2279
|
+
if (err instanceof NotFreshHolderError)
|
|
2280
|
+
throw err;
|
|
2228
2281
|
process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2229
2282
|
});
|
|
2230
2283
|
}
|
|
@@ -2296,8 +2349,16 @@ async function advanceState(p) {
|
|
|
2296
2349
|
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
|
|
2297
2350
|
// with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
|
|
2298
2351
|
// are visible (stderr warning), not silently swallowed.
|
|
2352
|
+
// #356 AC6: an InvalidBundleShapeError refusal is NOT one of those best-effort failure
|
|
2353
|
+
// modes — it must be LOUD and cause advance-state itself to fail (rethrown here so the
|
|
2354
|
+
// outer command surfaces a non-zero exit), never silently swallowed alongside a genuine
|
|
2355
|
+
// repo-root-resolution/I-O failure.
|
|
2299
2356
|
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
2300
2357
|
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
2358
|
+
if (err instanceof InvalidBundleShapeError)
|
|
2359
|
+
throw err;
|
|
2360
|
+
if (err instanceof NotFreshHolderError)
|
|
2361
|
+
throw err;
|
|
2301
2362
|
process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2302
2363
|
});
|
|
2303
2364
|
}
|
|
@@ -2393,8 +2454,16 @@ async function recordRelease(p) {
|
|
|
2393
2454
|
// publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
|
|
2394
2455
|
// no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
|
|
2395
2456
|
// visible (stderr warning), not silently swallowed.
|
|
2457
|
+
// #356 AC6: an InvalidBundleShapeError refusal is NOT best-effort — rethrow so record-release
|
|
2458
|
+
// itself fails loudly (non-zero exit) rather than silently publishing nothing while reporting
|
|
2459
|
+
// success. This is the crux of AC6: record-release is one of the auto-publish paths that must
|
|
2460
|
+
// never let a shape-invalid bundle slip past unnoticed.
|
|
2396
2461
|
const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
2397
2462
|
await publishDelivery(dir, publishRepoRoot).catch((err) => {
|
|
2463
|
+
if (err instanceof InvalidBundleShapeError)
|
|
2464
|
+
throw err;
|
|
2465
|
+
if (err instanceof NotFreshHolderError)
|
|
2466
|
+
throw err;
|
|
2398
2467
|
process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2399
2468
|
});
|
|
2400
2469
|
return 0;
|
|
@@ -2596,6 +2665,660 @@ async function sealCheckpoint(p) {
|
|
|
2596
2665
|
}
|
|
2597
2666
|
return 0;
|
|
2598
2667
|
}
|
|
2668
|
+
// ─── Reconcile Preflight (#356) ───────────────────────────────────────────────
|
|
2669
|
+
// Local, pre-push shape-only preflight for a session's trust.bundle, reusing
|
|
2670
|
+
// scripts/lib/reconcile-shape.js (WS8/#356 extraction) and scripts/ci/trust-reconcile.js's
|
|
2671
|
+
// own exported manifest resolver — never a forked reimplementation, so this can never
|
|
2672
|
+
// silently drift from what the CI trust-reconcile job actually enforces. Deliberately
|
|
2673
|
+
// shape-only: it never spawns a fresh manifest/CI command (AC5) — only the already-cheap
|
|
2674
|
+
// `run-baseline.sh --manifest-json` static registry emit, which prints the manifest, not
|
|
2675
|
+
// test results.
|
|
2676
|
+
/**
|
|
2677
|
+
* Delegate to the shared pure-CJS bundle-shape module (scripts/lib/reconcile-shape.js),
|
|
2678
|
+
* mirroring the createRequire pattern used by loadActorIdentityHelper()/
|
|
2679
|
+
* loadLivenessWriteHelper() above — the one repo/runtime boundary #356's plan flagged as
|
|
2680
|
+
* worth double-checking (workflow-sidecar.ts is TS→ESM-compiled-to-CJS-compatible-output;
|
|
2681
|
+
* scripts/lib/reconcile-shape.js is plain CommonJS). Verified clean via `npm run build`
|
|
2682
|
+
* + a require() smoke against build/src/cli/workflow-sidecar.js before this was wired in.
|
|
2683
|
+
*/
|
|
2684
|
+
function loadReconcileShapeHelper() {
|
|
2685
|
+
const _req = createRequire(import.meta.url);
|
|
2686
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/lib/reconcile-shape.js");
|
|
2687
|
+
return _req(helperPath);
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Delegate to scripts/ci/trust-reconcile.js's own EXPORTED pure helpers (manifest
|
|
2691
|
+
* resolution + the git-ancestor primitive) — same createRequire idiom as
|
|
2692
|
+
* loadReconcileShapeHelper() above. trust-reconcile.js is CommonJS; these exports were
|
|
2693
|
+
* added alongside `runTrustReconcile` specifically so a local caller (this preflight)
|
|
2694
|
+
* never needs a second implementation of "how the manifest is resolved" (Q1/AC5).
|
|
2695
|
+
*/
|
|
2696
|
+
function loadTrustReconcileHelper() {
|
|
2697
|
+
const _req = createRequire(import.meta.url);
|
|
2698
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/trust-reconcile.js");
|
|
2699
|
+
return _req(helperPath);
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Shell to scripts/ci/derive-claim-status.mjs exactly as trust-reconcile.js's own
|
|
2703
|
+
* deriveClaimStatuses() does (Q1's recommendation: full status-misassertion/unwaived-assumed
|
|
2704
|
+
* parity with CI, at the cost of one cheap local-only spawn — no CI command execution, just
|
|
2705
|
+
* Surface's pure deriveClaimStatus over the bundle's own evidence/events/policies). Returns
|
|
2706
|
+
* null when re-derivation is unavailable (Surface could not load / helper failed) — callers
|
|
2707
|
+
* degrade to reconcile-shape.js's documented reduced-coverage mode, they do not fail the
|
|
2708
|
+
* whole preflight over this.
|
|
2709
|
+
*/
|
|
2710
|
+
function derivePreflightClaimStatuses(bundlePath, repoRoot) {
|
|
2711
|
+
const helper = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/derive-claim-status.mjs");
|
|
2712
|
+
if (!fs.existsSync(helper))
|
|
2713
|
+
return null;
|
|
2714
|
+
let stdout;
|
|
2715
|
+
try {
|
|
2716
|
+
stdout = execFileSync(process.execPath, [helper, bundlePath], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 });
|
|
2717
|
+
}
|
|
2718
|
+
catch {
|
|
2719
|
+
return null;
|
|
2720
|
+
}
|
|
2721
|
+
if (!stdout)
|
|
2722
|
+
return null;
|
|
2723
|
+
try {
|
|
2724
|
+
const obj = JSON.parse(stdout);
|
|
2725
|
+
const m = new Map();
|
|
2726
|
+
for (const [k, v] of Object.entries(obj))
|
|
2727
|
+
m.set(k, v);
|
|
2728
|
+
return m;
|
|
2729
|
+
}
|
|
2730
|
+
catch {
|
|
2731
|
+
return null;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Distinct, identifiable error for a shape-invalid trust.bundle — NOT a generic Error, so
|
|
2736
|
+
* every publishDelivery() call site (advanceState/recordRelease/promote/publishDeliveryCmd)
|
|
2737
|
+
* can positively distinguish "refuses to publish an invalid bundle" (must be LOUD, fail
|
|
2738
|
+
* closed, AC6) from the other failure modes those call sites already tolerate as best-effort
|
|
2739
|
+
* (missing kits/ ancestor for a scratch dir, I/O errors, etc — see each catch's own comment).
|
|
2740
|
+
* A bare `instanceof Error` check would not suffice since every thrown failure in this file is
|
|
2741
|
+
* already an Error; `code` is the recognizable, grep-stable discriminator.
|
|
2742
|
+
*/
|
|
2743
|
+
export class InvalidBundleShapeError extends Error {
|
|
2744
|
+
code = "RECONCILE_PREFLIGHT_INVALID_SHAPE";
|
|
2745
|
+
issues;
|
|
2746
|
+
constructor(issues) {
|
|
2747
|
+
super(`trust.bundle failed the reconcile-preflight shape check (${issues.length} issue(s)) — see .issues for the full report`);
|
|
2748
|
+
this.name = "InvalidBundleShapeError";
|
|
2749
|
+
this.issues = issues;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Distinct, identifiable error for a publish attempt by an actor who is NOT the fresh,
|
|
2754
|
+
* non-superseded holder of the subject (issue #293, ADR 0021 §3) — NOT a generic Error and NOT
|
|
2755
|
+
* `InvalidBundleShapeError` (#356), so every publishDelivery() call site can positively
|
|
2756
|
+
* distinguish "refuses to publish because the bundle shape is invalid" from "refuses to publish
|
|
2757
|
+
* because this actor no longer holds the subject" — two genuinely distinct fail-closed tiers
|
|
2758
|
+
* that must never be conflated in a catch handler (a worker fixing a shape issue must not be
|
|
2759
|
+
* told to "re-record evidence" when the real problem is a stale/superseded claim, and vice
|
|
2760
|
+
* versa). Mirrors `InvalidBundleShapeError`'s shape exactly: same `extends Error` base, same
|
|
2761
|
+
* `readonly code` discriminator convention, same doc-comment structure — only the payload
|
|
2762
|
+
* differs (the effective-state/holder/reason/guidance `runVerifyHold()` computed, rather than a
|
|
2763
|
+
* shape-issue list).
|
|
2764
|
+
*/
|
|
2765
|
+
export class NotFreshHolderError extends Error {
|
|
2766
|
+
code = "VERIFY_HOLD_NOT_FRESH_HOLDER";
|
|
2767
|
+
effective_state;
|
|
2768
|
+
holder;
|
|
2769
|
+
reason;
|
|
2770
|
+
guidance;
|
|
2771
|
+
constructor(result) {
|
|
2772
|
+
super(`verify-hold refused publish — not the fresh holder of this subject (${result.reason}) — see .guidance for reconcile steps`);
|
|
2773
|
+
this.name = "NotFreshHolderError";
|
|
2774
|
+
this.effective_state = result.effective_state;
|
|
2775
|
+
this.holder = result.holder;
|
|
2776
|
+
this.reason = result.reason;
|
|
2777
|
+
this.guidance = result.guidance;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
|
|
2782
|
+
* carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
|
|
2783
|
+
* NOT a distinct predicate, it is #2 enriched, per the plan's Q2 resolution).
|
|
2784
|
+
*/
|
|
2785
|
+
function preflightFixHint(type) {
|
|
2786
|
+
switch (type) {
|
|
2787
|
+
case "unwaived-assumed":
|
|
2788
|
+
return "FIX: make it pass for real, or re-record with --accepted-gap-reason/--waived-by on a SEPARATE record-evidence call — a command-backed check in the SAME call voids/rejects the waiver (see the command-backed-waiver guard in workflow-sidecar.ts's recordEvidence).";
|
|
2789
|
+
case "waiver-on-command-check":
|
|
2790
|
+
return "FIX: a command-backed (test_output) check cannot be waived — either drop the waiver metadata and let it reconcile against the manifest for real, or record it as a session-local (non-command) claim if it genuinely cannot be re-run.";
|
|
2791
|
+
case "not-run":
|
|
2792
|
+
return "FIX: fold this into a non-command summary (session-local claim, no execution.label), or name the EXACT verbatim manifest command in evidence.execution.label so it reconciles.";
|
|
2793
|
+
case "laundering":
|
|
2794
|
+
return "FIX: remove the exit-code-laundering operator (||, ; true, ; exit 0, etc.) from the command — a laundered command cannot be trusted to reconcile.";
|
|
2795
|
+
case "session-local-failed":
|
|
2796
|
+
return "FIX: a disputed/failing claim always blocks reconcile. Document a disjoint pre-existing failure as prose in a WAIVED non-command summary, not as a standalone claim.";
|
|
2797
|
+
case "status-misassertion":
|
|
2798
|
+
return "FIX: the claim's asserted status does not match what Surface re-derives from the bundle's own evidence/events/policies — re-record evidence so the bundle's own data supports the asserted status; do not hand-edit status.";
|
|
2799
|
+
case "status-underivable":
|
|
2800
|
+
return "FIX: CI-side status re-derivation failed for this claim — ensure @kontourai/surface is installed/resolvable and the claim's evidence/events are well-formed, then re-record.";
|
|
2801
|
+
case "unwaived-session-local":
|
|
2802
|
+
return "FIX: this claim asserts pass but has neither a waiver nor a CI-re-derived 'verified' status — add a waiver (--accepted-gap-reason/--waived-by) or resolve it so Surface derives 'verified'.";
|
|
2803
|
+
default:
|
|
2804
|
+
return "FIX: see the divergence message above for the specific shape defect.";
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
|
|
2809
|
+
* the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
|
|
2810
|
+
* not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Already a
|
|
2811
|
+
* self-contained try/catch with no effect on the caller's `ok`/`issues`.
|
|
2812
|
+
*/
|
|
2813
|
+
function checkpointStalenessWarning(dir, repoRoot, tr) {
|
|
2814
|
+
const warnings = [];
|
|
2815
|
+
try {
|
|
2816
|
+
const checkpointPath = path.join(dir, "trust.checkpoint.json");
|
|
2817
|
+
if (fs.existsSync(checkpointPath)) {
|
|
2818
|
+
const checkpoint = loadJson(checkpointPath);
|
|
2819
|
+
const commitSha = checkpoint && typeof checkpoint === "object" ? checkpoint.commit_sha : undefined;
|
|
2820
|
+
if (typeof commitSha === "string" && commitSha) {
|
|
2821
|
+
let headSha = "";
|
|
2822
|
+
try {
|
|
2823
|
+
headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2824
|
+
}
|
|
2825
|
+
catch {
|
|
2826
|
+
headSha = "";
|
|
2827
|
+
}
|
|
2828
|
+
if (headSha && !tr.isAncestorCommit(repoRoot, commitSha, headSha)) {
|
|
2829
|
+
warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
// best-effort only — never affects ok/exit code.
|
|
2836
|
+
}
|
|
2837
|
+
return warnings;
|
|
2838
|
+
}
|
|
2839
|
+
export function runReconcilePreflight(dir, repoRoot, manifestOverride) {
|
|
2840
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2841
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2842
|
+
// A preflight with nothing to check is a usage error, not a soft pass — an agent must
|
|
2843
|
+
// never read "no bundle" as "bundle is valid" (see reconcilePreflightCmd for the CLI-side
|
|
2844
|
+
// die()). The library entrypoint throws too, since publishDelivery() itself already has
|
|
2845
|
+
// its OWN, separate, pre-existing fail-soft branch for bundle-absence (guarded before this
|
|
2846
|
+
// function is ever called there) — this function is never invoked without a bundle.
|
|
2847
|
+
throw new Error(`reconcile-preflight: no trust.bundle found at ${bundlePath} — nothing to check. This is a usage error, not a soft pass.`);
|
|
2848
|
+
}
|
|
2849
|
+
const bundle = loadJson(bundlePath);
|
|
2850
|
+
const shape = loadReconcileShapeHelper();
|
|
2851
|
+
const tr = loadTrustReconcileHelper();
|
|
2852
|
+
// Resolve the SAME canonical (fresh-verify) commands CI would fall back to feeding into
|
|
2853
|
+
// resolveManifest's legacy tier (tier 5), for genuine parity on that fallback path too —
|
|
2854
|
+
// CLI --commands is not a preflight concept, so only the TRUST_RECONCILE_COMMANDS env /
|
|
2855
|
+
// package.json trust-reconcile-verify tiers apply locally; a repo with neither and no
|
|
2856
|
+
// manifest source resolves to the same empty legacy fallback CI itself would in that case.
|
|
2857
|
+
const canonicalCommands = tr.resolveCanonicalCommands({ commands: [] }, repoRoot) ?? [];
|
|
2858
|
+
const manifestResolution = tr.resolveManifest({ manifest: manifestOverride ?? null }, repoRoot, canonicalCommands);
|
|
2859
|
+
const manifestByCmd = new Map();
|
|
2860
|
+
for (const e of manifestResolution.entries)
|
|
2861
|
+
manifestByCmd.set(tr.normalizeCmd(e.command), e);
|
|
2862
|
+
const derivedStatus = derivePreflightClaimStatuses(bundlePath, repoRoot);
|
|
2863
|
+
const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = shape.classifyBundleClaims(bundle);
|
|
2864
|
+
const issues = [];
|
|
2865
|
+
issues.push(...shape.waiverOnCommandIssues(waiverOnCommand));
|
|
2866
|
+
issues.push(...shape.noEvidenceCommandIssues(noEvidenceCommand));
|
|
2867
|
+
const { issues: manifestIssues } = shape.reconcilableManifestIssues(reconcilable, manifestByCmd);
|
|
2868
|
+
issues.push(...manifestIssues);
|
|
2869
|
+
// iteration-1 F1: the local preflight explicitly opts into reduced-coverage degradation
|
|
2870
|
+
// when re-derivation is unavailable (Surface not installed, spawn failure, etc.) — CI's
|
|
2871
|
+
// trust-reconcile.js does NOT opt in and stays fail-closed on the same condition (see
|
|
2872
|
+
// reconcile-shape.js's sessionLocalShapeIssues docstring for the full mode contract).
|
|
2873
|
+
const { issues: sessionLocalIssues } = shape.sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: "reduce" });
|
|
2874
|
+
issues.push(...sessionLocalIssues);
|
|
2875
|
+
const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
|
|
2876
|
+
const warnings = checkpointStalenessWarning(dir, repoRoot, tr);
|
|
2877
|
+
return { ok: report.length === 0, issues: report, warnings };
|
|
2878
|
+
}
|
|
2879
|
+
/**
|
|
2880
|
+
* reconcile-preflight <artifact-dir> [--manifest <json>] [--repo-root <path>]
|
|
2881
|
+
*
|
|
2882
|
+
* Local, pre-push shape-only check of the session's trust.bundle — reuses
|
|
2883
|
+
* scripts/lib/reconcile-shape.js and scripts/ci/trust-reconcile.js's own exported
|
|
2884
|
+
* classification/manifest-resolution so this can never silently drift from what CI's
|
|
2885
|
+
* Trust Reconcile job enforces. Prints every divergence with the claim id, divergence
|
|
2886
|
+
* type, and a human fix instruction; exits 0 with zero issues, 1 otherwise. Never spawns a
|
|
2887
|
+
* fresh manifest/CI command re-run (AC5) — resolves the manifest and re-derives status
|
|
2888
|
+
* (both local-only, no CI command execution) but never re-runs a manifest command itself.
|
|
2889
|
+
*
|
|
2890
|
+
* Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]
|
|
2891
|
+
*/
|
|
2892
|
+
async function reconcilePreflightCmd(p) {
|
|
2893
|
+
if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
|
|
2894
|
+
if (p.flags.has("help")) {
|
|
2895
|
+
console.log("Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]");
|
|
2896
|
+
console.log("Local, pre-push shape-only check of <artifactDir>/trust.bundle. Exits 0 with no issues, 1 otherwise.");
|
|
2897
|
+
return 0;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
2901
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
2902
|
+
if (!repoRoot)
|
|
2903
|
+
die(`reconcile-preflight: no kits/ ancestor found from ${dir}; pass --repo-root explicitly.`);
|
|
2904
|
+
const manifestOverride = p.opts["manifest"]?.at(-1) ?? null;
|
|
2905
|
+
const bundlePath = path.join(dir, "trust.bundle");
|
|
2906
|
+
if (!fs.existsSync(bundlePath)) {
|
|
2907
|
+
die(`reconcile-preflight: no trust.bundle at ${bundlePath} — a preflight with nothing to check is a usage error, not a soft pass. Record evidence first (record-evidence) before running reconcile-preflight.`);
|
|
2908
|
+
}
|
|
2909
|
+
const result = runReconcilePreflight(dir, repoRoot, manifestOverride);
|
|
2910
|
+
for (const w of result.warnings) {
|
|
2911
|
+
process.stderr.write(`[reconcile-preflight] ${w}\n`);
|
|
2912
|
+
}
|
|
2913
|
+
if (result.ok) {
|
|
2914
|
+
console.log(`[reconcile-preflight] OK — no shape issues found in ${bundlePath}`);
|
|
2915
|
+
return 0;
|
|
2916
|
+
}
|
|
2917
|
+
process.stderr.write(`[reconcile-preflight] FAILED — ${result.issues.length} shape issue(s) found in ${bundlePath}:\n`);
|
|
2918
|
+
for (const issue of result.issues) {
|
|
2919
|
+
process.stderr.write(`[reconcile-preflight] - ${issue}\n`);
|
|
2920
|
+
}
|
|
2921
|
+
return 1;
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* The ONE hard-stop gate (issue #293, ADR 0021 §3): "is my actor the fresh, non-superseded
|
|
2925
|
+
* holder of this subject (or is the subject free/self-held)?" Reuses the SAME assignment ⋈
|
|
2926
|
+
* liveness join #290/#291 already built (`computeEffectiveState` + `readLocalAssignmentStatus`
|
|
2927
|
+
* + `readLivenessEvents`/`freshHolders`) — no second computation is invented here. This is the
|
|
2928
|
+
* mirror-image check, at the OTHER end of the session lifecycle, of
|
|
2929
|
+
* `enforceEnsureSessionOwnership`'s pre-entry guard above: that guard decides whether entry
|
|
2930
|
+
* should claim/supersede/refuse; this gate decides whether PUBLISH should proceed, with a
|
|
2931
|
+
* different (new) per-effective-state mapping — the ownership guard's "claim"/"supersede"
|
|
2932
|
+
* actions are wrong at publish time, so this is new interpretation of the existing join output,
|
|
2933
|
+
* not a new join and not a new `EffectiveState` value (there is no literal `"superseded"` state
|
|
2934
|
+
* — a superseded-away actor's own re-check naturally resolves to `held`(holder=successor) or
|
|
2935
|
+
* `reclaimable`, never `self_is_holder`; see assignment-provider.ts's `computeEffectiveState`).
|
|
2936
|
+
*
|
|
2937
|
+
* Effective-state -> publish-decision mapping (the operative spec, from the plan's table).
|
|
2938
|
+
* IMPORTANT (bug fix, #397): this gate fences the durable ASSIGNMENT hold, not ambient
|
|
2939
|
+
* liveness — liveness is advisory everywhere else in the system, and this is the ONE hard
|
|
2940
|
+
* gate, so it must never false-block on liveness alone. A subject with fresh liveness by
|
|
2941
|
+
* another actor but NO durable assignment record (`held`, reason
|
|
2942
|
+
* `liveness_claim_present_assignment_lagging`) therefore PASSES, not BLOCKs — there is no
|
|
2943
|
+
* durable ownership conflict to protect, only ambient presence. This does NOT weaken zombie
|
|
2944
|
+
* protection: a superseded zombie always has an assignment record (either its own stale
|
|
2945
|
+
* assignment — `reclaimable` — or the successor's assignment-backed `held`/
|
|
2946
|
+
* `fresh_liveness_heartbeat`), both of which still BLOCK below.
|
|
2947
|
+
* - `free` (no assignment ever recorded) -> PASS (never false-block an untracked subject)
|
|
2948
|
+
* - `held`, reason `self_is_holder` -> PASS (the caller IS the current holder)
|
|
2949
|
+
* - `held`, reason `liveness_claim_present_assignment_lagging`
|
|
2950
|
+
* (fresh liveness by another actor, NO durable assignment record) -> PASS (liveness alone
|
|
2951
|
+
* is never a durable ownership conflict — see #397 fix note above)
|
|
2952
|
+
* - `held`, reason `fresh_liveness_heartbeat`
|
|
2953
|
+
* (durable assignment held by another actor + fresh liveness) -> BLOCK (a different
|
|
2954
|
+
* actor durably holds this subject and is demonstrably still live)
|
|
2955
|
+
* - `reclaimable` (assignment present, stale/absent liveness) -> BLOCK (not proof of
|
|
2956
|
+
* self — safe default blocks; a durable assignment record exists)
|
|
2957
|
+
* - `human-held` -> BLOCK (never publish over a human assignee)
|
|
2958
|
+
* - no resolvable join at all (non-local-file provider
|
|
2959
|
+
* with no --effective-state-json) -> `not_evaluated`, PASS (documented scope
|
|
2960
|
+
* boundary, loud stderr note, never a silent block)
|
|
2961
|
+
*
|
|
2962
|
+
* Actor resolution mirrors `enforceEnsureSessionOwnership` EXACTLY: the bare
|
|
2963
|
+
* `resolveActor(env).actor` / branchActorKey string, NEVER `serializeActor(actorStruct)`'s
|
|
2964
|
+
* triple — the #291 flat-vs-triple seam. `opts.actorKey` (the CLI's --actor / FLOW_AGENTS_ACTOR
|
|
2965
|
+
* override, already sanitized by the caller) takes precedence when provided.
|
|
2966
|
+
*
|
|
2967
|
+
* Slug/artifactRoot derivation matches `publishDelivery()`'s own `slug =
|
|
2968
|
+
* path.basename(path.resolve(dir))` byte-for-byte (both must resolve the SAME assignment
|
|
2969
|
+
* record) and the `artifactRoot = path.dirname(dir)` pattern already used elsewhere in this
|
|
2970
|
+
* file for a `dir`-only consumer.
|
|
2971
|
+
*
|
|
2972
|
+
* github provider: read-only precomputed `--effective-state-json` escape hatch, matching
|
|
2973
|
+
* `enforceEnsureSessionOwnership`'s existing github branch precedent exactly (render-don't-
|
|
2974
|
+
* execute — assignment-provider.ts never calls `gh` directly from this CLI).
|
|
2975
|
+
*
|
|
2976
|
+
* Every interpolated actor/holder/reason string is sanitized via
|
|
2977
|
+
* `stripControlCharsForDisplay(...).slice(0, 64)` — reusing the exact top-level helper, never a
|
|
2978
|
+
* second sanitizer (AC7).
|
|
2979
|
+
*
|
|
2980
|
+
* SECOND CI-blocking false-block fix (this iteration): the hard gate above enforces ONLY for a
|
|
2981
|
+
* STABLY-identified actor. A caller's identity is STABLE when it comes from an explicit
|
|
2982
|
+
* `opts.actorKey` (a caller that hands in an actor key is asserting a stable identity — e.g. the
|
|
2983
|
+
* CLI's --actor / FLOW_AGENTS_ACTOR override, or the github skill's precomputed path), from
|
|
2984
|
+
* `FLOW_AGENTS_ACTOR` (`resolveActor` source `"explicit-override"`), or from a runtime-native
|
|
2985
|
+
* session id (`resolveActor` source `` `runtime-session-id:${runtime}` ``, e.g. Claude Code's
|
|
2986
|
+
* `CLAUDE_CODE_SESSION_ID`). It is UNSTABLE when `resolveActor` falls all the way through to the
|
|
2987
|
+
* process-ancestry fallback (source `"process-ancestry"`) or fails to resolve at all (source
|
|
2988
|
+
* `"unresolved"` / `isUnresolvedActor(actor)`). This mirrors `enforceEnsureSessionOwnership`'s
|
|
2989
|
+
* Design Decision 4 (never hard-fail on actor ambiguity): a session that cannot stably
|
|
2990
|
+
* self-identify cannot be meaningfully fenced against a durable assignment record, so hard-
|
|
2991
|
+
* blocking it produces exactly the false-block CI hit (no `FLOW_AGENTS_ACTOR`/runtime session id
|
|
2992
|
+
* in CI -> ancestry fallback -> a DIFFERENT ancestry-derived actor string than the one that
|
|
2993
|
+
* created the claim -> `reclaimable`/not-self -> hard BLOCK of a legitimate self-publish). A
|
|
2994
|
+
* real coordination participant always has a stable identity (explicit override or runtime
|
|
2995
|
+
* session id), so zombie protection under a stable actor is completely unaffected — this only
|
|
2996
|
+
* changes behavior for a caller that was never fenceable in the first place. When the resolved
|
|
2997
|
+
* actor identity is UNSTABLE, this function short-circuits to `{ ok: true, effective_state:
|
|
2998
|
+
* "not_evaluated", reason: "actor-identity-unstable-advisory-only" }` (holder included, sanitized,
|
|
2999
|
+
* when one exists) BEFORE running the assignment ⋈ liveness join at all, with one loud stderr
|
|
3000
|
+
* note — the gate degrades to advisory-only rather than ever false-blocking an unstable identity.
|
|
3001
|
+
* When the actor identity IS stable, the effective-state -> publish-decision mapping above
|
|
3002
|
+
* applies exactly as documented, unchanged.
|
|
3003
|
+
*/
|
|
3004
|
+
export function runVerifyHold(dir, repoRoot, opts) {
|
|
3005
|
+
// repoRoot is accepted (not derived) for signature symmetry with runReconcilePreflight(dir,
|
|
3006
|
+
// repoRoot, ...) / publishDelivery(dir, repoRoot) — the local-file join below only needs
|
|
3007
|
+
// artifactRoot/slug, but a future non-local-file provider branch that resolves a live
|
|
3008
|
+
// repo-relative fixture would need it, so the parameter stays part of the public contract.
|
|
3009
|
+
void repoRoot;
|
|
3010
|
+
const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
|
|
3011
|
+
const slug = path.basename(path.resolve(dir));
|
|
3012
|
+
const artifactRoot = path.dirname(path.resolve(dir));
|
|
3013
|
+
const nowMs = opts?.now ?? Date.now();
|
|
3014
|
+
const assignmentProviderKind = opts?.assignmentProviderKind || "local-file";
|
|
3015
|
+
const helper = loadActorIdentityHelper();
|
|
3016
|
+
// Stability check (SECOND CI-blocking false-block fix, this iteration): an explicitly-provided
|
|
3017
|
+
// opts.actorKey is treated as stable outright (the caller is asserting a stable identity — see
|
|
3018
|
+
// doc comment above). Otherwise resolve BOTH the actor and its source so we can tell an
|
|
3019
|
+
// explicit-override / runtime-session-id actor (stable, enforce as today) apart from a
|
|
3020
|
+
// process-ancestry / unresolved one (unstable, advisory-only — never hard-block).
|
|
3021
|
+
const resolved = opts?.actorKey ? { actor: opts.actorKey, source: "explicit-override" } : helper.resolveActor(process.env);
|
|
3022
|
+
const actorKey = resolved.actor;
|
|
3023
|
+
const isStableActor = !!opts?.actorKey
|
|
3024
|
+
|| resolved.source === "explicit-override"
|
|
3025
|
+
|| resolved.source.startsWith("runtime-session-id")
|
|
3026
|
+
// #398: a CI-runtime actor is STABLE across every subprocess in a CI job (derived from the
|
|
3027
|
+
// provider's published run/job identifiers), so the verify-hold gate ENFORCES for CI sessions
|
|
3028
|
+
// instead of degrading to advisory — the root fix for the #293 CI false-block workaround.
|
|
3029
|
+
|| resolved.source.startsWith("ci-runtime");
|
|
3030
|
+
const guidanceLines = (holderActor) => [
|
|
3031
|
+
"Re-run pull-work/pickup-probe to discover the current holder of this subject and hand off cleanly (learning-review/handoff) rather than publishing over it.",
|
|
3032
|
+
holderActor
|
|
3033
|
+
? `The current holder appears to be ${sanitize(holderActor)} — confirm with them before proceeding.`
|
|
3034
|
+
: "Confirm with the assigned human before proceeding.",
|
|
3035
|
+
"If a human confirms this session should resume ownership, run `ensure-session --supersede-stale` to explicitly re-claim the subject before retrying publish.",
|
|
3036
|
+
];
|
|
3037
|
+
let effective = null;
|
|
3038
|
+
if (opts?.effectiveStateJson !== undefined) {
|
|
3039
|
+
const parsed = opts.effectiveStateJson;
|
|
3040
|
+
const candidate = parsed && typeof parsed === "object" ? parsed.effective : undefined;
|
|
3041
|
+
const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
|
|
3042
|
+
if (candidate && typeof candidate.effective_state === "string" && validStates.has(candidate.effective_state)) {
|
|
3043
|
+
effective = candidate;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
else if (assignmentProviderKind === "local-file") {
|
|
3047
|
+
// Same call shape as enforceEnsureSessionOwnership's local-file branch — no second
|
|
3048
|
+
// freshness/ownership computation is invented (see that function's F1 fix comment for why
|
|
3049
|
+
// branchActorKey, not the serialized ActorStruct triple, is the correct selfActor here).
|
|
3050
|
+
const assignment = readLocalAssignmentStatus(artifactRoot, slug);
|
|
3051
|
+
const events = readLivenessEvents(artifactRoot);
|
|
3052
|
+
const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
|
|
3053
|
+
effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
|
|
3054
|
+
}
|
|
3055
|
+
if (!effective) {
|
|
3056
|
+
// Documented scope boundary (Design Constraint): a non-local-file provider with no
|
|
3057
|
+
// precomputed --effective-state-json cannot be evaluated in-CLI (render-don't-execute — no
|
|
3058
|
+
// live `gh` call from workflow-sidecar.ts). PASS-through, never a silent block, with a loud
|
|
3059
|
+
// stderr note — mirroring enforceEnsureSessionOwnership's existing github branch precedent.
|
|
3060
|
+
process.stderr.write(`[verify-hold] not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json (or use --assignment-provider local-file)\n`);
|
|
3061
|
+
return { ok: true, effective_state: "not_evaluated", reason: "provider_not_evaluated", guidance: [] };
|
|
3062
|
+
}
|
|
3063
|
+
// F1 fix (fix-plan iteration 1, HIGH): sanitize EVERY untrusted string field of
|
|
3064
|
+
// effective.holder before it reaches the returned JSON / NotFreshHolderError.holder — never
|
|
3065
|
+
// spread the raw holder and override only the discriminated field. `last_at` (sourced from
|
|
3066
|
+
// record.claimed_at or a liveness event's `at`, both attacker-writable in the shared
|
|
3067
|
+
// multi-writer liveness/assignment stream) and `actor`/`assignee` are all display strings and
|
|
3068
|
+
// go through the SAME `sanitize` helper (stripControlCharsForDisplay(...).slice(0, 64)) as
|
|
3069
|
+
// enforceEnsureSessionOwnership's equivalent call sites (AC7 injection-discipline parity).
|
|
3070
|
+
// `idle_days` is a `number | null` computed by computeEffectiveState from Date.parse/Math.floor
|
|
3071
|
+
// arithmetic (assignment-provider.ts) — never an attacker-controlled string — so it is passed
|
|
3072
|
+
// through as-is, but defensively re-coerced with `typeof === "number"` so a future shape change
|
|
3073
|
+
// upstream can never smuggle a string through this field.
|
|
3074
|
+
const sanitizeHolder = (holder) => {
|
|
3075
|
+
if (!holder)
|
|
3076
|
+
return undefined;
|
|
3077
|
+
return {
|
|
3078
|
+
actor: holder.actor ? sanitize(holder.actor) : undefined,
|
|
3079
|
+
assignee: holder.assignee ? sanitize(holder.assignee) : holder.assignee,
|
|
3080
|
+
idle_days: typeof holder.idle_days === "number" ? holder.idle_days : undefined,
|
|
3081
|
+
last_at: holder.last_at ? sanitize(holder.last_at) : undefined,
|
|
3082
|
+
};
|
|
3083
|
+
};
|
|
3084
|
+
// SECOND CI-blocking false-block fix (this iteration): an actor identity that cannot stably
|
|
3085
|
+
// self-identify (process-ancestry fallback or unresolved) cannot be meaningfully fenced against
|
|
3086
|
+
// a durable assignment record — hard-blocking it is exactly the CI false-block this fix targets
|
|
3087
|
+
// (see doc comment above). Degrade to advisory-only: never hard-block, regardless of what the
|
|
3088
|
+
// join above computed. This check runs AFTER the join so an existing holder (if any) can still
|
|
3089
|
+
// be surfaced for visibility, but strictly BEFORE the effective-state switch below so no
|
|
3090
|
+
// unstable-actor request can ever reach a `case` that returns `ok: false`.
|
|
3091
|
+
if (!isStableActor) {
|
|
3092
|
+
process.stderr.write("[verify-hold] actor identity is ancestry-derived/unresolved — gate is advisory only for unstable identities; not hard-blocking publish\n");
|
|
3093
|
+
return {
|
|
3094
|
+
ok: true,
|
|
3095
|
+
effective_state: "not_evaluated",
|
|
3096
|
+
reason: "actor-identity-unstable-advisory-only",
|
|
3097
|
+
guidance: [],
|
|
3098
|
+
...(effective.holder ? { holder: sanitizeHolder(effective.holder) } : {}),
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
switch (effective.effective_state) {
|
|
3102
|
+
case "free":
|
|
3103
|
+
return { ok: true, effective_state: "free", reason: effective.reason, guidance: [] };
|
|
3104
|
+
case "held": {
|
|
3105
|
+
const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === actorKey);
|
|
3106
|
+
if (isSelf)
|
|
3107
|
+
return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
|
|
3108
|
+
// Bug fix (#397): `liveness_claim_present_assignment_lagging` means computeEffectiveState
|
|
3109
|
+
// found NO durable assignment record at all — only ambient liveness by another actor
|
|
3110
|
+
// (assignment-provider.ts's `if (!isAssigned) { if (freshHoldersList.length > 0) return
|
|
3111
|
+
// held/liveness_claim_present_assignment_lagging }`). Liveness alone is advisory
|
|
3112
|
+
// everywhere else in this system; this ONE hard gate must fence the durable ASSIGNMENT
|
|
3113
|
+
// hold, not ambient presence, so this case PASSES rather than false-blocking. Every other
|
|
3114
|
+
// `held` reason (`fresh_liveness_heartbeat`) is reached only when an assignment record
|
|
3115
|
+
// DOES exist (assignment-provider.ts's `isAssigned` branch) and the recorded holder is
|
|
3116
|
+
// still fresh-live — a genuine durable-ownership conflict, which still BLOCKs below.
|
|
3117
|
+
if (effective.reason === "liveness_claim_present_assignment_lagging") {
|
|
3118
|
+
return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
|
|
3119
|
+
}
|
|
3120
|
+
const holderActor = effective.holder?.actor;
|
|
3121
|
+
return {
|
|
3122
|
+
ok: false,
|
|
3123
|
+
effective_state: "held",
|
|
3124
|
+
holder: sanitizeHolder(effective.holder),
|
|
3125
|
+
reason: `subject ${sanitize(slug)} is currently held by a different, still-live actor (${sanitize(holderActor ?? "unknown")})`,
|
|
3126
|
+
guidance: guidanceLines(holderActor),
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
case "reclaimable": {
|
|
3130
|
+
// Stop-short risk (plan): reclaimable is NEVER treated as PASS. A lapsed self-claim looks
|
|
3131
|
+
// reclaimable, not self_is_holder, to a woken zombie's own re-check — the safe default
|
|
3132
|
+
// blocks and asks for reconcile guidance rather than auto-passing a stale self-claim.
|
|
3133
|
+
const holderActor = effective.holder?.actor;
|
|
3134
|
+
return {
|
|
3135
|
+
ok: false,
|
|
3136
|
+
effective_state: "reclaimable",
|
|
3137
|
+
holder: sanitizeHolder(effective.holder),
|
|
3138
|
+
reason: `subject ${sanitize(slug)}'s existing claim (held by ${sanitize(holderActor ?? "unknown")}) is stale/unverified as self — not a confirmed fresh hold`,
|
|
3139
|
+
guidance: guidanceLines(holderActor),
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
case "human-held": {
|
|
3143
|
+
const assignee = effective.holder?.assignee;
|
|
3144
|
+
return {
|
|
3145
|
+
ok: false,
|
|
3146
|
+
effective_state: "human-held",
|
|
3147
|
+
holder: sanitizeHolder(effective.holder),
|
|
3148
|
+
reason: `subject ${sanitize(slug)} is assigned to ${assignee ? sanitize(assignee) : "an assigned human"} — never publish over a human assignment without confirmation`,
|
|
3149
|
+
guidance: guidanceLines(undefined),
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
default:
|
|
3153
|
+
return { ok: true, effective_state: "not_evaluated", reason: "unrecognized_effective_state", guidance: [] };
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* verify-hold <artifact-dir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>]
|
|
3158
|
+
* [--effective-state-json <path|->]
|
|
3159
|
+
*
|
|
3160
|
+
* CLI surface for runVerifyHold() (issue #293). Prints `{ role: "VerifyHoldResult", ... }` JSON
|
|
3161
|
+
* and exits 0 when `ok` (including `not_evaluated` — a documented scope boundary is never a
|
|
3162
|
+
* failure exit), 1 otherwise. NEVER throws at the CLI boundary — matches
|
|
3163
|
+
* `reconcilePreflightCmd`'s existing convention of printing then exiting rather than throwing;
|
|
3164
|
+
* the thrown `NotFreshHolderError` variant is reserved for the LIBRARY call inside
|
|
3165
|
+
* `publishDelivery()`.
|
|
3166
|
+
*
|
|
3167
|
+
* Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>]
|
|
3168
|
+
* [--assignment-provider <kind>] [--effective-state-json <path|->]
|
|
3169
|
+
*/
|
|
3170
|
+
async function verifyHoldCmd(p) {
|
|
3171
|
+
if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
|
|
3172
|
+
if (p.flags.has("help")) {
|
|
3173
|
+
console.log("Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>] [--effective-state-json <path|->]");
|
|
3174
|
+
console.log("Checks whether the calling actor is the fresh, non-superseded holder of <artifactDir>'s subject. Exits 0 if ok (including not_evaluated), 1 otherwise.");
|
|
3175
|
+
return 0;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3179
|
+
const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
|
|
3180
|
+
// SECOND CI-blocking false-block fix (this iteration): only pass an explicit actorKey through
|
|
3181
|
+
// to runVerifyHold when the CALLER explicitly passed --actor — that is the one case where an
|
|
3182
|
+
// actorKey should be treated as an asserted-stable identity (see runVerifyHold's doc comment).
|
|
3183
|
+
// When --actor is omitted, actorKey is left undefined so runVerifyHold performs its OWN
|
|
3184
|
+
// resolveActor(process.env) call internally and can see the real `source` (explicit-override /
|
|
3185
|
+
// runtime-session-id / process-ancestry / unresolved) to decide stability — calling
|
|
3186
|
+
// resolveReadActorKey(p) here first would discard that source and wrongly force every ambient
|
|
3187
|
+
// resolution (including an ancestry fallback) to be treated as an explicit/stable actorKey.
|
|
3188
|
+
const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
|
|
3189
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
|
|
3190
|
+
const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
|
|
3191
|
+
const effectiveStateJsonFlag = opt(p, "effective-state-json");
|
|
3192
|
+
const effectiveStateJson = effectiveStateJsonFlag ? loadJsonInputFile(effectiveStateJsonFlag) : undefined;
|
|
3193
|
+
const result = runVerifyHold(dir, repoRoot, { actorKey, now: nowMs, assignmentProviderKind, effectiveStateJson });
|
|
3194
|
+
printJson({ role: "VerifyHoldResult", ...result });
|
|
3195
|
+
return result.ok ? 0 : 1;
|
|
3196
|
+
}
|
|
3197
|
+
// ─── Takeover Preflight (#294, ADR 0021 §5) ───────────────────────────────────
|
|
3198
|
+
/**
|
|
3199
|
+
* Render (never execute) the takeover protocol for a candidate the skill has selected. Takeover is
|
|
3200
|
+
* RESUMPTION, not restart: when a subject is `reclaimable` (a durable assignment whose incumbent is
|
|
3201
|
+
* no longer heartbeating), a successor must (1) grace-beat — wait one heartbeat interval and re-check,
|
|
3202
|
+
* backing off if the incumbent revives — (2) supersede via `ensure-session --supersede-stale`, and
|
|
3203
|
+
* (3) CONTINUE THE INCUMBENT'S BRANCH (from the incumbent's claim record), never a parallel one, and
|
|
3204
|
+
* re-enter the SAME artifact dir (deterministic slug). This function computes the effective state
|
|
3205
|
+
* (the same assignment ⋈ liveness join verify-hold/ensure-session use) and emits the appropriate
|
|
3206
|
+
* action + the incumbent's `resume_branch`; the skill executes the beat/supersede/git steps.
|
|
3207
|
+
*
|
|
3208
|
+
* Race-safety is CLI-enforced at supersede time, not here: `ensure-session --supersede-stale`
|
|
3209
|
+
* re-computes the state and refuses (dies on `held`) if the incumbent revived during the beat, so a
|
|
3210
|
+
* successor that skipped or lost the grace race still cannot supersede a live incumbent (AC2a).
|
|
3211
|
+
*
|
|
3212
|
+
* ADVISORY, not a gate: the assignment ⋈ liveness JOIN is identical to verify-hold's, but this render
|
|
3213
|
+
* only advises the skill which action to take — the ACTUAL enforcement is `ensure-session
|
|
3214
|
+
* --supersede-stale` (re-check under a subject lock). So it deliberately does NOT replicate
|
|
3215
|
+
* verify-hold's #398 `isStableActor` tiering (which exists there only because verify-hold is a hard
|
|
3216
|
+
* publish BLOCK that must not false-block an unstable/ancestry identity): a wrong self-identification
|
|
3217
|
+
* here yields only a wrong advisory string, never a wrong mutation.
|
|
3218
|
+
*
|
|
3219
|
+
* Ordering note: ADR 0021 §5's literal wording is supersede→grace→maybe-undo; this implements
|
|
3220
|
+
* grace→recheck→supersede (never write until the beat confirms the incumbent stayed stale) — a
|
|
3221
|
+
* deliberate, safer variant that preserves the ADR's intent ("laptop just woke resolves in the
|
|
3222
|
+
* incumbent's favor") without a premature write to walk back.
|
|
3223
|
+
*
|
|
3224
|
+
* All untrusted fields (incumbent actor / branch / last_at, sourced from the shared multi-writer
|
|
3225
|
+
* assignment+liveness stream) pass through `sanitize`/`sanitizeWide` (stripControlCharsForDisplay +
|
|
3226
|
+
* the repo's 64/240 cap tiers — branch uses the 240 free-text tier so a valid long branch is never
|
|
3227
|
+
* truncated into a bad checkout target) — the #287/#320/#293 injection class.
|
|
3228
|
+
*/
|
|
3229
|
+
export function runTakeoverPreflight(dir, opts) {
|
|
3230
|
+
const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
|
|
3231
|
+
// Branch names use the repo's 240-char free-text tier (matching assignment-provider.ts's
|
|
3232
|
+
// sanitizeDisplayField(record.branch, 240)), NOT the 64-char id tier: a realistic
|
|
3233
|
+
// `agent/<actor>/<slug>` branch can exceed 64 chars, and truncating `resume_branch` would hand the
|
|
3234
|
+
// skill a `git checkout <truncated>` target that does not exist — defeating the resume-the-branch
|
|
3235
|
+
// guarantee. Control chars are still stripped (injection), only the cap widens.
|
|
3236
|
+
const sanitizeWide = (value) => stripControlCharsForDisplay(value).slice(0, 240);
|
|
3237
|
+
const slug = path.basename(path.resolve(dir));
|
|
3238
|
+
const artifactRoot = path.dirname(path.resolve(dir));
|
|
3239
|
+
const nowMs = opts?.now ?? Date.now();
|
|
3240
|
+
const helper = loadActorIdentityHelper();
|
|
3241
|
+
const actorKey = opts?.actorKey ? helper.sanitizeSegment(opts.actorKey) : helper.resolveActor(process.env).actor;
|
|
3242
|
+
const graceSeconds = opts?.graceSeconds ?? loadLivenessPolicyHelper().resolveHeartbeatThrottleSeconds(process.env);
|
|
3243
|
+
const assignment = readLocalAssignmentStatus(artifactRoot, slug);
|
|
3244
|
+
const events = readLivenessEvents(artifactRoot);
|
|
3245
|
+
const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
|
|
3246
|
+
const effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
|
|
3247
|
+
const reason = sanitize(effective.reason);
|
|
3248
|
+
const holderActor = effective.holder?.actor ? sanitize(effective.holder.actor) : undefined;
|
|
3249
|
+
const holderLastAt = effective.holder?.last_at ? sanitize(effective.holder.last_at) : undefined;
|
|
3250
|
+
switch (effective.effective_state) {
|
|
3251
|
+
case "reclaimable": {
|
|
3252
|
+
// The incumbent's branch is the resume target — read it from the pre-supersede record, never
|
|
3253
|
+
// derive a new one (AC1: takeover continues the incumbent's branch, no parallel branch).
|
|
3254
|
+
const resumeBranch = assignment.record?.branch ? sanitizeWide(assignment.record.branch) : undefined;
|
|
3255
|
+
return {
|
|
3256
|
+
ok: true,
|
|
3257
|
+
action: "grace-then-supersede",
|
|
3258
|
+
effective_state: "reclaimable",
|
|
3259
|
+
reason,
|
|
3260
|
+
holder: { actor: holderActor, last_at: holderLastAt },
|
|
3261
|
+
...(resumeBranch ? { resume_branch: resumeBranch } : {}),
|
|
3262
|
+
grace_seconds: graceSeconds,
|
|
3263
|
+
next_steps: [
|
|
3264
|
+
`Grace beat: wait ${graceSeconds}s (one heartbeat interval) for the incumbent to revive.`,
|
|
3265
|
+
`Re-run \`workflow-sidecar takeover-preflight ${slug}\`; if it now reports action "back-off" the incumbent revived — STOP and reselect.`,
|
|
3266
|
+
`If still "grace-then-supersede", run \`ensure-session --supersede-stale\` to take over (it re-checks and refuses if the incumbent revived), recording the audit trail.`,
|
|
3267
|
+
resumeBranch
|
|
3268
|
+
? `Continue the incumbent's branch: \`git fetch origin ${resumeBranch} && git checkout ${resumeBranch}\` — NEVER create a new branch. Re-enter the existing artifact dir (deterministic slug); do not restart the plan.`
|
|
3269
|
+
: `Re-enter the existing artifact dir (deterministic slug) and continue the incumbent's branch from state.json.branch — never a new branch; do not restart the plan.`,
|
|
3270
|
+
],
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
case "held": {
|
|
3274
|
+
const selfIsHolder = effective.reason === "self_is_holder";
|
|
3275
|
+
return {
|
|
3276
|
+
ok: selfIsHolder,
|
|
3277
|
+
action: selfIsHolder ? "proceed" : "back-off",
|
|
3278
|
+
effective_state: "held",
|
|
3279
|
+
reason,
|
|
3280
|
+
holder: { actor: holderActor, last_at: holderLastAt },
|
|
3281
|
+
next_steps: selfIsHolder
|
|
3282
|
+
? ["This subject is already held by you — proceed with your existing session; no takeover needed."]
|
|
3283
|
+
: [`The incumbent is live/revived (held by ${holderActor ?? "another actor"}). Back off — do NOT supersede a live holder — and reselect a different item.`],
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
case "human-held": {
|
|
3287
|
+
return {
|
|
3288
|
+
ok: false,
|
|
3289
|
+
action: "ask-first",
|
|
3290
|
+
effective_state: "human-held",
|
|
3291
|
+
reason,
|
|
3292
|
+
holder: { actor: holderActor, last_at: holderLastAt },
|
|
3293
|
+
next_steps: [`A human is assigned (${holderActor ?? "assignee"}). Ask first — never auto-supersede a human assignment (ADR 0021 §6).`],
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
case "free":
|
|
3297
|
+
default: {
|
|
3298
|
+
return {
|
|
3299
|
+
ok: true,
|
|
3300
|
+
action: "claim",
|
|
3301
|
+
effective_state: "free",
|
|
3302
|
+
reason,
|
|
3303
|
+
next_steps: ["No durable claim to supersede — this is a normal claim, not a takeover. Run `ensure-session` to claim it."],
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
async function takeoverPreflightCmd(p) {
|
|
3309
|
+
if (p.flags.has("help")) {
|
|
3310
|
+
console.log("Usage: workflow-sidecar takeover-preflight <artifactDir> [--actor <key>] [--now <iso>] [--grace-seconds <n>]");
|
|
3311
|
+
console.log("Renders the ADR 0021 §5 takeover protocol (grace-then-supersede | back-off | claim | ask-first | proceed) for a reclaimable candidate. Read-only; never mutates.");
|
|
3312
|
+
return 0;
|
|
3313
|
+
}
|
|
3314
|
+
const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
|
|
3315
|
+
const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
|
|
3316
|
+
const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
|
|
3317
|
+
const graceSeconds = opt(p, "grace-seconds") ? Number(opt(p, "grace-seconds")) : undefined;
|
|
3318
|
+
const result = runTakeoverPreflight(dir, { actorKey, now: nowMs, graceSeconds });
|
|
3319
|
+
printJson({ role: "TakeoverPreflight", ...result });
|
|
3320
|
+
return result.ok ? 0 : 1;
|
|
3321
|
+
}
|
|
2599
3322
|
// ─── Publish Delivery Bundle ──────────────────────────────────────────────────
|
|
2600
3323
|
// Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
|
|
2601
3324
|
// session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
|
|
@@ -2679,6 +3402,28 @@ function pruneSupersededSeals(deliveryDir, keepSlug) {
|
|
|
2679
3402
|
* evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
|
|
2680
3403
|
* .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
|
|
2681
3404
|
* Idempotent: overwrites on re-delivery to the same slug.
|
|
3405
|
+
*
|
|
3406
|
+
* #356 Wave 3 (AC6): Fail-CLOSED on bundle SHAPE. After the fail-soft absence guard below,
|
|
3407
|
+
* runs the SAME reconcile-preflight shape check (runReconcilePreflight) the
|
|
3408
|
+
* `reconcile-preflight` CLI subcommand exposes, BEFORE copying anything into delivery/. A
|
|
3409
|
+
* shape-invalid bundle throws InvalidBundleShapeError (a distinct, identifiable error — see
|
|
3410
|
+
* its own doc comment) rather than silently publishing a bundle CI's Trust Reconcile job
|
|
3411
|
+
* would reject anyway. This is intentionally a NEW, additive fail-closed branch — it must
|
|
3412
|
+
* never be conflated with the pre-existing fail-soft absence/repo-root branches above/below,
|
|
3413
|
+
* which stay exactly as before (see each publishDelivery call site's catch handler for how
|
|
3414
|
+
* the distinction is preserved end-to-end).
|
|
3415
|
+
*
|
|
3416
|
+
* #293 (SECOND, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
|
|
3417
|
+
* writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
|
|
3418
|
+
* asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
|
|
3419
|
+
* free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
|
|
3420
|
+
* `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
|
|
3421
|
+
* bundle shape). There are now THREE tiers here, and they must never be conflated in prose or
|
|
3422
|
+
* in a call site's catch handler: (1) fail-SOFT bundle absence / repo-root resolution (silent
|
|
3423
|
+
* no-op / visible warning, unchanged from before #356), (2) fail-CLOSED bundle shape (#356,
|
|
3424
|
+
* `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293, `NotFreshHolderError`). The
|
|
3425
|
+
* shape check runs first (per the plan's ordering) — a bundle that is BOTH shape-invalid and
|
|
3426
|
+
* not-held throws `InvalidBundleShapeError` specifically, never `NotFreshHolderError`.
|
|
2682
3427
|
*/
|
|
2683
3428
|
export async function publishDelivery(dir, repoRoot) {
|
|
2684
3429
|
const bundleSrc = path.join(dir, "trust.bundle");
|
|
@@ -2688,6 +3433,29 @@ export async function publishDelivery(dir, repoRoot) {
|
|
|
2688
3433
|
process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/ seal. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
|
|
2689
3434
|
return;
|
|
2690
3435
|
}
|
|
3436
|
+
// #356 AC6: fail-CLOSED on shape-invalidity — runs AFTER the fail-soft absence/repo-root
|
|
3437
|
+
// guards above (both preserved unchanged) and BEFORE any copy into delivery/. Throws
|
|
3438
|
+
// InvalidBundleShapeError (never a generic Error) so every call site can positively
|
|
3439
|
+
// distinguish this from the failure modes they already tolerate as best-effort.
|
|
3440
|
+
const preflight = runReconcilePreflight(dir, repoRoot);
|
|
3441
|
+
if (!preflight.ok) {
|
|
3442
|
+
process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
|
|
3443
|
+
for (const issue of preflight.issues) {
|
|
3444
|
+
process.stderr.write(`[publish-delivery] - ${issue}\n`);
|
|
3445
|
+
}
|
|
3446
|
+
throw new InvalidBundleShapeError(preflight.issues);
|
|
3447
|
+
}
|
|
3448
|
+
// #293: SECOND, DISTINCT fail-closed gate — runs after the shape check above and before any
|
|
3449
|
+
// copy into delivery/. Throws NotFreshHolderError (never conflated with InvalidBundleShapeError)
|
|
3450
|
+
// so every call site can positively distinguish "not the fresh holder" from "bundle shape
|
|
3451
|
+
// invalid".
|
|
3452
|
+
const holdCheck = runVerifyHold(dir, repoRoot);
|
|
3453
|
+
if (!holdCheck.ok) {
|
|
3454
|
+
process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
|
|
3455
|
+
for (const line of holdCheck.guidance)
|
|
3456
|
+
process.stderr.write(`[publish-delivery] - ${line}\n`);
|
|
3457
|
+
throw new NotFreshHolderError(holdCheck);
|
|
3458
|
+
}
|
|
2691
3459
|
const deliveryDir = path.join(repoRoot, "delivery");
|
|
2692
3460
|
// #379: slug is the session artifact dir's basename — the same human-meaningful id used
|
|
2693
3461
|
// throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
|
|
@@ -3948,6 +4716,9 @@ async function main() {
|
|
|
3948
4716
|
case "resolve-slug": return resolveSlugCmd(p);
|
|
3949
4717
|
case "seal-checkpoint": return sealCheckpoint(p);
|
|
3950
4718
|
case "publish-delivery": return publishDeliveryCmd(p);
|
|
4719
|
+
case "reconcile-preflight": return reconcilePreflightCmd(p);
|
|
4720
|
+
case "verify-hold": return verifyHoldCmd(p);
|
|
4721
|
+
case "takeover-preflight": return takeoverPreflightCmd(p);
|
|
3951
4722
|
default: die(`unknown command: ${p.command}`);
|
|
3952
4723
|
}
|
|
3953
4724
|
});
|