yadflow 3.15.1 → 3.15.3

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,19 @@
1
+ ## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **bridge:** pass the PR head ref through env, not into the run script ([306f49f](https://github.com/abdelrahmannasr/yadflow/commit/306f49f569c5398bbfb36ce9aa3fb38d8341336c))
7
+ * **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)
8
+ * **gate:** stage the merge commit from an allowlist, not the whole epic dir ([8142ddc](https://github.com/abdelrahmannasr/yadflow/commit/8142ddcce0f889505a29558c6664e42548d9d671))
9
+
10
+ ## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **checks:** exempt a new epic's ledger seed from ledger-guard ([ba923c2](https://github.com/abdelrahmannasr/yadflow/commit/ba923c2a3823e8bf17b2fc59b41f0160a3a11a19)), closes [#162](https://github.com/abdelrahmannasr/yadflow/issues/162)
16
+
1
17
  ## [3.15.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.0...v3.15.1) (2026-08-11)
2
18
 
3
19
 
@@ -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.1",
3
+ "version": "3.15.3",
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",
@@ -138,6 +138,10 @@ Notes:
138
138
  - Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
139
139
  and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
140
140
  `[]`, and the `reviews/` directory.
141
+ - Commit the seed on the `analysis/EP-<slug>` branch, and cut `review/EP-<slug>/analysis` from it so the
142
+ epic's **first** review PR/MR carries the ledger to the default branch. In bridge mode `ledger-guard`
143
+ exempts a new epic's ledger (creation, not mutation, #162); every later change to it is CI's. See
144
+ `../yad-epic/references/state-schema.md`, "Authoring branches".
141
145
 
142
146
  ### Step 7 — Stop at the gate (do NOT advance)
143
147
  Report: epic ID, the path to `analysis.md`, and that the next action is **review** via
@@ -134,6 +134,12 @@ Seed `.sdlc/approvals.json` with one **provenance** record per inherited gate (N
134
134
  `{ "artifact": "<art>", "step": "<…-review>", "status": "inherited", "from": "<epic>", "boundHash": "<hash>", "date": "<today>" }`.
135
135
  Seed `.sdlc/comments.json` = `[]` and create `reviews/`.
136
136
 
137
+ Commit the seed on the `change/EP-<slug>` branch. It reaches the hub's default branch through this
138
+ change-epic's **first** review PR/MR — cut the `review/EP-<slug>/<artifact>` branch from `change/…` so
139
+ it carries the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation,
140
+ #162), so no direct push to a protected default branch is needed; every later change to that ledger is
141
+ CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
142
+
137
143
  When `architecture` is **inherited**, materialize the **pointer-lock** `.sdlc/contract-lock.json`:
138
144
  `{ "artifact": "contract.md", "hash": "<parent surface hash, verbatim>", "lockedAt": "<today>", "inheritedFrom": "<epic>", "ref": "../../<epic>/.sdlc/contract-lock.json" }`.
139
145
  There is no `contract.md` in the change-epic, so the surface cannot drift, and `contract-check` passes
@@ -55,7 +55,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
55
55
  when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
56
56
  CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
57
57
  is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
58
- spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. Runs in `yad-hub-checks`
58
+ spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. So is a **new epic's seed**:
59
+ no CI path can create a ledger (`gate ci` only *advances* an existing chain, at merge, on the
60
+ default branch), so an epic whose `.sdlc/state.json` is absent from the base ref may be created by
61
+ a human on its first review PR/MR — **creation, not mutation** (#162). Once the ledger is on the
62
+ default branch the guard is absolute again. Runs in `yad-hub-checks`
59
63
  alongside `verified-commits` (which waives the allowlist for the bot but still requires its
60
64
  signature). See `yad-hub-bridge`.
61
65
  - `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
@@ -11,6 +11,8 @@
11
11
  # NOT protected:
12
12
  # epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
13
13
  # `gate open`, so a human legitimately commits it alongside the architecture artifact.
14
+ # A brand-new epic's ledger — CREATION is not mutation (#162). No CI path can seed one, so the
15
+ # seed rides the first review PR/MR; see the carve-out below. Mutation stays bot-only.
14
16
  #
15
17
  # A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
16
18
  # platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
@@ -114,19 +116,81 @@ trusted_bot() {
114
116
  signature_verified "$1"
115
117
  }
116
118
 
119
+ # ---- seeding carve-out: creation is not mutation (#162) ---------------------------------------
120
+ # A brand-new epic's ledger has no CI author. `gate ci` only ADVANCES an existing chain — it bails on
121
+ # a missing state.json ("the review branch is cut from the default branch, so it should carry it") and
122
+ # writes only at merge, on the default branch — and the engine itself reads a missing state.json as
123
+ # "not seeded yet". `gate open` writes nothing in bridge mode, and `checkpoint` stages back-half
124
+ # ledgers only. So the seed the authoring skills write (yad-epic / yad-change / yad-analysis /
125
+ # yad-discovery / yad-stub) can reach the trunk ONLY through the first review PR/MR — the one place
126
+ # this gate runs. Guarding it there makes the documented flow unshippable on a protected trunk, so an
127
+ # epic whose ledger is absent from the BASE ref is exempt: that ledger is human-authored by
128
+ # construction and the reviewer sees the whole of it in the diff. The moment its state.json is on the
129
+ # base ref the guard is absolute again.
130
+ #
131
+ # Anchored on state.json — the ledger root, the same "is this epic seeded?" question the engine asks —
132
+ # NOT on the individual file: adding hub-prs.json to an epic that IS on the base ref still FAILs.
133
+ # Anchored on the BASE ref, not the parent commit, so delete-then-re-add cannot reset the exemption
134
+ # (the deletion is itself a guarded change).
135
+ #
136
+ # The slug match is CASE-FOLDED on purpose. Git paths are byte-exact, but macOS/Windows checkouts are
137
+ # not: seeding `epics/ep-x/.sdlc/state.json` beside an on-base `epics/EP-X/` would probe as a brand-new
138
+ # epic, pass, and then land ON TOP of the real ledger in every case-insensitive clone — a mutation
139
+ # laundered as a creation. So the base's seeded slugs are read once (one ls-tree, not one probe per
140
+ # path) and compared folded.
141
+ #
142
+ # Slugs are held in ARRAYS read from NUL-delimited git output, never in a space- or newline-delimited
143
+ # string: git permits a newline inside a path, and a `EP-<newline>x` slug split across two records
144
+ # would drop the real epic out of the on-base list and let a mutation through as a "creation". Both
145
+ # arrays carry one empty sentinel element so `"${a[@]}"` is safe under `set -u` on bash 3.2 (macOS),
146
+ # which has no associative arrays; a slug is never empty, so the sentinel can never match.
147
+ base_slugs=("") # every epic with a ledger on BASE, lowercased
148
+ base_slugs_loaded=0
149
+ noted_slugs=("") # slugs already announced, so the note prints once each
150
+ fold() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
151
+ in_list() { # $1 = needle, $2… = haystack
152
+ _needle="$1"; shift
153
+ for _item in "$@"; do [ "$_item" = "$_needle" ] && return 0; done
154
+ return 1
155
+ }
156
+ is_seeding() { # $1 = epic slug; 0 when that epic has no ledger on BASE
157
+ if [ "$base_slugs_loaded" = 0 ]; then
158
+ while IFS= read -r -d '' _p; do
159
+ case "$_p" in
160
+ epics/*/.sdlc/state.json)
161
+ _s="${_p#epics/}"; _s="${_s%%/*}"
162
+ base_slugs[${#base_slugs[@]}]="$(fold "$_s")"
163
+ ;;
164
+ esac
165
+ done < <(git -c core.quotePath=false ls-tree -r --name-only -z "${BASE}" -- epics 2>/dev/null || true)
166
+ base_slugs_loaded=1
167
+ fi
168
+ _f="$(fold "$1")"
169
+ in_list "$_f" "${base_slugs[@]}" && return 1
170
+ in_list "$_f" "${noted_slugs[@]}" && return 0
171
+ noted_slugs[${#noted_slugs[@]}]="$_f"
172
+ echo "note [ledger-guard]: epics/$1 has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
173
+ return 0
174
+ }
175
+
117
176
  violations=0
118
177
  for sha in $commits; do
119
178
  touches_ledger=0
120
- while IFS= read -r f; do
179
+ # quotePath=false + -z on both git reads: a path git chose to escape ("epics/EP-caf\303\251/…") and a
180
+ # path holding a newline both match no arm below, so the gate would fail OPEN on exactly the paths it
181
+ # is meant to guard. NUL is the only byte a git path cannot contain.
182
+ while IFS= read -r -d '' f; do
121
183
  [ -n "$f" ] || continue
122
184
  case "$f" in
123
185
  epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
124
186
  epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
187
+ _slug="${f#epics/}"; _slug="${_slug%%/*}"
188
+ is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
125
189
  touches_ledger=1
126
190
  echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
127
191
  ;;
128
192
  esac
129
- done < <(git diff-tree --no-commit-id --name-only -r "$sha")
193
+ done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
130
194
  if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
131
195
  violations=$((violations + 1))
132
196
  fi
@@ -134,7 +198,18 @@ done
134
198
 
135
199
  if [ "$violations" -gt 0 ]; then
136
200
  echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
201
+ # An epic's seed is exempt only while its ledger is off the base ref. Once the first review PR merges
202
+ # (squashed or rebased, so the SHAs differ), those same seed commits still sitting on a sibling
203
+ # authoring branch read as mutations — the author did nothing wrong and the remedy is a rebase, so
204
+ # name it rather than leaving them with "do not commit the ledger yourself".
205
+ echo "hint [ledger-guard]: if these are seed commits from an already-merged review PR, rebase this branch onto the updated ${BASE} — the seed carve-out applies only until the ledger is on the base ref."
137
206
  exit 1
138
207
  fi
139
- echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
208
+ # Say WHICH rule passed the range: claiming "every change is a bot commit" would be false on a range
209
+ # whose only ledger change was a human seed the carve-out let through.
210
+ if [ "${#noted_slugs[@]}" -gt 1 ]; then # >1: the sentinel element is always there
211
+ echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit or a new epic's seed."
212
+ else
213
+ echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
214
+ fi
140
215
  exit 0
@@ -55,7 +55,9 @@ jobs:
55
55
  body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
56
56
  bash checks/pr-template.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$body"
57
57
 
58
- # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
58
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
59
+ # or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
60
+ # brand-new epic's seed, which no CI path can write, so it rides this first review PR (#162).
59
61
  ledger-guard:
60
62
  runs-on: ubuntu-latest
61
63
  if: github.event.action != 'edited'
@@ -44,7 +44,9 @@ yad-hub-pr-template:
44
44
  - body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
45
45
  - bash checks/pr-template.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$body"
46
46
 
47
- # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
47
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
48
+ # or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
49
+ # brand-new epic's seed, which no CI path can write, so it rides this first review MR (#162).
48
50
  yad-hub-ledger-guard:
49
51
  extends: .yad_hub_mr_only
50
52
  needs: []
@@ -112,6 +112,10 @@ Notes:
112
112
  never escalates to domain owners (no contract surface is touched yet).
113
113
  - Also create an empty approvals ledger `.sdlc/approvals.json` and comments ledger
114
114
  `.sdlc/comments.json`, each containing `[]`, and the `reviews/` directory.
115
+ - Commit the seed on the `discovery/EP-discovery` branch, and cut `review/EP-discovery/discovery` from
116
+ it so the **first** review PR/MR carries the ledger to the default branch. In bridge mode
117
+ `ledger-guard` exempts a new epic's ledger (creation, not mutation, #162); every later change to it
118
+ is CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
115
119
 
116
120
  ### Step 6 — Stop at the gate (do NOT advance)
117
121
  Report: the path to the discovery set, and that the next action is **review** via `yad-review-gate`
@@ -152,6 +152,10 @@ Notes:
152
152
  - `test-cases` / `test-cases-review` are a **parallel, non-blocking track**: they seed `blocked` and open
153
153
  when `stories-review` passes — at which point the epic is already `ready-for-build`, so the build half
154
154
  runs alongside the tester. They never gate `ready-for-build` (see `references/state-schema.md`).
155
+ - Commit the seed on this step's authoring branch. It reaches the hub's default branch through the
156
+ epic's **first** review PR/MR — cut `review/EP-<slug>/epic` from the authoring branch so it carries
157
+ the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation, #162);
158
+ every later change to it is CI's. See `references/state-schema.md`, "Authoring branches".
155
159
  - Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
156
160
  and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
157
161
  `[]`, and the `reviews/` directory. (`comments.json` is the machine-readable counterpart to the
@@ -94,6 +94,16 @@ The shared procedure (run once the `EP-<slug>` is known):
94
94
  hub's default branch (`git checkout -b <step>/EP-<slug>`).
95
95
  3. Author and commit the step's artifact(s) on that branch. The bridge's `review/…` branch is created
96
96
  separately at review time and is untouched by this step.
97
+
98
+ **How the seed reaches the default branch.** The `.sdlc/` ledger is seeded once, by hand, on the
99
+ **entry** step's authoring branch (`analysis/…`, `epic/…`, `change/…`, `discovery/…`) — no CLI or CI
100
+ path creates one (`yad gate ci` only *advances* an existing chain, at merge, on the default branch).
101
+ So for the **first** gate of an epic, cut `review/EP-<slug>/<artifact-base>` from that authoring
102
+ branch: the review PR/MR then carries the seed alongside the artifact, and the ledger lands on the
103
+ default branch when it merges. In bridge mode `ledger-guard` exempts exactly this case — **creation,
104
+ not mutation** (#162) — so no direct push to a protected default branch is needed. For every **later**
105
+ gate the ledger is already on the default branch: cut the review branch from there, commit the
106
+ artifact only, and leave `.sdlc/{state,approvals,comments,hub-prs}.json` and `reviews/*.md` to CI.
97
107
  | `type` | `author` \| `review+approve` | Authoring step or a team review gate. |
98
108
  | `artifact` | filename or folder | The file/folder this step produces or gates. |
99
109
  | `assistance` | `none` \| `review` \| `heavy` | Dial 1 — how much AI helps (build plan §2). |
@@ -52,7 +52,10 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
52
52
  ### Step 2 — `open` (create the review PR/MR)
53
53
  1. From the hub default branch, create `review/EP-<slug>/<artifact-base>` and ensure the artifact file
54
54
  (and, for architecture, `contract.md` + `.sdlc/contract-lock.json`) is committed on it. Push as the
55
- local user.
55
+ local user. **First gate of a new epic:** cut the review branch from the **authoring** branch
56
+ (`epic/…`, `change/…`, `analysis/…`, `discovery/…`) instead, so it carries the `.sdlc/` **seed** —
57
+ that PR/MR is the only way a new epic's ledger reaches the default branch, and `ledger-guard`
58
+ exempts it (see step 4).
56
59
  2. Open the PR/MR with `gh`/`glab` using the hub body template (`yad-pr-template` `templates/hub/…`),
57
60
  filled with the epic, artifact, gate step, owner, `epic.repos`, and the step's risk tags.
58
61
  3. **Request the required reviewers** (their logins) and add a `domain:<repo>` label per touched repo so
@@ -68,7 +71,9 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
68
71
  A human commit touching the gate-state files (`.sdlc/{state,approvals,comments,hub-prs}.json` or
69
72
  `reviews/*.md`; `.sdlc/contract-lock.json` is artifact-side and allowed) on a review PR is rejected
70
73
  by the `ledger-guard` check. (The `yad gate open` CLI behaves the same: in bridge mode it opens the
71
- PR only and writes no ledger.)
74
+ PR only and writes no ledger.) The **one** exception is a brand-new epic's **seed** — no CI path can
75
+ create a ledger, so an epic whose `.sdlc/state.json` is absent from the base ref may be created by a
76
+ human on this first PR/MR (#162). Every later change to it is CI's alone.
72
77
  5. Report the PR/MR URL and the required reviewers. **Do not** record approvals or advance — reviewers
73
78
  act on the platform; CI (`yad gate ci`) reconciles it onto the default branch at merge.
74
79
 
@@ -98,6 +103,10 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
98
103
  - GitLab → `.gitlab/ci/yad-gate-sync.yml` (from `templates/gitlab/yad-gate-sync.gitlab-ci.yml`)
99
104
  - plus the hub-side **verified-commits** gate (`checks/verified-commits.sh` + its workflow/fragment,
100
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`.
101
110
  2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
102
111
  - add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
103
112
  `templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
@@ -143,7 +152,8 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
143
152
  - **Protect the hub default branch.** Require that `epics/**` artifacts change only through a review
144
153
  PR/MR (branch protection). This keeps revoke-on-change sound: it removes the only window where a
145
154
  delayed reconcile could advance on an out-of-band post-merge artifact change (see `references/bridge.md`,
146
- "Known limitation").
155
+ "Known limitation"). Safe to require: a new epic's `.sdlc/` seed rides its first review PR/MR, so
156
+ nobody needs a direct push to the default branch to start an epic (#162).
147
157
  - **Degrade gracefully.** No platform / disabled bridge / no CLI → the gate runs file-only with no error.
148
158
 
149
159
  ## Reference
@@ -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
@@ -155,7 +169,9 @@ During review CI writes nothing: the platform PR/MR is the source of truth (nati
155
169
  threads). The CLI is self-sufficient at merge: it derives the epic + artifact from the
156
170
  `review/EP-<slug>/<artifact-base>` head branch, takes the PR/MR number from the event (GitHub) or
157
171
  resolves it from the platform (GitLab), upserts the `hub-prs.json` entry itself, and **re-reads
158
- approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch.
172
+ approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch. (It only
173
+ *advances* a chain, though: it cannot **create** one. A brand-new epic's seed therefore travels the
174
+ other way — up through its first review PR/MR; see "the seed of a new epic" below.)
159
175
 
160
176
  | Platform event | Phase | CI action |
161
177
  |---|---|---|
@@ -163,6 +179,22 @@ approvals fresh from the platform** — so no ledger needs to be pre-seeded on t
163
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** |
164
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) |
165
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
+
166
198
  **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
167
199
  never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
168
200
  Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
@@ -174,13 +206,26 @@ commit — the advance plus the `draft → approved` status flip — lands on th
174
206
  check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
175
207
  .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
176
208
  CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
177
- edit — which it rejects. (The `verified-commits` gate still vets every commit's signature + author;
209
+ edit — which it rejects, with one carve-out for a new epic's seed (below). (The `verified-commits`
210
+ gate still vets every commit's signature + author;
178
211
  its gate-bot exemption is now vestigial in-PR because CI no longer commits there.) `yad gate open`
179
212
  opens the PR only; local `yad gate sync` is advisory in bridge mode (writes nothing). After a merge,
180
213
  everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
181
214
  these guards are no-ops.)
182
215
 
183
- **The one sanctioned human ledger write: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
216
+ **The one sanctioned human ledger write *in a review PR*: the seed of a new epic.** `gate ci` only
217
+ **advances** an existing chain — it bails on a missing `state.json`, and the engine reads that absence
218
+ as "not seeded yet" — so no CI path can ever create a ledger. The seed the authoring skills write
219
+ (`yad-epic`, `yad-change`, `yad-analysis`, `yad-discovery`, `yad-stub`) can reach the default branch
220
+ only through the epic's **first** review PR/MR, which is exactly where `ledger-guard` runs. So the gate
221
+ exempts **creation**: an epic whose `.sdlc/state.json` is absent from the PR's base ref may have its
222
+ ledger written by a human there (#162). It stays a narrow carve-out — the probe is against the base
223
+ ref, not the parent commit, so deleting an on-trunk `state.json` to "re-seed" it is itself a rejected
224
+ mutation, and the moment the ledger is on the default branch every further change is CI's alone. Cut
225
+ that first review branch from the authoring branch (`epic/…`, `change/…`) so it carries the seed; no
226
+ direct push to a protected default branch is needed.
227
+
228
+ **The one sanctioned human ledger write *on the default branch*: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
184
229
  authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
185
230
  is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
186
231
  only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
@@ -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.
@@ -176,7 +176,9 @@ PR only — against the `review/<epic>/<artifact>` branch, which must already ex
176
176
  records this skill describes. The skill's
177
177
  job is the human half: presenting the artifact, helping the owner address comments, and narrating the
178
178
  gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
179
- nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it).
179
+ nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it). The single
180
+ exception is an epic's **seed** — no CI path can create a ledger, so a brand-new epic's `.sdlc/` rides
181
+ its **first** review PR/MR, cut from the authoring branch (creation, not mutation, #162).
180
182
 
181
183
  Under that CLI the gate **advances on merge**: a review PR/MR whose reviewer rule is satisfied, whose
182
184
  comment threads are **all resolved**, and which has been **merged** auto-marks the step `done` and
@@ -115,6 +115,11 @@ into normal authoring with zero re-seeding.
115
115
  Also create the empty ledgers `{.sdlc/approvals.json}` and `{.sdlc/comments.json}` (each `[]`) and the
116
116
  `reviews/` directory. **Do NOT** write a `contract-lock.json` — a stub has no locked surface yet.
117
117
 
118
+ Commit the seed on this step's authoring branch; it reaches the hub's default branch through the
119
+ epic's **first** review PR/MR (or, for a stub, the PR that carries the stub itself). In bridge mode
120
+ `ledger-guard` exempts a new epic's ledger — creation, not mutation (#162) — while every later change
121
+ to it stays CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
122
+
118
123
  ### Step 6 — Stop; hand off (NO auto-advance)
119
124
  Report the new `EP-<slug>`, that it is a **stub (backfill pending)**, and the two next moves:
120
125
  - **File bugs now:** `yad-change` (`--parent EP-<slug>`, `kind: defect|change`) — the defect threads off