yadflow 3.15.2 → 3.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [3.15.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.3...v3.15.4) (2026-08-12)
2
+
3
+ ## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **bridge:** pass the PR head ref through env, not into the run script ([306f49f](https://github.com/abdelrahmannasr/yadflow/commit/306f49f569c5398bbfb36ce9aa3fb38d8341336c))
9
+ * **gate:** make the reconcile sweep converge instead of committing forever ([6bcb8fd](https://github.com/abdelrahmannasr/yadflow/commit/6bcb8fd8cd296b669bcf11226013fcd2e107f650)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
10
+ * **gate:** stage the merge commit from an allowlist, not the whole epic dir ([8142ddc](https://github.com/abdelrahmannasr/yadflow/commit/8142ddcce0f889505a29558c6664e42548d9d671))
11
+
1
12
  ## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
2
13
 
3
14
 
@@ -59,9 +59,15 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
59
59
  const epics = epic
60
60
  ? [epic]
61
61
  : (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
62
- if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0 }; }
62
+ if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0, files: [] }; }
63
63
 
64
64
  let changed = 0;
65
+ // The root-relative paths actually rewritten. `gate ci` stages its merge commit from an explicit
66
+ // allowlist — the ledger, the generated reviews, and exactly these artifacts — so a run on a dirty
67
+ // checkout (the documented manual recovery) can never sweep an unrelated file onto the default
68
+ // branch. Reporting the paths is what lets the caller be that specific. Named `written`, not
69
+ // `files`: the per-epic loop below already binds a local `files` (its candidate list).
70
+ const written = [];
65
71
  for (const e of epics) {
66
72
  const dir = epicRoot(root, e);
67
73
  const state = readJSONStrict(epicFiles(dir).state, null);
@@ -98,10 +104,10 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
98
104
  continue;
99
105
  }
100
106
  const prev = setFrontmatterStatus(file, want);
101
- if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
107
+ if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; written.push(path.relative(root, file)); }
102
108
  }
103
109
  }
104
110
  if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
105
111
  else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
106
- return { changed };
112
+ return { changed, files: written };
107
113
  }
@@ -52,9 +52,45 @@ export function artifactPaths(base) {
52
52
  return [`${base}.md`];
53
53
  }
54
54
 
55
+ // ---- canonical ledger order ---------------------------------------------------------------------
56
+ // Every epic-ledger upsert in this codebase is drop-and-re-append: the records being refreshed are
57
+ // filtered out of the array and pushed back at the TAIL. That makes the file's bytes depend on WHICH
58
+ // step was synced last, not on what the ledger holds — and the wired sweep drives one `gate ci` per
59
+ // merged PR/MR, so a pass over N merged reviews ROTATES the array:
60
+ //
61
+ // [A,B,C,D,E] -sync A-> [B,C,D,E,A] -sync B-> [C,D,E,A,B] -> … -sync E-> [A,B,C,D,E]
62
+ //
63
+ // Every hop is a non-empty diff, so every hop commits and pushes, and the pass lands back where it
64
+ // started — an unbounded commit loop with zero semantic change (issue #163). Sorting the array on
65
+ // write makes the bytes a pure function of the record SET, so an unchanged re-sync is byte-identical
66
+ // and `gate ci`'s existing "nothing staged -> nothing to commit" guard finally holds.
67
+ //
68
+ // The per-record JSON.stringify tiebreak is what makes the order TOTAL. `Array#sort` is stable, so
69
+ // records tying on the tuple key would keep their (rotating) insertion order — and a manual,
70
+ // skill-written approval can tie with a bridge one on (step, approver, role, domain). The tiebreak is
71
+ // position-independent, so ties resolve by content instead.
72
+ //
73
+ // Compared by CODE UNIT (`<`), not localeCompare: the ledger is written by CI and read/re-written by
74
+ // every teammate's machine, and localeCompare's order depends on the host locale and ICU build. Two
75
+ // machines disagreeing on where one record sorts would reintroduce exactly the churn this prevents.
76
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
77
+ const canonical = (list, keyOf) => [...list].sort(
78
+ (x, y) => cmp(keyOf(x), keyOf(y)) || cmp(JSON.stringify(x), JSON.stringify(y)),
79
+ );
80
+ const field = (v) => (v == null ? '' : String(v));
81
+
82
+ export const canonicalApprovals = (approvals = []) => canonical(approvals, (a) =>
83
+ [a.step, a.artifact, a.role, a.domain, a.approver, a.source, a.approvedAt, a.date].map(field).join('|'));
84
+
85
+ export const canonicalComments = (comments = []) => canonical(comments, (cm) =>
86
+ [cm.step, String(cm.round ?? '').padStart(6, '0'), cm.commenter, cm.role, cm.date].map(field).join('|'));
87
+
88
+ export const canonicalHubPrs = (hubPrs = []) => canonical(hubPrs, (p) =>
89
+ [p.artifact, p.step].map(field).join('|'));
90
+
55
91
  // Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
56
92
  export function upsertHubPr(hubPrs = [], rec) {
57
- return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
93
+ return canonicalHubPrs([...hubPrs.filter((p) => p.artifact !== rec.artifact), rec]);
58
94
  }
59
95
 
60
96
  // SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
package/cli/gate.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
14
  upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
15
+ canonicalApprovals, canonicalComments, canonicalHubPrs,
15
16
  } from './epic-state.mjs';
16
17
  import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
17
18
  import {
@@ -179,7 +180,11 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNum
179
180
  ...(r.unverified ? { unverified: true } : {}),
180
181
  });
181
182
  }
182
- return kept;
183
+ // Canonical order, not insertion order: this function re-appends at the tail, so without it the
184
+ // bytes depend on which step was synced last and the sweep rotates the file forever (issue #163 —
185
+ // see canonicalApprovals). Sorting here also makes the in-memory before/after comparison below
186
+ // (`approvalsBefore`) mean what it says, so an unchanged re-sync no longer re-stamps lastSyncedAt.
187
+ return canonicalApprovals(kept);
183
188
  }
184
189
 
185
190
  // Mutates in place, returns how many it stamped. Backfill `pr` on this step's bridge approvals that
@@ -216,14 +221,28 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
216
221
  if (!blocking.length) return comments;
217
222
  const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
218
223
  const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
219
- const round = (comments.filter((cm) => cm.step === stepId).reduce((m, cm) => Math.max(m, cm.round || 0), 0)) + 1;
220
224
  const counts = new Map();
221
225
  for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
226
+ // A round is a CHANGE in the thread state, not a sync. Allocating max+1 on every call made an
227
+ // unchanged re-read append a whole new record set each pass — and the sweep re-reads a merged review
228
+ // every 15 minutes for a week. A step whose gate never passes (one unresolved thread, or a missing
229
+ // approval) is never `alreadyDone`, so it kept reaching here: ~96 ledger commits a day per stuck
230
+ // review, the same unbounded loop as #163 from the other side. So when the latest recorded round
231
+ // already describes exactly these commenters and counts, REWRITE it in place instead.
232
+ const rounds = comments.filter((cm) => cm.step === stepId);
233
+ const latest = rounds.reduce((m, cm) => Math.max(m, cm.round || 0), 0);
234
+ const prior = rounds.filter((cm) => cm.round === latest);
235
+ const now = new Map([...counts].map(([login, count]) => [byName(login), count]));
236
+ const same = latest > 0 && prior.length === now.size && prior.every((cm) => now.get(cm.commenter) === cm.count);
237
+ const round = same ? latest : latest + 1;
222
238
  const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
223
239
  for (const [login, count] of counts) {
224
- kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: today });
240
+ // An unchanged round keeps its original date, so re-syncing it is byte-identical rather than a
241
+ // daily one-line churn (the same rule upsertBridge applies to an unchanged approval).
242
+ const was = prior.find((cm) => cm.commenter === byName(login));
243
+ kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: (same && was?.date) || today });
225
244
  }
226
- return kept;
245
+ return canonicalComments(kept); // same drop-and-re-append churn as approvals — see canonicalApprovals
227
246
  }
228
247
 
229
248
  // ---- actions ------------------------------------------------------------------------------------
@@ -306,7 +325,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
306
325
  if (s) stampLegacyPr(approvals, s.id, p.number);
307
326
  }
308
327
  const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
309
- const targets = resolved.targets;
328
+ // Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
329
+ // it closes, so syncing two passing gates out of chain order rewinds the epic: closing
330
+ // architecture-review first (next: ui-design) and epic-review second (next: architecture) reopens the
331
+ // already-done `architecture` author step and points currentStep backward — the YAD-STATE-005 chain
332
+ // inconsistency `yad gate repair` exists to undo. This used to hold only by accident, because
333
+ // hub-prs.json happened to be in insertion order; now that the file is written sorted by artifact
334
+ // (see canonicalHubPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
335
+ // either way — it always names a single artifact.
336
+ const stepIndex = (p) => {
337
+ const s = findReviewStep(state, p.artifact);
338
+ return s ? state.steps.indexOf(s) : Number.MAX_SAFE_INTEGER;
339
+ };
340
+ const targets = [...resolved.targets].sort((a, b) => stepIndex(a) - stepIndex(b));
310
341
  if (!targets.length) {
311
342
  warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
312
343
  hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
@@ -417,11 +448,17 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
417
448
  info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
418
449
  return { synced, advanced };
419
450
  }
451
+ // Belt-and-braces: the upserts above already return canonical order, but a ledger this run only
452
+ // READ (no matching target, or a pre-canonical file written by an older release) still gets sorted
453
+ // here, so the first sweep after the upgrade converges the file once and never churns it again.
454
+ approvals = canonicalApprovals(approvals);
455
+ comments = canonicalComments(comments);
456
+ hubPrs = canonicalHubPrs(hubPrs);
420
457
  writeJSON(ledger.files.approvals, approvals);
421
458
  writeJSON(ledger.files.comments, comments);
422
459
  writeJSON(ledger.files.hubPrs, hubPrs);
423
460
  writeJSON(ledger.files.state, state);
424
- refreshRoster(epicDir, open, approvals, today);
461
+ refreshRoster(epicDir, open, approvals, today); // the dated side file lists them in the same order
425
462
  return { synced, advanced };
426
463
  }
427
464
 
@@ -482,6 +519,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
482
519
  let synced = 0;
483
520
  const touched = new Set();
484
521
  const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
522
+ const statusFiles = new Map(); // epic -> the artifact files syncStatuses rewrote (staging allowlist)
485
523
  for (const job of jobs) {
486
524
  const epicDir = epicRoot(root, job.epic);
487
525
  // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
@@ -508,10 +546,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
508
546
  // Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
509
547
  // recorded before replacing it, or a re-review on the replacement PR can never be told from a
510
548
  // re-read of the old one and stays permanently stale.
511
- if (existing?.number != null && number !== existing.number) {
549
+ //
550
+ // MERGE PHASE ONLY. This is the one approvals.json write gateCi itself performs, and pre-merge the
551
+ // run persists nothing and commits nothing — so a stamp there would be a working-tree edit with no
552
+ // purpose, left behind for `ledger-guard` to reject (and a `git checkout` broad enough to undo it
553
+ // would also revert approvals this run never wrote, e.g. a human's uncommitted manual record). The
554
+ // stamp loses nothing by waiting: pre-merge writes nothing, so the OLD pointer is still on disk when
555
+ // the merge event arrives and re-runs this. In sweep mode `job.pr` comes from the ledger itself, so
556
+ // `number === existing.number` and the condition is false regardless.
557
+ if (merged && existing?.number != null && number !== existing.number) {
512
558
  const stamped = stampLegacyPr(ledger.approvals, step.id, existing.number);
513
559
  if (stamped) {
514
- writeJSON(ledger.files.approvals, ledger.approvals);
560
+ writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
515
561
  info(`${job.epic}: recorded PR #${existing.number} on ${stamped} approval(s) that predate PR provenance`);
516
562
  }
517
563
  }
@@ -535,7 +581,13 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
535
581
  // reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
536
582
  // --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
537
583
  // on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
538
- if (r.advanced > 0) { advancedEpics.add(job.epic); await syncStatuses(root, { epic: job.epic }); }
584
+ if (r.advanced > 0) {
585
+ advancedEpics.add(job.epic);
586
+ const st = await syncStatuses(root, { epic: job.epic });
587
+ // Remember exactly which artifacts were rewritten — that, and nothing else, is what the
588
+ // commit below may stage outside the ledger (see the staging allowlist).
589
+ statusFiles.set(job.epic, [...(statusFiles.get(job.epic) || []), ...(st.files || [])]);
590
+ }
539
591
  } catch (err) {
540
592
  if (branch) throw err; // event mode: one epic — surface the failure
541
593
  warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
@@ -554,10 +606,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
554
606
  // unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
555
607
  const advancedAny = advancedEpics.size > 0;
556
608
  if (!merged && !advancedAny) {
557
- // Pre-merge is read-only (Path B): the gate was evaluated with a dry sync that persists nothing.
558
- // The one working-tree write is the hub-prs.json seed above (so the dry sync could find the PR);
559
- // restore exactly that file per epic so the checkout stays clean — never touching anything else,
560
- // so a local `yad gate ci --branch` cannot disturb unrelated files.
609
+ // EVENT mode (--branch) pre-merge is read-only (Path B): the gate was evaluated with a dry sync
610
+ // that persists nothing, so the one working-tree write is the hub-prs.json seed above (which let
611
+ // the dry sync find the PR). Restore exactly that file per epic so the checkout stays clean —
612
+ // never touching anything else, so a local `yad gate ci --branch` cannot disturb unrelated files.
613
+ // The stampLegacyPr backfill is gated on `merged` above precisely so there is no second file to
614
+ // undo: a restore wide enough to cover approvals.json would also revert records this run never
615
+ // wrote — a human's uncommitted manual approval, or an untracked ledger seed `git clean` deletes.
616
+ //
617
+ // SWEEP mode (no --branch) reaches here too, and its sync was NOT dry, so state.json/comments.json
618
+ // and reviews/*.md may be modified and are deliberately left alone: reverting a sync that genuinely
619
+ // ran would discard platform state the run just recorded. A bare `yad gate ci` that advances
620
+ // nothing therefore leaves those files dirty for the operator to inspect and commit (or discard).
561
621
  for (const e of touched) {
562
622
  const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
563
623
  git('checkout', '-q', '--', hp); // restore it if it was tracked
@@ -568,13 +628,22 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
568
628
  }
569
629
  const target = defaultBranch; // CI only ever commits the ledger to the default branch
570
630
 
571
- // Stage what this merge-phase run owns, per epic (everything lands on the default branch):
572
- // - advanced the whole epic (ledger advance + the status flip syncStatuses wrote into the .md).
573
- // - merged but not advanced (merged before the rule passed) → the ledger (.sdlc) + the generated
574
- // reviews/ summaries only; the artifact is the owner's, left untouched.
631
+ // Stage what this merge-phase run owns, per epic, by an EXPLICIT ALLOWLIST never `git add -A`
632
+ // over the whole epic directory:
633
+ // - always → the ledger (.sdlc) + the generated reviews/ summaries.
634
+ // - advanced plus exactly the artifact files syncStatuses rewrote (the draft approved flip).
635
+ // The owner's artifact is otherwise theirs, and is left untouched.
636
+ //
637
+ // `git add -A -- epics/<e>` would also sweep up anything else sitting in that directory. In CI the
638
+ // checkout is fresh, so it is invisible there — but `yad gate ci … --merged` on the default branch
639
+ // is the DOCUMENTED manual recovery for a stuck gate, and a human's checkout is rarely pristine.
640
+ // Half-finished edits to another artifact, or a stray untracked file, would be committed and pushed
641
+ // straight to the default branch under a `chore(gate)` subject with [skip ci] — unreviewed, and
642
+ // contradicting the "CI commits only the ledger" contract every doc in this repo states.
575
643
  for (const e of touched) {
576
- if (advancedEpics.has(e)) git('add', '-A', '--', path.join('epics', e));
577
- else { git('add', '-A', '--', path.join('epics', e, '.sdlc')); git('add', '-A', '--', path.join('epics', e, 'reviews')); }
644
+ git('add', '-A', '--', path.join('epics', e, '.sdlc'));
645
+ git('add', '-A', '--', path.join('epics', e, 'reviews'));
646
+ for (const f of statusFiles.get(e) || []) git('add', '--', f);
578
647
  }
579
648
  if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
580
649
  // [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
package/cli/lib.mjs CHANGED
@@ -123,6 +123,13 @@ export function readJSONStrict(p, def = null) {
123
123
  // file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
124
124
  export function writeJSON(p, obj) {
125
125
  const data = JSON.stringify(obj, null, 2) + '\n';
126
+ // Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
127
+ // whether or not anything changed — so this keeps an unchanged sync from touching the file at all
128
+ // (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
129
+ // load-bearing guarantee is that the serialized bytes are canonically ordered (see #163).
130
+ try {
131
+ if (fs.readFileSync(p, 'utf8') === data) return;
132
+ } catch { /* missing or unreadable — fall through and write it */ }
126
133
  fs.mkdirSync(path.dirname(p), { recursive: true });
127
134
  const tmp = `${p}.${process.pid}.tmp`;
128
135
  fs.writeFileSync(tmp, data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.15.2",
3
+ "version": "3.15.4",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -178,9 +178,22 @@ awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
178
178
  > of hash-binding.
179
179
 
180
180
  ### Step 6 — Advance the authoring step (NOT the gate)
181
- In `state.json`: set `architecture.status: "done"`, set `architecture-review.status: "in_review"`, and
182
- set `currentStep: "architecture-review"`. Write `state.json`. Do **not** touch `approvals.json` — only
183
- real reviewers approve, through the gate.
181
+ **Check the mode first the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
182
+ **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
183
+
184
+ **Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
185
+ any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
186
+ `epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
187
+ `yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
188
+ fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
189
+ is pushed around the gate. Commit the artifact set — **`architecture.md`, `contract.md`, and
190
+ `.sdlc/contract-lock.json`** (artifact-side, not ledger) — then hand off to `yad-review-gate`.
191
+
192
+ **Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
193
+ `architecture.status: "done"`, set `architecture-review.status: "in_review"`, and set
194
+ `currentStep: "architecture-review"`. Write `state.json`. Do **not** touch `approvals.json` — only
195
+ real reviewers approve, through the gate. On this branch `yad gate open` makes the same edit, so it
196
+ is a no-op once the gate has run.
184
197
 
185
198
  ### Step 7 — Stop at the gate (do NOT advance)
186
199
  Report: the paths to `architecture.md`, `contract.md`, and `contract-lock.json`; the contract hash;
@@ -99,6 +99,12 @@ thread off it, `promote` is what makes that anchor real — run it once the feat
99
99
  "wake" the state chain — set `currentStep: "epic"`, `epic` step `status: "in_progress"` — then run
100
100
  `yad-epic` → `yad-architecture` → … the normal way. This re-locks a contract that subsequent thread
101
101
  changes will inherit.
102
+ - **Bridge mode — promote is not wired.** The `state.json` edits above mutate an epic whose ledger is
103
+ already on the base ref, so the `#162` seed exemption does not apply and `ledger-guard` rejects the
104
+ commit; unlike the authoring steps there is no `yad backfill` CLI and no `gate ci` path that performs
105
+ the promotion instead. On a bridge hub, STOP and report this — the promotion needs the gate bot (or a
106
+ maintainer landing it out of band). Only the `epic.md` half is safe to commit. Tracked as a gap; do
107
+ **not** push the ledger edit around the guard.
102
108
  - Never auto-advances; a human confirms the promotion.
103
109
 
104
110
  ### Step 7 — Stop (no auto-advance)
@@ -169,14 +169,31 @@ Notes:
169
169
 
170
170
  ### Step 5b — Advance the authoring step — analysis-ran only
171
171
  *(Only when analysis ran — `state.json` already exists from `yad-analysis`.)*
172
- In `state.json`: set `epic.status: "done"`, set `epic-review.status: "in_review"`, and set
173
- `currentStep: "epic-review"`. Write `state.json`. Do **not** re-seed and do **not** touch
174
- `approvals.json` — only real reviewers approve, through the gate.
175
-
176
- > Since 3.11 the CLI closes the authoring step itself whenever its review gate opens or advances
177
- > (`yad gate open` / `sync`), so this edit is a no-op when the gate has already run. Keep making it —
178
- > it keeps `state.json` truthful before the gate opens but it is no longer load-bearing: an epic
179
- > whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`).
172
+ **Check the mode first the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
173
+ **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
174
+
175
+ **Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
176
+ any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
177
+ `epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
178
+ `yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
179
+ fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
180
+ is pushed around the gate. Commit **`epic.md` only** — nothing else under `.sdlc/` — then hand off
181
+ to `yad-review-gate`.
182
+
183
+ > **This is not the Step 5 seed exemption.** `ledger-guard` exempts a *brand-new* epic's ledger
184
+ > (creation, not mutation, #162). On this path `state.json` already exists from `yad-analysis` and
185
+ > reached the base ref through the analysis review — so the guard is absolute here.
186
+
187
+ **Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
188
+ `epic.status: "done"`, set `epic-review.status: "in_review"`, and set `currentStep: "epic-review"`.
189
+ Write `state.json`. Do **not** re-seed and do **not** touch `approvals.json` — only real reviewers
190
+ approve, through the gate.
191
+
192
+ > **File-only branch only.** Since 3.11 the CLI closes the authoring step itself whenever its review
193
+ > gate opens or advances (`yad gate open` / `sync`), so this edit is a no-op when the gate has already
194
+ > run. It keeps `state.json` truthful before the gate opens, but it is no longer load-bearing: an epic
195
+ > whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`). In bridge mode
196
+ > `gate open` writes nothing and local `gate sync` is advisory — `gate ci` closes the step at merge.
180
197
 
181
198
  ### Step 6 — Stop at the gate (do NOT advance)
182
199
  Report: epic ID, the path to `epic.md`, and that the next action is **review** via
@@ -103,6 +103,10 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
103
103
  - GitLab → `.gitlab/ci/yad-gate-sync.yml` (from `templates/gitlab/yad-gate-sync.gitlab-ci.yml`)
104
104
  - plus the hub-side **verified-commits** gate (`checks/verified-commits.sh` + its workflow/fragment,
105
105
  owned by `yad-checks`) so review PRs accept only signed commits from roster-known authors
106
+ - the wired job runs `yadflow@${YAD_VERSION}`, defaulting to the floating major `3` so a published
107
+ fix reaches the schedule on its own. To pin an exact version, set a `YAD_VERSION` CI/CD (GitLab) or
108
+ Actions (GitHub) **variable** — never edit it into the wired file, which `yad check --fix` rewrites
109
+ from the template. See `references/bridge.md`.
106
110
  2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
107
111
  - add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
108
112
  `templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
@@ -95,9 +95,23 @@ login and requested too — otherwise an escalated step is structurally unsatisf
95
95
  - Update the step's `hub-prs.json` `lastSyncedAt` when the sync **learned something** — every sync on
96
96
  an open step, and on a closed one only when the approval record actually changed (a re-opened review
97
97
  that was re-approved). An identical re-sync leaves it alone, so the ledger does not churn.
98
+ - **Write the ledgers in a canonical order.** `approvals.json`, `comments.json` and `hub-prs.json` are
99
+ sorted on write, so the bytes are a function of the record *set* and never of which step was synced
100
+ last. Without this the upsert above — which drops the records it refreshes and re-appends them at the
101
+ tail — makes the file depend on sync order, and the wired sweep (one `gate ci --branch <ref>
102
+ --merged` per merged PR/MR, every 15 minutes) walks a rotation:
103
+ `[A,B,C] → sync A → [B,C,A] → sync B → [C,A,B] → sync C → [A,B,C]`. Every hop is a non-empty diff, so
104
+ every hop commits and pushes, and the pass ends where it began — an unbounded commit loop with zero
105
+ semantic change. That is issue #163: ~1,800 bot commits/day on the hub that reported it. Sorting is
106
+ what makes the "nothing staged → nothing to commit" guard in `gate ci` actually hold.
98
107
  - Running `sync` twice with no platform change is a no-op on the ledger — byte-identical, including
99
108
  `comments.json` and the dated `reviews/*.md` side files.
100
109
 
110
+ **Upgrading past #163.** The first sweep on a yadflow carrying the fix writes one canonicalizing
111
+ reorder commit per epic (the records are the same; only their order changes), then converges
112
+ permanently. On an older yadflow the workaround is to disable the pipeline schedule — merges still
113
+ advance gates via the push path; only the catch-up for squash merges and bare approvals is lost.
114
+
101
115
  ## Contract re-lock invalidates prior platform approvals too
102
116
 
103
117
  For the **architecture+contract** review, the gate already drops approvals when the contract-surface hash
@@ -165,6 +179,22 @@ other way — up through its first review PR/MR; see "the seed of a new epic" be
165
179
  | PR/MR closed **and merged** (the human act) | merge | `gate ci --branch <head> --pr <n> --merged` → re-read approvals, advance the step + flip the artifact `status:` **on the default branch** |
166
180
  | Schedule (`*/15`) | reconcile | Safety net: enumerate recently-**merged** `review/EP-*` PRs/MRs via the API and advance any not yet `done` (idempotent). Recovers a merge whose merge-time run failed transiently, and on GitLab also picks up a squash merge whose commit dropped the branch name (and a bare approval — GitLab fires no pipeline on one). **GitHub:** a scheduled workflow, automatic once committed. **GitLab:** a pipeline schedule with `SDLC_GATE_SYNC=true` (one-time setup) |
167
181
 
182
+ **Which yadflow the wired job runs.** Both fragments resolve the version from a `YAD_VERSION` variable
183
+ and fall back to `3`:
184
+
185
+ | Platform | Where the job reads it | Where you set the pin |
186
+ |---|---|---|
187
+ | GitHub | workflow `env: YAD_VERSION: ${{ vars.YAD_VERSION \|\| '3' }}` | Settings → Secrets and variables → Actions → **Variables** |
188
+ | GitLab | `npx -y -p "yadflow@${YAD_VERSION:-3}"` | Settings → CI/CD → **Variables** (beside `SDLC_GATE_TOKEN`) |
189
+
190
+ The default `3` floats on the major, so a published fix reaches a scheduled job on its next pass with
191
+ nobody in the loop — which is how you want a correctness or gate-churn fix to arrive (this page's own
192
+ issue #163 is the example). To adopt releases deliberately instead, set `YAD_VERSION` to an exact
193
+ version.
194
+ The pin lives in **platform config, not in the wired file**: `yad` owns that file and `yad check --fix`
195
+ rewrites it byte-for-byte from the template, so a version edited into it would be silently reverted on
196
+ the next sync. A hub that pins then owns its own upgrade decision — including for fixes.
197
+
168
198
  **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
169
199
  never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
170
200
  Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
@@ -17,10 +17,14 @@
17
17
  # RECONCILE (schedule, every 15 min): the safety net. The merge job's `closed` event fires once and
18
18
  # never repeats, so a transient API/GraphQL/push failure (or a fail-closed degraded approval read)
19
19
  # would otherwise strand a merged review until someone reran it by hand. This periodic job discovers
20
- # recently-merged review PRs from the API and advances any not yet done. Idempotent: a step that is
21
- # already `done` is never re-advanced (the chain is one-way), and re-syncing it writes nothing unless
22
- # its approvals genuinely changed so re-visiting a merged review for a week costs one no-op read per
23
- # pass, not a commit. On GitHub a scheduled workflow runs automatically once committed (no setup).
20
+ # recently-merged review PRs from the API and advances any not yet done. Idempotent DOWN TO THE
21
+ # BYTES: a step that is already `done` is never re-advanced (the chain is one-way), and re-syncing it
22
+ # rewrites the ledger in a canonical order, so an unchanged approval record produces an unchanged file
23
+ # and nothing is committed re-visiting a merged review for a week costs one no-op read per pass. That
24
+ # last part is load-bearing: before the #163 fix the re-sync re-appended each step's approvals at the
25
+ # tail, so this job rotated approvals.json and committed the reorder every 15 minutes, forever (issue
26
+ # #163). If you pin YAD_VERSION (below) to something older, disable the schedule.
27
+ # On GitHub a scheduled workflow runs automatically once committed (no setup).
24
28
  #
25
29
  # CI never approves and never merges — the merge click is the human approval act.
26
30
  #
@@ -40,6 +44,15 @@ permissions:
40
44
  contents: write # push the merge-advance ledger commit to the default branch
41
45
  pull-requests: read # gh pr view + reviewThreads GraphQL
42
46
 
47
+ env:
48
+ # Which yadflow these jobs run. The default `3` floats on the major, so a published fix reaches the
49
+ # schedule on its next pass with nobody in the loop — which is how you want a correctness or
50
+ # gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact version by setting a
51
+ # repository (or organization) Actions **variable** named YAD_VERSION to e.g. `3.15.2`:
52
+ # Settings → Secrets and variables → Actions → Variables. It lives there, deliberately NOT in this
53
+ # file, which `yad` owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
54
+ YAD_VERSION: ${{ vars.YAD_VERSION || '3' }}
55
+
43
56
  jobs:
44
57
  mergesync:
45
58
  # The human merge of a review branch: advance the step + flip the artifact status on the default
@@ -57,20 +70,27 @@ jobs:
57
70
  GH_TOKEN: ${{ github.token }}
58
71
  steps:
59
72
  # Check out the default branch — the merge already landed the artifact here.
60
- - uses: actions/checkout@v4
73
+ - uses: actions/checkout@v7
61
74
  with:
62
75
  ref: ${{ github.event.pull_request.base.ref }}
63
76
  fetch-depth: 0
64
- - uses: actions/setup-node@v4
77
+ - uses: actions/setup-node@v7
65
78
  with:
66
79
  node-version: "20"
67
80
  - name: Advance the gate on merge
81
+ # The head ref reaches the shell through `env:`, never through `${{ }}` inside `run:`. A
82
+ # branch name may legally contain `$`, backticks and parentheses, so a PR opened from
83
+ # `review/EP-x$(curl …)` would otherwise execute on a runner holding `contents: write` — and
84
+ # the `if:` guard above does not stop it, since that name still starts with `review/EP-`.
85
+ env:
86
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
87
+ PR_NUMBER: ${{ github.event.pull_request.number }}
68
88
  run: |
69
89
  git config user.name "yad-gate-sync[bot]"
70
90
  git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
71
- npx -y -p yadflow@3 yad gate ci \
72
- --branch "${{ github.event.pull_request.head.ref }}" \
73
- --pr "${{ github.event.pull_request.number }}" \
91
+ npx -y -p "yadflow@${YAD_VERSION}" yad gate ci \
92
+ --branch "$HEAD_REF" \
93
+ --pr "$PR_NUMBER" \
74
94
  --merged
75
95
 
76
96
  reconcile:
@@ -85,11 +105,11 @@ jobs:
85
105
  env:
86
106
  GH_TOKEN: ${{ github.token }}
87
107
  steps:
88
- - uses: actions/checkout@v4
108
+ - uses: actions/checkout@v7
89
109
  with:
90
110
  ref: ${{ github.event.repository.default_branch }}
91
111
  fetch-depth: 0
92
- - uses: actions/setup-node@v4
112
+ - uses: actions/setup-node@v7
93
113
  with:
94
114
  node-version: "20"
95
115
  - name: Reconcile recently-merged review PRs
@@ -110,7 +130,7 @@ jobs:
110
130
  [ -n "$N" ] || continue
111
131
  REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
112
132
  case "$REF" in
113
- review/EP-*) npx -y -p yadflow@3 yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
133
+ review/EP-*) npx -y -p "yadflow@${YAD_VERSION}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
114
134
  esac
115
135
  done < /tmp/yad-merged-prs
116
136
  exit $rc
@@ -35,6 +35,13 @@
35
35
  # SDLC_GATE_TOKEN. Without it the job fails visibly; recover by setting the token, then re-run the
36
36
  # pipeline or run `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default
37
37
  # branch (advisory `yad gate sync` is read-only in bridge mode and cannot recover a stuck gate).
38
+ #
39
+ # Which yadflow this job runs: `npx -y -p "yadflow@${YAD_VERSION:-3}"`. The default `3` floats on the
40
+ # major, so a published fix reaches this job on its next run with nobody in the loop — which is how you
41
+ # want a correctness or gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact
42
+ # version by setting a CI/CD variable YAD_VERSION (e.g. `3.15.2`) in project Settings → CI/CD →
43
+ # Variables — the same place SDLC_GATE_TOKEN lives, and deliberately NOT in this file, which `yad`
44
+ # owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
38
45
  variables:
39
46
  GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
40
47
 
@@ -79,8 +86,12 @@ yad-gate-sync:
79
86
  rc=0
80
87
  if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
81
88
  # SCHEDULED SWEEP — discover merged review MRs from the platform (Path B keeps no per-branch
82
- # ledger), then advance each. `gate ci` is idempotent: an already-`done` step is never
83
- # re-advanced, and re-syncing it writes nothing unless its approvals changed.
89
+ # ledger), then advance each. `gate ci` is idempotent DOWN TO THE BYTES: an already-`done` step
90
+ # is never re-advanced, and re-syncing it rewrites the ledger in a canonical order, so an
91
+ # unchanged approval record produces an unchanged file and nothing is committed. That last part
92
+ # is load-bearing — before the #163 fix the re-sync re-appended each step's approvals at the
93
+ # tail, so this loop rotated approvals.json and committed the reorder on every pass, forever
94
+ # (issue #163). If you are on an older yadflow, disable this schedule.
84
95
  # A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
85
96
  # push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
86
97
  # — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
@@ -94,7 +105,7 @@ yad-gate-sync:
94
105
  while read -r IID REF; do
95
106
  [ -n "$IID" ] || continue
96
107
  git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
97
- npx -y -p yadflow@3 yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
108
+ npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
98
109
  done < /tmp/yad-merged-mrs
99
110
  else
100
111
  # MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
@@ -106,7 +117,7 @@ yad-gate-sync:
106
117
  IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
107
118
  if [ -n "$IID" ]; then
108
119
  # Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
109
- npx -y -p yadflow@3 yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
120
+ npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
110
121
  else
111
122
  # Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
112
123
  # retries) rather than running a green no-op that silently leaves the gate unadvanced.
@@ -54,16 +54,28 @@ touched `repos` — never a forked or copied gate.
54
54
 
55
55
  ### Step 2 — Dispatch on `action`
56
56
 
57
+ > **Check the mode first — in bridge mode you write nothing to the ledger.** Read `.sdlc/hub.json`:
58
+ > **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`. Under the bridge
59
+ > the ledger is CI-owned — `ledger-guard` rejects any non-bot commit touching
60
+ > `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`, local `yad gate
61
+ > sync` is advisory, and `yad gate ci --merged` writes the whole transition when the review PR merges.
62
+ > So every "set / append / write" instruction below is the **file-only, or a platform with no
63
+ > gate-sync CI** path. In bridge mode do the human-facing half — present the artifact, route the
64
+ > required reviewers, help the owner address comments — and let the platform PR/MR carry the review
65
+ > state; the approvals, comments, review records and the advance all land through CI at merge.
66
+
57
67
  **`open`** — Present the artifact for review. Summarise what changed, list the required reviewers per
58
68
  the rule above, and tell reviewers how to comment/approve. Set the step `status` to `in_review` and
59
69
  `currentStep` to this step in `state.json` if not already. Do not advance.
60
70
 
61
- If `.sdlc/hub.json` has a non-null `platform`, `bridge_enabled: true`, `config.yaml` `hub.bridge: true`,
62
- and `gh`/`glab` is authenticated, **also open a review PR/MR on the hub** by invoking
63
- `yad-hub-bridge action: open` (epic + artifact). Record the PR in `epics/<epic>/.sdlc/hub-prs.json`
64
- (`{step, artifact, platform, number, url, branch, lastSyncedAt}`) and report the URL + required
65
- reviewers. Otherwise (no platform / disabled / no CLI) proceed **file-only** exactly as before no
66
- error. Opening the PR records no approvals and never advances.
71
+ If `.sdlc/hub.json` has a non-null `platform` and `bridge_enabled: true` (or legacy `bridge: true` —
72
+ `.sdlc/hub.json` is the only source the CLI reads, see `isBridge` in `cli/gate.mjs`), and `gh`/`glab`
73
+ is authenticated, **also open a review PR/MR on the hub** by invoking `yad-hub-bridge action: open`
74
+ (epic + artifact), and report the URL + required reviewers. **CI records the PR** in
75
+ `epics/<epic>/.sdlc/hub-prs.json` (`{step, artifact, platform, number, url, branch, lastSyncedAt}`) —
76
+ write that file yourself only on the file-only path. Otherwise (no platform / disabled / no CLI)
77
+ proceed **file-only** exactly as before — no error. Opening the PR records no approvals and never
78
+ advances.
67
79
 
68
80
  **`comment`** — Capture reviewer feedback. Append/create a review file
69
81
  `reviews/<artifact-base>--<YYYY-MM-DD>--comments.md` with a heading per reviewer:
@@ -101,12 +101,26 @@ As a <role>, I want <capability>, so that <outcome>.
101
101
  `repos` is the field the later build phase reads to know where to scaffold specs — set it precisely.
102
102
 
103
103
  ### Step 6 — Advance the authoring step (NOT the gate)
104
- In `state.json`: set `stories.status: "done"`, set `stories-review.status: "in_review"`, and set
104
+ **Check the mode first the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
105
+ **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
106
+
107
+ **Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
108
+ any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
109
+ `epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
110
+ `yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
111
+ fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
112
+ is pushed around the gate. Commit **the story files under `stories/` only** — nothing else
113
+ under `.sdlc/` — then hand off to `yad-review-gate`.
114
+
115
+ **Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
116
+ `stories.status: "done"`, set `stories-review.status: "in_review"`, and set
105
117
  `currentStep: "stories-review"`. Write `state.json`. Do **not** touch `approvals.json`.
106
118
 
107
- > Since 3.11 the CLI also closes the authoring step when its review gate opens or advances, so this
108
- > edit is a no-op once `yad gate open` has run. A `stories` step left `in_progress` behind a passed
109
- > `stories-review` used to block the parallel `test-cases` track (`YAD-STATE-005`).
119
+ > **File-only branch only.** Since 3.11 the CLI also closes the authoring step when its review gate
120
+ > opens or advances, so this edit is a no-op once `yad gate open` has run. A `stories` step left
121
+ > `in_progress` behind a passed `stories-review` used to block the parallel `test-cases` track
122
+ > (`YAD-STATE-005`). In bridge mode `gate open` writes nothing and local `gate sync` is advisory —
123
+ > `gate ci` closes the step at merge.
110
124
 
111
125
  ### Step 7 — Stop at the gate (do NOT advance)
112
126
  Report: the story IDs created, the repos each touches, and that the next action is **review** via
@@ -162,9 +162,24 @@ Keep the `## Automation (<tool>)` section of `test-cases.md` in step with this f
162
162
  degraded (`testing: none`), do **not** write `test-links.json`.
163
163
 
164
164
  ### Step 5 — Advance the authoring step (NOT the gate)
165
- In `state.json`: set `test-cases.status: "done"` and set `test-cases-review.status: "in_review"`. **Leave
166
- `currentStep` at `ready-for-build`** this is the parallel track; moving `currentStep` would pull it
167
- back from the build half. Write `state.json`. Do **not** touch `approvals.json`.
165
+ **Check the mode first the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
166
+ **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
167
+
168
+ **Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
169
+ any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
170
+ `epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
171
+ `yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
172
+ fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
173
+ is pushed around the gate. Commit the artifact set — **`test-cases.md` and, when a testing
174
+ tool was used, `.sdlc/test-links.json`** (artifact-side, not ledger; generated tests live in their
175
+ own code repo, not here) — then hand off to `yad-review-gate`.
176
+
177
+ **Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
178
+ `test-cases.status: "done"` and set `test-cases-review.status: "in_review"`. **Leave `currentStep` at
179
+ `ready-for-build`** — this is the parallel track; moving `currentStep` would pull it back from the
180
+ build half. Write `state.json`. Do **not** touch `approvals.json`. On this branch `yad gate open`
181
+ makes the same edit — `markInReview` leaves `currentStep` alone once it is `ready-for-build`
182
+ (`cli/epic-state.mjs`) — so it is a no-op once the gate has run.
168
183
 
169
184
  ### Step 6 — Stop at the gate (do NOT advance)
170
185
  Report: the path to `test-cases.md`, the connected testing tool and what it produced (e.g. "Playwright —
@@ -171,8 +171,22 @@ Keep the `## Design (<tool>)` section of `ui-design.md` in step with this file.
171
171
  (`design: none`), do **not** write `design-links.json`.
172
172
 
173
173
  ### Step 5 — Advance the authoring step (NOT the gate)
174
- In `state.json`: set `ui-design.status: "done"`, set `ui-design-review.status: "in_review"`, and set
175
- `currentStep: "ui-design-review"`. Write `state.json`. Do **not** touch `approvals.json`.
174
+ **Check the mode first the two modes have opposite instructions here.** Read `.sdlc/hub.json`:
175
+ **bridge mode** is `platform` set AND `bridge_enabled` (or legacy `bridge`) `true`.
176
+
177
+ **Bridge mode — do NOT write `state.json`.** The ledger is CI-owned: the `ledger-guard` check rejects
178
+ any non-bot commit touching `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or
179
+ `epics/*/reviews/*.md`, `yad gate open` deliberately skips this write for the same reason, and
180
+ `yad gate ci --merged` performs the whole transition when the review PR merges. Making the edit here
181
+ fails the gate if it rides the review PR, and desynchronises the ledger CI is about to rewrite if it
182
+ is pushed around the gate. Commit the artifact set — **`ui-design.md`, `DESIGN.md`, and, when a
183
+ design tool was used, `.sdlc/design-links.json`** (artifact-side, not ledger) — then hand off to
184
+ `yad-review-gate`.
185
+
186
+ **Otherwise — file-only, or a platform with no gate-sync CI — write it.** In `state.json`: set
187
+ `ui-design.status: "done"`, set `ui-design-review.status: "in_review"`, and set
188
+ `currentStep: "ui-design-review"`. Write `state.json`. Do **not** touch `approvals.json`. On this
189
+ branch `yad gate open` makes the same edit, so it is a no-op once the gate has run.
176
190
 
177
191
  ### Step 6 — Stop at the gate (do NOT advance)
178
192
  Report: the paths to `ui-design.md` and `DESIGN.md`, whether Impeccable was used, the connected design