@cratis/pi 7.85.0 → 7.87.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.
@@ -158,6 +158,8 @@ Do not open a pull request per task when the tasks belong to the same body of wo
158
158
 
159
159
  **Before consolidating open PRs, review each PR’s release intent and workflow effects.** Integration may trigger completion/publication behavior on an absorbed PR. Use supported non-release intent where appropriate. A direct request to consolidate the named pull requests authorizes the necessary relabeling and merge; it does not authorize unrelated notifications.
160
160
 
161
+ Before a consolidation or a re-land of reverted work merges, prove it dropped nothing with `cratis-engineering-consolidation-guard`.
162
+
161
163
  Split into separate pull requests when the changes are genuinely unrelated, when one is urgent and the others are not, or when one is risky enough to want its own revert.
162
164
 
163
165
  ## Quality Gates
@@ -157,6 +157,8 @@ Do not open a pull request per task when the tasks belong to the same body of wo
157
157
 
158
158
  **Before consolidating open PRs, review each PR’s release intent and workflow effects.** Integration may trigger completion/publication behavior on an absorbed PR. Use supported non-release intent where appropriate. A direct request to consolidate the named pull requests authorizes the necessary relabeling and merge; it does not authorize unrelated notifications.
159
159
 
160
+ Before a consolidation or a re-land of reverted work merges, prove it dropped nothing with `cratis-engineering-consolidation-guard`.
161
+
160
162
  Split into separate pull requests when the changes are genuinely unrelated, when one is urgent and the others are not, or when one is risky enough to want its own revert.
161
163
 
162
164
  ## Quality Gates
@@ -0,0 +1,2 @@
1
+ Copyright (c) Cratis. All rights reserved.
2
+ Licensed under the MIT license. See LICENSE file in the canonical Cratis/AI repository for the full license text.
@@ -0,0 +1,197 @@
1
+ ---
2
+ name: cratis-engineering-consolidation-guard
3
+ description: Prove that a consolidation or re-land dropped nothing - compare the result with every original branch or commit, detect lost files that commit pairing cannot see, treat a merge resolution that reverts a file as a finding, ratchet the specification count, and enforce it as a required CI check. Use when folding several pull requests into one branch, re-landing reverted work, restoring commits by cherry-pick, or reviewing such a branch before it merges. Not for an ordinary merge of the base into a feature branch, for pull request descriptions or labels (follow the pull-requests rule), or for general code review (use cratis-code-review).
4
+ license: LICENSE
5
+ ---
6
+
7
+ # Consolidations and re-lands prove nothing was dropped
8
+
9
+ A **consolidation** is any branch whose job is to carry changes authored somewhere
10
+ else: several pull requests folded into one, reverted work brought back, commits
11
+ restored from an abandoned branch. Its **sources** are those original branches
12
+ and commits.
13
+
14
+ The combined tree is built and specified before it merges, and that is exactly
15
+ why loss goes unnoticed. A change deleted *by the merge itself* leaves nothing
16
+ behind to fail: a deleted specification cannot go red, and a file a conflict
17
+ resolution put back to its old content compiles fine. The guard compares the
18
+ result with every source, path by path, and turns each loss into a finding.
19
+
20
+ **A re-land is a consolidation.** Work that landed, was reverted and is landed
21
+ again has one source, the original change, and loses files the same way. Run
22
+ the same guard against that original change, not against the revert.
23
+
24
+ ## When the guard applies
25
+
26
+ - Folding two or more pull requests onto one branch, as
27
+ [pull-requests.md](../../rules/pull-requests.md) asks for small related work.
28
+ - Re-landing reverted work, whether by reverting the revert, cherry-picking the
29
+ original commits, or merging the original branch again.
30
+ - Restoring commits by cherry-pick: from a deleted branch, a closed pull
31
+ request, or a backport.
32
+ - Reviewing any of the above before it merges.
33
+
34
+ It does not apply to merging the base branch into a feature branch (the base is
35
+ not a source), or to a fresh implementation that shares no history with the
36
+ work it replaces; there, specify the behavior instead.
37
+
38
+ ## Consolidate without rewriting history
39
+
40
+ [git-commits.md](../../rules/git-commits.md) forbids rebase, squash and force.
41
+ Each permitted way to consolidate also records its source in history, which is
42
+ what lets CI find the sources without being told:
43
+
44
+ | Situation | Default | Recorded as |
45
+ | --- | --- | --- |
46
+ | Fold a pull request | `git merge --no-ff pr631` | a merge whose second parent is the folded tip |
47
+ | Restore commits | `git cherry-pick -x <sha>` | a `(cherry picked from commit <sha>)` line |
48
+ | Re-land reverted work | `git revert <revert-sha>` | `This reverts commit <revert-sha>.` |
49
+
50
+ Choose cherry-pick over reverting the revert when only some of the original
51
+ commits come back; every change left out is then a finding that needs a
52
+ disposition.
53
+
54
+ ## Name the sources
55
+
56
+ A source is either a ref or a range `<from>..<ref>`. A bare ref means the change
57
+ since `merge-base(<base>, <ref>)`; the range means the change since
58
+ `merge-base(<from>, <ref>)`.
59
+
60
+ | Source | Argument |
61
+ | --- | --- |
62
+ | A folded pull request | `git fetch origin "refs/pull/631/head:pr631"`, then `pr631` |
63
+ | A merged pull request being re-landed, merge commit `M` | `M^1..M^2` |
64
+ | A single commit `C`, or a pull request that landed as one commit | `C^..C` |
65
+ | A branch commits were cherry-picked from | its head ref, or `C^..C` per commit |
66
+
67
+ Fetch the pull request's own head ref rather than its branch: `refs/pull/<n>/head`
68
+ survives the branch being deleted.
69
+
70
+ Work that already landed needs the range form. Its commits are ancestors of the
71
+ base, so `merge-base(<base>, <ref>)` is the tip itself and the comparison would
72
+ be empty; the script refuses that with exit `2` rather than reporting a pass.
73
+
74
+ Confirm a folded branch has not moved since it was fetched. When
75
+ `gh pr view 631 --json headRefOid --jq .headRefOid` differs from
76
+ `git rev-parse pr631`, fetch again and fold the new commits before verifying.
77
+
78
+ ## Run the guard
79
+
80
+ Execute [scripts/verify-consolidation.sh](scripts/verify-consolidation.sh) inside
81
+ the repository, with the consolidation ref first and every source after it.
82
+
83
+ Requires git 2.28 or later (`git --version`); with an older git, run the three
84
+ checks below by hand.
85
+
86
+ ```bash
87
+ guard=<skill-directory>/scripts/verify-consolidation.sh
88
+ git fetch origin
89
+ for n in 631 640 652; do git fetch origin "refs/pull/$n/head:pr$n"; done
90
+ "$guard" consolidate/backlog-filters pr631 pr640 pr652
91
+ ```
92
+
93
+ A re-land of a merged pull request, given the original merge commit:
94
+
95
+ ```bash
96
+ landed=<original-merge-sha>
97
+ "$guard" reland/dashboard-counters "$landed^1..$landed^2"
98
+ ```
99
+
100
+ Options: `--base <ref>` (default `origin/main`), `--spec-pattern <regex>` for a
101
+ repository whose specifications are not `for_*`/`when_*` folders,
102
+ `--allow-rewritten <sha>` and `--allow-spec-delta <n>` for dispositions, and
103
+ `--creation-factor <n>` for `git range-diff` pairing.
104
+
105
+ Before choosing sources by hand, list the ones history records and compare:
106
+
107
+ ```bash
108
+ "$guard" --discover consolidate/backlog-filters
109
+ ```
110
+
111
+ A source on one list and not the other is a finding of its own: either a fold
112
+ that was not intended, or an intended one that never happened.
113
+
114
+ **Pass:** exit `0` with `0 finding(s)` and a nonzero path count. Exit `1` lists
115
+ findings. Exit `2` means it could not run (an unresolvable ref, or a source
116
+ with nothing to compare); that is never a pass.
117
+ After changing the script, run `"$guard" --self-test`;
118
+ it plants each kind of loss in a scratch repository and passes only when every
119
+ one is reported.
120
+
121
+ ## Why commit pairing is not enough
122
+
123
+ `git range-diff` compares commits, not the resulting tree.
124
+
125
+ - **After a true merge, every folded commit is an ancestor of the
126
+ consolidation** and pairs with itself, however much the merge resolution
127
+ threw away. A re-land is the same: the original commits are already in the
128
+ base's history.
129
+ - **What matters is per path.** A file the source added must exist; a file it
130
+ modified must not still hold its pre-change content; a file it deleted must
131
+ not hold its pre-change content again. That content check is the one that sees
132
+ a resolution reverting a file.
133
+ - **Pairing still matters when commits are copied.** After cherry-picks, a
134
+ source commit with no identical patch in the consolidation was dropped or
135
+ rewritten. The script matches by `git patch-id` first, because the `-x` line
136
+ alone makes `range-diff` report every cherry-pick as changed.
137
+ - **Byte equality catches whole-file loss, not partial loss.** The script lists
138
+ every path that differs from the source tip as `review:`; read
139
+ `git diff <source-tip> <consolidation> -- <path>` for each one another source
140
+ or the base does not explain.
141
+
142
+ The specification ratchet covers what the other checks share a blind spot for:
143
+ the consolidation must carry at least as many specification files as the base
144
+ plus each source's net addition. It is a count, so read the content findings
145
+ first; they name the paths.
146
+
147
+ ## Disposition of every finding
148
+
149
+ Each finding gets exactly one disposition before the merge:
150
+
151
+ - **Restore it** in a new commit, for example
152
+ `git checkout pr631 -- src/Filters/ByOwner.cs`, then commit. Re-run the guard.
153
+ - **Accept it in history** when a rewritten commit differs only by conflict
154
+ resolution (after reading its range-diff), or when two sources added the same
155
+ specification path. Commit the disposition as a trailer, so CI honors it and
156
+ the reviewer sees it; the guard prints each one as `disposition:`.
157
+
158
+ ```bash
159
+ git commit --allow-empty -m "Accept the conflict-resolved owner filter restoration" \
160
+ -m "Consolidation-Allow-Rewritten: <sha>"
161
+ ```
162
+
163
+ `Consolidation-Allow-Spec-Delta: <n>` works the same way. The
164
+ `--allow-rewritten` and `--allow-spec-delta` options are the local-only
165
+ equivalents.
166
+ - **Remove it in its own pull request** when dropping part of a source is the
167
+ point. A consolidation carries its sources whole; the removal, made after it
168
+ merges, is then the reviewed change rather than a side effect of a merge.
169
+ - **Stop the consolidation** when the loss cannot be explained.
170
+
171
+ A merge resolution that reverts a file is a finding even when it was
172
+ intentional, because nothing else records the intent. Never clear a finding by
173
+ removing its source from the argument list. Explain each disposition in a pull
174
+ request comment; the description is the release note and carries no
175
+ verification notes.
176
+
177
+ ## Make it a required check
178
+
179
+ A guard an agent can skip is a suggestion. Run it in CI on every pull request,
180
+ discover the sources from history, fail on exit `1` and `2`, and add the job to
181
+ the branch's required status checks with no bypass for the identity that
182
+ merges. Read [references/ci-required-check.md](references/ci-required-check.md)
183
+ when adding or reviewing that workflow.
184
+
185
+ ## Pitfalls
186
+
187
+ - **Verifying against the revert.** A re-land's source is the original change;
188
+ the revert's own diff only proves the revert was undone.
189
+ - **Passing the landed tip as a bare ref.** It is already in the base, so the
190
+ script exits `2` with `already contained`; use `M^1..M^2` or `C^..C`.
191
+ - **Trusting a green build.** The specifications that would have failed are the
192
+ ones the merge deleted.
193
+ - **Fixing a finding by force-pushing or squashing the branch.** That removes
194
+ the evidence and breaks [git-commits.md](../../rules/git-commits.md); restore
195
+ in a new commit.
196
+ - **Folding from a branch name.** A deleted branch cannot be fetched again;
197
+ `refs/pull/<n>/head` can.
@@ -0,0 +1,131 @@
1
+ # Consolidation guard as a required CI check
2
+
3
+ ## Contents
4
+
5
+ - What makes it unskippable
6
+ - Workflow
7
+ - Make the check required
8
+ - What discovery cannot see
9
+
10
+ ## What makes it unskippable
11
+
12
+ An agent asked to "run the guard before merging" can forget, run it against the
13
+ wrong sources, or read exit `2` as a pass. A required check removes all three:
14
+
15
+ - **It runs on every pull request.** The job decides whether the branch is a
16
+ consolidation, so no one has to label it. A workflow-level `paths` filter
17
+ would leave the required check pending on unrelated pull requests; decide
18
+ inside the job instead.
19
+ - **It discovers the sources from history.** `--discover` reads merge parents
20
+ the base does not contain, `cherry-pick -x` lines and reverts of reverts. The
21
+ author does not have to declare anything, and cannot leave a source out.
22
+ - **It fails on `1` and on `2`.** A source that cannot be resolved or compared
23
+ fails the check; unknown is not pass. A `cherry-pick -x` of a commit that was
24
+ never pushed, or exists only on a fork, stays at `2`: push the source commit
25
+ so the job can fetch it. No trailer clears an unresolvable source.
26
+ - **Dispositions live in history.** An accepted rewrite or specification delta
27
+ is a `Consolidation-Allow-Rewritten:` or `Consolidation-Allow-Spec-Delta:`
28
+ commit trailer, which the job honors and prints as `disposition:`. Accepting a
29
+ loss is therefore a reviewable commit, never a bypass. The job reads trailers
30
+ from every commit the consolidation adds, folded source commits included, so
31
+ a reviewer reads each `disposition:` line, not only the consolidation's own
32
+ commits.
33
+ - **Merging requires it.** A red or missing required check blocks
34
+ `gh pr merge`. With no bypass actor, `gh pr merge --admin` cannot get past it
35
+ either.
36
+
37
+ ## Workflow
38
+
39
+ Copy `scripts/verify-consolidation.sh` from this skill into the repository's
40
+ own `scripts/` folder and commit it. A CI gate must not change underneath the
41
+ repository when the shared corpus is updated, and a copy the repository owns
42
+ changes only through its own pull requests. Run the copy's `--self-test` in the
43
+ same job so a broken edit to it cannot pass silently.
44
+
45
+ ```yaml
46
+ name: Consolidation guard
47
+
48
+ on:
49
+ pull_request:
50
+ types: [opened, synchronize, reopened]
51
+
52
+ concurrency:
53
+ group: ${{ github.workflow }}-${{ github.ref }}
54
+ cancel-in-progress: true
55
+
56
+ permissions:
57
+ contents: read
58
+
59
+ jobs:
60
+ consolidation-guard:
61
+ runs-on: ubuntu-latest
62
+ timeout-minutes: 15
63
+ steps:
64
+ - uses: actions/checkout@v4
65
+ with:
66
+ ref: ${{ github.event.pull_request.head.sha }}
67
+ fetch-depth: 0
68
+
69
+ - name: Fetch pull request heads
70
+ # Cherry-pick and revert sources can live only on deleted branches; their pull request refs remain.
71
+ run: git fetch --no-tags origin '+refs/pull/*/head:refs/remotes/origin/pull/*'
72
+
73
+ - name: Self-test the guard
74
+ run: scripts/verify-consolidation.sh --self-test
75
+
76
+ - name: Verify every folded and re-landed source survives
77
+ env:
78
+ BASE: origin/${{ github.base_ref }}
79
+ run: |
80
+ set -euo pipefail
81
+ sources="$(scripts/verify-consolidation.sh --discover --base "$BASE" HEAD)"
82
+ if [ -z "$sources" ]; then
83
+ echo "No folded, cherry-picked or re-landed sources between $BASE and HEAD; checked $(git rev-list --count "$BASE..HEAD") commits."
84
+ exit 0
85
+ fi
86
+ printf 'Sources:\n%s\n' "$sources"
87
+ # Word splitting is intended: one source per line.
88
+ # shellcheck disable=SC2086
89
+ scripts/verify-consolidation.sh --base "$BASE" HEAD $sources
90
+ ```
91
+
92
+ Notes on the choices:
93
+
94
+ - `ref: ${{ github.event.pull_request.head.sha }}` checks out the branch as
95
+ pushed. The default pull request checkout is GitHub's synthetic merge with the
96
+ base, whose own merge commit would be discovered as a fold.
97
+ - `fetch-depth: 0` brings the full history and the base branch as
98
+ `origin/<base>`; discovery and merge-base need both.
99
+ - Fetching every pull request head costs time in a large repository. Drop that
100
+ step only if the repository never restores from deleted branches; an
101
+ unresolvable source then fails the check with exit `2`, which is the safe
102
+ direction.
103
+ - Keep this a `pull_request` job on a hosted runner in a public repository; it
104
+ needs only read access.
105
+ - Repositories with specifications outside `for_*`/`when_*` folders pass
106
+ `--spec-pattern` to the verifying invocation.
107
+
108
+ ## Make the check required
109
+
110
+ Add the job's check name, `consolidation-guard`, to the default branch's
111
+ required status checks: a repository ruleset with **Require status checks to
112
+ pass**, or classic branch protection with **Require status checks to pass
113
+ before merging** and **Do not allow bypassing the above settings**. Leave the
114
+ ruleset's bypass list empty, or at least without the identity agents merge as.
115
+
116
+ Renaming the job changes the check name. The old required check then waits for
117
+ a status that never arrives, which blocks every pull request; update the
118
+ required check in the same change.
119
+
120
+ ## What discovery cannot see
121
+
122
+ Discovery reads only what history records. Work restored by copying files,
123
+ re-implemented by hand, or folded with a squash leaves no trace, which is one
124
+ more reason [git-commits.md](../../../rules/git-commits.md) forbids squashing.
125
+ For such a branch, run the guard locally with explicit sources and post the
126
+ command and its result as a pull request comment, so the reviewer can repeat it.
127
+
128
+ A file a source added and the consolidation deliberately removes has no
129
+ trailer: the check stays red until the file is restored. Carry the source whole
130
+ and remove the file in its own pull request after the consolidation merges, so
131
+ the removal is reviewed as a change of its own.
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env bash
2
+ # Copyright (c) Cratis. All rights reserved.
3
+ # Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+ #
5
+ # Prove that a consolidation or re-land branch still carries every change it claims to carry.
6
+ #
7
+ # Usage:
8
+ # verify-consolidation.sh [options] <consolidation-ref> <source>...
9
+ # verify-consolidation.sh --discover [--base <ref>] <consolidation-ref>
10
+ # verify-consolidation.sh --self-test
11
+ #
12
+ # A <source> is one folded or re-landed change:
13
+ # <ref> a folded branch or pull-request head; its change is everything since merge-base(<base>, <ref>)
14
+ # <from>..<ref> the change <ref> made since merge-base(<from>, <ref>). Required for work that already
15
+ # landed once: <merge>^1..<merge>^2 for a merged pull request, <commit>^..<commit> for one commit.
16
+ #
17
+ # Checks, each reported per source:
18
+ # A commits source commits with no identical patch (git patch-id) in the consolidation's own commits;
19
+ # git range-diff then classifies each as dropped or rewritten. Skipped when the source tip is an
20
+ # ancestor of the consolidation, because every commit then pairs with itself whatever the
21
+ # merge resolution threw away.
22
+ # B content every path the source touched: an added file must exist, a modified file must not still hold
23
+ # the pre-change blob, a deleted file must not still hold the pre-change blob. This is the check
24
+ # that sees a merge resolution reverting a file.
25
+ # C specs the consolidation carries at least as many specification files as the base plus every
26
+ # source's net addition.
27
+ #
28
+ # Options:
29
+ # --base <ref> mainline the consolidation targets (default: origin/main)
30
+ # --creation-factor <n> git range-diff pairing aggressiveness, 0-100 (default: 90)
31
+ # --allow-rewritten <sha> accept one commit reported as rewritten after reading its range-diff. Repeatable.
32
+ # --allow-spec-delta <n> accept n fewer specification files than the ratchet expects (default: 0)
33
+ # --spec-pattern <regex> extended regex selecting specification files
34
+ # (default: (^|/)(for_|when_)[^/]*/.*\.(cs|ts|tsx)$)
35
+ # --discover print the sources recorded in the consolidation's history, one per line
36
+ # --self-test plant known defects in a scratch repository and require every one to be found
37
+ #
38
+ # Dispositions recorded in the consolidation's own commits count like the options, so CI honors them:
39
+ # Consolidation-Allow-Rewritten: <sha> same as --allow-rewritten <sha>
40
+ # Consolidation-Allow-Spec-Delta: <n> added to --allow-spec-delta
41
+ #
42
+ # Exit codes: 0 every source survives, 1 findings, 2 could not run (bad arguments, an unresolvable ref, or a
43
+ # source with nothing to compare).
44
+ set -euo pipefail
45
+
46
+ BASE="origin/main"
47
+ CREATION_FACTOR=90
48
+ ALLOW_SPEC_DELTA=0
49
+ ALLOWED_REWRITTEN=""
50
+ SPEC_PATTERN='(^|/)(for_|when_)[^/]*/.*\.(cs|ts|tsx)$'
51
+ MODE="verify"
52
+
53
+ usage() { awk 'NR < 5 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0"; }
54
+ die() { printf 'ERROR %s\n' "$*" >&2; exit 2; }
55
+ # A whole number in base 10 (so '08' is eight, not an octal error), or exit 2.
56
+ whole_number() { case "$2" in ''|*[!0-9]*) die "$1 must be a whole number, found '$2'." ;; esac; printf '%s' "$((10#$2))"; }
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --base) [ $# -ge 2 ] || die "--base needs a value"; BASE="$2"; shift 2 ;;
61
+ --creation-factor) [ $# -ge 2 ] || die "--creation-factor needs a value"; CREATION_FACTOR="$(whole_number --creation-factor "$2")"; shift 2 ;;
62
+ --allow-rewritten) [ $# -ge 2 ] || die "--allow-rewritten needs a value"; ALLOWED_REWRITTEN="$ALLOWED_REWRITTEN $2"; shift 2 ;;
63
+ --allow-spec-delta) [ $# -ge 2 ] || die "--allow-spec-delta needs a value"; ALLOW_SPEC_DELTA="$(whole_number --allow-spec-delta "$2")"; shift 2 ;;
64
+ --spec-pattern) [ $# -ge 2 ] || die "--spec-pattern needs a value"; SPEC_PATTERN="$2"; shift 2 ;;
65
+ --discover) MODE="discover"; shift ;;
66
+ --self-test) MODE="self-test"; shift ;;
67
+ -h|--help) usage; exit 0 ;;
68
+ --) shift; break ;;
69
+ -*) die "unknown option: $1" ;;
70
+ *) break ;;
71
+ esac
72
+ done
73
+
74
+ commit_of() { git rev-parse --quiet --verify "$1^{commit}" 2>/dev/null || die "cannot resolve '$1' to a commit; fetch it first (a pull request: git fetch origin refs/pull/<n>/head:pr<n>)."; }
75
+ blob_of() { git rev-parse --quiet --verify "$1:$2" 2>/dev/null || true; }
76
+ is_ancestor() { git merge-base --is-ancestor "$1" "$2"; }
77
+ # True when an allowed entry (at least 7 hex characters) is a prefix of the full commit SHA.
78
+ is_allowed_rewrite() {
79
+ local allowed
80
+ for allowed in $ALLOWED_REWRITTEN; do
81
+ [ "${#allowed}" -ge 7 ] || continue
82
+ case "$1" in "$allowed"*) return 0 ;; esac
83
+ done
84
+ return 1
85
+ }
86
+
87
+ spec_count() {
88
+ local count
89
+ count="$(git ls-tree -r --name-only "$1" | grep -cE "$SPEC_PATTERN" || true)"
90
+ printf '%s' "${count:-0}"
91
+ }
92
+
93
+ patch_ids() {
94
+ # One line per non-merge commit with a diff: "<patch-id> <commit>".
95
+ git log --no-merges -p --no-color --format='commit %H' "$1" | git patch-id --stable
96
+ }
97
+
98
+ # ---------------------------------------------------------------------------------------------------------
99
+ discover() {
100
+ [ $# -eq 1 ] || die "--discover takes exactly one consolidation ref"
101
+ local consolidation base_commit cons_base merge parent candidates="" reverted original parents
102
+ consolidation="$(commit_of "$1")"
103
+ base_commit="$(commit_of "$BASE")"
104
+ cons_base="$(git merge-base "$base_commit" "$consolidation")"
105
+
106
+ # Folded branches: every non-first parent of a merge that the base does not already contain.
107
+ for merge in $(git rev-list --merges "$cons_base..$consolidation"); do
108
+ for parent in $(git rev-list --parents -n 1 "$merge" | cut -d' ' -f3-); do
109
+ is_ancestor "$parent" "$base_commit" || candidates="$candidates $parent"
110
+ done
111
+ done
112
+ # A branch folded twice contributes each tip; an older tip is checked too, which only costs time.
113
+ for parent in $candidates; do printf '%s\n' "$parent"; done | awk '!seen[$0]++'
114
+
115
+ # Restorations recorded by git cherry-pick -x.
116
+ git log --format=%B "$cons_base..$consolidation" \
117
+ | sed -n 's/.*(cherry picked from commit \([0-9a-f]\{7,40\}\)).*/\1/p' \
118
+ | sort -u | while read -r original; do printf '%s^..%s\n' "$original" "$original"; done
119
+
120
+ # Re-lands recorded by git revert of a revert: the original change is what the reverted revert undid.
121
+ git log --format=%B "$cons_base..$consolidation" \
122
+ | sed -n 's/^This reverts commit \([0-9a-f]\{7,40\}\).*/\1/p' | sort -u \
123
+ | while read -r reverted; do
124
+ git rev-parse --quiet --verify "$reverted^{commit}" >/dev/null || die "a revert names $reverted, which is not in this clone; fetch it before discovering."
125
+ original="$(git log -1 --format=%B "$reverted" | sed -n 's/^This reverts commit \([0-9a-f]\{7,40\}\).*/\1/p' | head -n 1)"
126
+ [ -n "$original" ] || continue
127
+ parents="$(git rev-list --parents -n 1 "$original" | wc -w | tr -d ' ')"
128
+ if [ "$parents" -gt 2 ]; then printf '%s^1..%s^2\n' "$original" "$original"; else printf '%s^..%s\n' "$original" "$original"; fi
129
+ done
130
+ }
131
+
132
+ # ---------------------------------------------------------------------------------------------------------
133
+ verify() {
134
+ [ $# -ge 2 ] || { usage >&2; exit 2; }
135
+ local consolidation base_commit cons_base source from to fork
136
+ consolidation="$(commit_of "$1")"; shift
137
+ base_commit="$(commit_of "$BASE")"
138
+ cons_base="$(git merge-base "$base_commit" "$consolidation")"
139
+
140
+ # Dispositions recorded as commit trailers in the consolidation's own history.
141
+ local trailer
142
+ for trailer in $(git log --format='%(trailers:key=Consolidation-Allow-Rewritten,valueonly)' "$cons_base..$consolidation"); do
143
+ printf 'disposition: Consolidation-Allow-Rewritten %s\n' "$trailer"
144
+ ALLOWED_REWRITTEN="$ALLOWED_REWRITTEN $trailer"
145
+ done
146
+ for trailer in $(git log --format='%(trailers:key=Consolidation-Allow-Spec-Delta,valueonly)' "$cons_base..$consolidation"); do
147
+ trailer="$(whole_number Consolidation-Allow-Spec-Delta "$trailer")"
148
+ printf 'disposition: Consolidation-Allow-Spec-Delta %s\n' "$trailer"
149
+ ALLOW_SPEC_DELTA=$((ALLOW_SPEC_DELTA + trailer))
150
+ done
151
+
152
+ # Resolve every source before checking any, so a bad argument cannot produce a partial verdict.
153
+ local resolved="" changed
154
+ for source in "$@"; do
155
+ case "$source" in
156
+ *..*) from="${source%%..*}"; to="${source#*..}" ;;
157
+ *) from="$BASE"; to="$source" ;;
158
+ esac
159
+ from="$(commit_of "$from")"; to="$(commit_of "$to")"
160
+ fork="$(git merge-base "$from" "$to")" || die "'$source' shares no history with its base."
161
+ if [ "$fork" = "$to" ]; then
162
+ die "'$source' is already contained in its base, so there is nothing to compare. A change that landed once needs the <from>..<ref> form, for example <merge>^1..<merge>^2."
163
+ fi
164
+ changed="$(git diff --name-only "$fork" "$to" | wc -l | tr -d ' ')"
165
+ [ "$changed" -gt 0 ] || die "'$source' changes no files relative to ${fork:0:12}; there is nothing to verify."
166
+ resolved="$resolved $source|$fork|$to"
167
+ done
168
+
169
+ local failures=0 paths=0 commits=0 entry left status subject cons_ids="" unmatched range_line abbrev
170
+ fail() { printf 'FAIL %s\n' "$*"; failures=$((failures + 1)); }
171
+ note() { printf ' %s\n' "$*"; }
172
+
173
+ for entry in $resolved; do
174
+ source="${entry%%|*}"; entry="${entry#*|}"; fork="${entry%%|*}"; to="${entry#*|}"
175
+ printf '== %s (change since %s, tip %s)\n' "$source" "${fork:0:12}" "${to:0:12}"
176
+
177
+ # --- Check A: commits ----------------------------------------------------------------------------
178
+ if is_ancestor "$to" "$consolidation"; then
179
+ note "commits: tip is an ancestor of the consolidation, so commit pairing proves nothing; the content check decides."
180
+ else
181
+ [ -n "$cons_ids" ] || cons_ids="$(patch_ids "$cons_base..$consolidation" | cut -d' ' -f1)"
182
+ unmatched=""
183
+ while read -r patch_id left; do
184
+ [ -n "$patch_id" ] || continue
185
+ commits=$((commits + 1))
186
+ printf '%s\n' "$cons_ids" | grep -qxF "$patch_id" || unmatched="$unmatched $left"
187
+ done < <(patch_ids "$fork..$to")
188
+ for left in $unmatched; do
189
+ subject="$(git log -1 --format=%s "$left")"
190
+ status=""
191
+ while read -r range_line; do
192
+ abbrev="$(printf '%s' "$range_line" | sed -E 's/^ *([0-9]+|-+): +([0-9a-f]+|-+) .*/\2/')"
193
+ case "$left" in "$abbrev"*) status="$(printf '%s' "$range_line" | sed -E 's/^ *([0-9]+|-+): +([0-9a-f]+|-+) +([<>=!]) .*/\3/')"; break ;; esac
194
+ done < <(git range-diff --no-color --creation-factor="$CREATION_FACTOR" "$fork..$to" "$cons_base..$consolidation" | grep -E '^ *([0-9]+|-+): ')
195
+ if [ "$status" = "!" ]; then
196
+ if is_allowed_rewrite "$left"; then
197
+ note "allowed rewrite: ${left:0:12} ($subject)"
198
+ else
199
+ fail "$source: commit ${left:0:12} ($subject) survives with a different patch. Read 'git range-diff $fork..$to $cons_base..$consolidation'; if the difference is only conflict resolution, re-run with --allow-rewritten ${left:0:12}."
200
+ fi
201
+ elif [ -z "$status" ] || [ "$status" = "<" ]; then
202
+ fail "$source: commit ${left:0:12} ($subject) has no counterpart in the consolidation; it was dropped."
203
+ else
204
+ fail "$source: commit ${left:0:12} ($subject) has no patch-identical counterpart, but range-diff pairs it ('$status'). Read 'git range-diff $fork..$to $cons_base..$consolidation' and decide."
205
+ fi
206
+ done
207
+ fi
208
+
209
+ # --- Check B: content ---------------------------------------------------------------------------
210
+ local kind path dest fork_blob tip_blob cons_blob
211
+ while IFS="$(printf '\t')" read -r kind path dest; do
212
+ paths=$((paths + 1))
213
+ case "$kind" in R*|C*) ;; *) dest="$path" ;; esac
214
+ fork_blob="$(blob_of "$fork" "$path")"
215
+ tip_blob="$(blob_of "$to" "$dest")"
216
+ cons_blob="$(blob_of "$consolidation" "$dest")"
217
+ case "$kind" in
218
+ A*|R*|C*)
219
+ if [ -z "$cons_blob" ]; then fail "$source: added '$dest', which does not exist in the consolidation."
220
+ elif [ "$cons_blob" != "$tip_blob" ]; then note "review: '$dest' differs from the source tip (git diff $to $consolidation -- '$dest')"; fi
221
+ case "$kind" in R*) [ -z "$(blob_of "$consolidation" "$path")" ] || note "review: '$path' was renamed to '$dest' but still exists in the consolidation" ;; esac
222
+ ;;
223
+ M*)
224
+ if [ -z "$cons_blob" ]; then fail "$source: modified '$path', which does not exist in the consolidation."
225
+ elif [ "$cons_blob" = "$fork_blob" ]; then fail "$source: modified '$path', but the consolidation still holds the pre-change version byte for byte; the change was lost."
226
+ elif [ "$cons_blob" != "$tip_blob" ]; then note "review: '$path' differs from the source tip (git diff $to $consolidation -- '$path')"; fi
227
+ ;;
228
+ D*)
229
+ if [ -n "$cons_blob" ] && [ "$cons_blob" = "$fork_blob" ]; then fail "$source: deleted '$path', but the consolidation still holds the pre-change version; the deletion was lost."
230
+ elif [ -n "$cons_blob" ]; then note "review: '$path' was deleted by the source but exists, changed, in the consolidation"; fi
231
+ ;;
232
+ *) note "unclassified change '$kind' to '$path'" ;;
233
+ esac
234
+ done < <(git diff --name-status --find-renames "$fork" "$to")
235
+ done
236
+
237
+ # --- Check C: specification ratchet -----------------------------------------------------------------
238
+ local expected actual
239
+ # Count from where the consolidation left the base, not the base tip: specification files the base gained
240
+ # or lost since then are not the consolidation's to account for.
241
+ expected="$(spec_count "$cons_base")"
242
+ for entry in $resolved; do
243
+ entry="${entry#*|}"; fork="${entry%%|*}"; to="${entry#*|}"
244
+ expected=$(( expected + $(spec_count "$to") - $(spec_count "$fork") ))
245
+ done
246
+ actual="$(spec_count "$consolidation")"
247
+ printf '== specification files: consolidation has %s, base plus sources account for %s\n' "$actual" "$expected"
248
+ if [ "$actual" -lt $(( expected - ALLOW_SPEC_DELTA )) ]; then
249
+ fail "specification ratchet: the consolidation carries $actual specification files where $expected are accounted for. Find the deleted ones; if two sources added the same path, re-run with --allow-spec-delta $(( expected - actual ))."
250
+ fi
251
+
252
+ printf '\nChecked %s source(s), %s path(s), %s commit(s) compared; %s finding(s).\n' "$#" "$paths" "$commits" "$failures"
253
+ if [ "$failures" -ne 0 ]; then
254
+ printf 'Every finding needs a disposition before this merges.\n'
255
+ exit 1
256
+ fi
257
+ }
258
+
259
+ # ---------------------------------------------------------------------------------------------------------
260
+ self_test() {
261
+ local self result=0 output status
262
+ self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
263
+ SCRATCH="$(mktemp -d)"
264
+ trap 'rm -rf "$SCRATCH"' EXIT
265
+ local scratch="$SCRATCH"
266
+ export HOME="$scratch" GIT_CONFIG_NOSYSTEM=1
267
+ export GIT_AUTHOR_NAME=self-test GIT_AUTHOR_EMAIL=self-test@example.com
268
+ export GIT_COMMITTER_NAME=self-test GIT_COMMITTER_EMAIL=self-test@example.com
269
+ cd "$scratch"
270
+ git init -q -b main
271
+ commit() { git add -A && git commit -q -m "$1"; }
272
+ mkdir -p src for_shared/when_used
273
+ printf 'one\n' > src/shared.txt; printf 'spec\n' > for_shared/when_used/spec.cs; printf 'old\n' > src/obsolete.txt
274
+ commit "Add base"
275
+
276
+ git checkout -q -b first main
277
+ printf 'first\n' > src/first.txt; mkdir -p for_first/when_added; printf 'spec\n' > for_first/when_added/spec.cs
278
+ commit "Add first feature"
279
+ printf 'one\nfirst\n' > src/shared.txt; git rm -q src/obsolete.txt
280
+ commit "Extend shared and remove obsolete"
281
+ git checkout -q -b second main
282
+ printf 'second\n' > src/second.txt
283
+ commit "Add second feature"
284
+
285
+ expect() {
286
+ local name="$1" code="$2" needle="$3"; shift 3
287
+ set +e; output="$(cd "$scratch" && bash "$self" --base main "$@" 2>&1)"; status=$?; set -e
288
+ if [ "$status" -ne "$code" ]; then
289
+ printf 'SELF-TEST FAIL %s: exit %s, expected %s\n%s\n' "$name" "$status" "$code" "$output"; result=1
290
+ elif [ -n "$needle" ] && ! printf '%s' "$output" | grep -qF -- "$needle"; then
291
+ printf 'SELF-TEST FAIL %s: output lacks "%s"\n%s\n' "$name" "$needle" "$output"; result=1
292
+ else
293
+ printf 'self-test pass: %s\n' "$name"
294
+ fi
295
+ }
296
+
297
+ # A clean consolidation by true merges.
298
+ git checkout -q -b clean main
299
+ git merge -q --no-ff -m "Fold first" first; git merge -q --no-ff -m "Fold second" second
300
+ expect "clean consolidation passes" 0 "0 finding(s)" clean first second
301
+
302
+ # The base moves on after the consolidation branched: its new specification files are not the consolidation's.
303
+ git checkout -q main
304
+ mkdir -p for_later/when_added; printf 'spec\n' > for_later/when_added/spec.cs
305
+ commit "Add a later spec on main"
306
+ expect "base gaining a spec after the branch point passes" 0 "0 finding(s)" clean first second
307
+ expect "non-numeric spec delta cannot run" 2 "whole number" --allow-spec-delta abc clean first second
308
+ expect "leading-zero spec delta is decimal" 0 "0 finding(s)" --allow-spec-delta 08 clean first second
309
+
310
+ # A merge resolution that throws away a file, a modification, a deletion and a spec.
311
+ git checkout -q -b lossy main
312
+ git merge -q --no-ff -m "Fold second" second
313
+ git merge -q --no-ff --no-commit first >/dev/null 2>&1
314
+ git checkout -q main -- src/shared.txt src/obsolete.txt
315
+ git rm -q --cached for_first/when_added/spec.cs; rm -f for_first/when_added/spec.cs
316
+ git commit -q -m "Fold first"
317
+ expect "lost added spec found" 1 "added 'for_first/when_added/spec.cs'" lossy first second
318
+ expect "reverted modification found" 1 "modified 'src/shared.txt'" lossy first second
319
+ expect "lost deletion found" 1 "deleted 'src/obsolete.txt'" lossy first second
320
+ expect "spec ratchet trips" 1 "specification ratchet" lossy first second
321
+
322
+ # A cherry-picked consolidation that leaves one commit behind.
323
+ git checkout -q -b picked main
324
+ git cherry-pick -x first~1 >/dev/null
325
+ expect "dropped commit found" 1 "has no counterpart" picked first
326
+
327
+ # A re-land: landed, reverted, restored by reverting the revert.
328
+ # A restoration whose cherry-pick needed a different patch, then accepted through a recorded disposition.
329
+ local first_commit; first_commit="$(git rev-parse first~1)"
330
+ git checkout -q -b rewritten main
331
+ git cherry-pick -n "$first_commit" >/dev/null
332
+ printf 'first, resolved\n' > src/first.txt
333
+ commit "Restore first feature"
334
+ expect "rewritten commit found" 1 "different patch" rewritten "$first_commit^..$first_commit"
335
+ git commit -q --allow-empty -m "Accept the resolved first feature" -m "Consolidation-Allow-Rewritten: ${first_commit:0:12}"
336
+ expect "recorded disposition accepts the rewrite" 0 "allowed rewrite" rewritten "$first_commit^..$first_commit"
337
+
338
+ git checkout -q main
339
+ git merge -q --no-ff -m "Land first" first
340
+ local landed; landed="$(git rev-parse HEAD)"
341
+ git revert -m 1 --no-edit "$landed" >/dev/null
342
+ local revert; revert="$(git rev-parse HEAD)"
343
+ git checkout -q -b reland main
344
+ git revert --no-edit "$revert" >/dev/null
345
+ expect "clean re-land passes" 0 "0 finding(s)" reland "$landed^1..$landed^2"
346
+ expect "landed source without a range cannot run" 2 "already contained" reland first
347
+ expect "discover finds the re-land" 0 "$landed^1..$landed^2" --discover reland
348
+ git checkout -q -b lossy-reland main
349
+ git revert --no-edit --no-commit "$revert"
350
+ git rm -q -r --cached for_first; rm -rf for_first
351
+ git commit -q -m "Re-land first"
352
+ expect "re-land that drops a spec found" 1 "added 'for_first/when_added/spec.cs'" lossy-reland "$landed^1..$landed^2"
353
+
354
+ expect "discover finds folded tips" 0 "$(git rev-parse second)" --discover clean
355
+ expect "unknown ref cannot run" 2 "cannot resolve" clean no-such-ref
356
+
357
+ if [ "$result" -ne 0 ]; then printf 'Self-test failed: a planted defect was not reported as expected.\n'; exit 1; fi
358
+ printf 'Self-test passed: every planted defect was found.\n'
359
+ }
360
+
361
+ case "$MODE" in
362
+ discover) discover "$@" ;;
363
+ self-test) self_test ;;
364
+ *) verify "$@" ;;
365
+ esac
@@ -9,6 +9,11 @@ For `source api`, webhook, poll and other record sources, a first record of a ty
9
9
 
10
10
  This applies independently to each child identity in its parent's previous collection and each nested record: a missing/null previous nested value is new, and every child of a new parent is new. Expression guards still evaluate the mapped current record on first sight; maps and unguarded appends keep their meaning. These record fixtures are not valid for `source events`: use typed events instead (automations-and-translations [public event captures](../../cratis-screenplay-automations-and-translations/references/public-events-and-direction.md)).
11
11
 
12
+ A capture **over events** carries a public event's list across the boundary two ways (decision 0098, no new ESM version): `lines = $.lines`
13
+ copies the whole list into a list property of the same shape (matched by property name), and `children lines identified by sku` with an
14
+ `append ... when added` makes one private event per element, so a repeated delivery appends nothing and one with an extra line appends only
15
+ that line. `nested <object>` scopes into an object the same way.
16
+
12
17
  The complete model separates a new observation, an update and an unchanged repeat:
13
18
 
14
19
  ```screenplay
@@ -68,8 +68,9 @@ to the routed scope. This does not make command `reads` protected.
68
68
  computed expressions parse but block binding (`PLAY0268`).
69
69
  - **`for <identifier>`** on an indented line names the event source the event is
70
70
  appended to. At most one per `produces`. To bind, it must name the command's
71
- `identifier` property (`PLAY0273` otherwise; a `generated identifier` command, ESM v7, may name another required scalar property); fanning out to another event
72
- source parses but does not run today.
71
+ `identifier` property (`PLAY0273` otherwise; a `generated identifier` command, ESM v7, may name another required scalar property). From ESM v30 (decision 0098)
72
+ a command may append to several event sources: each `produces` may name any required scalar command property, and the events of one accepted command are appended
73
+ together. Optional and collection properties still cannot be destinations. A model using it selects v30; state each destination with `for` on the matching `then` event.
73
74
  - **`tag`** lines apply literal tags to this append. Tags also exist on the
74
75
  `event` declaration, where they apply to every append of that type.
75
76
  - **Several unconditional `produces` blocks** are allowed — that is co-production,
@@ -13,6 +13,7 @@
13
13
  | `matches "<regex>"` | Portable linear dialect; any substring unless anchored `^…$`; invalid syntax PLAY0367, refused constructs PLAY0699 |
14
14
  | `contains "<text>"` / `not contains "<text>"` | Ordinal substring presence/absence on scalar non-enum text |
15
15
  | `starts with "<text>"` / `ends with "<text>"` | Ordinal prefix/suffix; quoted literal operands, not conditions |
16
+ | `decimals <= <n>` | `price decimals <= 2`; a `Decimal` property or concept only, `<n>` a whole number 0 to 28, trailing zeros do not count; ESM v29 (decision 0098) |
16
17
  | `all > <v>` / `all >= <v>` | `lines.quantity all > 0` |
17
18
  | `rule <Name>` | `orgNumber rule BeAValidOrganizationNumber` |
18
19
  | `checksum luhn|mod11|mod97` | String concept `validate checksum luhn`; validates supplied/stored values too |
@@ -35,7 +35,8 @@ A join cannot declare its own key or trigger removal — that is
35
35
  | `increment <prop>` | +1, expressing *modifying state* |
36
36
  | `decrement <prop>` | −1 |
37
37
  | `add <prop> by <expr>` | += expression |
38
- | `subtract <prop> by <expr>` | −= expression |
38
+ | `add <prop> by <a> * <b>` | += the exact product of two values (ESM v29, decision 0098); only after `by`, one `*` |
39
+ | `subtract <prop> by <expr>` | −= expression (also `by <a> * <b>`) |
39
40
 
40
41
  `count` and `increment` behave identically; the difference is intent. The target
41
42
  must be numeric.
@@ -66,6 +66,10 @@ specification RegistersWithPinnedValues
66
66
  differences in response field order. An absent optional source yields `Null`; arrays compare in order.
67
67
  - `then returns` counts as a success outcome but does not relax event comparison: a command specification with
68
68
  no expected events still asserts that no facts were produced.
69
+ - A command that a **reaction** invokes takes its generated values from `given generated <Command>.<property> = <value>` lines
70
+ before the `when` (ESM v30, decision 0098). Each invocation takes the next value pinned for that
71
+ command and property in authored order; two invocations need two lines. The property must be `generated`; the value is a
72
+ concrete literal.
69
73
  - If execution reaches generation without every generated value supplied, the run is `Unsupported(IdentityAllocation)` and
70
74
  the specification never passes; nothing is invented. A denial or validation failure happens before generation and
71
75
  keeps its own outcome.
@@ -59,6 +59,13 @@ module Collections
59
59
  or mixed effects (`-037`). Single-hop reaction append specs render; deeper appends and
60
60
  command specs cascading into reactions remain `-011`. Keep refused scope as gap-fill against this contract: `cratis-screenplay-render-and-gap-fill`.
61
61
 
62
+ ### A command specification states the public event its slice publishes
63
+
64
+ When an outbound Translate slice publishes an event from the private events a command produces, the command's specification
65
+ lists the public event after the private ones (`then ProductPriceChanged` after `then ProductPriceSet`). `PLAY0285` follows the
66
+ outbound projection (decision 0098); the accepted path is proven where the command is. An exact fact count
67
+ fails without it.
68
+
62
69
  ### Version skew: cascades and the false `PLAY0285`
63
70
 
64
71
  `PLAY0285` (a specification's expected event contradicts every producer of its
@@ -16,6 +16,10 @@ exact language/semantic pairs; binding, reference execution and rendering are se
16
16
  | v26 | Chronological command Date/DateTime comparisons | Same-primitive `==`, `!=`, `>`, `>=`, `<`, `<=`; DateTime UTC ticks and pre-rule offset validation | v26 admitted; Host/executor run it; renderer supports admitted shapes with remaining STAGE-ESM-046 refusals; legacy engines refuse -046 |
17
17
  | v27 | A read model with two or more `key` marks | Untyped projection `key` blocks, equality-filter lists, single-instance `by` blocks, per-part fixtures | v27 admitted; Host runs it, renderer refuses STAGE-ESM-048/-047, executor refuses specs over it |
18
18
  | v28 | `==`/`!=` between Uuid values in command `require` and `produces when` | Uuid properties, Uuid concepts and identities, canonical literals; protected read paths in `require` only | v28 refused STAGE-ESM-016 |
19
+ | v29 | `decimals <= N`; `add`/`subtract` by `a * b`; a concept mapped to another concept over the same primitive (decision 0098) | Decimal-places rule on a Decimal concept or property; the exact decimal product; the conversion is admitted only when the target declares no rule or compliance the source does not, never for enums, composites or optional into required | v29 refused until Stage admits it |
20
+ | v30 | A command `produces ... for <required scalar property>` (several event sources); a specification `given generated <Command>.<property> = <value>` (decision 0098) | Several sources in one accepted command; pinned generated values for reaction-invoked commands, taken in authored order | v30 refused until Stage admits it |
21
+
22
+ Constructs of decision 0098 with no executable-model content need no version: application-level toolbar buttons in the layout's `navigation topbar` region (`button <id> title "..." slice <Slice>` with an optional indented `authorize <Policy>`), a command specification that states the public event its outbound Translate slice publishes (no more false `PLAY0285`), and a capture over events that copies a list (`lines = $.lines`) or fans out with `children lines identified by sku`. Check `screenplay --version` against the release that admitted v29 and v30 before relying on them.
19
23
 
20
24
  Requires Screenplay 4.181.0 or later (`screenplay --version`) for atomic batches; older compilers cannot admit the preamble.
21
25
 
@@ -166,6 +166,25 @@ own.
166
166
  - `item` entries (`action`, `navigate to`, `dialog`) stay valid beside buttons. Use `button` to start a slice, and
167
167
  `item ... navigate to` to go to a screen.
168
168
 
169
+ ### Application-level buttons
170
+
171
+ A layout's `navigation topbar` region takes the same `button` line once for the whole application, so an action stays at hand
172
+ whichever page the user is on. An indented `authorize <Policy>` line under a button limits who sees it. A module's
173
+ `navigation topbar extends|overrides|inherits` governs the buttons as it does the items. A button in `navigation sidebar` is
174
+ `PLAY0741`. The slice rules above (`PLAY0761`-`PLAY0765`) apply unchanged (decision 0098).
175
+
176
+ ```text
177
+ layout AppShell
178
+ category application
179
+ topbar
180
+ navigation contributes Navigation
181
+ content
182
+ navigation topbar
183
+ item shop title "Shop" target Shop
184
+ button newProduct title "New product" slice Merchandising.Products.DraftProduct emphasis primary icon plus
185
+ authorize CanCurateCatalog
186
+ ```
187
+
169
188
  ⚠️ When a screen uses a `template`, put the `toolbar` inside a slot body (`body` above). Stage refuses a screen that
170
189
  mixes a top-level `template` directive with other top-level directives.
171
190
 
@@ -982,6 +982,7 @@
982
982
  "id": "cratis/engineering/core",
983
983
  "availableTargets": [
984
984
  "cratis-documentation-writing",
985
+ "cratis-engineering-consolidation-guard",
985
986
  "cratis-engineering-decision-record",
986
987
  "cratis-engineering-docs-authoring",
987
988
  "cratis-engineering-effect-boundaries",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.85.0",
3
+ "version": "7.87.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [