yadflow 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/platform.mjs CHANGED
@@ -141,10 +141,21 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
141
141
 
142
142
  // Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
143
143
  // so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
144
- export function mapApprovers(reviews = [], { roster, repos, touchedDomains }) {
144
+ export function mapApprovers(reviews = [], { roster, repos, touchedDomains, headOid } = {}) {
145
145
  const out = [];
146
146
  for (const r of reviews) {
147
147
  if (r.state !== 'APPROVED') continue;
148
+ // Revoke-on-change, enforced in code where the platform binds an approval to a commit. The reader
149
+ // sets `commit` to the review's SHA (GitHub), to `null` when that read DEGRADED, or leaves it
150
+ // ABSENT when the platform exposes no per-approval SHA (GitLab):
151
+ // - `null` (degraded read) → FAIL CLOSED → drop, independently of headOid: we cannot prove the
152
+ // approval is for the merged content, so a transient failure holds
153
+ // the gate rather than advancing on unverifiable approvals;
154
+ // - a known SHA ≠ head → the approval is stale (artifact moved) → drop;
155
+ // - absent (GitLab) → keep: revoke-on-change is the platform's "remove approvals on new
156
+ // commits" setting.
157
+ if (r.commit === null) continue;
158
+ if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
148
159
  for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
149
160
  out.push({ ...rec, submittedAt: r.submittedAt || null });
150
161
  }
@@ -157,17 +168,35 @@ function readPrGitHub(n, { cwd } = {}) {
157
168
  const view = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
158
169
  if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
159
170
  const meta = JSON.parse(view.stdout);
160
- // latestReviews collapses a reviewer's superseded reviews to their current one.
161
- const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
162
- const reviews = rev.ok
163
- ? (JSON.parse(rev.stdout).latestReviews || []).map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt }))
164
- : [];
171
+ let reviews = [];
172
+ let reviewsOk = false;
165
173
  // Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
166
174
  // >100 threads is not mistakenly read as "all resolved".
167
175
  let threads = [];
168
176
  const nwo = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
169
177
  if (nwo.ok) {
170
178
  const { owner, name } = JSON.parse(nwo.stdout);
179
+ // latestReviews collapses a reviewer's superseded reviews to their current one; commit.oid binds
180
+ // each approval to the revision it was made on, so an approval on an older commit than the merged
181
+ // head is dropped as stale (revoke-on-change in code — see mapApprovers). `gh pr view --json
182
+ // latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
183
+ // reviewers never silently omits one; any page failure aborts to the commitless fallback below,
184
+ // which fails closed rather than advancing on a partial read.
185
+ const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt commit{oid}}}}}}`;
186
+ let rcursor = null;
187
+ reviewsOk = true;
188
+ for (let guard = 0; guard < 50; guard++) {
189
+ const args = ['api', 'graphql', '-f', `query=${rq}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
190
+ if (rcursor) args.push('-F', `c=${rcursor}`);
191
+ const rg = run('gh', args, { cwd });
192
+ if (!rg.ok) { reviewsOk = false; reviews = []; break; }
193
+ const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
194
+ for (const x of conn?.nodes || []) {
195
+ reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
196
+ }
197
+ if (!conn?.pageInfo?.hasNextPage) break;
198
+ rcursor = conn.pageInfo.endCursor;
199
+ }
171
200
  const q = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:1){nodes{author{login} body}}}}}}}`;
172
201
  let cursor = null;
173
202
  for (let guard = 0; guard < 50; guard++) {
@@ -188,6 +217,15 @@ function readPrGitHub(n, { cwd } = {}) {
188
217
  cursor = page.pageInfo.endCursor;
189
218
  }
190
219
  }
220
+ // Fallback if the GraphQL reviews read failed (no nwo / API hiccup): take the plain JSON view with
221
+ // commit=null. Approvals then FAIL CLOSED in mapApprovers (a degraded read cannot prove an approval
222
+ // is for the merged content), while CHANGES_REQUESTED is still honored — so a transient failure
223
+ // holds the gate, never advances it.
224
+ if (!reviewsOk) {
225
+ const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
226
+ if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
227
+ .map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
228
+ }
191
229
  return {
192
230
  ok: true,
193
231
  state: meta.state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
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). A BMAD module + 30 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -38,6 +38,13 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
38
38
  - Canonical gate sources live in this skill's `templates/` (the source of truth that gets installed
39
39
  into each code repo):
40
40
  - `templates/checks/{spec-link,contract-check,build-test-lint,verified-commits}.sh`
41
+ - `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** (a no-op
42
+ when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
43
+ CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
44
+ is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
45
+ spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. Runs in `yad-hub-checks`
46
+ alongside `verified-commits` (which waives the allowlist for the bot but still requires its
47
+ signature). See `yad-hub-bridge`.
41
48
  - `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
42
49
  → the standalone hub-side verified-commits CI (installed by `yad check --fix` with the hub wiring)
43
50
  - `templates/github/yad-checks.yml` → installs to `.github/workflows/yad-checks.yml` (marked `# yad-managed: yad-checks`)
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # ledger-guard gate.
3
+ # In BRIDGE mode the gate ledger is CI-owned: only the yad gate-sync bot may change the
4
+ # machine-written gate-state files. A commit on a review PR by anyone else that modifies them is
5
+ # rejected — the human keeps the artifact, CI keeps the ledger. This makes "CI is the sole writer of
6
+ # the ledger" a mechanical guarantee instead of a convention.
7
+ #
8
+ # Protected (gate-state, machine-written):
9
+ # epics/*/.sdlc/state.json, approvals.json, comments.json, hub-prs.json
10
+ # epics/*/reviews/*.md
11
+ # NOT protected:
12
+ # epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
13
+ # `gate open`, so a human legitimately commits it alongside the architecture artifact.
14
+ #
15
+ # A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
16
+ # platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
17
+ # Verified signature (a key the contributor cannot forge under the bot identity) is what actually
18
+ # distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
19
+ # human edit and rejected.
20
+ #
21
+ # Scope: enforced ONLY when the bridge is enabled (a platform + gate-sync CI). Without the bridge
22
+ # (file-only / non-bridge) humans legitimately write the ledger locally, so the gate is a no-op.
23
+ #
24
+ # Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
25
+ # badge) WARNs and waives the signature half — the same stance verified-commits takes.
26
+ set -euo pipefail
27
+
28
+ # ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
29
+ HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
30
+ if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space:]]*true' "$HUB"; then
31
+ echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
32
+ exit 0
33
+ fi
34
+
35
+ BASE="${1:-${SDLC_BASE:-origin/main}}"
36
+ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
37
+ echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
38
+ exit 1
39
+ fi
40
+ RANGE="${BASE}..HEAD"
41
+
42
+ commits="$(git rev-list "$RANGE")"
43
+ if [ -z "$commits" ]; then
44
+ echo "PASS [ledger-guard]: no commits in ${RANGE}"
45
+ exit 0
46
+ fi
47
+
48
+ # ---- platform for the signature check (mirrors verified-commits) ------------------------------
49
+ remote="$(git remote get-url origin 2>/dev/null || true)"
50
+ platform=""
51
+ case "$remote" in
52
+ *github*) platform=github ;;
53
+ *gitlab*) platform=gitlab ;;
54
+ esac
55
+ platform="${SDLC_PLATFORM:-$platform}"
56
+ case "$platform" in
57
+ github|gitlab) ;;
58
+ ""|none) platform=""; echo "WARN [ledger-guard]: no GitHub/GitLab remote — bot signature NOT verified (the Verified badge is a platform concept)." ;;
59
+ *) echo "FAIL [ledger-guard]: unknown platform '${platform}' (SDLC_PLATFORM must be github|gitlab|none)."; exit 1 ;;
60
+ esac
61
+
62
+ # 0 when the platform marks the commit's signature verified.
63
+ signature_verified() {
64
+ local sha v body
65
+ sha="$1"
66
+ case "$platform" in
67
+ github)
68
+ v="$(gh api "repos/{owner}/{repo}/commits/${sha}" --jq '.commit.verification.verified' 2>/dev/null || echo api-error)"
69
+ [ "$v" = "true" ]
70
+ ;;
71
+ gitlab)
72
+ if [ -n "${CI_API_V4_URL:-}" ] && [ -n "${CI_PROJECT_ID:-}" ]; then
73
+ body="$(curl -fsS --header "PRIVATE-TOKEN: ${GITLAB_TOKEN:-${SDLC_API_TOKEN:-}}" \
74
+ "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/repository/commits/${sha}/signature" 2>/dev/null || true)"
75
+ else
76
+ body="$(glab api "projects/:id/repository/commits/${sha}/signature" 2>/dev/null || true)"
77
+ fi
78
+ printf '%s' "$body" | grep -qE '"verification_status"[[:space:]]*:[[:space:]]*"verified"'
79
+ ;;
80
+ *) return 1 ;;
81
+ esac
82
+ }
83
+
84
+ # A trusted bot commit = bot-attributed AND (platform-Verified, or no platform to check against).
85
+ trusted_bot() {
86
+ case "$(git show -s --format='%an|%ae' "$1")" in
87
+ *yad-gate-sync*) ;;
88
+ *) return 1 ;;
89
+ esac
90
+ [ -z "$platform" ] && return 0 # degraded: cannot read the Verified badge — waive (warned above)
91
+ signature_verified "$1"
92
+ }
93
+
94
+ violations=0
95
+ for sha in $commits; do
96
+ touches_ledger=0
97
+ while IFS= read -r f; do
98
+ [ -n "$f" ] || continue
99
+ case "$f" in
100
+ epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
101
+ epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
102
+ touches_ledger=1
103
+ echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
104
+ ;;
105
+ esac
106
+ done < <(git diff-tree --no-commit-id --name-only -r "$sha")
107
+ if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
108
+ violations=$((violations + 1))
109
+ fi
110
+ done
111
+
112
+ if [ "$violations" -gt 0 ]; then
113
+ 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."
114
+ exit 1
115
+ fi
116
+ echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
117
+ exit 0
@@ -93,8 +93,14 @@ while IFS= read -r sha; do
93
93
  [ -z "$sha" ] && continue
94
94
  short="$(git log -1 --format=%h "$sha")"
95
95
  author="$(git log -1 --format=%ae "$sha" | tr '[:upper:]' '[:lower:]')"
96
+ # The gate-sync bot is a machine identity, not a roster human — waive the allowlist for it. Its
97
+ # commits are still held to the SIGNATURE check below, so a contributor cannot spoof the bot author
98
+ # to dodge the allowlist (a forged-author commit is not platform-Verified). Mirrors how the
99
+ # platform-committer merge commits are allowlist-exempt but signature-covered.
100
+ is_bot=0
101
+ case "${author}|$(git log -1 --format=%an "$sha" | tr '[:upper:]' '[:lower:]')" in *yad-gate-sync*) is_bot=1 ;; esac
96
102
 
97
- if [ "$authors_on" = 1 ]; then
103
+ if [ "$authors_on" = 1 ] && [ "$is_bot" = 0 ]; then
98
104
  # tolerate CRLF / stray surrounding whitespace in a hand-edited allowlist
99
105
  if grep -vE '^[[:space:]]*(#|$)' "$ALLOWLIST" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
100
106
  | tr '[:upper:]' '[:lower:]' | grep -qxF "$author"; then
@@ -103,6 +109,8 @@ while IFS= read -r sha; do
103
109
  echo "FAIL [verified-commits]: ${short} author <${author}> is not in ${ALLOWLIST} — unverified user."
104
110
  rc=1
105
111
  fi
112
+ elif [ "$is_bot" = 1 ]; then
113
+ echo "PASS [verified-commits]: ${short} gate-sync bot — allowlist waived (signature still required)"
106
114
  fi
107
115
 
108
116
  if [ -n "$platform" ]; then
@@ -34,3 +34,11 @@ jobs:
34
34
  - run: |
35
35
  body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
36
36
  bash checks/pr-template.sh --profile hub "$body"
37
+
38
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
39
+ ledger-guard:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ with: { fetch-depth: 0 }
44
+ - run: bash checks/ledger-guard.sh "origin/${{ github.base_ref }}"
@@ -37,3 +37,10 @@ yad-hub-pr-template:
37
37
  script:
38
38
  - body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
39
39
  - bash checks/pr-template.sh --profile hub "$body"
40
+
41
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
42
+ yad-hub-ledger-guard:
43
+ extends: .yad_hub_mr_only
44
+ needs: []
45
+ script:
46
+ - bash checks/ledger-guard.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yad-hub-bridge
3
- description: 'The templated PR/MR bridge for the front-half review gate. When the product hub has a platform (.sdlc/hub.json), it opens a review PR/MR on the hub for an authored artifact (the optional analysis / epic / architecture+contract / ui-design / stories / test-cases), sets the required reviewers/labels from the routing rule, and provides the read-only gh/glab recipes that yad-review-gate uses to pull platform comments + approvals back into the file ledger. Can also wire event-driven sync on the hub: a CI workflow that runs `yad gate ci` whenever a reviewer approves / requests changes / a human merges, committing the ledger to the default branch. Local-user auth only — no stored tokens. The file ledger stays the source of truth; degrades to the file-only gate when there is no platform / no CLI. Use when the user says "open the review PR", "route the review", "wire the gate sync", or it is invoked by yad-review-gate open/sync.'
3
+ description: 'The templated PR/MR bridge for the front-half review gate. When the product hub has a platform (.sdlc/hub.json), it opens a review PR/MR on the hub for an authored artifact (the optional analysis / epic / architecture+contract / ui-design / stories / test-cases), sets the required reviewers/labels from the routing rule, and provides the read-only gh/glab recipes that yad-review-gate uses to pull platform comments + approvals back into the file ledger. Can also wire merge-time sync on the hub: a CI workflow that runs `yad gate ci` when a human merges a review PR/MR CI is the sole ledger writer and writes only at merge, on the default branch (during review the platform PR/MR is the source of truth; CI never touches the review branch). Local-user auth only — no stored tokens. The file ledger stays the source of truth; degrades to the file-only gate when there is no platform / no CLI. Use when the user says "open the review PR", "route the review", "wire the gate sync", or it is invoked by yad-review-gate open/sync.'
4
4
  ---
5
5
 
6
6
  # SDLC — Hub Review Bridge (the templated PR/MR bridge)
@@ -57,24 +57,40 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
57
57
  filled with the epic, artifact, gate step, owner, `epic.repos`, and the step's risk tags.
58
58
  3. **Request the required reviewers** (their logins) and add a `domain:<repo>` label per touched repo so
59
59
  per-repo routing is legible on the PR.
60
- 4. Upsert a record into `epics/<epic>/.sdlc/hub-prs.json`:
60
+ 4. **Do not write the ledger.** CI is the sole writer and writes only at merge. During review nothing
61
+ is recorded in the ledger — the platform PR/MR holds the review state (native approvals + threads).
62
+ At merge, CI records the `hub-prs.json` entry (in the shape below) and advances on the default
63
+ branch:
61
64
  ```json
62
65
  { "step": "<review step id>", "artifact": "<artifact>", "platform": "github|gitlab",
63
66
  "number": <n>, "url": "<pr/mr url>", "branch": "review/EP-<slug>/<artifact-base>", "lastSyncedAt": null }
64
67
  ```
68
+ A human commit touching the gate-state files (`.sdlc/{state,approvals,comments,hub-prs}.json` or
69
+ `reviews/*.md`; `.sdlc/contract-lock.json` is artifact-side and allowed) on a review PR is rejected
70
+ 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.)
65
72
  5. Report the PR/MR URL and the required reviewers. **Do not** record approvals or advance — reviewers
66
- act on the platform; `yad-review-gate action: sync` pulls it back.
73
+ act on the platform; CI (`yad gate ci`) reconciles it onto the default branch at merge.
67
74
 
68
75
  ### Step 3 — `route` (print required reviewers)
69
76
  Compute and print the required reviewers as above. Use `templates/checks/hub-route.sh <body>` to parse a
70
77
  PR/MR body's Impact & Risk block when given one; otherwise derive from `epic.repos` + the step's
71
78
  `risk_tags`. Advisory only — it routes the human review, it does not approve.
72
79
 
73
- ### Step 4 — `wire` (event-driven sync on the hub)
74
- Install the hub CI that turns platform actions a review **approval**, a **change request**, a review
75
- dismissal, or the human **merge** into an automatic `yad gate ci` run that syncs the ledger and
76
- commits it to the hub's default branch. No more waiting on a manual `yad gate sync`; the manual command
77
- remains valid and is the fallback whenever CI cannot push.
80
+ ### Step 4 — `wire` (merge-time sync on the hub)
81
+ Install the hub CI that turns the human **merge** into a `yad gate ci` run, with CI as the **sole
82
+ writer** of the ledger. There is no pre-merge CI write during review the platform PR/MR is the
83
+ source of truth (native approvals + threads). On merge, CI re-reads approvals from the platform,
84
+ advances the step, and flips the artifact `status:` on the **default branch** (the only place CI ever
85
+ commits). Also install the `ledger-guard` check (yad-checks) so humans cannot commit gate-state files.
86
+ Revoke-on-change is enforced at merge: on **GitHub** in code (an approval whose commit ≠ the merged
87
+ head is dropped — no setting needed); on **GitLab** it has no per-approval commit SHA, so enabling the
88
+ platform's **"remove all approvals when commits are added to the source branch"** is **required** for
89
+ the guarantee. Either way it is safe because CI never pushes the review branch — only the owner's own
90
+ artifact pushes dismiss approvals. In bridge mode `yad gate sync` is advisory (read-only) and is **not**
91
+ a recovery path; if a merge-time run fails, the scheduled reconcile job re-advances it automatically, or
92
+ a maintainer can force it with `yad gate ci --branch <review-branch> --pr <n> --merged` locally on the
93
+ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
78
94
 
79
95
  1. Run `yad check --fix` (the wiring is manifest-driven, like `yad-checks`): with a platform +
80
96
  enabled bridge in `.sdlc/hub.json` it installs
@@ -87,8 +103,9 @@ remains valid and is the fallback whenever CI cannot push.
87
103
  `templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
88
104
  - create the 15-minute pipeline **schedule** (variable `SDLC_GATE_SYNC=true`) and the masked
89
105
  `SDLC_GATE_TOKEN` project-access-token variable (`read_api` + `write_repository`). GitLab fires no
90
- pipeline on an approval alone, so the schedule is the path that picks approvals up (≤ ~15 min
91
- latency); MR events and the merge are near-immediate.
106
+ pipeline on an approval alone, and a squash merge can drop the branch name, so the schedule is the
107
+ catch-up path that advances merged reviews it discovers via the API (≤ ~15 min latency); a merge
108
+ push whose commit names the review branch advances near-immediately.
92
109
  - **If your runners are tag-locked** (`run_untagged: false`, common on self-hosted): set a
93
110
  `YAD_RUNNER_TAGS` CI/CD variable (e.g. `dind_runner`) so the docker-image `yad-gate-sync` job
94
111
  is routed to a runner — otherwise it sits `pending` forever. The fragment emits
@@ -98,8 +115,12 @@ remains valid and is the fallback whenever CI cannot push.
98
115
  every `yad` sync. Single value only — `tags: [$VAR]` is one tag equal to the whole variable,
99
116
  not a comma-split.
100
117
  3. Commit the workflow to the hub. GitHub needs nothing else — the ephemeral `github.token` reads the
101
- PR and pushes the ledger. If the default branch is protected, see the workflow header for the
102
- bypass / PAT options; until then the run fails visibly and manual `yad gate sync` still works.
118
+ PR and pushes the merge advance to the default branch, and the workflow's reconcile **schedule**
119
+ runs automatically (no setup) as the safety net that recovers a merge whose run failed transiently.
120
+ If the default branch is protected, see the workflow header for the bypass / PAT options; until then
121
+ the run fails visibly — recover by granting the push path, then re-run the workflow or run
122
+ `yad gate ci --branch <review-branch> --pr <n> --merged` locally on the default branch (advisory
123
+ `yad gate sync` cannot recover a bridge gate).
103
124
 
104
125
  ## Hard rules
105
126
 
@@ -111,12 +132,18 @@ remains valid and is the fallback whenever CI cannot push.
111
132
  act) — `yad gate sync` records the approvals + resolution + merged state and advances; unresolved
112
133
  comments or a changed artifact hold it `in_review`. The mechanical sync is the `yad gate` CLI.
113
134
  - **CI never approves and never merges.** The wired workflow only runs `gate ci` — the same sync +
114
- unchanged predicate and commits the **ledger files only** to the default branch; the artifact lands
115
- on the default branch exclusively via the human merge. Front gates stay permanently human.
135
+ unchanged predicate. It does **nothing pre-merge** (the platform PR/MR holds the review state); at
136
+ merge it re-reads approvals from the platform, advances the step, and flips the artifact status on
137
+ the **default branch** (the only place CI ever commits). CI is the sole ledger writer; front gates
138
+ stay permanently human.
116
139
  - **The CI tokens are the one documented bend of "no stored tokens".** GitHub uses the platform's own
117
140
  ephemeral `github.token` (nothing stored). GitLab requires a stored masked `SDLC_GATE_TOKEN`
118
141
  project access token because `CI_JOB_TOKEN` can neither read the approvals API nor push — say so
119
142
  when wiring, and scope it to `read_api` + `write_repository` only.
143
+ - **Protect the hub default branch.** Require that `epics/**` artifacts change only through a review
144
+ PR/MR (branch protection). This keeps revoke-on-change sound: it removes the only window where a
145
+ delayed reconcile could advance on an out-of-band post-merge artifact change (see `references/bridge.md`,
146
+ "Known limitation").
120
147
  - **Degrade gracefully.** No platform / disabled bridge / no CLI → the gate runs file-only with no error.
121
148
 
122
149
  ## Reference
@@ -73,64 +73,106 @@ comments, replies, the reviewer **resolves** their thread, then `sync` runs agai
73
73
  - **Merge → advance.** When the reviewer rule is satisfied, every thread is resolved, **and the review
74
74
  PR/MR is merged**, `sync` marks the step `done` and unblocks the next step. The merge is the human
75
75
  approval act — there is no separate machine advance. (`yad gate sync` performs this deterministically.)
76
- - **Revoke on artifact change.** Each bridge approval is stamped with the content hash it was given
77
- against (the file bytes; the locked contract surface for architecture). On re-sync, an approval whose
78
- stamped hash ≠ the current hash is **dropped** — the reviewer must re-approve the changed artifact. A
79
- genuinely newer review (later `submittedAt`) re-stamps against the new hash. This is "revoke only when
80
- the artifact changed", not "revoke on any PR commit".
81
-
82
- ## Event-driven sync (hub CI)
83
-
84
- The `wire` action (SKILL.md Step 4) installs a CI workflow on the hub so the platform events drive
85
- `yad gate ci` instead of waiting on a manual `yad gate sync`. The CLI entry is self-sufficient: it
86
- derives the epic + artifact from the `review/EP-<slug>/<artifact-base>` head branch and takes the PR/MR
87
- number from the event payload it even upserts the `hub-prs.json` entry when the author never committed
88
- it, so the first CI commit converges the views. `yad gate ci` with no `--branch` sweeps every open
89
- review PR (the scheduled path).
90
-
91
- | Platform event | CI action |
92
- |---|---|
93
- | review submitted (approve / changes requested) or dismissed | `gate ci --branch <head> --pr <n>` sync the ledger; the predicate may hold or pass |
94
- | PR/MR `synchronize` (new commits on the review branch) | same — promptly re-stamps/revokes approvals on artifact change |
95
- | PR/MR closed **and merged** (the human act) | same predicate sees `merged: true` and advances the step |
96
- | GitLab schedule (`*/15 * * * *`, `SDLC_GATE_SYNC=true`) | `gate ci` sweep — the **only** GitLab path that sees a bare approval (≤ ~15 min latency) |
97
-
98
- **The overlay.** Pre-merge, the artifact under review exists only on the review branch, while the
99
- ledger lives on the default branch. `gate ci` checks out the default branch, fetches the head ref and
100
- overlays just the artifact paths (for architecture: `architecture.md` + `contract.md` +
101
- `.sdlc/contract-lock.json`; for stories: `stories/`) so `artifactHash` binds each approval to **what the
102
- reviewers actually approved** then drops the overlay before committing. Only
103
- `epics/<epic>/.sdlc/*.json` + `reviews/*.md` are committed (message `chore(gate): … [skip ci]`); the
104
- artifact reaches the default branch exclusively via the human merge.
105
-
106
- **Loop prevention & races.** The GitHub triggers (`pull_request_review`, `pull_request`) cannot fire on
107
- a push to the default branch, `[skip ci]` guards every other workflow on both platforms, and a
108
- repo-wide concurrency group serializes runs; the ledger push retries with a rebase (ledger-only commits
109
- across epics touch disjoint files).
76
+ - **Revoke on artifact change (checked at merge).** Path B reconciles at merge, so an approval given
77
+ to an earlier revision must not count for the merged content. How that is enforced differs by
78
+ platform:
79
+ - **GitHub in code.** Each approval carries the **commit SHA it was made on**; `mapApprovers`
80
+ drops any approval whose commit ≠ the merged head (`headOid`). The reviewer approved an older
81
+ revision → their review is stale → dropped. No platform setting required.
82
+ - **GitLab — platform setting.** GitLab approvals expose no per-approval commit SHA (the reader omits
83
+ it), so the in-code SHA check does not apply and the approval is kept. Revoke-on-change there is the
84
+ platform's **"remove all approvals when commits are added to the source branch"** setting **enable
85
+ it** (required for the guarantee on GitLab; safe under Path B because CI never pushes the source
86
+ branch, so only the owner's own artifact pushes drop approvals).
87
+ - **Degraded GitHub readfail closed.** If the GitHub commit read fails (the reader returns a
88
+ `null` commit), the approval is **dropped**: a transient failure holds the gate rather than
89
+ advancing on approvals whose freshness cannot be proven (re-run `yad gate sync` to recover).
90
+ CHANGES_REQUESTED is still honored, so a degraded read can only ever *hold* the gate.
91
+ The `artifactHash` stamp still binds architecture approvals to the locked contract surface (see
92
+ "Contract re-lock" above).
93
+ - **Known limitation protect the hub default branch.** The advance hashes the artifact from the
94
+ default branch as it stands when CI runs, while approvals are SHA-bound to the reviewed PR/MR head.
95
+ Those can differ if the artifact changes on the **base** outside this review while the PR/MR is open
96
+ (the merge then integrates a change the reviewers never saw) or if a later out-of-band commit edits
97
+ the merged artifact before a delayed reconcile advances it. In both cases each approval's commit
98
+ still equals the reviewed head, so the SHA check passes, yet the live content was not reviewed. Close
99
+ it operationally: **require branch protection on the hub default branch so `epics/**` artifacts can
100
+ only change through their own review PR/MR** (one open review per artifact) — then the base copy of an
101
+ artifact cannot move while its review is open, so the merged/live content always equals the reviewed
102
+ content. (The complete in-code fix would hash the artifact at the reviewed PR-head revision before
103
+ advancing; deferred in favor of the branch-protection mitigation.)
104
+
105
+ ## Event-driven sync (hub CI) — Path B
106
+
107
+ The `wire` action (SKILL.md Step 4) installs CI on the hub so a **merge** drives `yad gate ci` —
108
+ **CI is the SOLE writer of the ledger, and it writes only at merge, only to the default branch.**
109
+ During review CI writes nothing: the platform PR/MR is the source of truth (native approvals +
110
+ threads). The CLI is self-sufficient at merge: it derives the epic + artifact from the
111
+ `review/EP-<slug>/<artifact-base>` head branch, takes the PR/MR number from the event (GitHub) or
112
+ resolves it from the platform (GitLab), upserts the `hub-prs.json` entry itself, and **re-reads
113
+ approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch.
114
+
115
+ | Platform event | Phase | CI action |
116
+ |---|---|---|
117
+ | PR/MR opened / reopened / pushed / reviewed | pre-merge | **none** — review state lives on the platform; CI never touches the branch |
118
+ | 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** |
119
+ | 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) |
120
+
121
+ **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
122
+ never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
123
+ Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
124
+ each `artifactHash` against the merged content, so revoke-on-change still holds. The only ledger
125
+ commit — the advance plus the `draft → approved` status flip — lands on the **default branch** with
126
+ `[skip ci]`.
127
+
128
+ **The ledger is CI-owned (bridge mode only).** Humans never commit gate-state files: the `ledger-guard`
129
+ check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
130
+ .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
131
+ CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
132
+ edit — which it rejects. (The `verified-commits` gate still vets every commit's signature + author;
133
+ its gate-bot exemption is now vestigial in-PR because CI no longer commits there.) `yad gate open`
134
+ opens the PR only; local `yad gate sync` is advisory in bridge mode (writes nothing). After a merge,
135
+ everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
136
+ these guards are no-ops.)
137
+
138
+ **Loop prevention & races.** The only ledger commit lands on the **default branch** at merge, which
139
+ fires no PR trigger; it carries `[skip ci]` to guard sibling workflows. Because CI never pushes the
140
+ review branch, there is no `synchronize` / MR-pipeline loop to prevent — and it is now **safe to enable**
141
+ the platform's **"dismiss stale approvals on push"** (GitHub) / **"remove all approvals when commits
142
+ are added to the source branch"** (GitLab): only the owner's own artifact pushes dismiss approvals,
143
+ which is exactly the intended revoke-on-change. Merge advances serialize on the default branch; the
144
+ push retries with a rebase.
110
145
 
111
146
  **Tokens.**
112
147
  - GitHub: the ephemeral `github.token` with `contents: write` + `pull-requests: read` — nothing stored.
148
+ Only the merge job pushes, and only the default branch.
113
149
  - GitLab: a masked `SDLC_GATE_TOKEN` project access token (`read_api` + `write_repository`) — the one
114
150
  documented bend of the no-stored-tokens rule; `CI_JOB_TOKEN` can neither read the approvals API nor
115
- push.
116
- - Protected default branch (GitHub): prefer a ruleset bypass for Actions; else a fine-grained PAT as
117
- `SDLC_GATE_TOKEN` passed to `actions/checkout`; else leave it the run fails **visibly** and manual
118
- `yad gate sync` remains the fallback. Never wire branch protection that couples the merge itself to
119
- the gate.
120
-
121
- **Manual sync stays first-class.** `yad gate sync <epic> [artifact]` is unchanged and always valid —
122
- the CI is the same sync on a trigger, and the file ledger is still the source of truth.
151
+ push. Used only for the merge-time default-branch push + the API reads.
152
+ - Protected default branch (GitHub): the merge advance needs to push it — prefer a ruleset bypass for
153
+ Actions, else a fine-grained PAT as `SDLC_GATE_TOKEN` on the mergesync checkout.
154
+
155
+ **Manual sync & recovery.** In bridge mode `yad gate sync` is **advisory** (read-only) — it prints the
156
+ predicate but writes nothing, so it is **not** a recovery path when CI fails. If a merge-time run fails
157
+ (can't push, API hiccup), recovery is the scheduled **reconcile** job (automatic; it re-advances merged
158
+ reviews not yet `done`). To force it immediately, a maintainer runs the same command CI runs, locally on
159
+ the default branch: `yad gate ci --branch <review-branch> --pr <n> --merged` (this writes + pushes,
160
+ unlike advisory `yad gate sync`). File-only mode (no platform) keeps `yad gate sync` as the local writer.
161
+ The file ledger is still the source of truth.
123
162
 
124
163
  ### Manual end-to-end verification (GitHub)
125
164
 
126
165
  1. On a scratch hub: `yad setup` (platform github, roster with a second account) → `yad check --fix`
127
166
  installs `.github/workflows/yad-gate-sync.yml`; commit + push it.
128
- 2. Author an epic → `yad gate open EP-x epic.md` → the review PR opens.
129
- 3. Second account **approves** → the Actions run commits `approvals.json` to the default branch.
130
- 4. Second account **requests changes** → the run records the blocking comment; `yad gate status EP-x`
131
- (after `git pull`) shows the gate held.
132
- 5. Resolve the thread, approve again, a human **merges** → the closed-event run advances `state.json`
133
- (`epic-review: done`, `currentStep: architecture`).
134
- 6. `git pull` locally `yad gate status EP-x` matches the platform history.
135
-
136
- GitLab variant: same flow on an MR; a bare approval appears after the next schedule tick.
167
+ 2. Author an epic → `yad gate open EP-x epic.md` → the review PR opens. CI writes nothing yet — review
168
+ state lives on the platform.
169
+ 3. Second account **approves** / **requests changes** → no CI run touches the branch; the PR's native
170
+ approvals + threads are the source of truth (local `yad gate sync` is advisory and shows the
171
+ predicate without writing).
172
+ 4. Resolve the thread, approve again, a human **merges** → the mergesync run advances `state.json`
173
+ (`epic-review: done`, `currentStep: architecture`) + flips `epic.md` to `approved` **on the
174
+ default branch**.
175
+ 5. `git checkout <default> && git pull` locally `yad gate status EP-x` matches the platform history.
176
+
177
+ GitLab variant: same flow on an MR; the advance lands on the merge push (or the next schedule tick if
178
+ a squash merge dropped the branch name).