yadflow 3.15.3 → 3.15.5

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.5](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.4...v3.15.5) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **bridge:** resolve the wired gate-sync pin from the repo, not a floating major ([8489bf5](https://github.com/abdelrahmannasr/yadflow/commit/8489bf5298f529868dc65085068882b59e89bbb5)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163) [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
7
+ * **checks:** require a platform for the ledger-guard bridge gate ([297d13a](https://github.com/abdelrahmannasr/yadflow/commit/297d13a0ebf26bec995ce64e0aacfa022b228e18)), closes [#185](https://github.com/abdelrahmannasr/yadflow/issues/185) [#186](https://github.com/abdelrahmannasr/yadflow/issues/186)
8
+ * **checks:** scope the ledger-guard bridge read to root-level keys ([4fb83a9](https://github.com/abdelrahmannasr/yadflow/commit/4fb83a968d4537e89a9367d72418aede9cf305f0)), closes [#186](https://github.com/abdelrahmannasr/yadflow/issues/186) [#186](https://github.com/abdelrahmannasr/yadflow/issues/186)
9
+
10
+ ## [3.15.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.3...v3.15.4) (2026-08-12)
11
+
1
12
  ## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
2
13
 
3
14
 
package/cli/doctor.mjs CHANGED
@@ -62,7 +62,11 @@ export function projectChecks(checks, root) {
62
62
  // version stamp
63
63
  const ver = readJSON(verPath, null);
64
64
  if (!ver) check(checks, 'cli-version', 'project', 'warn', `${PROJECT_FILES.version} missing or unreadable`, 'run `yad check --fix`');
65
- else if (ver.version !== VERSION) check(checks, 'cli-version', 'project', 'warn', `project stamped v${ver.version}, CLI is v${VERSION}`, 'run `yad update` to reconcile');
65
+ // The stamp is not only cosmetic: in bridge mode the wired gate-sync job resolves the yadflow it
66
+ // RUNS from it — unless hub.json pins `gate_sync_version`, a YAD_VERSION variable overrides, or the
67
+ // stamp is not an exact release of the current major (then the job skips it and floats). So a stale
68
+ // stamp can mean CI is running an old gate; say so, or the warning reads as bookkeeping.
69
+ else if (ver.version !== VERSION) check(checks, 'cli-version', 'project', 'warn', `project stamped v${ver.version}, CLI is v${VERSION} — this also drives the wired gate-sync pin`, 'run `yad update` to reconcile');
66
70
  else check(checks, 'cli-version', 'project', 'ok', `version stamp matches (v${VERSION})`);
67
71
 
68
72
  // hub.json: parse + shape
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.15.3",
3
+ "version": "3.15.5",
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)
@@ -51,8 +51,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
51
51
  - Canonical gate sources live in this skill's `templates/` (the source of truth that gets installed
52
52
  into each code repo):
53
53
  - `templates/checks/{spec-link,contract-check,build-test-lint,verified-commits}.sh`
54
- - `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** (a no-op
55
- when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
54
+ - `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** hub.json
55
+ carries BOTH a `platform` and `bridge_enabled` (or the legacy `bridge`) true, the same predicate
56
+ `isBridge` (`cli/gate.mjs`) applies, so the gate and the CLI can never disagree about who owns the
57
+ ledger (#186). A no-op otherwise, when humans legitimately own it. On review PRs it FAILs any
58
+ commit that touches the
56
59
  CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
57
60
  is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
58
61
  spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. So is a **new epic's seed**:
@@ -20,16 +20,55 @@
20
20
  # distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
21
21
  # human edit and rejected.
22
22
  #
23
- # Scope: enforced ONLY when the bridge is enabled (a platform + gate-sync CI). Without the bridge
24
- # (file-only / non-bridge) humans legitimately write the ledger locally, so the gate is a no-op.
23
+ # Scope: enforced ONLY when the bridge is enabled hub.json carries BOTH a `platform` and
24
+ # `bridge_enabled` (or the legacy `bridge`) true, the same predicate `isBridge` (cli/gate.mjs) and
25
+ # `hubActions` (cli/plan.mjs) apply. Without the bridge (file-only / non-bridge, or a platform-less
26
+ # hub) humans legitimately write the ledger locally, so the gate is a no-op.
25
27
  #
26
28
  # Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
27
29
  # badge) WARNs and waives the signature half — the same stance verified-commits takes.
28
30
  set -euo pipefail
29
31
 
30
32
  # ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
33
+ # The predicate is BOTH a platform and the bridge flag, exactly as `isBridge` (cli/gate.mjs) and
34
+ # `hubActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
35
+ # other bridge detector (issue #186): a hub carrying `bridge_enabled: true` with no `platform` would
36
+ # have its human ledger commits rejected here while the CLI, reading the same file, called it
37
+ # file-only and kept the LOCAL write path — no CI writer and no permitted human writer, so no gate
38
+ # could advance. Reachable through a stale install (platform set, script wired, platform later
39
+ # nulled), not through `yad setup`, which derives both from one value.
40
+ #
41
+ # `tr -d '\n'` first, like every other hub.json read in these gates: a key and its value may legally
42
+ # sit on separate lines, and a per-line match would MISS the flag and silently no-op a security gate
43
+ # (the fail-open direction of issue #161).
44
+ #
45
+ # Matched at the ROOT LEVEL only. The shared `default_branch` read below is depth-blind, and that is
46
+ # survivable there — a false match yields a bogus branch name and the gate fails loudly. Here it is
47
+ # not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a hub whose
48
+ # `isBridge` is false, recreating the exact no-writer deadlock #186 is about, from the other side. So
49
+ # the nesting is stripped rather than ignored: peel the outermost braces, then delete innermost
50
+ # objects/arrays until none remain, leaving only root-level pairs to match against. Not a JSON parser
51
+ # — a value containing a literal brace would confuse it — but hub.json is machine-written and the
52
+ # failure it prevents is the one that matters.
53
+ #
54
+ # Flattened ONCE into a variable and matched with here-strings, never `tr … | grep -q`: under the
55
+ # `pipefail` set above, `grep -q` exits at the first match and can SIGPIPE `tr`, which would make a
56
+ # MATCHING pipeline report failure. Reading from a here-string has no upstream process to kill.
31
57
  HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
32
- if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space:]]*true' "$HUB"; then
58
+ HUB_FLAT="$(tr -d '\n' < "$HUB" 2>/dev/null || true)"
59
+ HUB_ROOT="${HUB_FLAT#*\{}"
60
+ HUB_ROOT="${HUB_ROOT%\}*}"
61
+ while :; do
62
+ _stripped="$(sed -E 's/\{[^{}]*\}//g; s/\[[^][]*\]//g' <<< "$HUB_ROOT")"
63
+ [ "$_stripped" = "$HUB_ROOT" ] && break
64
+ HUB_ROOT="$_stripped"
65
+ done
66
+ # One line in, so `sed` emits at most one line out — no `head` needed (which would re-introduce the
67
+ # SIGPIPE-under-pipefail problem this avoids).
68
+ hub_str() { sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" <<< "$HUB_ROOT"; }
69
+ hub_true() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*true" <<< "$HUB_ROOT"; }
70
+
71
+ if [ ! -f "$HUB" ] || [ -z "$(hub_str platform)" ] || { ! hub_true bridge_enabled && ! hub_true bridge; }; then
33
72
  echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
34
73
  exit 0
35
74
  fi
@@ -148,7 +148,11 @@ write only `{project-root}/.sdlc/hub.json` (`config.yaml` `hub.config`) — neve
148
148
  - **`detect-hub`** — detect the hub's own platform and upsert `.sdlc/hub.json`. Run
149
149
  `git remote get-url origin` **on the hub** and read the host with the SAME logic Step 1 uses for code
150
150
  repos: `github.com` → `github`, GitLab host → `gitlab`, no remote → `platform: null`. Record
151
- `git_url`, `default_branch`, `detectedAt`, and `bridge_enabled: true` (preserve an existing roster).
151
+ `git_url`, `default_branch`, `detectedAt`, and `bridge_enabled` — **`true` only when a platform was
152
+ detected, `false` alongside `platform: null`** (preserve an existing roster). The two travel
153
+ together: bridge mode is a platform AND the flag (`isBridge`, `cli/gate.mjs`), and `yad setup`
154
+ derives both from one value, so writing the flag onto a platform-less hub creates a state no CLI
155
+ path can produce and the gates read differently (#186).
152
156
  Auth is the local user's own `gh`/`glab`/git; **store no tokens**. Idempotent — safe to re-run.
153
157
  - **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `email` + a per-scope
154
158
  `roles` map (`roles: { hub: ["owner","reviewer"], <repo>: ["domain-owner", …] }`). Upsert by `login`;
@@ -18,7 +18,8 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
18
18
  "platform": "github", // github | gitlab (from the hub's own remote host); null when local-only
19
19
  "git_url": "https://github.com/abdelrahmannasr/yadflow.git", // REQUIRED when platform is non-null (scopes auth + opens PRs); yad doctor warns YAD-CFG-005 if absent
20
20
  "default_branch": "main",
21
- "bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
21
+ "bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews; travels WITH platform — bridge mode is both (isBridge), so never true beside platform: null (#186)
22
+ "gate_sync_version": "3.15.3", // OPTIONAL exact pin for the wired gate-sync job; an exact 3.x.y, prereleases included (3.16.0-rc.1) — anything else is skipped. Omitted => the .sdlc/cli-version.json stamp if that qualifies, else floating 3
22
23
  "review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
23
24
  "detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
24
25
  "roster": [
@@ -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,9 +103,12 @@ 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
106
+ - the wired job runs an **exact** version, resolved at run time from the repo: the `YAD_VERSION`
107
+ variable, else `hub.json` `gate_sync_version`, else the `.sdlc/cli-version.json` stamp, else the
108
+ floating `3`. It stopped floating on the major because that let a release change a scheduled job's
109
+ behaviour unattended — how #163's churn reached hubs that never opted in. So upgrading is now a
110
+ deliberate act (`yad update`); if the resolved pin predates 3.15.3, upgrade or disable the
111
+ schedule. Never edit a version into the wired file — `yad check --fix` rewrites it byte-for-byte
109
112
  from the template. See `references/bridge.md`.
110
113
  2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
111
114
  - add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
@@ -111,6 +111,9 @@ login and requested too — otherwise an escalated step is structurally unsatisf
111
111
  reorder commit per epic (the records are the same; only their order changes), then converges
112
112
  permanently. On an older yadflow the workaround is to disable the pipeline schedule — merges still
113
113
  advance gates via the push path; only the catch-up for squash merges and bare approvals is lost.
114
+ Since the wired job now runs an exact pin rather than floating on the major, that upgrade is a
115
+ deliberate act: `yad update` (which re-stamps `.sdlc/cli-version.json`), or a `gate_sync_version` in
116
+ `hub.json` — see the version table below.
114
117
 
115
118
  ## Contract re-lock invalidates prior platform approvals too
116
119
 
@@ -182,18 +185,32 @@ other way — up through its first review PR/MR; see "the seed of a new epic" be
182
185
  **Which yadflow the wired job runs.** Both fragments resolve the version from a `YAD_VERSION` variable
183
186
  and fall back to `3`:
184
187
 
185
- | Platform | Where the job reads it | Where you set the pin |
188
+ | # | Source | Set it in |
186
189
  |---|---|---|
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.
190
+ | 1 | `YAD_VERSION` used **verbatim**, the operator's override | GitHub: Settings → Secrets and variables → Actions → **Variables**. GitLab: Settings → CI/CD → **Variables** (beside `SDLC_GATE_TOKEN`) |
191
+ | 2 | `.sdlc/hub.json` `gate_sync_version` this hub's committed pin | edit `hub.json`, commit it |
192
+ | 3 | `.sdlc/cli-version.json` → `version` — the yadflow that last wired the hub | `yad update` re-stamps it |
193
+ | 4 | `3` floating major, only when nothing above resolves | |
194
+
195
+ Sources 2 and 3 are **validated** before use: an exact `3.x.y` release token, prereleases included
196
+ (`3.16.0-rc.1` is a legitimate pin; `latest` and a bare `3` are not). A `.sdlc/cli-version.json`
197
+ written by a long-untouched project can still say something like `1.0.2`, a version with no `yad gate ci`
198
+ in it at all, and the value is interpolated into `npx -p "yadflow@$V"` on a runner holding a push token —
199
+ so anything that is not an exact release of this major is skipped, loudly, in favour of the next source.
200
+ `YAD_VERSION` is exempt: it is a human's deliberate act, and it is the only way to cross a major.
201
+
202
+ **Why this is no longer a floating major.** It used to be, on the argument that a published fix should
203
+ reach a scheduled job with nobody in the loop — this page's own issue #163 as the example. The same
204
+ mechanism is how #163's churn *arrived*: the CI fragment ran `yadflow@3`, so 3.13.1 rolled onto every
205
+ wired hub automatically and took the reporting one from 20 to 96 churn commits an hour, with nobody
206
+ deciding to upgrade. Issue #163's fourth suggested fix was to stop that. The trade-off is real and cuts
207
+ both ways — a hub is no longer carried onto a fix for free, so **if the resolved pin is older than
208
+ 3.15.3, run `yad update` or disable the schedule** (`yad doctor` flags a stale stamp).
209
+
210
+ The pin is **never stamped into the wired file**: `yad` owns that file and `yad check --fix` rewrites it
211
+ byte-for-byte from the template, so a version edited into it would report `outdated` on every check and
212
+ be reverted on the next sync. That is exactly why the job resolves it from committed files at run time
213
+ instead — the fragment itself stays byte-identical to what ships.
197
214
 
198
215
  **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
199
216
  never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
@@ -204,7 +221,10 @@ commit — the advance plus the `draft → approved` status flip — lands on th
204
221
 
205
222
  **The ledger is CI-owned (bridge mode only).** Humans never commit gate-state files: the `ledger-guard`
206
223
  check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
207
- .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
224
+ .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). "Bridge mode" there
225
+ means the same thing it means everywhere else — a `platform` **and** the bridge flag, `isBridge`'s
226
+ predicate. The gate used to enable itself on the flag alone, which let a platform-less hub reject the
227
+ human's ledger write while the CLI still expected one (#186). Under Path B **no
208
228
  CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
209
229
  edit — which it rejects, with one carve-out for a new epic's seed (below). (The `verified-commits`
210
230
  gate still vets every commit's signature + author;
@@ -23,7 +23,9 @@
23
23
  # and nothing is committed — re-visiting a merged review for a week costs one no-op read per pass. That
24
24
  # last part is load-bearing: before the #163 fix the re-sync re-appended each step's approvals at the
25
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.
26
+ # #163). These jobs run an EXACT version resolved from the repo (see the resolver in each job), so a
27
+ # hub sitting on a pre-#163 yadflow keeps that behaviour until someone upgrades it — if the resolved
28
+ # pin is older than 3.15.3, run `yad update` or disable the schedule.
27
29
  # On GitHub a scheduled workflow runs automatically once committed (no setup).
28
30
  #
29
31
  # CI never approves and never merges — the merge click is the human approval act.
@@ -45,13 +47,16 @@ permissions:
45
47
  pull-requests: read # gh pr view + reviewThreads GraphQL
46
48
 
47
49
  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' }}
50
+ # The manual override for which yadflow these jobs run: set a repository (or organization) Actions
51
+ # **variable** named YAD_VERSION to e.g. `3.15.2`, under Settings Secrets and variables Actions
52
+ # Variables. It lives there and not in this file, which `yad` owns and rewrites byte-for-byte from
53
+ # its template on every sync a version edited in here would be silently reverted.
54
+ #
55
+ # Left EMPTY the jobs resolve an exact pin from the repo itself instead of floating on the major (see
56
+ # the resolver step in each job). Floating meant a release could change what a scheduled job does with
57
+ # nobody deciding to upgrade — the amplification path in issue #163, where 3.13.1 took one hub from 20
58
+ # to 96 churn commits an hour.
59
+ YAD_VERSION: ${{ vars.YAD_VERSION || '' }}
55
60
 
56
61
  jobs:
57
62
  mergesync:
@@ -77,6 +82,39 @@ jobs:
77
82
  - uses: actions/setup-node@v7
78
83
  with:
79
84
  node-version: "20"
85
+ - name: Resolve the yadflow pin
86
+ run: |
87
+ # >>> yad-pin
88
+ # Which yadflow this job runs, most explicit source first:
89
+ # 1. $YAD_VERSION — the platform variable, a human's deliberate override, verbatim
90
+ # 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
91
+ # 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
92
+ # 4. 3 — floating major, only when nothing above resolves
93
+ # Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
94
+ # they are VALIDATED: an exact release token of the major this fragment was written for. That
95
+ # also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
96
+ # string reaching a shell on a runner holding `contents: write` is the same class of hole the
97
+ # HEAD_REF fix closed. Anything unparseable is skipped loudly rather than failing the run.
98
+ # Pipes, not bash here-strings, so the block stays byte-identical to the GitLab fragment's
99
+ # (which must run under `sh` when an image has no bash). `sed` drains its input, so nothing
100
+ # SIGPIPEs `tr`.
101
+ yad_pin_read() { # <file> <key>
102
+ [ -f "$1" ] || return 0
103
+ tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
104
+ }
105
+ yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
106
+ YAD_PKG="${YAD_VERSION:-}"
107
+ if [ -z "$YAD_PKG" ]; then
108
+ for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
109
+ [ -n "$_cand" ] || continue
110
+ if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
111
+ echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
112
+ done
113
+ fi
114
+ YAD_PKG="${YAD_PKG:-3}"
115
+ echo "yad-gate-sync: running yadflow@${YAD_PKG}"
116
+ # <<< yad-pin
117
+ echo "YAD_PKG=${YAD_PKG}" >> "$GITHUB_ENV"
80
118
  - name: Advance the gate on merge
81
119
  # The head ref reaches the shell through `env:`, never through `${{ }}` inside `run:`. A
82
120
  # branch name may legally contain `$`, backticks and parentheses, so a PR opened from
@@ -88,7 +126,7 @@ jobs:
88
126
  run: |
89
127
  git config user.name "yad-gate-sync[bot]"
90
128
  git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
91
- npx -y -p "yadflow@${YAD_VERSION}" yad gate ci \
129
+ npx -y -p "yadflow@${YAD_PKG}" yad gate ci \
92
130
  --branch "$HEAD_REF" \
93
131
  --pr "$PR_NUMBER" \
94
132
  --merged
@@ -112,6 +150,39 @@ jobs:
112
150
  - uses: actions/setup-node@v7
113
151
  with:
114
152
  node-version: "20"
153
+ - name: Resolve the yadflow pin
154
+ run: |
155
+ # >>> yad-pin
156
+ # Which yadflow this job runs, most explicit source first:
157
+ # 1. $YAD_VERSION — the platform variable, a human's deliberate override, verbatim
158
+ # 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
159
+ # 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
160
+ # 4. 3 — floating major, only when nothing above resolves
161
+ # Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
162
+ # they are VALIDATED: an exact release token of the major this fragment was written for. That
163
+ # also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
164
+ # string reaching a shell on a runner holding `contents: write` is the same class of hole the
165
+ # HEAD_REF fix closed. Anything unparseable is skipped loudly rather than failing the run.
166
+ # Pipes, not bash here-strings, so the block stays byte-identical to the GitLab fragment's
167
+ # (which must run under `sh` when an image has no bash). `sed` drains its input, so nothing
168
+ # SIGPIPEs `tr`.
169
+ yad_pin_read() { # <file> <key>
170
+ [ -f "$1" ] || return 0
171
+ tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
172
+ }
173
+ yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
174
+ YAD_PKG="${YAD_VERSION:-}"
175
+ if [ -z "$YAD_PKG" ]; then
176
+ for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
177
+ [ -n "$_cand" ] || continue
178
+ if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
179
+ echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
180
+ done
181
+ fi
182
+ YAD_PKG="${YAD_PKG:-3}"
183
+ echo "yad-gate-sync: running yadflow@${YAD_PKG}"
184
+ # <<< yad-pin
185
+ echo "YAD_PKG=${YAD_PKG}" >> "$GITHUB_ENV"
115
186
  - name: Reconcile recently-merged review PRs
116
187
  run: |
117
188
  git config user.name "yad-gate-sync[bot]"
@@ -130,7 +201,7 @@ jobs:
130
201
  [ -n "$N" ] || continue
131
202
  REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
132
203
  case "$REF" in
133
- review/EP-*) npx -y -p "yadflow@${YAD_VERSION}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
204
+ review/EP-*) npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
134
205
  esac
135
206
  done < /tmp/yad-merged-prs
136
207
  exit $rc
@@ -36,12 +36,16 @@
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
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.
39
+ # Which yadflow this job runs: an EXACT version resolved from the repo at run time — see the `yad-pin`
40
+ # block in the script below for the precedence. It no longer floats on the major, because floating let a
41
+ # release change what a scheduled job does with nobody deciding to upgrade (issue #163, where 3.13.1
42
+ # took one hub from 20 to 96 churn commits an hour). The trade-off is deliberate and cuts both ways: a
43
+ # hub is no longer carried onto a fix automatically, so if the resolved pin is older than 3.15.3, run
44
+ # `yad update` or disable this schedule.
45
+ #
46
+ # To override it, set a CI/CD variable YAD_VERSION (e.g. `3.15.2`) in project Settings → CI/CD →
47
+ # Variables — the same place SDLC_GATE_TOKEN lives, and deliberately NOT in this file, which `yad` owns
48
+ # and rewrites byte-for-byte from its template on every sync.
45
49
  variables:
46
50
  GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
47
51
 
@@ -82,6 +86,37 @@ yad-gate-sync:
82
86
  - export GITLAB_TOKEN="$SDLC_GATE_TOKEN" GITLAB_HOST="$CI_SERVER_URL"
83
87
  - git fetch origin "$CI_DEFAULT_BRANCH"
84
88
  - git checkout -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
89
+ - |
90
+ # >>> yad-pin
91
+ # Which yadflow this job runs, most explicit source first:
92
+ # 1. $YAD_VERSION — the CI/CD variable, a human's deliberate override, verbatim
93
+ # 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
94
+ # 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
95
+ # 4. 3 — floating major, only when nothing above resolves
96
+ # Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
97
+ # they are VALIDATED: an exact release token of the major this fragment was written for. That
98
+ # also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
99
+ # string reaching a shell on a runner holding a push token. Anything unparseable is skipped
100
+ # loudly rather than failing the run. Resolved once here; GitLab runs every `script` line in
101
+ # the same shell, so YAD_PKG carries into the block below.
102
+ # Pipes, not bash here-strings: a GitLab runner falls back to `sh` when the image has no bash,
103
+ # and `<<<` is a bashism. `sed` drains its input (no early exit, so nothing SIGPIPEs `tr`).
104
+ yad_pin_read() { # <file> <key>
105
+ [ -f "$1" ] || return 0
106
+ tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
107
+ }
108
+ yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
109
+ YAD_PKG="${YAD_VERSION:-}"
110
+ if [ -z "$YAD_PKG" ]; then
111
+ for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
112
+ [ -n "$_cand" ] || continue
113
+ if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
114
+ echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
115
+ done
116
+ fi
117
+ YAD_PKG="${YAD_PKG:-3}"
118
+ echo "yad-gate-sync: running yadflow@${YAD_PKG}"
119
+ # <<< yad-pin
85
120
  - |
86
121
  rc=0
87
122
  if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
@@ -91,7 +126,8 @@ yad-gate-sync:
91
126
  # unchanged approval record produces an unchanged file and nothing is committed. That last part
92
127
  # is load-bearing — before the #163 fix the re-sync re-appended each step's approvals at the
93
128
  # 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.
129
+ # (issue #163). This job runs an exact pin resolved from the repo, so a hub still sitting on a
130
+ # pre-3.15.3 yadflow keeps that behaviour — upgrade it or disable this schedule.
95
131
  # A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
96
132
  # push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
97
133
  # — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
@@ -105,7 +141,7 @@ yad-gate-sync:
105
141
  while read -r IID REF; do
106
142
  [ -n "$IID" ] || continue
107
143
  git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
108
- npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
144
+ npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
109
145
  done < /tmp/yad-merged-mrs
110
146
  else
111
147
  # MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
@@ -117,7 +153,7 @@ yad-gate-sync:
117
153
  IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
118
154
  if [ -n "$IID" ]; then
119
155
  # Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
120
- npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
156
+ npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
121
157
  else
122
158
  # Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
123
159
  # 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