forgedock 1.46.0 → 1.48.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.
Files changed (69) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/issuewatch.mjs +134 -37
  20. package/bin/lean/issuewatch.test.mjs +123 -1
  21. package/bin/lean/lane.mjs +13 -0
  22. package/bin/lean/lane.test.mjs +28 -1
  23. package/bin/lean/lean.mjs +60 -12
  24. package/bin/lean/lean.test.mjs +80 -0
  25. package/bin/lean/limits.mjs +6 -2
  26. package/bin/lean/limits.test.mjs +86 -0
  27. package/bin/lean/milestonesync.mjs +2 -2
  28. package/bin/lean/milestonesync.test.mjs +22 -0
  29. package/bin/lean/orchestrate.mjs +79 -9
  30. package/bin/lean/probe.mjs +4 -2
  31. package/bin/lean/probe.test.mjs +4 -0
  32. package/bin/lean/prompts.mjs +18 -11
  33. package/bin/lean/rotator.mjs +96 -0
  34. package/bin/lean/rotator.test.mjs +143 -0
  35. package/bin/lean/routecheck.mjs +48 -0
  36. package/bin/lean/routecheck.test.mjs +69 -0
  37. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  38. package/bin/lean/sandbox-remote.mjs +4 -2
  39. package/bin/lean/verify.mjs +2 -1
  40. package/bin/review-staging.mjs +20 -3
  41. package/commands/deploy-info.md +4 -4
  42. package/commands/issue.md +6 -1
  43. package/commands/review-pr-agents/frontend.md +1 -0
  44. package/commands/review-pr-agents/infra.md +2 -0
  45. package/commands/review-pr-staging.md +2 -0
  46. package/commands/work-on/build/context.md +7 -1
  47. package/commands/work-on/investigate.md +15 -3
  48. package/docs/CONFIG.md +21 -2
  49. package/docs/ISSUE-WATCH.md +22 -1
  50. package/docs/OUTCOME-CODES.md +2 -1
  51. package/package.json +1 -1
  52. package/scripts/file-deferred-followups.sh +125 -28
  53. package/scripts/file-deferred-followups.test.sh +49 -0
  54. package/scripts/issue-create.sh +7 -0
  55. package/scripts/issue-create.test.sh +13 -0
  56. package/scripts/issue-dedup.sh +5 -4
  57. package/scripts/issue-dedup.test.sh +15 -0
  58. package/scripts/owner-route-parity.test.sh +32 -0
  59. package/scripts/plan-validate-live.mjs +18 -6
  60. package/scripts/prior-attempts.mjs +72 -0
  61. package/scripts/prior-attempts.test.mjs +52 -0
  62. package/scripts/review-finalize.sh +35 -4
  63. package/scripts/review-finalize.test.sh +10 -0
  64. package/scripts/review-prepare.sh +16 -4
  65. package/scripts/review-prepare.test.sh +8 -0
  66. package/scripts/review-roster.sh +3 -1
  67. package/scripts/review-roster.test.sh +30 -2
  68. package/scripts/review-staging-pre.sh +8 -6
  69. package/scripts/review-staging-pre.test.sh +11 -1
@@ -57,8 +57,9 @@ export const OUTCOME_CODES = Object.freeze({
57
57
  ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
58
58
  ENV_LANE_LIMIT: "env.lane_limit", // forge#4690: the lane hit its own scope limit (TasksMax or MemoryMax) and was stopped; an environment error, never an engine-error, so the breaker ignores it
59
59
  ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
60
- SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
60
+ SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start (a non-auth 4xx/config error or a non-HTTP start failure); needs-human, not relaunched
61
61
  SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
62
+ SANDBOX_TRANSIENT: "sandbox.transient", // forge#5095: the sandbox provider answered a 5xx on launch (router/agent infrastructure); deferred with backoff, capped by max_deferrals, never needs-human
62
63
  SANDBOX_AUTH: "sandbox.auth", // forge#4685: the sandbox provider refused or lacked the credential (HTTP 401/403, no token); deferred, never needs-human
63
64
  SANDBOX_UNREACHABLE: "sandbox.unreachable", // forge#4672: the sandbox provider stayed unreachable past execution.unreachable_window_min; deferred, the sandbox is kept for adopt
64
65
  LEASE_HELD: "lease.held",
@@ -27,6 +27,7 @@ import { normalizeKindModes } from "./lean/conformance.mjs";
27
27
  import { viewIssue } from "./lean/gh-assoc.mjs";
28
28
  import { otherLaneOwner } from "./lean/lanelock.mjs";
29
29
  import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
30
+ import { openRotator } from "./lean/rotator.mjs";
30
31
  import { relayPrefetchCommands } from "./lean/relay.mjs";
31
32
  import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
32
33
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
@@ -285,6 +286,10 @@ export function readSequenceRegenerate(cwd = process.cwd()) {
285
286
  export function readFollowupsDefaultMilestone(cwd = process.cwd()) {
286
287
  try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.default_milestone ?? "").trim() || undefined; } catch { return undefined; }
287
288
  }
289
+ /** #5078: forge.yaml `followups.rollup_issue_label` (a repo's own open issue with this label is the post-deploy rollup), or undefined. */
290
+ export function readFollowupsRollupLabel(cwd = process.cwd()) {
291
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.rollup_issue_label ?? "").trim() || undefined; } catch { return undefined; }
292
+ }
288
293
  /** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
289
294
  export function readPreconditionCmd(cwd = process.cwd()) {
290
295
  try {
@@ -527,10 +532,12 @@ export function readEnvConfig(cwd = process.cwd()) {
527
532
  const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
528
533
  const rm = readYamlMap(cwd, "validate", "recipes"); // #5020: y?.validate?.recipes, a block map {match regex: replacement} applied to frozen validate and repro commands
529
534
  const recipes = Object.entries(rm ?? {}).map(([match, replace]) => ({ match, replace }));
535
+ const cf = readYamlMap(cwd, "verification", "coupled_files"); // #5079: {when glob: required glob[, glob]}
530
536
  const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
537
+ const rr = s(y?.topology?.route_reachability); // #5087: repo-supplied command: exits non-zero when the ingress does not deliver the added route paths (args) to their service
531
538
  const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
532
539
  return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
533
- ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}) };
540
+ ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}), ...(cf && Object.keys(cf).length ? { coupledFiles: cf } : {}), ...(rr ? { routeReachability: rr } : {}) };
534
541
  } catch { return {}; }
535
542
  }
536
543
  export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
@@ -981,9 +988,10 @@ export async function runFromCli(argv, deps = {}) {
981
988
  let lres;
982
989
  try {
983
990
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
991
+ ...(readExecution(process.cwd())?.accountRotator ? { accountRotator: readExecution(process.cwd()).accountRotator } : {}), // #5094
984
992
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
985
993
  emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
986
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
994
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}), ...(readFollowupsRollupLabel() ? { followupsRollupLabel: readFollowupsRollupLabel() } : {}) });
987
995
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
988
996
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
989
997
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1699,7 +1707,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1699
1707
  const { runCore } = deps.leanModule;
1700
1708
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1701
1709
  lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1702
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readConformanceKinds(cwd) ? { conformanceKinds: readConformanceKinds(cwd) } : {}), ...(readConformanceMutation(cwd) ? { conformanceMutation: "report" } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
1710
+ ...(readExecution(cwd)?.accountRotator ? { accountRotator: readExecution(cwd).accountRotator } : {}), // #5094
1711
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readConformanceKinds(cwd) ? { conformanceKinds: readConformanceKinds(cwd) } : {}), ...(readConformanceMutation(cwd) ? { conformanceMutation: "report" } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}), ...(readFollowupsRollupLabel(cwd) ? { followupsRollupLabel: readFollowupsRollupLabel(cwd) } : {}) }) };
1703
1712
  } else {
1704
1713
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1705
1714
  // #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
@@ -1736,7 +1745,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1736
1745
  const { makeLedger } = await import("./lean/ledger.mjs");
1737
1746
  const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1738
1747
  const bud = exec.budget ?? {};
1739
- const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1748
+ const holdId = execMod.remoteHoldId(provider); // #5095: the pool account's usage-limit hold key
1749
+ const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0, holdId,
1740
1750
  ...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
1741
1751
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
1742
1752
  logTail: (h) => { try { return readFileSync(batch.logFile(h.issue), "utf8").slice(-4000); } catch { return ""; } } }); // #4866: a clone failure in the lane log types sandbox.clone
@@ -1775,7 +1785,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1775
1785
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1776
1786
  };
1777
1787
  remote = {
1778
- capacity, max: exec.maxRemote, isRemote: () => true, place, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1788
+ capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1779
1789
  admit: (u) => execBudget.admit(u),
1780
1790
  onUsage: (n, sb, rec) => {
1781
1791
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
@@ -1803,7 +1813,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1803
1813
  // #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
1804
1814
  const limitsMod = await import("./lean/limits.mjs");
1805
1815
  const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
1806
- const identity = deps.identity ?? (() => limitsMod.laneBackendId(env));
1816
+ // #5094: with an account rotator the identity is its current account, and admissions hold only when every account is exhausted.
1817
+ const rotator = deps.rotator !== undefined ? deps.rotator : openRotator({ kind: readExecution(cwd)?.accountRotator });
1818
+ const identity = deps.identity ?? (() => limitsMod.laneBackendId(env, rotator));
1807
1819
  const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
1808
1820
  const schedMod = await import("./lean/sched.mjs");
1809
1821
  const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
@@ -1925,7 +1937,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1925
1937
  autoChildren,
1926
1938
  ...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
1927
1939
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
1928
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1940
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, rotator, batchId: batch.id, drift, lifecycle, integration,
1929
1941
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1930
1942
  ...(remote ? { remote } : {}),
1931
1943
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
@@ -2202,13 +2214,15 @@ export async function poolFromCli(argv, deps = {}) {
2202
2214
  export async function limitsFromCli(argv, deps = {}) {
2203
2215
  const limitsMod = await import("./lean/limits.mjs");
2204
2216
  const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
2205
- const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env)))();
2217
+ const rotator = deps.rotator !== undefined ? deps.rotator : openRotator({ kind: readExecution(process.cwd())?.accountRotator }); // #5094
2218
+ const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env, rotator)))();
2206
2219
  const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
2207
2220
  const [sub, arg] = argv;
2208
2221
  if (!sub || sub === "show") {
2209
2222
  const holds = limits.list();
2210
2223
  if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
2211
2224
  for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
2225
+ for (const a of rotator?.accounts ?? []) console.log(` ${rotator.kind} account ${a.n} (${rotator.accountIdentity(a.n)}): 5h ${a.fiveHourPct}% resets ${new Date(a.fiveHourResetMs).toISOString()}`);
2212
2226
  return { holds, current };
2213
2227
  }
2214
2228
  if (sub === "clear") {
@@ -247,6 +247,26 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
247
247
  assert.equal(engine.self_check, "red");
248
248
  assert.deepEqual(o.log.filter((e) => e.event === "SELF_CHECK_MISSING").map((e) => e.state), ["red"]);
249
249
  });
250
+ // #5087 (R2/R6): the lane adds a Next.js route handler; only a declared forge.yaml topology.route_reachability may block it
251
+ const SIGNUP = "web/src/app/(site)/api/v1/agent/signup/pending/route.js";
252
+ const ROUTED = () => { const k = FULL(); k.deliverables.push({ id: "D5", path: SIGNUP, change: "signup handler", requirements: ["R1"] }); return k; };
253
+ const addRoute = (t) => { fix(t, ["scrape", "search", "export"]); t.write({ [SIGNUP]: "export async function GET() { return new Response(\"ok\"); }\n" }); };
254
+ it("#5087 without topology a route-adding lane merges as today: no route check runs, no new blocking failure", async () => {
255
+ const ran = [];
256
+ const o = await runCase({ contract: ROUTED, impl: addRoute }, "enforce", { runCommand: async (a) => { ran.push(a.command); return runCheckCommand(a); } });
257
+ merged(o);
258
+ assert.ok(!ran.some((c) => c.includes("/api/v1/agent/signup/pending")), "no route reachability command ran");
259
+ assert.ok(!o.log.some((e) => e.event === "PRECHECK" && (e.failures ?? []).some((f) => f.kind === "route_unreachable")));
260
+ });
261
+ it("#5087 a declared topology.route_reachability that rejects the new route blocks before merge (#38241 fixture)", async () => {
262
+ const ingress = path.join(tmpDir("ingress-"), "check-ingress.sh");
263
+ fs.writeFileSync(ingress, "for p in \"$@\"; do case \"$p\" in /api/v1/batch*) ;; /api/v1/*) echo \"unreachable: $p -> api\" >&2; exit 1 ;; esac; done\n");
264
+ const o = await runCase({ contract: ROUTED, impl: addRoute }, "enforce", { runCommand: runCheckCommand, opts: { routeReachability: `bash ${ingress}`, verify: { maxRounds: 0 } } });
265
+ assert.equal(o.staging.search, false, "staging untouched");
266
+ assert.equal(o.ret.terminalReason, "needs-human"); assert.equal(o.pr, undefined, "no PR");
267
+ assert.ok(o.log.some((e) => e.event === "PRECHECK" && e.failures.includes("conformance.unimplemented")));
268
+ assert.match(o.ret.detail, /topology\.route_reachability[\s\S]*\/api\/v1\/agent\/signup\/pending[\s\S]*unreachable: \/api\/v1\/agent\/signup\/pending -> api/);
269
+ });
250
270
  it("6 a generic PASS over an incomplete implementation never merges (review.incomplete)", async () => {
251
271
  const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "export"]), generic: true });
252
272
  blocked(o, "review.incomplete"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 2]);
@@ -266,6 +266,28 @@ export function tmpdirInputs(command) {
266
266
 
267
267
  const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
268
268
 
269
+ const E2E_WORD_RE = /\b(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)\b/i;
270
+ const WEAKEN_RE = /\b(?:no|not|without|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|instead of|rather than|in lieu of|swap\w*)\b/i;
271
+ const MOCKED_RE = /\b(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)\b/i;
272
+ const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
273
+ const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
274
+ /**
275
+ * #5087: a plan decision whose effect is to drop an e2e/integration check for mocked tests ("no Playwright e2e; integration
276
+ * coverage via route and component tests"). The #4726 rule keys on the `[type:e2e]` tag, which a reworded criterion
277
+ * escapes. Exempt when an acceptance probe (#4399) is declared: in the decision itself, or by another row of the contract.
278
+ * Returns the decision ids; pure.
279
+ */
280
+ export function weakenedE2eDecisions(c) {
281
+ const decisions = Array.isArray(c?.decisions) ? c.decisions : [];
282
+ const elsewhere = (id) => JSON.stringify({ r: c?.requirements ?? [], d: c?.deliverables ?? [], k: decisions.filter((k) => k?.id !== id) });
283
+ return decisions.filter((k) => {
284
+ const choice = String(k?.choice ?? "");
285
+ if (!E2E_WORD_RE.test(choice) || !WEAKEN_RE.test(choice) || !MOCKED_RE.test(choice)) return false;
286
+ const own = `${choice} ${k?.justification ?? ""}`;
287
+ return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
288
+ }).map((k) => k.id).filter(Boolean);
289
+ }
290
+
269
291
  export function validateContract(c, issueText, { repro = false } = {}) {
270
292
  const problems = [];
271
293
  if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
@@ -297,6 +319,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
297
319
  if (!k?.id || blank(k?.choice)) problems.push(`decision without id or choice: ${JSON.stringify(k ?? null).slice(0, 80)}`);
298
320
  else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
299
321
  }
322
+ for (const id of weakenedE2eDecisions(c)) problems.push(`${id}: a plan decision may not replace an e2e/integration check with mocked tests; run a real e2e check, or declare a \`forge-probe\` acceptance probe (#4399) that runs after promotion and reopens on failure`);
300
323
  if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
301
324
  else for (const v of c.validate) {
302
325
  if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
@@ -379,6 +402,21 @@ function amendmentProblem(a, tag) {
379
402
  return `unknown or incomplete amendment: ${why}: ${tag}`;
380
403
  }
381
404
 
405
+ /** #5089: shape-only check of the builder's amendments (unknown kind, missing path or requirement), run before the gate so one in-lane correction can fix them. Returns the rejection strings; [] when valid. */
406
+ export function amendmentKindProblems(amendments) {
407
+ const out = [];
408
+ for (const raw of Array.isArray(amendments) ? amendments : []) {
409
+ const a = normalizeAmendment(raw);
410
+ if (a?.kind === UNRELATED_CI_KIND || INFO_KINDS.includes(a?.kind)) continue;
411
+ const tag = JSON.stringify(raw ?? null).slice(0, 400);
412
+ const known = AMEND_KINDS.includes(a?.kind);
413
+ const missing = a?.kind === "add-deliverable" ? blank(a.path)
414
+ : TRACKED.includes(a?.kind) || a?.kind === "amend-requirement" ? blank(a.requirement) : false;
415
+ if (!known || missing) out.push(amendmentProblem(a, tag));
416
+ }
417
+ return out;
418
+ }
419
+
382
420
  /**
383
421
  * Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
384
422
  * defer|post-deploy {requirement: id}. Every amendment needs a why (or the engine's defaultWhy); `addedPaths` are lane-added files that count as decision anchors when a deliverable lists them. Returns {contract, applied[], toTrack[], problems[]}.
@@ -720,7 +758,7 @@ export function repoTracks(paths) {
720
758
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
721
759
  * tool), for the trajectory; always an array.
722
760
  */
723
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
761
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, coupledFailures = null, routeFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
724
762
  const failures = [], superseded = [], ciCovered = [];
725
763
  // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
726
764
  if (checkIds) for (const r of contract.requirements ?? []) {
@@ -785,9 +823,12 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
785
823
  }
786
824
  failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
787
825
  if (repro) failures.push(...reproFailures(contract, repro));
826
+ failures.push(...sourceDeliverableFailures(contract, changedFiles, ignore));
788
827
  // #4693: a broken guard fails unless the contract carries a supersedes amendment for it
789
828
  const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
790
829
  for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
830
+ for (const f of coupledFailures ?? []) failures.push(f); // #5079
831
+ failures.push(...(routeFailures ?? [])); // #5087: topology.route_reachability, blocking when declared
791
832
  return { ok: failures.length === 0, failures, superseded, ciCovered };
792
833
  }
793
834
 
@@ -819,10 +860,40 @@ export function reproFailures(contract, { base, head } = {}) {
819
860
  detail: `repro test ${name} (\`${t.command ?? ""}\`): the harness could not run the test on head (environment, not the change); got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
820
861
  }
821
862
  const skipped = base === "SKIPPED" ? " (base not run: head did not pass)" : "";
863
+ // #5080: the test already passes on the base: the fix is there (a hotfix or another PR), not missing from this change
864
+ if (base === "RAN") return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_base_green",
865
+ detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=RAN HEAD=RAN: it passes on the base already (the fix may already be on the base)` }];
822
866
  return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
823
867
  detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head}${skipped}` }];
824
868
  }
825
869
 
870
+ const DOC_ONLY_RE = /\.(?:md|mdx|txt|rst|adoc)$/i;
871
+ const SOURCE_EXT_RE = /\.(?:m?[jt]sx?|cjs|py|go|rs|rb|java|kt|swift|php|cs|c|cc|cpp|h|hpp|sh)$/i;
872
+ /**
873
+ * #5080: a diff of test files only, for a contract that names a code deliverable (a non-test source file): the tests pin
874
+ * behaviour but nothing was changed, so it must not be accepted as the fix. Returns the failure rows ([] otherwise).
875
+ */
876
+ export function sourceDeliverableFailures(contract, changedFiles = [], ignore = []) {
877
+ const files = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)));
878
+ if (!files.length || !files.every(isTestPath)) return [];
879
+ const src = (contract.deliverables ?? []).map((d) => cleanPath(d.path)).filter((p) => p && !/[*?]|\/$/.test(p) && !isTestPath(p) && SOURCE_EXT_RE.test(p));
880
+ if (!src.length) return [];
881
+ return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "source_deliverable_untouched",
882
+ detail: `the diff changes only test files (${files.slice(0, 3).join(", ")}${files.length > 3 ? ", ..." : ""}) but the contract names source deliverable(s) ${src.slice(0, 3).join(", ")}: tests that pin current behaviour are not the fix` }];
883
+ }
884
+
885
+ /**
886
+ * #5080: the repro already passes on the base (repro_base_green), the diff has no non-test source change, and the only other
887
+ * failure is source_deliverable_untouched: the issue is already fixed on the base, so it closes as delivered instead of
888
+ * needs-human. A diff that changes source keeps the conformance.unimplemented path (K3).
889
+ */
890
+ export function alreadyFixedOnBase(failures = [], changedFiles = [], ignore = []) {
891
+ const files = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)));
892
+ // Release review of #5103 (MAT-1): any non-test, non-doc change (css, json, yaml, sql, tf, ...) is work the lane did, never "already fixed".
893
+ if (files.some((f) => !isTestPath(f) && !DOC_ONLY_RE.test(f))) return false;
894
+ return failures.some((f) => f.kind === "repro_base_green") && failures.every((f) => f.kind === "repro_base_green" || f.kind === "source_deliverable_untouched");
895
+ }
896
+
826
897
  const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
827
898
  /**
828
899
  * forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
@@ -1130,10 +1201,10 @@ export function formatShadowSummary(rows) {
1130
1201
  return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}${kinds.length ? `; per kind: ${kinds.join(", ")}` : ""}`;
1131
1202
  }
1132
1203
 
1133
- /** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
1134
- export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
1204
+ /** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724), external one aggregate per batch and host a `host-task` (#5078). */
1205
+ export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops", "host"];
1135
1206
  /** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
1136
- export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
1207
+ export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue", "host"]);
1137
1208
  const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
1138
1209
  /** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
1139
1210
  export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
@@ -1143,19 +1214,38 @@ export function bucketProblems(r) {
1143
1214
  if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
1144
1215
  if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
1145
1216
  const k = bucketKind(b), out = [];
1146
- if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action> or new-issue`);
1217
+ if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action>, new-issue or host`);
1147
1218
  else if (k === "date") {
1148
1219
  const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
1149
1220
  if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
1150
1221
  } else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
1151
- if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
1222
+ if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue" || k === "host")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
1223
+ if (k === "owner" && ownerRoute(r) === "host") out.push("read-only production data or capture is not an owner action: use bucket host");
1152
1224
  if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
1153
1225
  return out;
1154
1226
  }
1155
- /** The follow-up class of a requirement: owner -> external, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
1227
+ /** #5078: post-promotion / production-verify / rollout text (the rollup) and read-only production data or capture (the host: the prod_monitor signals of execution.mjs plus a read-only prod query). Mirrored in scripts/file-deferred-followups.sh. */
1228
+ const POST_PROMOTION_RE = /(?:after|post)[- ]?(?:promotion|release|deploy)|in production after|production[- ]verif|promot(?:e|ion)|rollout|purge|deploy[- ]time/i;
1229
+ export const READONLY_PROD_RE = /\bssh\s+[\w.-]*-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor\b|\bread[- ]only\b[^.]{0,40}\bprod(?:uction)?\b|\bprod(?:uction)?\b[^.]{0,30}\b(?:query|queries|capture|telemetry|logs?)\b/i;
1230
+ /** An item that truly needs the outside world (or a write to production) stays external even when it also reads production. */
1231
+ export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write)\b/i;
1232
+ /**
1233
+ * #5078: where an owner deferral goes. `owner:orchestrator`, `owner:promote:*` or post-promotion text -> post-deploy (the
1234
+ * rollup); read-only production data or capture (with no credential/spend/account/third-party/customer need) -> host; else external (credentials, spend, an account, a third party or a customer).
1235
+ */
1236
+ export function ownerRoute(r) {
1237
+ const action = String(r?.bucket ?? "").replace(/^owner:/, "");
1238
+ if (/^(?:orchestrator$|promote(?::|$))/i.test(action)) return "post-deploy";
1239
+ const text = `${action} ${r?.why ?? ""}`;
1240
+ // Release review #5103 (BUG-3): a credential/spend/write need is external even when it also names a rollout or release.
1241
+ if (POST_PROMOTION_RE.test(text) && !EXTERNAL_NEED_RE.test(text)) return "post-deploy";
1242
+ return READONLY_PROD_RE.test(text) && !EXTERNAL_NEED_RE.test(text) ? "host" : "external";
1243
+ }
1244
+ /** The follow-up class of a requirement: owner -> ownerRoute (ops kept), host -> host, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
1156
1245
  export function followupClass(r) {
1157
1246
  const k = bucketKind(r?.bucket);
1158
- if (k === "owner") return r?.class === "ops" ? "ops" : "external";
1247
+ if (k === "owner") return r?.class === "ops" ? "ops" : ownerRoute(r);
1248
+ if (k === "host") return "host";
1159
1249
  if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
1160
1250
  if (k) return "post-deploy";
1161
1251
  return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
@@ -1175,7 +1265,7 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
1175
1265
  const k = bucketKind(r.bucket);
1176
1266
  const tag = k ? `[bucket:${k}] ` : "";
1177
1267
  const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
1178
- const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
1268
+ const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k === "host" ? "host: " : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
1179
1269
  return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
1180
1270
  });
1181
1271
  }
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
7
7
 
8
8
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
9
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -649,7 +649,7 @@ describe("repro_test on bug lanes (#4271)", () => {
649
649
  it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
650
650
  const k = { ...C, repro_test: RT };
651
651
  assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
652
- for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
652
+ for (const repro of [{ base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
653
653
  const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
654
654
  assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
655
655
  assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base} HEAD=${repro.head}`));
@@ -657,6 +657,26 @@ describe("repro_test on bug lanes (#4271)", () => {
657
657
  assert.match(reproFailures(k, { base: "SKIPPED", head: "FAILED" })[0].detail, /HEAD=FAILED \(base not run: head did not pass\)$/);
658
658
  assert.doesNotMatch(reproFailures(k, { base: "RAN", head: "RAN" })[0].detail, /base not run/);
659
659
  });
660
+ it("#5080 repro_base_green: BASE=RAN HEAD=RAN is its own kind, and a test-only diff with a source deliverable is never the fix", () => {
661
+ const k = { ...C, repro_test: RT };
662
+ const green = precheck({ contract: k, result: res, changedFiles: files, repro: { base: "RAN", head: "RAN" } });
663
+ assert.deepEqual(green.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_base_green"]]);
664
+ assert.match(green.failures[0].detail, /must fail on the base and pass on head; got BASE=RAN HEAD=RAN/);
665
+ const testOnly = ["test/leak.test.js"];
666
+ const pc = precheck({ contract: k, result: res, changedFiles: testOnly, repro: { base: "RAN", head: "RAN" } });
667
+ assert.deepEqual(pc.failures.map((f) => f.kind), ["repro_base_green", "source_deliverable_untouched"]);
668
+ assert.equal(alreadyFixedOnBase(pc.failures, testOnly), true);
669
+ assert.equal(alreadyFixedOnBase(green.failures, testOnly), true);
670
+ assert.equal(alreadyFixedOnBase(green.failures, ["src/leak.js", "test/leak.test.js"]), false, "a source change keeps conformance.unimplemented (K3)");
671
+ assert.equal(alreadyFixedOnBase(green.failures, ["docs/a.md", ".forge-lean/x.json"], [".forge-lean/"]), true);
672
+ assert.equal(alreadyFixedOnBase([{ kind: "repro_base_green" }, { kind: "validate_failed" }]), false);
673
+ assert.equal(alreadyFixedOnBase([{ kind: "source_deliverable_untouched" }]), false);
674
+ const only = precheck({ contract: k, result: res, changedFiles: testOnly, repro: { base: "FAILED", head: "RAN" } });
675
+ assert.deepEqual(only.failures.map((f) => f.kind), ["source_deliverable_untouched"]);
676
+ assert.deepEqual(sourceDeliverableFailures(k, files), []);
677
+ assert.deepEqual(sourceDeliverableFailures({ deliverables: [{ id: "D1", path: "docs/a.md" }, { id: "D2", path: "test/leak.test.js" }] }, testOnly), []);
678
+ assert.deepEqual(sourceDeliverableFailures(k, ["test/leak.test.js", ".forge-lean/x.json"], [".forge-lean/"]).map((f) => f.kind), ["source_deliverable_untouched"]);
679
+ });
660
680
  it("a harness UNRUNNABLE on head is env.repro_unrunnable, not conformance.unimplemented (#4334)", () => {
661
681
  const k = { ...C, repro_test: RT };
662
682
  for (const repro of [{ base: "SKIPPED", head: "UNRUNNABLE" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }, {}]) {
@@ -930,7 +950,7 @@ describe("post-deploy check ids (#4626)", () => {
930
950
  assert.equal(cls({ disposition: "post-deploy", check: "x" }), "post-deploy");
931
951
  assert.equal(cls({ disposition: "defer", class: "external" }), "external");
932
952
  assert.equal(cls({ disposition: "defer", class: "bogus" }), "engineering");
933
- assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops"]);
953
+ assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops", "host"]);
934
954
  assert.equal(followupClass({ disposition: "post-deploy" }), "post-deploy"); assert.equal(followupClass({ class: "ops" }), "ops");
935
955
  const v = validateContract({ requirements: [{ id: "R1", quote: Q1, disposition: "defer", why: "w", class: "bogus" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] }, Q1);
936
956
  assert.ok(v.problems.some((x) => /R1: class must be one of/.test(x)), v.problems.join(";"));
@@ -942,7 +962,7 @@ describe("post-deploy check ids (#4626)", () => {
942
962
  it("#4724 a deferred row needs exactly one valid bucket; owner actions are never post-deploy; dates are absolute", () => {
943
963
  const contract = (r) => ({ requirements: [{ id: "R1", quote: Q1, why: "w", ...r }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] });
944
964
  const probs = (r) => validateContract(contract(r), Q1).problems.filter((x) => /^R1: .*(bucket|never post-deploy|owner)/.test(x));
945
- assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue"]);
965
+ assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue", "host"]);
946
966
  assert.equal(probs({ disposition: "defer" }).length, 1); // missing bucket
947
967
  for (const bucket of ["post-deploy", "verify-now", "new-issue", "date:2026-12-01", "owner:rotate the key"]) assert.deepEqual(probs({ disposition: "defer", bucket }), [], bucket);
948
968
  for (const bucket of ["date:5 weeks after deploy", "date:2026-02-30", "date", "owner:", "owner", "later", "post-deploy:x"]) assert.equal(probs({ disposition: "defer", bucket }).length, 1, bucket);
@@ -965,6 +985,29 @@ describe("post-deploy check ids (#4626)", () => {
965
985
  assert.equal(one({ bucket: "new-issue" }).why, "defer: [bucket:new-issue] w");
966
986
  assert.equal(one({}).why, "defer: w"); // an unbucketed (older) row is unchanged
967
987
  });
988
+ it("#5078 owner is narrowed: post-promotion -> rollup, read-only prod -> host, credentials/spend -> external", () => {
989
+ const d = (bucket, why = "w") => ({ disposition: "defer", bucket, why });
990
+ assert.equal(ownerRoute(d(ORCHESTRATOR_BUCKET)), "post-deploy");
991
+ assert.equal(followupClass(d("owner:orchestrator", "post-deploy action with no registered check")), "post-deploy");
992
+ assert.equal(followupClass(d("owner:promote:new-nav", "activate the flag")), "post-deploy");
993
+ assert.equal(followupClass(d("owner:ops", "purge the CDN after release")), "post-deploy");
994
+ assert.equal(followupClass(d("owner:qa", "verify in production after promotion")), "post-deploy");
995
+ assert.equal(followupClass(d("owner:obs-psql", "capture the read-only production query counts")), "host");
996
+ assert.equal(followupClass(d("owner:ssh ovh-monitor", "tail the logs")), "host");
997
+ assert.equal(followupClass(d("owner:stripe", "needs live Stripe credentials")), "external");
998
+ assert.equal(followupClass(d("owner:finance", "approve the spend")), "external");
999
+ assert.equal(followupClass(d("owner:ops", "needs a customer account to read production data")), "external");
1000
+ assert.equal(followupClass({ ...d("owner:obs-psql", "read prod data"), class: "ops" }), "ops");
1001
+ assert.equal(followupClass(d("host")), "host");
1002
+ assert.deepEqual(bucketProblems(d("host")), []);
1003
+ assert.match(bucketProblems({ disposition: "post-deploy", check: "a", bucket: "host" }).join(";"), /never post-deploy/);
1004
+ assert.match(bucketProblems(d("owner:obs-psql", "capture production query counts")).join(";"), /use bucket host/);
1005
+ assert.deepEqual(bucketProblems(d("owner:stripe", "needs live credentials")), []);
1006
+ assert.deepEqual(bucketProblems(d(ORCHESTRATOR_BUCKET, "manual prod action")), []);
1007
+ const [h, o] = followupItems([{ id: "R1", quote: Q1, ...d("host", "count rows") }, { id: "R2", quote: Q1, why: "w", ...d("owner:orchestrator") }]);
1008
+ assert.equal(h.why, "host: [bucket:host] count rows"); assert.equal(h.class, "host");
1009
+ assert.equal(o.class, "post-deploy"); assert.match(o.why, /^owner: orchestrator\. \[bucket:owner\]/);
1010
+ });
968
1011
  it("#4724 bucketTally counts deferred items per bucket kind; an amendment may carry the bucket", () => {
969
1012
  assert.deepEqual(bucketTally([{ bucket: "date:2026-12-01" }, { bucket: "date:2026-12-02" }, { bucket: "owner:x" }, {}]), { date: 2, owner: 1, none: 1 });
970
1013
  const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [], preserve: [], validate: [] };
@@ -1116,6 +1159,7 @@ describe("per-kind modes and requirement evidence", () => {
1116
1159
  });
1117
1160
 
1118
1161
  import { mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED } from "./conformance.mjs";
1162
+ import { alreadyFixedOnBase, sourceDeliverableFailures } from "./conformance.mjs";
1119
1163
 
1120
1164
  describe("mutation check (#4913)", () => {
1121
1165
  const c = {
@@ -1267,3 +1311,76 @@ describe("#5020 freeze-time environment defects", () => {
1267
1311
  assert.equal(r.contract.validate[0].command, "poetry run pytest");
1268
1312
  });
1269
1313
  });
1314
+
1315
+ describe("#5087 a plan decision may not weaken an e2e/integration criterion to mocked tests", () => {
1316
+ const K2 = { id: "K2", choice: "No Playwright e2e; integration coverage via route and component tests with mocked getServerSession and fetch", justification: "web/e2e needs a live stack", source: "precedent" };
1317
+ it("rejects the K2 wording of AlterLab#38180 at contract validation", () => {
1318
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [K2] }), ["K2"]);
1319
+ const v = validateContract({ ...C, decisions: [K2] }, ISSUE);
1320
+ assert.equal(v.ok, false);
1321
+ assert.ok(v.problems.some((p) => p.startsWith("K2:") && p.includes("forge-probe")), v.problems.join("|"));
1322
+ });
1323
+ it("accepts it when an acceptance probe is declared, in the decision or elsewhere in the contract", () => {
1324
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "a forge-probe acceptance probe runs the flow after promotion" }] }), []);
1325
+ assert.deepEqual(weakenedE2eDecisions({ ...C, deliverables: [...C.deliverables, { id: "D9", path: "x.md", change: "declare a forge-probe block" }], decisions: [K2] }), []);
1326
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "no acceptance probe" }] }), ["K2"]);
1327
+ });
1328
+ it("leaves unrelated decisions and contracts without decisions alone", () => {
1329
+ assert.deepEqual(weakenedE2eDecisions(C), []);
1330
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
1331
+ assert.deepEqual(validateContract({ ...C, decisions: [{ id: "K1", choice: "copy the recipes pattern", source: "precedent" }] }, ISSUE), { ok: true, problems: [] });
1332
+ });
1333
+ it("precheck appends routeFailures as blocking failures; absent keeps today's behaviour", () => {
1334
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
1335
+ assert.equal(precheck({ contract: C, result: res, changedFiles: files }).ok, true);
1336
+ assert.equal(precheck({ contract: C, result: res, changedFiles: files, routeFailures: [] }).ok, true);
1337
+ const pc = precheck({ contract: C, result: res, changedFiles: files, routeFailures: [{ code: "conformance.unimplemented", kind: "route_unreachable", detail: "x" }] });
1338
+ assert.equal(pc.ok, false);
1339
+ assert.deepEqual(pc.failures.map((f) => f.kind), ["route_unreachable"]);
1340
+ });
1341
+ it("#4726 [type:e2e] behaviour is unchanged", () => {
1342
+ assert.equal(isE2eCommand("node --test test/leak.test.js"), false);
1343
+ assert.equal(isE2eCommand("npx playwright test"), true);
1344
+ });
1345
+ });
1346
+
1347
+ describe("amendmentKindProblems (#5089)", () => {
1348
+ it("flags the invented deliverable_change kind and names the valid kinds", () => {
1349
+ const p = amendmentKindProblems([{ kind: "deliverable_change", requirement: "R1", why: "no Linux build" }]);
1350
+ assert.equal(p.length, 1);
1351
+ assert.match(p[0], /unknown kind 'deliverable_change'; valid kinds are .*defer/);
1352
+ });
1353
+ it("accepts valid kinds, normalised slips and informational kinds", () => {
1354
+ assert.deepEqual(amendmentKindProblems([{ kind: "defer", requirement: "R1", why: "w" }, { type: "limitation", requirement: "R2", why: "w" }, { kind: "scope-note", why: "w" }, { kind: "add-deliverable", path: "a.js", why: "w" }]), []);
1355
+ assert.deepEqual(amendmentKindProblems(undefined), []);
1356
+ });
1357
+ it("flags a missing path or requirement", () => {
1358
+ assert.equal(amendmentKindProblems([{ kind: "add-deliverable", why: "w" }, { kind: "defer", why: "w" }]).length, 2);
1359
+ });
1360
+ });
1361
+
1362
+ // Release review of #5103: MAT-1/REG-1 (non-code fixes are never "already fixed") and the read-only-prod routing theme
1363
+ // (SPEC-1, SEC-4, AUTH-1, BUG-2/3, QA-3, MAT-7, REG-4).
1364
+ import { READONLY_PROD_RE as RO5103, EXTERNAL_NEED_RE as EX5103, ownerRoute as route5103, alreadyFixedOnBase as afb5103 } from "./conformance.mjs";
1365
+ describe("release review #5103", () => {
1366
+ const green = [{ kind: "repro_base_green" }, { kind: "source_deliverable_untouched" }];
1367
+ it("a lane that changed css/json/yaml/sql/vue/tf is never closed as already fixed on base", () => {
1368
+ for (const f of ["web/app.css", "config/limits.json", "deploy/values.yaml", "db/0042.sql", "web/App.vue", "infra/main.tf", "web/index.html"]) {
1369
+ assert.equal(afb5103(green, [f, "test/x.test.js"]), false, f);
1370
+ }
1371
+ });
1372
+ it("tests and docs only still close as already fixed", () => {
1373
+ assert.equal(afb5103(green, ["test/x.test.js", "docs/a.md", "devdocs/x.mdx"]), true);
1374
+ });
1375
+ const host = (why) => RO5103.test(why) && !EX5103.test(why);
1376
+ it("read-only production telemetry, captures and queries route to host", () => {
1377
+ for (const why of ["read-only production telemetry from ovh", "ssh ovh-monitor to capture the queue", "obs-psql query of prod", "run a production query against the replica", "capture production logs for the run"]) assert.equal(host(why), true, why);
1378
+ });
1379
+ it("owner asks, writes, credentials and product wording stay external", () => {
1380
+ for (const why of ["Should we delete production data older than 90 days?", "add uptime-monitor dashboard", "needs the production database password to query prod data",
1381
+ "needs the prod ssh key to read production logs", "product manager decides the pricing", "production dataset retention policy", "drop the production table after the capture"]) {
1382
+ assert.equal(host(why), false, why);
1383
+ }
1384
+ assert.equal(route5103({ bucket: "owner:decide", why: "Should we delete production data older than 90 days?" }), "external");
1385
+ });
1386
+ });