@kontourai/flow-agents 3.7.0 → 3.8.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.
@@ -367,10 +367,21 @@ async function syncAndProject(
367
367
  const snapshot = stageTrustBundleSnapshot(context);
368
368
  try {
369
369
  const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
370
- const gateEvidence = await bundleGateEvidence(rawBundle, gates[0]!, run.state.subject, context.projectRoot);
370
+ const gateEvidence = await bundleGateEvidence(
371
+ rawBundle,
372
+ gates[0]!,
373
+ run.state,
374
+ run.state.subject,
375
+ context.projectRoot,
376
+ manifestEvidence(run.manifest),
377
+ );
371
378
  if (gateEvidence) {
372
379
  const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
373
- entry.gate_id === gates[0]!.id && entry.sha256 === snapshot.sha256
380
+ entry.gate_id === gates[0]!.id
381
+ && entry.sha256 === snapshot.sha256
382
+ && typeof entry.superseded_by !== "string"
383
+ && timestampAtOrAfter(entry.attached_at, gateEvidence.visitEnteredAt)
384
+ && gateEvidence.expectationIds.every((expectationId) => Array.isArray(entry.expectation_ids) && entry.expectation_ids.includes(expectationId))
374
385
  );
375
386
  if (!alreadyAttached) {
376
387
  const supersede = manifestEvidence(run.manifest)
@@ -386,6 +397,7 @@ async function syncAndProject(
386
397
  ...(supersede.length > 0 ? { supersede } : {}),
387
398
  ...(gateEvidence.failed ? { status: "failed" } : {}),
388
399
  ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
400
+ expectationIds: gateEvidence.expectationIds,
389
401
  },
390
402
  });
391
403
  attached = true;
@@ -478,21 +490,72 @@ function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: s
478
490
  ) as Array<FlowGate & { id: string }>;
479
491
  }
480
492
 
481
- async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string, projectRoot: string): Promise<{ failed: boolean; routeReason: string | null } | null> {
493
+ async function bundleGateEvidence(
494
+ bundle: unknown,
495
+ gate: FlowGate,
496
+ state: FlowRunState,
497
+ subject: string,
498
+ projectRoot: string,
499
+ manifest: AnyRecord[],
500
+ ): Promise<{ failed: boolean; routeReason: string | null; expectationIds: string[]; visitEnteredAt: number } | null> {
482
501
  if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
483
- const selectors = (expectationsForGate(gate) as FlowExpectation[]).map((expectation) => expectation.bundle_claim);
502
+ const expectations = expectationsForGate(gate) as FlowExpectation[];
503
+ const visit = currentGateVisit(state, String((gate as AnyRecord).step));
504
+ const enteredAt = visit.enteredAt;
505
+ const synchronizedAt = Date.now();
506
+ const maxClockSkewMs = 30_000;
507
+ const priorVisitClaimIds = new Set<string>();
508
+ const priorVisitEvidenceIds = new Set<string>();
509
+ for (const entry of manifest) {
510
+ if (entry.gate_id !== String((gate as AnyRecord).id)) continue;
511
+ const claims = isRecord(entry.bundle) && Array.isArray(entry.bundle.claims) ? entry.bundle.claims : [];
512
+ for (const historical of claims) {
513
+ if (isRecord(historical) && typeof historical.id === "string") priorVisitClaimIds.add(historical.id);
514
+ }
515
+ const evidence = isRecord(entry.bundle) && Array.isArray(entry.bundle.evidence) ? entry.bundle.evidence : [];
516
+ for (const historical of evidence) {
517
+ if (isRecord(historical) && typeof historical.id === "string") priorVisitEvidenceIds.add(historical.id);
518
+ }
519
+ }
520
+ const claimIsCurrent = (claim: AnyRecord): boolean => {
521
+ if (typeof claim.id !== "string" || priorVisitClaimIds.has(claim.id)) return false;
522
+ const timestamps: number[] = [];
523
+ const createdAt = parseTimestamp(claim.createdAt);
524
+ if (createdAt !== null) timestamps.push(createdAt);
525
+ if (Array.isArray((bundle as AnyRecord).evidence)) for (const evidence of (bundle as AnyRecord).evidence) {
526
+ if (!isRecord(evidence) || evidence.claimId !== claim.id) continue;
527
+ if (typeof evidence.id !== "string" || priorVisitEvidenceIds.has(evidence.id)) return false;
528
+ const observedAt = parseTimestamp(evidence.observedAt);
529
+ if (observedAt !== null) timestamps.push(observedAt);
530
+ }
531
+ const initialAcquisitionSkew = visit.initial && claim.claimType === "builder.pull-work.selected" ? maxClockSkewMs : 0;
532
+ return timestamps.some((timestamp) => timestamp >= enteredAt - initialAcquisitionSkew
533
+ && timestamp <= synchronizedAt + maxClockSkewMs);
534
+ };
484
535
  const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
485
536
  if (!isRecord(claim)) return false;
486
- return selectors.some((candidate: FlowExpectation["bundle_claim"]) =>
537
+ if (claim.producerStatus === "superseded") return false;
538
+ const metadata = isRecord(claim.metadata) ? claim.metadata : null;
539
+ if (metadata && typeof metadata.superseded_by === "string") return false;
540
+ return expectations.some((expectation) => {
541
+ const candidate = expectation.bundle_claim;
542
+ return candidate
543
+ && claimIsCurrent(claim)
544
+ &&
487
545
  candidate.claimType === claim.claimType
488
546
  && (!candidate.subjectType || candidate.subjectType === claim.subjectType)
489
- );
547
+ });
490
548
  });
491
549
  if (relevant.length === 0) return null;
492
550
  if (relevant.some((claim) => workflowSubjectRef(claim) !== subject)) {
493
551
  throw new BuilderBuildRunInputError("evidence.claims.metadata.workflow_subject_ref", "must match the persisted run subject");
494
552
  }
495
553
  const failed = relevant.some((claim) => claim.value === "fail" || claim.status === "disputed");
554
+ const expectationIds = expectations.filter((expectation) => relevant.some((claim: AnyRecord) => {
555
+ const selector = expectation.bundle_claim;
556
+ return selector.claimType === claim.claimType && (!selector.subjectType || selector.subjectType === claim.subjectType);
557
+ })).map((expectation) => expectation.id);
558
+ const missingRequired = expectations.filter((expectation) => expectation.required && !expectationIds.includes(expectation.id));
496
559
  const routeReasons = [...new Set(relevant.flatMap((claim) => {
497
560
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
498
561
  const gateClaim = metadata && isRecord(metadata.gate_claim) ? metadata.gate_claim : null;
@@ -502,6 +565,11 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
502
565
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "must agree across current-gate claims");
503
566
  }
504
567
  const routeReason = routeReasons[0] ?? null;
568
+ if (failed && !routeReason) return null;
569
+ // Passing evidence waits for the complete expectation set. A failing
570
+ // snapshot is complete only when a gate producer explicitly declares its
571
+ // route reason; report-only disputed critique state remains pending.
572
+ if (!failed && missingRequired.length > 0) return null;
505
573
  if (routeReason && !failed) {
506
574
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", "requires failed current-gate evidence");
507
575
  }
@@ -512,7 +580,31 @@ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: stri
512
580
  if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
513
581
  await assertVerifiedTestsTrust(bundle.claims, projectRoot);
514
582
  }
515
- return { failed, routeReason };
583
+ return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
584
+ }
585
+
586
+ function currentGateVisit(state: FlowRunState, step: string): { enteredAt: number; initial: boolean } {
587
+ let enteredAt: number | null = null;
588
+ for (const transition of state.transitions ?? []) {
589
+ if (transition.to_step !== step) continue;
590
+ const parsed = parseTimestamp(transition.at);
591
+ if (parsed !== null) enteredAt = parsed;
592
+ }
593
+ const initial = parseTimestamp(state.updated_at);
594
+ if (enteredAt !== null) return { enteredAt, initial: false };
595
+ if (initial !== null) return { enteredAt: initial, initial: true };
596
+ throw new BuilderBuildRunInputError("flow_run.state.updated_at", "must establish the current gate visit boundary");
597
+ }
598
+
599
+ function parseTimestamp(value: unknown): number | null {
600
+ if (typeof value !== "string") return null;
601
+ const parsed = Date.parse(value);
602
+ return Number.isFinite(parsed) ? parsed : null;
603
+ }
604
+
605
+ function timestampAtOrAfter(value: unknown, boundary: number): boolean {
606
+ const parsed = parseTimestamp(value);
607
+ return parsed !== null && parsed >= boundary;
516
608
  }
517
609
 
518
610
  async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
@@ -775,7 +775,19 @@ export function performLocalReleaseUnderLock(
775
775
  // seam, relocated to this write path).
776
776
  const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
777
777
  const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
778
- if (holderActorKey !== releasedByActorKey) {
778
+ // Pre-3.7 lifecycle events could persist the derived ancestry actor before
779
+ // sanitizeSegment removed ':' separators. Modern explicit/env release paths
780
+ // always use the sanitized form. Accept only that one-way legacy migration;
781
+ // never normalize two modern keys or relax ownership to a prefix match.
782
+ const sameActorStruct = existing.actor.runtime === releasedBy.runtime
783
+ && existing.actor.session_id === releasedBy.session_id
784
+ && existing.actor.host === releasedBy.host
785
+ && (existing.actor.human ?? null) === (releasedBy.human ?? null);
786
+ const legacyActorKeyMatches = holderActorKey.includes(":")
787
+ && holderActorKey === helper.serializeActor(existing.actor)
788
+ && helper.sanitizeSegment(holderActorKey) === releasedByActorKey
789
+ && sameActorStruct;
790
+ if (holderActorKey !== releasedByActorKey && !legacyActorKeyMatches) {
779
791
  if (tolerateNoActiveClaim) return null;
780
792
  throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
781
793
  }
@@ -19,9 +19,9 @@ import {
19
19
  } from "../../build/src/builder-flow-runtime.js";
20
20
  import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
21
21
  import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
22
- import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
22
+ import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
23
23
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
24
- import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
24
+ import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
25
25
 
26
26
  const SUBJECT = "local:work-item/runtime-projection";
27
27
  const NOW = "2026-07-09T20:00:00.000Z";
@@ -214,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
214
214
  assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
215
215
  }
216
216
 
217
- function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = NOW }) {
217
+ function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = new Date().toISOString() }) {
218
218
  const claimId = `claim.${expectation}`;
219
219
  return {
220
220
  claim: {
@@ -264,7 +264,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
264
264
  };
265
265
  }
266
266
 
267
- function verifiedTestsPrerequisites(session, timestamp = NOW) {
267
+ function verifiedTestsPrerequisites(session, timestamp = new Date().toISOString()) {
268
268
  const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
269
269
  const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
270
270
  const implementationFile = path.relative(session.projectRoot, implementation);
@@ -312,6 +312,24 @@ function writeBundle(sessionDir, entries) {
312
312
  });
313
313
  }
314
314
 
315
+ function historicalProducerSuperseded(entry, suffix = "historical") {
316
+ const copy = withIdentitySuffix(entry, suffix);
317
+ copy.claim.producerStatus = "superseded";
318
+ return copy;
319
+ }
320
+
321
+ function withIdentitySuffix(entry, suffix) {
322
+ const copy = structuredClone(entry);
323
+ const priorId = copy.claim.id;
324
+ copy.claim.id = `${priorId}.${suffix}`;
325
+ copy.evidence.id = `${copy.evidence.id}.${suffix}`;
326
+ copy.evidence.claimId = copy.claim.id;
327
+ copy.event.id = `${copy.event.id}.${suffix}`;
328
+ copy.event.claimId = copy.claim.id;
329
+ copy.event.evidenceIds = [copy.evidence.id];
330
+ return copy;
331
+ }
332
+
315
333
  async function writeAndSync(session, entries) {
316
334
  writeBundle(session.sessionDir, entries);
317
335
  return syncBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -736,13 +754,15 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
736
754
  const verify = await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
737
755
  assert.equal(verify.run.state.current_step, "verify");
738
756
 
757
+ const failureTimestamp = new Date().toISOString();
739
758
  const routed = await writeAndSync(session, [bundleClaim({
740
759
  expectation: "tests-evidence",
741
760
  claimType: "builder.verify.tests",
742
761
  subjectType: "flow-step",
743
762
  status: "fail",
744
763
  routeReason: "implementation_defect",
745
- })]);
764
+ timestamp: failureTimestamp,
765
+ }), ...verifiedTestsPrerequisites(session, failureTimestamp)]);
746
766
 
747
767
  assert.equal(routed.run.state.current_step, "execute");
748
768
  assert.equal(routed.projection.flow_run.route_back_attempt, 1);
@@ -750,17 +770,28 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
750
770
  assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
751
771
  assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
752
772
 
753
- const reentered = await writeAndSync(session, [bundleClaim({
773
+ const reentered = await writeAndSync(session, [withIdentitySuffix(bundleClaim({
754
774
  expectation: "implementation-scope",
755
775
  claimType: "builder.execute.scope",
756
776
  subjectType: "change",
757
777
  timestamp: new Date().toISOString(),
758
- })]);
778
+ }), "reentry")]);
759
779
  assert.equal(reentered.run.state.current_step, "verify");
780
+ const staleRetry = await writeAndSync(session, [bundleClaim({
781
+ expectation: "tests-evidence",
782
+ claimType: "builder.verify.tests",
783
+ subjectType: "flow-step",
784
+ status: "fail",
785
+ routeReason: "implementation_defect",
786
+ timestamp: NOW,
787
+ })]);
788
+ assert.equal(staleRetry.attached, false);
789
+ assert.equal(staleRetry.run.state.current_step, "verify");
790
+ assert.equal(staleRetry.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
760
791
  const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
761
792
  const corrected = await writeAndSync(session, [
762
- bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
763
- ...verifiedTestsPrerequisites(session, correctedAt),
793
+ withIdentitySuffix(bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }), "corrected"),
794
+ ...verifiedTestsPrerequisites(session, correctedAt).map((entry, index) => withIdentitySuffix(entry, `corrected-${index}`)),
764
795
  ]);
765
796
  assert.equal(corrected.run.state.current_step, "merge-ready");
766
797
  const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
@@ -768,6 +799,190 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
768
799
  assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
769
800
  });
770
801
 
802
+ test("producer-superseded FAIL is audit history and live PASS drives verify", async () => {
803
+ const session = makeSession("producer-superseded-verify");
804
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
805
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
806
+ await writeAndSync(session, [
807
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
808
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
809
+ ]);
810
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
811
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
812
+
813
+ const livePass = bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" });
814
+ const historicalFail = historicalProducerSuperseded(bundleClaim({
815
+ expectation: "tests-evidence",
816
+ claimType: "builder.verify.tests",
817
+ subjectType: "flow-step",
818
+ status: "fail",
819
+ routeReason: "implementation_defect",
820
+ }));
821
+ const result = await writeAndSync(session, [historicalFail, livePass, ...verifiedTestsPrerequisites(session)]);
822
+
823
+ assert.equal(result.run.state.current_step, "merge-ready");
824
+ const attached = result.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate" && !entry.superseded_by);
825
+ assert.equal(attached.length, 1);
826
+ assert.deepEqual(attached[0].expectation_ids.sort(), ["acceptance-criteria", "clean-critique", "tests-evidence"]);
827
+ });
828
+
829
+ test("partial passing review snapshot waits for the complete verify expectation set", async () => {
830
+ const session = makeSession("atomic-review-sync");
831
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
832
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
833
+ await writeAndSync(session, [
834
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
835
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
836
+ ]);
837
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
838
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
839
+
840
+ const disputedCritique = verifiedTestsPrerequisites(session)[0];
841
+ disputedCritique.claim.value = "fail";
842
+ disputedCritique.claim.status = "disputed";
843
+ disputedCritique.event.status = "disputed";
844
+ const disputedPartial = await writeAndSync(session, [disputedCritique]);
845
+ assert.equal(disputedPartial.attached, false);
846
+ assert.equal(disputedPartial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
847
+
848
+ const partial = await writeAndSync(session, verifiedTestsPrerequisites(session).slice(0, 1));
849
+ assert.equal(partial.attached, false);
850
+ assert.equal(partial.run.state.current_step, "verify");
851
+ assert.equal(partial.run.state.transitions.filter((transition) => transition.type === "route_back").length, 0);
852
+
853
+ const complete = await writeAndSync(session, [
854
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }),
855
+ ...verifiedTestsPrerequisites(session),
856
+ ]);
857
+ assert.equal(complete.run.state.current_step, "merge-ready");
858
+ });
859
+
860
+ test("initial gate boundary rejects pre-run and far-future claims", async () => {
861
+ for (const [name, timestamp] of [
862
+ ["pre-run", NOW],
863
+ ["far-future", new Date(Date.now() + 60 * 60_000).toISOString()],
864
+ ]) {
865
+ const session = makeSession(`freshness-${name}`);
866
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
867
+ const result = await writeAndSync(session, [bundleClaim({
868
+ expectation: "selected-work",
869
+ claimType: "builder.pull-work.selected",
870
+ subjectType: "work-item",
871
+ timestamp,
872
+ })]);
873
+ assert.equal(result.attached, false, name);
874
+ assert.equal(result.run.state.current_step, "pull-work", name);
875
+ }
876
+ });
877
+
878
+ test("prior claim identity is rejected after re-entry and a new identity can recover", async () => {
879
+ const session = makeSession("same-digest-reentry");
880
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
881
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
882
+ await writeAndSync(session, [
883
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
884
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
885
+ ]);
886
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
887
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
888
+ const future = new Date(Date.now() + 10_000).toISOString();
889
+ const failure = [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", status: "fail", routeReason: "implementation_defect", timestamp: future }), ...verifiedTestsPrerequisites(session, future)];
890
+ const first = await writeAndSync(session, failure);
891
+ assert.equal(first.run.state.current_step, "execute");
892
+ await writeAndSync(session, [withIdentitySuffix(bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change", timestamp: future }), "reentry")]);
893
+ const replay = await writeAndSync(session, failure);
894
+ assert.equal(replay.attached, false);
895
+ assert.equal(replay.run.state.current_step, "verify");
896
+ assert.equal(replay.run.state.transitions.filter((transition) => transition.type === "route_back").length, 1);
897
+ const newIdentity = failure.map((entry, index) => withIdentitySuffix(entry, `visit-2-${index}`));
898
+ const second = await writeAndSync(session, newIdentity);
899
+ assert.equal(second.run.state.current_step, "execute");
900
+ const verifyEvidence = second.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
901
+ assert.equal(verifyEvidence.length, 2);
902
+ assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
903
+ });
904
+
905
+ test("legacy colon-bearing assignment actor releases only through its normalized equivalent", () => {
906
+ const session = makeSession("legacy-actor-release");
907
+ const legacyActor = { runtime: "unknown", session_id: "legacy-session", host: "Kontour", human: null };
908
+ performLocalClaim(session.artifactRoot, session.slug, legacyActor, {
909
+ ttlSeconds: 1800,
910
+ actorKey: "unknown:legacy-session:Kontour",
911
+ branch: `agent/${session.slug}`,
912
+ artifactDir: session.sessionDir,
913
+ workItemRef: SUBJECT,
914
+ reason: "legacy fixture",
915
+ });
916
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
917
+ actorKey: "different-actor",
918
+ }), /refusing to release/);
919
+ assert.throws(() => performLocalRelease(session.artifactRoot, session.slug, { ...legacyActor, session_id: "different" }, {
920
+ actorKey: "unknownlegacy-sessionKontour",
921
+ }), /refusing to release/);
922
+ const released = performLocalRelease(session.artifactRoot, session.slug, legacyActor, {
923
+ actorKey: "unknownlegacy-sessionKontour",
924
+ });
925
+ assert.equal(released.status, "released");
926
+ });
927
+
928
+ test("string-only legacy liveness identity cannot be released by a colliding modern actor", async () => {
929
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-legacy-liveness-"));
930
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
931
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
932
+ fs.writeFileSync(eventsFile, `${JSON.stringify({
933
+ type: "claim",
934
+ subjectId: "legacy-subject",
935
+ actor: "unknown:anc-123456789abc:Kontour",
936
+ at: NOW,
937
+ ttlSeconds: 1800,
938
+ })}\n`);
939
+
940
+ await assert.rejects(() => workflowSidecarMain([
941
+ "liveness", "release", "legacy-subject",
942
+ "--actor", "unknownanc-123456789abcKontour",
943
+ "--artifact-root", root,
944
+ "--at", "2026-07-09T20:01:00.000Z",
945
+ ]), /prior claim/);
946
+ const events = fs.readFileSync(eventsFile, "utf8").trim().split("\n").map(JSON.parse);
947
+ assert.equal(events.length, 1);
948
+ });
949
+
950
+ test("lossy modern liveness key collision cannot release a non-reversible legacy actor", async () => {
951
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lossy-liveness-"));
952
+ const eventsFile = path.join(root, "liveness", "events.jsonl");
953
+ fs.mkdirSync(path.dirname(eventsFile), { recursive: true });
954
+ fs.writeFileSync(eventsFile, `${JSON.stringify({ type: "claim", subjectId: "collision", actor: "a:b", at: NOW, ttlSeconds: 1800 })}\n`);
955
+ await assert.rejects(() => workflowSidecarMain([
956
+ "liveness", "release", "collision", "--actor", "ab", "--artifact-root", root,
957
+ ]), /prior claim/);
958
+ assert.equal(fs.readFileSync(eventsFile, "utf8").trim().split("\n").length, 1);
959
+ });
960
+
961
+ test("upgrade rebuild preserves legacy critique identity until a versioned new review", async () => {
962
+ const legacy = {
963
+ id: "upgrade-review",
964
+ reviewer: "reviewer",
965
+ reviewed_at: "2026-07-01T00:00:00Z",
966
+ verdict: "pass",
967
+ summary: "Legacy clean review",
968
+ findings: [],
969
+ lanes: [{ id: "code", status: "pass" }],
970
+ review_target: { artifacts: [] },
971
+ artifact_refs: [],
972
+ };
973
+ const beforeUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-01T00:00:00Z", [], [], [legacy]);
974
+ const rebuiltAfterUpgrade = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:00:00Z", [], [], [legacy]);
975
+ const newReview = await buildTrustBundle("upgrade-fixture", "2026-07-12T00:01:00Z", [], [], [{
976
+ ...legacy,
977
+ reviewed_at: "2026-07-12T00:01:00Z",
978
+ identity_version: 2,
979
+ }]);
980
+ assert.equal(beforeUpgrade.claims[0].id, rebuiltAfterUpgrade.claims[0].id);
981
+ assert.notEqual(newReview.claims[0].id, beforeUpgrade.claims[0].id);
982
+ assert.equal(rebuiltAfterUpgrade.claims[0].metadata.identity_version, undefined);
983
+ assert.equal(newReview.claims[0].metadata.identity_version, 2);
984
+ });
985
+
771
986
  test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
772
987
  const session = makeSession("stale-critique-artifact");
773
988
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -837,25 +1052,30 @@ test("verified sidecar claims drive the composed publish and learning prefix to
837
1052
  claimSessionAssignment(session);
838
1053
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
839
1054
  const steps = [
840
- [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
841
- [
1055
+ () => [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
1056
+ () => [
842
1057
  bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
843
1058
  bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
844
1059
  ],
845
- [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
846
- [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
847
- [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
848
- [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
1060
+ () => [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
1061
+ () => [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
1062
+ () => [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
1063
+ () => [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
849
1064
  ];
850
- for (const entries of steps) await writeAndSync(session, entries);
1065
+ for (const entries of steps) await writeAndSync(session, entries());
851
1066
 
852
1067
  const prOpen = readJson(path.join(session.sessionDir, "state.json"));
853
1068
  assert.equal(prOpen.flow_run.current_step, "pr-open");
1069
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "pr-open");
854
1070
  assert.deepEqual(prOpen.next_action.skills, []);
855
1071
  assert.deepEqual(prOpen.next_action.operations, ["publish-change"]);
856
1072
 
857
- await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
858
- await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1073
+ const mergeReadyCi = await writeAndSync(session, [bundleClaim({ expectation: "pull-request-opened", claimType: "builder.pr-open.pull-request", subjectType: "pull-request" })]);
1074
+ assert.equal(mergeReadyCi.run.state.current_step, "merge-ready-ci");
1075
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "merge-ready-ci");
1076
+ const learn = await writeAndSync(session, [bundleClaim({ expectation: "ci-merge-readiness", claimType: "builder.merge-ready-ci.readiness", subjectType: "pull-request" })]);
1077
+ assert.equal(learn.run.state.current_step, "learn");
1078
+ assert.equal(readJson(path.join(session.artifactRoot, "current.json")).active_step_id, "learn");
859
1079
  const completed = await writeAndSync(session, [
860
1080
  bundleClaim({ expectation: "decision-evidence", claimType: "builder.learn.decisions", subjectType: "decision" }),
861
1081
  bundleClaim({ expectation: "learning-evidence", claimType: "builder.learn.evidence", subjectType: "release" }),
@@ -865,6 +1085,18 @@ test("verified sidecar claims drive the composed publish and learning prefix to
865
1085
  assert.equal(completed.run.state.status, "completed");
866
1086
  assert.equal(completed.projection.status, "delivered");
867
1087
  assert.deepEqual(completed.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
1088
+ const liveEvidence = completed.run.manifest.evidence.filter((entry) => !entry.superseded_by);
1089
+ assert.deepEqual(liveEvidence.map((entry) => entry.expectation_ids), [
1090
+ ["selected-work"],
1091
+ ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"],
1092
+ ["implementation-plan"],
1093
+ ["implementation-scope"],
1094
+ ["clean-critique", "acceptance-criteria", "tests-evidence"],
1095
+ ["merge-readiness"],
1096
+ ["pull-request-opened"],
1097
+ ["ci-merge-readiness"],
1098
+ ["decision-evidence", "learning-evidence"],
1099
+ ]);
868
1100
 
869
1101
  const flowDirectory = runDir(session.slug, session.projectRoot);
870
1102
  const beforeFlow = snapshotTree(flowDirectory);