yadflow 3.12.2 → 3.13.1

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.
@@ -10,7 +10,7 @@ predicate (`../yad-review-gate/references/gating.md`) runs unchanged. The bridge
10
10
  |---|---|
11
11
  | GitHub review `APPROVED` / GitLab MR approval (`approved_by`) | an `approved` record in `approvals.json`, role resolved from the roster (owner/reviewer) or derived domain-owner, tagged `"source": "bridge"` |
12
12
  | GitHub `COMMENTED` / `CHANGES_REQUESTED`; GitLab discussions/notes | a line under `## <name> (<role>)` in `reviews/<artifact>--<date>--comments.md` + a `comments.json` record; **never** an approval. `CHANGES_REQUESTED` is also flagged as blocking in the comments file |
13
- | GitHub review dismissed / GitLab approval revoked | the prior bridge `approved` record for that approver is removed on re-sync (see idempotency) |
13
+ | GitHub review dismissed / GitLab approval revoked | the prior bridge `approved` record for that approver is removed on re-sync **while the step is open**; once the step is `done` the record is kept as the audit trail of why it passed (see idempotency) |
14
14
 
15
15
  `approvals.json` records from the bridge carry `"source": "bridge"`; **manual** approvals have no such
16
16
  tag and are **never** touched by `sync` — the two coexist.
@@ -82,10 +82,21 @@ login and requested too — otherwise an escalated step is structurally unsatisf
82
82
  ## Idempotent re-sync
83
83
 
84
84
  - Key bridge approvals on `(step, approver, role, domain)`. On re-sync, **upsert** — do not append a
85
- duplicate. Remove any bridge approval whose platform review was dismissed/revoked.
86
- - Key synced comments on the platform comment id so the same comment is not appended twice.
87
- - Update the step's `hub-prs.json` `lastSyncedAt` after a successful sync.
88
- - Running `sync` twice with no platform change is a no-op on the ledger.
85
+ duplicate.
86
+ - **On an OPEN step**, remove any bridge approval whose platform review was dismissed/revoked: the
87
+ platform is the live source of truth while the review is in flight.
88
+ - **On a step already `done`**, the record is only added to or refreshed in place — an approval the
89
+ platform no longer reports is **kept**. Those approvals are the audit record of *why* the gate
90
+ passed; a roster edit, an approval reset, or a degraded-but-successful read would otherwise erase
91
+ them and leave the step `done` with zero approvals.
92
+ - Key synced comments on the platform comment id so the same comment is not appended twice. Comment
93
+ rounds are recorded for an **open** step only, so re-visiting a merged review does not append a new
94
+ round per pass.
95
+ - Update the step's `hub-prs.json` `lastSyncedAt` when the sync **learned something** — every sync on
96
+ an open step, and on a closed one only when the approval record actually changed (a re-opened review
97
+ that was re-approved). An identical re-sync leaves it alone, so the ledger does not churn.
98
+ - Running `sync` twice with no platform change is a no-op on the ledger — byte-identical, including
99
+ `comments.json` and the dated `reviews/*.md` side files.
89
100
 
90
101
  ## Contract re-lock invalidates prior platform approvals too
91
102
 
@@ -201,6 +212,40 @@ the default branch: `yad gate ci --branch <review-branch> --pr <n> --merged` (th
201
212
  unlike advisory `yad gate sync`). File-only mode (no platform) keeps `yad gate sync` as the local writer.
202
213
  The file ledger is still the source of truth.
203
214
 
215
+ Because CI records the `hub-prs.json` pointer only at merge, a review the ledger has never seen still
216
+ has to be nameable — otherwise `sync` refuses a PR that is sitting merged on the platform. With no
217
+ recorded pointer, `yad gate sync <epic> <artifact>` resolves the PR/MR from the review branch
218
+ (`review/<epic>/<artifact>`) itself, and `--pr <n>` names it outright:
219
+
220
+ ```bash
221
+ yad gate sync EP-x architecture.md --pr 42 # advisory in bridge mode; the writer without the bridge
222
+ ```
223
+
224
+ An explicit `--pr` also **overrides** a recorded pointer, since a re-opened review is a new PR the
225
+ ledger has not seen. Before its reviewers are bound to the artifact's hash, the number is confirmed to
226
+ be the PR for that artifact's review branch — a mismatch is refused, and a platform that cannot answer
227
+ warns and proceeds. The bridge rule is unchanged: the resolved pointer is adopted into the ledger only
228
+ on the writer path, so a human never leaves a gate-state file in their working tree for `ledger-guard`
229
+ to reject.
230
+
231
+ **In bridge mode `gate sync` stays advisory even with `--pr`** — it prints the predicate and writes
232
+ nothing, because CI owns the ledger. The recovery that actually writes is the command CI itself runs,
233
+ on the default branch: `yad gate ci --branch <review-branch> --pr <n> --merged`.
234
+
235
+ **Serialization.** Both wired jobs push the ledger to the default branch, and on GitLab the scheduled
236
+ sweep's recent-MR window overlaps whatever the merge-push pipeline is handling — so they are pinned to
237
+ one at a time (`concurrency: yad-gate-mergesync` on GitHub, `resource_group: yad-gate-mergesync` on
238
+ GitLab). Without it both runs produce the same advance, one pushes, and the other rebases onto it and
239
+ lands a duplicate `chore(gate): advance …` commit under a different SHA — unreviewed churn, since
240
+ those commits carry `[skip ci]` and go straight to the default branch. An already-wired GitLab hub
241
+ picks the `resource_group` up on the next `yad update` / `yad check --fix`.
242
+
243
+ **`yad gate open` does not create or push the review branch.** It opens a PR/MR *against*
244
+ `review/<epic>/<artifact>`, so that branch must already be **on origin** — a local-only branch is no
245
+ use, because `gh pr create --head` does not push either. `yad open-pr`, run from the branch, pushes it
246
+ and then delegates here. Opening with the branch absent is refused up front, before any ledger write,
247
+ rather than failing inside `gh`/`glab`; an origin that cannot be reached at all only warns.
248
+
204
249
  ### Manual end-to-end verification (GitHub)
205
250
 
206
251
  1. On a scratch hub: `yad setup` (platform github, roster with a second account) → `yad check --fix`
@@ -17,8 +17,10 @@
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 already
21
- # `done` is skipped). 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: 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).
22
24
  #
23
25
  # CI never approves and never merges — the merge click is the human approval act.
24
26
  #
@@ -73,7 +75,8 @@ jobs:
73
75
 
74
76
  reconcile:
75
77
  # Safety net: recover any merged review PR whose merge-time run failed transiently (the `closed`
76
- # event never re-fires). Idempotent — `yad gate ci` skips a step that is already `done`.
78
+ # event never re-fires). Idempotent — `yad gate ci` never re-advances a step that is already `done`,
79
+ # and a re-sync of one writes nothing unless its approvals actually changed.
77
80
  if: github.event_name == 'schedule'
78
81
  runs-on: ubuntu-latest
79
82
  concurrency:
@@ -19,7 +19,8 @@
19
19
  # - MERGE push (default branch, commit names a review branch): resolve the merged MR's IID from its
20
20
  # source branch, then advance + flip status on the default branch — near-immediate.
21
21
  # - SCHEDULED sweep (catch-up): enumerate recently-merged review MRs via the API and advance any
22
- # not yet advanced (idempotent — a step already `done` is skipped). Covers bare approvals and
22
+ # not yet advanced (idempotent — an already-`done` step is never re-advanced, and re-syncing it
23
+ # writes nothing unless its approvals changed). Covers bare approvals and
23
24
  # squash merges whose commit message dropped the branch name. Create a pipeline schedule
24
25
  # (one-time, cannot be committed as code): cron `*/15 * * * *` with variable SDLC_GATE_SYNC=true.
25
26
  # UI: CI/CD > Schedules, or:
@@ -39,6 +40,15 @@ variables:
39
40
 
40
41
  yad-gate-sync:
41
42
  needs: []
43
+ # Serialize every advance: the merge-push pipeline and the scheduled sweep both commit the ledger
44
+ # and push the DEFAULT branch, and the sweep's 7-day window covers MRs the merge path is still
45
+ # processing — so without this they race on the same MR. Both produce the advance, one pushes, the
46
+ # loser rebases onto it and pushes the same commit message under a different SHA. That is where the
47
+ # duplicate `chore(gate): advance …` commits with shifting SHAs come from, and every one of them
48
+ # carries [skip ci] and lands directly on the default branch, so nothing validates the churn.
49
+ # `resource_group` is GitLab's equivalent of the `concurrency` group the GitHub workflow sets on both
50
+ # of its jobs: at most one instance runs at a time, the rest queue.
51
+ resource_group: yad-gate-mergesync
42
52
  # Runner selection: set the YAD_RUNNER_TAGS CI/CD variable (e.g. `dind_runner`) to route this
43
53
  # docker job on instances whose runners are tag-locked (run_untagged: false) — it lives in
44
54
  # project Settings → CI/CD → Variables, so it survives every `yad` sync. When unset, current
@@ -69,7 +79,8 @@ yad-gate-sync:
69
79
  rc=0
70
80
  if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
71
81
  # SCHEDULED SWEEP — discover merged review MRs from the platform (Path B keeps no per-branch
72
- # ledger), then advance each. `gate ci` is idempotent: a step already `done` is skipped.
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.
73
84
  # A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
74
85
  # push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
75
86
  # — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
@@ -131,8 +131,9 @@ via the local user's `gh`/`glab`. For each:
131
131
  - an `APPROVED` review / MR approval → append an `approved` record to `approvals.json` tagged
132
132
  `"source": "bridge"`; a `COMMENTED`/`CHANGES_REQUESTED`/note → write to
133
133
  `reviews/<artifact-base>--<YYYY-MM-DD>--comments.md` + `comments.json` (never an approval).
134
- **Idempotent:** upsert bridge approvals by `(step, approver, role, domain)`, supersede revoked ones, and
135
- key comments on the platform comment id (re-running `sync` does not duplicate). **Manual approvals (no
134
+ **Idempotent:** upsert bridge approvals by `(step, approver, role, domain)`, supersede revoked ones
135
+ **while the step is open** (a step already `done` keeps its approvals they are the record of why it
136
+ passed), and key comments on the platform comment id (re-running `sync` does not duplicate). **Manual approvals (no
136
137
  `source` tag) are never touched.** For the architecture+contract step, discard bridge approvals dated
137
138
  before a new contract lock (re-lock invalidates platform approvals too). Then refresh the `approved.md`
138
139
  roster, set `hub-prs.json` `lastSyncedAt`, and **re-evaluate Step 3**. Under the PR-driven CLI (`yad
@@ -170,7 +171,9 @@ If the predicate **passes**:
170
171
 
171
172
  ### PR-driven automation (the `yad gate` CLI)
172
173
  When the hub has a platform, **CI is the sole writer of the ledger**. `yad gate open` opens the review
173
- PR only; CI (`yad gate ci`) writes the `.sdlc/` + `reviews/` records this skill describes. The skill's
174
+ PR only against the `review/<epic>/<artifact>` branch, which must already exist (create it and run
175
+ `yad open-pr` from it, which pushes it first). CI (`yad gate ci`) writes the `.sdlc/` + `reviews/`
176
+ records this skill describes. The skill's
174
177
  job is the human half: presenting the artifact, helping the owner address comments, and narrating the
175
178
  gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
176
179
  nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it).
@@ -179,6 +182,13 @@ Under that CLI the gate **advances on merge**: a review PR/MR whose reviewer rul
179
182
  comment threads are **all resolved**, and which has been **merged** auto-marks the step `done` and
180
183
  unblocks the next step. (Until those three hold, the step stays `in_review`.)
181
184
 
185
+ **Re-reviewing a step that already advanced.** A step is advanced **once** — the chain is never pulled
186
+ backward. But a step that is `done` is still *synced*: when the artifact is edited (for architecture, a
187
+ re-locked contract surface) its prior approvals go stale, and the approvals arriving on the new review
188
+ PR/MR are recorded and bound to the new content. `yad gate status` then shows the truth — how many
189
+ approvals are live against what is in the file today, and how many were revoked — instead of a `done`
190
+ step whose approvals all belong to the version before the edit.
191
+
182
192
  The flow is **merge-driven** (wired by `yad-hub-bridge` `wire`): during review CI writes nothing — the
183
193
  platform PR/MR is the source of truth (native approvals + threads), and CI never touches the review
184
194
  branch (so an in-flight approval is never dismissed and required checks never strand). On the human
@@ -42,7 +42,9 @@ content. This prevents "approve, then quietly change it" (build plan §5 spirit)
42
42
  For the architecture+contract review there is a second, content-based staleness check: recompute the
43
43
  SHA-256 of the contract-surface block and compare it to `.sdlc/contract-lock.json`. A mismatch means
44
44
  the locked surface changed even if the file's mtime looks fine — approvals are stale, re-lock and
45
- re-approve. (Hash recipe: `yad-architecture/references/contract-format.md`.)
45
+ re-approve. (Hash recipe: `yad-architecture/references/contract-format.md`.) `yad` computes the
46
+ identical digest, so `yad doctor` performs this comparison for every epic and FAILs on a surface that
47
+ drifted from its lock — run it rather than recomputing by hand.
46
48
 
47
49
  ## Worked example — epic gate
48
50
 
@@ -91,8 +93,10 @@ approvals regardless of how they were recorded.
91
93
  - On PR/MR open the assignee is the committer and reviewers are the scope's `reviewer` + `domain-owner`
92
94
  members (minus the committer); the owner/author is recorded, not requested. See
93
95
  `../yad-hub-bridge/references/login-roster.md`.
94
- - `sync` is idempotent (upsert by `(step, approver, role, domain)`; supersede revoked; key comments on
95
- comment id) and never touches **manual** approvals.
96
+ - `sync` is idempotent (upsert by `(step, approver, role, domain)`; key comments on comment id) and
97
+ never touches **manual** approvals. A revoked approval is superseded **while the step is open**; once
98
+ the step is `done` its approvals are kept as the record of why it passed, and a re-sync only re-binds
99
+ new ones (see `../yad-hub-bridge/references/bridge.md` → "Idempotent re-sync").
96
100
  - The architecture+contract staleness rule applies to bridge approvals too: a re-lock discards bridge
97
101
  approvals dated before the new lock.
98
102
  - No platform / no CLI → the gate runs file-only with no error. Detail: `../yad-hub-bridge/references/bridge.md`.
@@ -68,7 +68,7 @@ story: EP-<slug>-S0N
68
68
  epic: EP-<slug>
69
69
  repo: <repo>
70
70
  feature-id: EP-<slug>-S0N
71
- product-repo: <absolute or relative path to the product repo>
71
+ product-repo: <path to the product repo — absolute, or relative to THIS file's dir (specs/<story>/)>
72
72
  contract-lock: sha256:<hex copied from epics/EP-<slug>/.sdlc/contract-lock.json>
73
73
  speckit: installed | not-installed
74
74
  generated: <YYYY-MM-DD>
@@ -86,6 +86,16 @@ The contract surface above is **referenced, not re-defined**. Any change to the
86
86
  back to the architecture gate in the product repo — it is never widened from this code repo.
87
87
  ```
88
88
 
89
+ `product-repo` is the one field CI resolves on disk (contract-check, lineage-check, epic-open,
90
+ reconcile-debt all read it). Every gate resolves it the SAME way: an **absolute** path is used as-is; a
91
+ **relative** path is joined to this `link.md`'s own directory, `specs/<story>/` — `../../` climbs out
92
+ of the story dir and out of `specs/` to the code-repo root, so a hub checked out beside the code repo
93
+ is `../../../<hub-dir>`. A relative value that only resolves from the **repo root** still works too
94
+ (what contract-check historically did), so older `link.md` files keep gating. Write it as an
95
+ **unquoted scalar** — `"..."` or `'...'` is taken literally, and a leading `~`/`$VAR` is never
96
+ expanded. An unreachable path degrades the hub-reading gates to a PASS-with-note rather than failing
97
+ them, and each one now says so in its output.
98
+
89
99
  ## Do not re-invent the contract
90
100
 
91
101
  The spec **quotes** the locked surface; it never extends it. To confirm the surface the spec relies on
@@ -93,7 +103,7 @@ matches the lock, run from the **product** repo:
93
103
 
94
104
  ```bash
95
105
  awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
96
- epics/EP-<slug>/contract.md | shasum -a 256
106
+ epics/EP-<slug>/contract.md | tr -d '\r' | shasum -a 256
97
107
  # compare against epics/EP-<slug>/.sdlc/contract-lock.json
98
108
  ```
99
109