Repository navigation
fix(#3461): publishing a generation is the lane's default, so all six prefixes flip - #4341
Conversation
… prefixes flip Phase 4 of the sealed-publication migration was described as "one line per producer, in its own repository" — core CD's `plugins-bake` (landed, #4269) plus five one-line PRs for `crm`, `education`, `reinsurance`, `socialmedia` and `manufacturing`. That framing left the larger half undone: what decided those prefixes' layout was the `publication-layout` INPUT DEFAULT, and while it was `flat`, a node repo that said nothing published in place — including every node repo that will ever be created. The default was `flat` for a measured reason, and the reason is gone. #4249 made the layout belong to the PREFIX rather than to the caller, so a `flat` caller that resolves a live `_current` publishes a generation anyway; the half-migration the default guarded against is unreachable rather than forbidden. The precondition that remained is a claim about the PUBLISHER each producer RUNS, because #4249 lives in `publish-bake-bundles.sh`. Measured 2026-09-14 over `ci.yml` on `main` of all six node repos (Plugins, Crm, Education, Reinsurance, SocialMedia, Manufacturing): every `uses:` of a `node-repo-*.yml` lane is `@main`, every `scripts-ref:` is `main`, NONE passes `publication-layout`, and `publish-bake-bundles.sh` is named in exactly one satellite file — in a comment. No pinned publisher and no pinned reader of these prefixes exists in the fleet, and nothing invokes the publisher outside the lane, so moving the default reaches all six producers at once. Safe at any portal age, which is what separates phase 4 from phase 5: a generation publication still writes the flat compatibility copy, so a reader that cannot resolve `_current` — an image predating phase 1, or a torn pointer read on any image — is served exactly what it was served before. The script's own `${BAKE_PUBLICATION_LAYOUT:-flat}` fallback deliberately stays `flat`. It is the DIRECT-INVOCATION default (the overlap harness, a manual run) and the lane always passes the input through explicitly, so no lane run reaches it. The comment above it used to read "IT DEFAULTS TO flat AND MUST STAY THAT WAY" and now says which default is which — that sentence, left standing, is how prose in this family has contradicted behaviour before. Harnesses, run locally against the branch: `test-publish-bake-overlap.py` 98 passed / 0 failed (unchanged — the script edits are comments, and the three flat control cases are still the regression suite for the direct-invocation default); `test-publication-pointer-readers.py` 14/14 over 2 readers; `bake-scope.sh --self-test` all green; `check-workflow-yaml-keys.py` 36 files / 0 violations; `check-workflow-timeouts.py` 87 jobs / 0 violations; MeshWeaver.Documentation.Test link + topic-map tests 6/6. Phase 5 (drop the flat copy) stays open. Its precondition is measured and stated in Doc/Architecture/SealedPublicationGenerations with its denominator: 2 of the 4 `Hosting/Deployment` records are confirmed past phase 1 by `/api/version`, `build` exposes no commit, `pearl` does not resolve, and the records are not provably the whole population. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
An unresolved publication-transition race, rollout/test gaps, and contradictory migration documentation remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Changes the reusable node-repository publish lane default from flat to generation, while retaining the script’s direct-invocation fallback.
Changes:
- Updates workflow, publisher, and reader guidance.
- Refreshes pointer-reader test documentation.
- Documents phase 4 rollout and adds a What's New entry.
File summaries
| File | Description |
|---|---|
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-every-prepared-update-is-now-published-whole.md |
Adds the user-facing release note. |
src/MeshWeaver.Documentation/Data/Architecture/SealedPublicationGenerations.md |
Documents rollout status and phase 5 preconditions. |
.github/workflows/node-repo-publish-bake.yml |
Changes the publication default to generation. |
.github/scripts/test-publication-pointer-readers.py |
Updates reader test documentation. |
.github/scripts/publish-bake-bundles.sh |
Clarifies publication transition and default guidance. |
.github/scripts/compose-sealed-modules.sh |
Clarifies pointer fallback behavior. |
.github/scripts/carry-forward-bundles.sh |
Clarifies generation fallback behavior. |
.github/scripts/bake-scope.sh |
Clarifies generation fallback behavior. |
Review details
Suppressed comments (2)
.github/scripts/publish-bake-bundles.sh:172
- The selector explanation is updated for the flip, but
publish_publicationstill has a live comment at lines 1179–1183 saying retention is not implemented and is a precondition on flipping. This change now relies on retention being implemented to make the lane default safe, so that comment is false and will mislead maintainers about whether generation publishing is allowed. Update it with this rollout.
# 🚨 THE FLEET'S DEFAULT IS `generation`, AND IT IS SET BY THE LANE, NOT HERE. `node-repo-publish-
# bake.yml`'s `publication-layout` input defaults to `generation` (#3461 phase 4) and is always
# passed through to `BAKE_PUBLICATION_LAYOUT`, so no lane run ever reaches the fallback below.
# The fallback stays `flat` on purpose: it is the DIRECT-INVOCATION default — the overlap harness
# and a manual run — and the conservative value is the right one for an invocation that named no
.github/workflows/node-repo-publish-bake.yml:329
- This fleet-wide claim relies on every caller resolving this revision, but the caller contract in this same file (lines 76–84) and
src/MeshWeaver.Documentation/Data/Architecture/CiContentBake.md:1042-1079require satellites to pin the reusable workflow to an immutable SHA. A caller pinned before this change keeps the oldflatdefault, so neither that producer nor future repos following the contract are flipped by this default alone. Please reconcile the rollout by bumping all pinned callers or changing the pin contract/explicit caller settings.
per-caller opt-in cost instead was a silent default: a node repo that says nothing — and
every node repo that will ever be created — published in place. Measured 2026-09-14 over
the `ci.yml` on `main` of all six node repos (Plugins, Crm, Education, Reinsurance,
SocialMedia, Manufacturing): every `uses:` of a `node-repo-*.yml` lane is `@main`, every
`scripts-ref:` is `main`, and NONE passes this input — so flipping the default here is
- Files reviewed: 8/8 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # The half-migration this used to guard against — a NEW writer and an OLD writer on one prefix, the | ||
| # new one moving the pointer while the old one replaces the flat copy in place and never touches it, | ||
| # so a pointer-following reader keeps serving its generation and never sees the old writer's NEWER | ||
| # publication — is now UNREACHABLE rather than forbidden: the layout belongs to the PREFIX (#4249, | ||
| # `pointer_moved_past_us` and the `$PUBLICATION_LAYOUT=generation` assignment below), so a flat | ||
| # caller that resolves a live `_current` publishes a generation anyway and says so. `plugins` is the |
There was a problem hiding this comment.
Correct that the residual exists, and the reasoning about where it now lives is what decides the action.
The flat arm has no reachable writer after this change, and I made that enforceable rather than observed. Three ways to reach it:
- A caller passing
publication-layout: flat. None exists — measured 2026-09-14 across all six node repos'ci.ymlonmain: none passes the input at all.NodeRepoLaneHostGuard.NoCallerInThisRepository_PublishesFlatnow refuses one in this repository, watched failing by flippingmain-cd.yml'splugins-baketoflat. - A run that resolves a live
_currentwhile asking for flat. That is not the flat arm: fix(#3461): the publication layout belongs to the PREFIX — phase 4 no longer has to be atomic across two repos #4249 promotes it to a generation and names the caller. Before this change a run resolving an empty prefix defaulted to flat; after it, the same run defaults togeneration. So this change removes the population your scenario needs rather than leaving it. - A run whose
publish-bake-bundles.shpredates fix(#3461): the publication layout belongs to the PREFIX — phase 4 no longer has to be atomic across two repos #4249 — core'splugins-bakeon a reconcile at an oldplatform-ref, the one producer that resolves scripts at the commit it publishes. 🚨 No check added to today's script can help this one, because such a run is not running today's script. And its direction is the safe one: it refreshes the flat copy without moving the pointer, so pointer-following readers stay on the newer generation.
A post-publish pointer re-check on the flat arm would therefore only ever fire for population 1, which the guard above keeps empty. That is a decision rather than an omission, and it is written down: Doc/Architecture/SealedPublicationGenerations gains "The residual phase 4 does NOT remove: a flat publication beside a generation one", with the three entry conditions as a table and the reason there is no check.
On the interleaving regression case: both publications you describe write the flat directory (a generation publication refreshes it last), so whenever they actually overlap there, the byte-level postcondition refuses one of them — which test-publish-bake-overlap.py already covers, same-lane shape included. The uncovered sliver is the one where they do not overlap (the flat run lands wholly after the generation run's seal), and a case asserting that would pin the residual's blast radius rather than prevent it. Phase 5 removes the flat copy and the residual with it; it is tracked on #3461 and its precondition is now measured, with its denominator, on the same page.
Addressed in 7c332547ea.
| required: false | ||
| type: string | ||
| default: 'flat' | ||
| default: 'generation' |
There was a problem hiding this comment.
Agreed, and this is the finding I should have reached myself — the question that catches this class is "if this default were flat right now, would anything else go red?", and the answer was no.
Added NodeRepoLaneHostGuard.ThePublishBakeLane_DefaultsToTheGenerationLayout, which asserts both halves of the property:
- the
publication-layoutinput block carriesdefault: 'generation'; - the publish step still exports
BAKE_PUBLICATION_LAYOUT: ${{ inputs.publication-layout }}. That second assertion is not decoration: the script's own fallback is deliberatelyflat(the direct-invocation default), so a lane that stopped exporting the variable would publish flat with thisdefault:still readinggeneration— a revert with no diff on the line you would look at.
Watched failing in both directions, since an assertion that has not been seen red is not yet an assertion:
| mutation | result |
|---|---|
default: 'generation' → 'flat' |
red — Not found: "default: 'generation'", NodeRepoLaneHostGuard.cs:235 |
BAKE_PUBLICATION_LAYOUT: → BAKE_PUBLICATION_LAYOUT_DISABLED: |
red at NodeRepoLaneHostGuard.cs:237 |
| both restored | green, 14/14 in the file |
A second guard, NoCallerInThisRepository_PublishesFlat, keeps the other half of the property: no caller in this repository may take the flat arm (watched failing by flipping main-cd.yml's plugins-bake to flat). Together they are the enforceable form of the measurement the PR body reports.
Landed in 7c332547ea.
| - **Phase 4 is landed** (2026-09-14) — first `main-cd.yml`'s `plugins-bake` passing | ||
| `publication-layout: generation`, then the lane input's own default moving to `generation`, which | ||
| is what flips the five single-producer prefixes (`crm`, `education`, `reinsurance`, `socialmedia`, | ||
| `manufacturing`) with no change in their repositories. Every reader that follows `_current` stops | ||
| reading a directory mutated in place, on every prefix. The satellite's own flip is explicitness, | ||
| not correctness (phase 4 above). **Phase 5 is open.** |
There was a problem hiding this comment.
All of it fair, and each one is now either corrected or explicitly dated. Prose that contradicts behaviour is this family's own recurring defect — the sentence I removed from the script's header ("IT DEFAULTS TO flat AND MUST STAY THAT WAY") is the same shape.
| what said what | now |
|---|---|
| Phase 2's heading and its "what actually shipped" bullet — "defaulting to flat" | the heading drops the claim; the section opens with a 🚨 line saying it is historical, as this phase shipped, and the bullet reads "defaulted to flat when this phase shipped and defaults to generation since phase 4" |
| "Its pin half, and phases 4–5, are open" | "Its pin half and phase 5 are open … phase 4 is landed" |
| "until the writer flips, the window is shrunk, not closed" | kept, and labelled as the half-truth it became: closed for readers that follow _current, still open for the flat compatibility copy, which is what the postcondition now carries alone |
| Verification: "90 assertions", one negative control | 98 assertions, 98 passed / 0 failed, and every negative control is now labelled with the change that measured it — phase 2's 16-of-67, #4249's 87/3, #4273's 4 failures against 77ec6d4c74, and phase 4's own (the script is comment-only, so the harness is a regression control at 98/0 before and after, and the default is guarded in C# instead) |
SealedPublicationReads.md — "defaults to flat, so nothing anywhere writes a generation" |
corrected, with the pointed remark that the flat compatibility copy is now the only thing on that page still describing what is live |
SealedPublicationReads.md — "two Azure-direct readers still address the PREFIX" |
that was stale independently of this PR: both were fixed in phase 3 and are executed by test-publication-pointer-readers.py (14 cases over the two readers) — which is exactly what made this default move safe for them |
BundleDeliveryStages.md — "open — phase 4/5 unlanded" and "phase 4 unreachable from every producer" |
stage ① now records phase 4 as landed on 2026-09-14, names both halves (#4269/#4273, then the default), and keeps the 2026-09-13 reading as the dated measurement it was |
"A figure quoted without its change is how a verification section goes stale" is now written into that section, so the next reader adds a row rather than overwriting one.
Verified after the edits: DocumentationLinkIntegrityTest + topic map + What's New + NodeRepoLaneHostGuard, 14 passed / 0 failed.
Landed in 7c332547ea.
…reconcile the migration prose Three findings from the Copilot review of the phase-4 default move. 1. The fleet-wide behaviour change had NO test. Reverting `node-repo-publish-bake.yml`'s `publication-layout` default to `flat` would leave every existing test green while all six satellite lanes silently returned to in-place publication. `NodeRepoLaneHostGuard. ThePublishBakeLane_DefaultsToTheGenerationLayout` asserts the input's `default: 'generation'` AND that the step exports it as `BAKE_PUBLICATION_LAYOUT` (the script's own fallback is `flat`, so a lane that stopped exporting the variable would publish flat with the default still reading `generation`). Watched failing in both directions: default reverted to `flat` → red on the first assertion; the env export renamed → red on the second; both restored → green. 2. The transition residual the review named is real, is documented rather than patched, and is kept at zero by a second guard. `NoCallerInThisRepository_PublishesFlat` refuses any caller in this repository that would take the flat arm (watched failing by flipping `main-cd.yml`'s `plugins-bake` to `flat`). The remaining way to reach that arm is a run whose `publish-bake-bundles.sh` predates #4249 — which no code added to today's script can help, because such a run is not running it, and which leaves pointer-followers on the NEWER generation, the safe direction. A new table in Doc/Architecture/SealedPublicationGenerations states the residual, its three entry conditions and why there is no check on the flat arm. 3. The migration prose contradicted itself after the flip. Corrected: - phase 2's heading and its "what actually shipped" bullet said the selector defaults to `flat` — now labelled as this phase's own history, with the phase-4 value beside it; - "Its pin half, and phases 4-5, are open" — phase 4 is landed, and the "until the writer flips, the window is shrunk not closed" sentence is now stated as the half-truth it became (closed for pointer-followers, open for the flat copy); - the Verification section reported 90 assertions and only #4249's negative control; it now reports 98/0 and labels EVERY negative control with the change that measured it, which is what keeps such a section from going stale; - SealedPublicationReads said the selector "defaults to flat, so nothing anywhere writes a generation", and separately that the two Azure-direct readers still address the prefix — the latter was fixed in phase 3 and is executed by test-publication-pointer-readers.py; - BundleDeliveryStages carried "open — phase 4/5 unlanded" and the "phase 4 unreachable from every producer" reading of 2026-09-13. Verified: MeshWeaver.Documentation.Test link integrity + topic map + What's New + NodeRepoLaneHostGuard, 14 passed / 0 failed; build -c Release -warnaserror, 0 Warning(s) 0 Error(s). No workflow or script behaviour changed by this commit — `.github/` is byte-identical to the previous one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of #3461, finished — by moving the default rather than opening five one-line PRs
main-cd.yml'splugins-bakeflipped in #4269 and its pointer-ordering defect was fixed in #4273.What was left on phase 4 was written down as "the five single-producer prefixes, each a one-line
change in its own repo" (
crm,education,reinsurance,socialmedia,manufacturing). Thatframing leaves the larger half undone.
The thing that decided those prefixes' layout is the
publication-layoutINPUT DEFAULT, andwhile that default is
flat, a node repo that says nothing publishes in place — including everynode repo that will ever be created. Five PRs would have flipped five prefixes and left the
default, and therefore the next repo, exactly where it is.
Why the default could not move before, and why it can now
The default was
flatfor a measured reason: a new writer and an old writer on one prefix leaves apointer-following reader serving its generation while the old writer refreshes the flat copy in
place — a stale serve with nothing red anywhere. #4249 removed that state rather than forbidding
it: the layout belongs to the PREFIX, so a
flatcaller that resolves a live_currentpublishes a generation too, loudly.
The precondition, measured today rather than inherited
#4249 lives in
publish-bake-bundles.sh, so the guarantee is a claim about the publisher eachproducer RUNS. Measured 2026-09-14 over
ci.ymlonmainof all six node repos:uses:scripts-ref:publication-layout@main(everynode-repo-*.yml)mainpublish-bake-bundles.shappears in exactly one satellite file, in a commentSo no pinned publisher and no pinned reader of these prefixes exists in the fleet, nothing runs the
publisher outside the lane, and moving the default reaches all six producers at once.
Safe at any portal age — this is what separates phase 4 from phase 5
A generation publication still writes the flat compatibility copy. A reader that cannot resolve
_current— an image predating phase 1, or a torn pointer read on any image — is served exactlywhat it was served before. Phase 5 is the change with a portal-age precondition; phase 4 has none.
The script's own default deliberately stays
flat${BAKE_PUBLICATION_LAYOUT:-flat}is the direct-invocation default (the overlap harness, amanual run); the lane always passes the input through explicitly, so no lane run reaches it. The
comment above it read "IT DEFAULTS TO
flatAND MUST STAY THAT WAY" — a sentence that, leftstanding, is exactly how prose in this family has contradicted behaviour before. It now says which
default is which, as do the three sibling readers (
bake-scope.sh,carry-forward-bundles.sh,compose-sealed-modules.sh) whose comments each claimed the flat layout was "the only one anythinghas written".
Watched, not assumed
test-publish-bake-overlap.pytest-publication-pointer-readers.pybake-scope.sh --self-testcheck-workflow-yaml-keys.pycheck-workflow-timeouts.pyMeshWeaver.Documentation.Test(link integrity + topic map + What's New)Phase 5 stays open, and its precondition is now stated with a denominator
Doc/Architecture/SealedPublicationGenerationsgains what "once no deployed portal predates phase1" actually needs:
memexandmemex-cloudboth answer/api/versionwith3.0.0+c84c6c055…, which has phase 1 (a4109d422) as an ancestor;buildexposes no committhrough
Ops/Status;client-bdoes not resolve and reportsreplicas: []; and the fourHosting/Deploymentrecords are not provably the whole population — an install that has stoppedself-updating appears in none of them. The per-replica instrument that would answer it is blind
(#4218). The page also records, explicitly labelled as an inference and not a measurement, the
argument that the precondition may be self-satisfying (a pre-phase-1 image resolves a framework
identity nothing publishes under any more).
Closes nothing on its own. #3461 stays open on phase 5.
🤖 Generated with Claude Code