From df5813c414f6b120c75600f122d84d9424e36309 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:15:34 -0700 Subject: [PATCH] fix(coordination): require merge approval flow for child runs --- AUDIT_OPEN.md | 11 +++++++++++ src/coordination.test.ts | 22 ++++++++++++++++++++++ src/coordination.ts | 4 ++++ 3 files changed, 37 insertions(+) diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index fd826d0..a8fc784 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1,5 +1,16 @@ # Open audit items +## 2026-09-27 — coordination on a minimal installation + +Coordination children inherited disabled change/merge gates, so successful PR +publication ended their runs without the merge receipt required by the pair. +The API was marked failed and held the client even after a separate forge merge. +New coordination children now explicitly request classification and the existing +merge boundary; operator approval and authority rechecks remain in that path. +Regression coverage reproduces the missing flags and checks both children. +Existing failed pairs are not rewritten or treated as merged; external merge +reconciliation and the full live producer/consumer run remain acceptance work. + ## 2026-09-25 — database retry pressure Fresh-connection retries bypassed the four-connection pool without a concurrency diff --git a/src/coordination.test.ts b/src/coordination.test.ts index e05b94f..c64fe43 100644 --- a/src/coordination.test.ts +++ b/src/coordination.test.ts @@ -691,3 +691,25 @@ test("sweepCoordinations advances every coordination and collects per-record err assert.deepEqual((await listCoordinations(runtime)).map((r) => r.id).sort(), [fake.record.id, second.id].sort(), "the broken record never lists"); assert.equal(third.errors.length, 0); }); + + +test("coordination requires a recorded merge even when deployment-wide change gates are off", async () => { + const saved = { change: process.env.SHIP_CHANGE_CLASS, merge: process.env.SHIP_MERGE_GATE }; + process.env.SHIP_CHANGE_CLASS = "0"; + process.env.SHIP_MERGE_GATE = "0"; + try { + const fake = await newPair(); + const api = await launchNext(fake.runtime, fake.record.id); + const input = recordedInput(fake, api.runId!) as unknown as { changeClass?: boolean; mergeGate?: boolean }; + assert.equal(input.changeClass, true, "API change must reach classification and its merge boundary"); + assert.equal(input.mergeGate, true, "publishing a PR alone cannot satisfy the dependency"); + mergeRun(fake, api.runId!, "abc123def456"); + const client = await launchNext(fake.runtime, fake.record.id); + const next = recordedInput(fake, client.runId!) as unknown as { changeClass?: boolean; mergeGate?: boolean }; + assert.equal(next.changeClass, true); + assert.equal(next.mergeGate, true, "client completion also requires a recorded merge"); + } finally { + if (saved.change === undefined) delete process.env.SHIP_CHANGE_CLASS; else process.env.SHIP_CHANGE_CLASS = saved.change; + if (saved.merge === undefined) delete process.env.SHIP_MERGE_GATE; else process.env.SHIP_MERGE_GATE = saved.merge; + } +}); diff --git a/src/coordination.ts b/src/coordination.ts index 079d39d..cbab7bc 100644 --- a/src/coordination.ts +++ b/src/coordination.ts @@ -1133,6 +1133,10 @@ async function launchChild( model: claimed.model, repo: child.repo, journey: "change", + // Coordination depends on merge receipts, so publishing a PR alone is + // insufficient even on deployments where standalone change gates are off. + changeClass: true, + mergeGate: true, source: "manual", // The repos were typed by an authenticated human on the coordination // form; the allowlist was checked at creation and binds here too.