Sealed Publication Generations

Sealed Publication Reads describes how a publication is written and read today, and ends with what is not closed. This page is that remainder: the layout that closes it, what each reader must do, and the order the migration has to land in.

It is a design plus a landed first phase, not a finished migration. Every section says which phase it belongs to, and "Where this stands" says exactly what is live. The order the phases have to land in is a property of who publishes and what they pin, which is measured below rather than assumed.

The defect this removes

A publication is replaced in place. publish-bake-bundles.sh deletes _complete, uploads over the live files, and re-seals — deliberately, because unsealing first is what makes "sealed" mean "every listed bundle is here".

Interleave two publishers on one prefix and the sealed-skip's answer is stale for whichever loses the race: both unseal, both upload, and the last to seal writes a sentinel over a directory holding some of each one's bytes. That is one seal with one generation — self-consistent to every consumer, and wrong. The read-side generation cannot see it (there is nothing stale to refuse), the boot seeder cannot see it (the sentinel is present and every listed bundle exists), and the first symptom is dependency record mismatch — built against mvid:…, live is mvid:… on a portal that renders nothing.

🚨 The publisher's byte-level postcondition (#3496) turns that from a silent mix into a loud refusal, and it is not the same thing as preventing one. It re-reads every uploaded file's digest immediately before the seal and refuses when any is foreign. What it cannot cover is the interval between that last read and the _complete upload: a publisher overwriting a file inside that single write still lands under this run's seal. The exposure drops from a whole ~90-second publication to one file upload — a smaller window, still a window. A postcondition is not mutual exclusion.

🚨 And "give the prefix one owner" is not the answer either — that was measured. Of the overlaps actually observed on 2026-09-06, zero were between the two lanes and four were two runs of the same lane on one identity. A rule about which repository owns a prefix does not address a lane racing itself. The numbers are in Sealed Publication Reads → "How many writers, measured".

The layout

Each publication is written into its own directory, and a one-line pointer says which one applies:

<root>/<identity>/<source>/_current                  the pointer: one line, a bare directory name
<root>/<identity>/<source>/<generation>/…            one publication INSTANCE
<root>/<identity>/<source>/<generation>/_complete    its seal, still written strictly last
<root>/<identity>/<source>/<generation>/modules/…    its module set, + _index

<generation> is the publisher's publication token<repository>-<run id>-<attempt>, already minted by publish-bake-bundles.sh and already restricted to [A-Za-z0-9._-] so it is a legal bare name on every backend. It is unique per run by construction.

That one property is the whole design:

No pointer means the flat layout

A source directory with no _current is its own publication directory. That is exactly today's behaviour, it stays legal for as long as any reader needs it, and it is what makes the migration below possible at all. Resolution is opt-in by the writer, never by the reader.

Why a pointer and not an atomic directory rename — measured, 2026-09-08

The obvious design is to stage the publication and then rename it over the live one. It is not available on this store through this client, and that is a measurement rather than a recollection.

Read off azure-cli 2.90.0 — the same version the publisher's read-back query was measured against:

group commands
az storage file copy · hard-link · metadata · symbolic-link · delete · delete-batch · download · download-batch · exists · generate-sas · list · resize · show · update · upload · upload-batch · url
az storage directory create · delete · exists · list · show

There is no rename in either, directory delete is documented "Delete the specified empty directory", and there is no lease command under either group — so a lease per identity is not reachable from this lane either. And even in the REST API, where Rename Directory has existed since API version 2021-04-10, a rename cannot replace an existing directory: the swap would be rename-away plus rename-in, two operations with a gap in which the live path does not exist at all.

🚨 So the smallest write this store actually offers is one small FILE, and that is what the pointer is. A design that assumed an atomic directory rename would have reintroduced a partial-visibility window wearing a better story.

What is still a window, stated honestly

The pointer is one small file, and writing it is not atomic on every backend: az storage file upload is a create-then-put-range, so a reader can catch it empty or short.

That does not produce a mix. An unreadable, empty, escaping or dangling pointer resolves to the source directory — see the resolution rules below — so the worst case is:

phase a torn pointer read gives what the reader does
flat copy still present the previous publication, whole serves it; correct, just not the newest
flat copy gone a directory with no _complete "being republished right now" → 503 + Retry-After, which every consumer already waits out

So the trade is: ~90 seconds in which a mix can be sealed becomes the duration of one small file write in which a reader may be told to come back. The failure mode changes, not only its size — from "a sealed mix nobody can detect" to "read it again". Where the backend offers an atomic rename (Azure Files' Rename File, present in the REST API since 2021-04-10 though not exposed by every az storage file build), the writer phase should publish the pointer that way and remove even that.

The reader contract

One function resolves the pointer, and every path is composed under its result:

ShippedPrebuiltBundles.PublicationDirectoryOf(sourceDirectory, logger)

🚨 The one way to get this wrong is silent, so the API is shaped against it. A reader that resolves the pointer for the listing and then composes its file paths under the source directory serves the flat publication's bytes under the generation's token — the very mix the generation exists to prevent, wearing a token that says it is not. During the migration both paths exist, so it is a wrong answer rather than a missing one. That is why SealedPublicationOf and SealedModulesOf hand the resolved directory back on the reading (Directory) instead of leaving each caller to resolve a second time.

Resolution rules. It never throws; it falls back. Each of these resolves to the source directory:

pointer why it falls back
absent the flat layout — the normal state today
empty or blank being replaced right now; the previous generation still applies
unreadable (IOException) same, mid-write
., .., anything with a separator, anything rooted 🚨 a pointer is a NAME. It must never be able to address bytes outside its own source directory — refused and logged as a warning, because a publisher wrote something this reader will not follow
names a directory that is not on disk a retention sweep outran the pointer; logged as a warning

Generation retention

Follow Released Artifact Retention (#3842). The 30-day age window and release/consumer references supersede the earlier 24-hour previous-generation grace proposal.

Where it runs. PrebuiltBundleStore — the portal's own sweep, one level down from the identity rules it already applies, sharing that pass's abort discipline, its report-only mode and its ledger. It is deliberately NOT in the publisher: the publisher has no consumer inventory, no release markers, no adoption stamps, and it holds none of them at the moment it publishes.

The rule, ORed as KEEPs, applied only inside an identity the pass RETAINS. A generation is collected when all of these hold, and kept when any one fails:

keep when
the pointer _current names it — it is the live publication, however old
the pointer's own health _current could not be resolved to a directory on disk: unreadable, blank, refused, or dangling. Then every generation of that source is kept
age its newest write is inside PreWarm:PrebuiltBundleRetention:MinimumAge (at least 30 days)

There is deliberately no "it could not be read" rule, and it is not missing: a generation the sweep's walk could not enter contributes no files, so it is never identified as a publication and never becomes a candidate. Unreadable still means kept — it arrives by not being enumerated rather than by a branch, which is the stronger of the two. Nor is there a rule on whether the generation was sealed: a superseded one is collected on age whether it was sealed, torn, or abandoned mid-upload. The entry records which (HasSentinel) so an operator can tell a wave of abandoned publications — a different problem — from ordinary supersession, and that field decides nothing.

One walk per identity, not one per generation. The bytes, the newest write and the marker files all come from the single recursive enumeration the identity-level sweep already performs. That is not a micro-optimisation: the root is an Azure Files share where every enumeration is a network round trip, and per-generation walks would have made the cost grow with the number of generations — the exact quantity this cleanup exists to bound. It is why HasSentinel reports the sentinel's presence rather than verifying its listing the way the source-level IsSealed does; verifying would cost one file read per generation per pass to answer a question no rule asks.

A generation under a collectable identity is not listed separately: that identity's directory goes whole, and its byte count already includes them.

🚨 Why this is sound without a per-generation consumer inventory — the one thing that could make it unsound. A non-current generation is unreachable, not merely unfashionable. Every read of a published source directory composes under ShippedPrebuiltBundles.PublicationDirectoryOf(sourceDirectory) — the boot seeder, the same-major adopted fallback, SealedPublicationIndex, PublishedBundleCatalogue and ServedModuleBytes alike — and nothing anywhere enumerates a source's subdirectories looking for a publication. So the set a consumer inventory would have to protect is exactly {the generation _current names}, and both are kept above by construction. The sweep therefore uses the readers' own resolution (ShippedPrebuiltBundles.ResolvePublicationPointer, which is PublicationDirectoryOf with the fallback reason kept rather than discarded) instead of a second copy of those rules: a sweep that decided reachability differently from the readers would delete bytes a portal was still resolving, and the two would drift silently.

🚨 "Publication must establish protection before moving _current" is satisfied by AGE, not by a lease. A generation is protected from the instant its first byte lands, because it is younger than the window. A publication in flight can therefore never be collected, and the publisher needs no claim, no lock and no ordering with the sweep — which is what makes this implementable at all across two repositories that share no lock. UnsealedGrace is deliberately not consulted here: it is hours where the window is at least 30 days, so a branch for it could never fire, and a check that cannot fail is not a check.

🚨 A directory is a generation only on POSITIVE evidence that it is a publication — it carries source-commit.txt, repository.txt or the completion sentinel, or the pointer names it. It is not "every subdirectory except the ones I know about". The publisher's modules/ sits beside the generations in the flat compatibility copy, and under an exclusion list every bookkeeping directory added later would become collectable the day it was added, silently. Under positive identification it is simply retained, and the worst case is bytes that stay.

Who actually publishes — measured, 2026-09-07

The migration order is decided by this table, so it is a measurement rather than a recollection. Every row was read off the producing repository's own ci.yml (or main-cd.yml) on 2026-09-07.

producer prefix (bake-source) which publish-bake-bundles.sh it runs (platform-ref) behind core main
core CD plugins-bake (main-cd.yml) plugins this run's own core commit (needs.gate.outputs.sha) 0 — it IS the tip
MeshWeaver.Plugins ci.yml plugins aa40758329216d57dcefc2d8e8a52101efd5f225 231 commits
MeshWeaver.Reinsurance reinsurance 1b5350d547473a5e2ca81e793e774cc962acfeb3 284 commits
MeshWeaver.SocialMedia socialmedia 1b5350d5… 284 commits
MeshWeaver.Manufacturing manufacturing 1b5350d5… 284 commits
MeshWeaver.Education — (it calls no node-repo-publish-bake)

Three things follow, and each of them changes the plan:

  1. 🚨 plugins is the ONLY prefix with two producers. Every other prefix has exactly one, and Education publishes no bake at all. The coordination this migration needs is therefore one pair — core CD and MeshWeaver.Plugins — not five repositories. Everything else flips on its own schedule, one repository at a time, with nobody to coordinate with.
  2. 🚨 Core CD has no pin to move. It checks the platform out at its own gate sha, so the day the writer merges, core CD runs it. If the writer were unconditional, the plugins prefix would become a new-writer/old-writer pair that same day, against a MeshWeaver.Plugins 231 commits behind — exactly the half-migration this page exists to prevent. That is what makes the selector below mandatory rather than tidy.
  3. Nothing is near the tip. The nearest producing pin is 231 commits back and none of the four carries phase 1. A plan that assumes a pin will "have moved by then" is assuming something that has not happened in a month.

🚨 The correction: "past phase 1" is not a satisfiable precondition

This page used to say phase 2 was "every producing repo's publish-bake pin moves past phase 1". Measured against what phase 1 actually changed (a4109d422), that instruction is a no-op: it touched src/ — the portal image's readers — plus documentation and one comment block in the publish script. It changed nothing a pinned lane executes. A producer whose platform-ref moves past it runs byte-identical behaviour, so the condition can be satisfied by the whole fleet without bringing the migration one step closer.

What a producer must be past is the writer commit itself — and a writer that is on by default cannot be got past, because it takes effect the moment a pin reaches it. Hence the selector, and hence the order below.

The migration, in order

Phase 1 — readers tolerate the pointer (landed, a4109d422)

PublicationDirectoryOf plus every read routed through it: the boot seeder, PublishedBundleCatalogue, ServedModuleBytes, and the registry's four prebuilt routes. Behaviour on a share with no pointer is unchanged, byte for byte.

Nothing writes a pointer yet, so this changes nothing observable — which is why it ships with a suite that builds the generation layout by hand and asserts the readers serve it, including the arm that catches the compose-under-the-source-directory mistake.

Phase 2 — the writer LEARNS the layout, selected per caller, defaulting to flat (landed)

publish-bake-bundles.sh gains generation publishing behind an explicit selector: a publication-layout input on node-repo-publish-bake.yml, carried into the script as an environment variable, valued flat (the default) or generation. bake-scope.sh and carry-forward-bundles.sh resolve the pointer in the SAME commit, because node-repo-publish-bake.yml fetches all three at one platform-ref and no pin can carry half of them.

At flat the script must behave byte-identically to today, and that is provable rather than asserted: test-publish-bake-overlap.py runs the real script, and its three control cases (a settled publish, a republish of new content, an already-published skip) are the regression suite for the flat path. The new mode earns its own cases — two interleaved publishers each seal their OWN directory, neither directory holds a byte of the other, and _current names exactly one of them.

At generation the writer uploads into <source>/<publication token>/, verifies there (the #3496 postcondition still applies, now over a directory nobody else writes), seals it, also writes the flat copy so a portal image that predates phase 1 keeps working, and moves _current last.

Every read that decides what is already published — the architecture marker, the sentinel, the source-commit marker, the module index — resolves the pointer first and reads inside the resolved directory, or the writer and the readers disagree about which publication is live. The resolution rules are the reader's, unchanged: an absent, blank, unreadable, escaping or dangling pointer means the source directory. carry-forward-bundles.sh must read the generation the listing came from, which is the shell analogue of the reader's If-Match.

🚨 Any new marker file is LISTED and UPLOADED before architecture.txt. That file is the LAST upload before the postcondition, and the overlap harness hooks its second publisher onto it — a marker written after it silently stops the harness detecting overlaps while every case still reads green. (repository.txt was added under this rule and says so in place.)

What actually shipped, and the one deliberate deviation

Phase 3 — every producer's pin reaches phase 2 (open — the next step)

Only now is the condition both satisfiable and meaningful: a producer past phase 2 can write generations and is still writing flat. Core CD needs no pin move. The four satellites move theirs the way they always do.

Phase 4 — flip, one prefix at a time (open)

Set publication-layout: generation on every producer of one prefix, in one change set where a prefix has more than one producer. That is plugins and nothing else: core CD's plugins-bake and MeshWeaver.Plugins' publish-bake, flipped together, platform half first. The single-producer prefixes each flip in their own repository's PR.

🚨 The residual window is a pin bump inside one lane. Even a single-producer prefix has a moment where a run started before the flip is still in flight while a run after it writes a generation. The loser leaves the pointer naming a stale generation until that lane publishes again — which happens on its next merge, so it is self-healing and bounded by one publication rather than permanent. Worth knowing before reading such a serve as a defect.

Phase 5 — drop the flat copy (open)

Once no deployed portal predates phase 1. From here the mix is unrepresentable and the republish window is gone; what remains is the sub-second pointer write described above, and an atomic rename removes even that.

🚨 Until then the flat copy is still replaced IN PLACE, so it still races. Phase 4 removes the window for readers that follow the pointer; the compatibility copy the writer keeps making for pre-phase-1 images is unsealed, rewritten and re-sealed exactly as today, and can still be sealed as a mix. The #3496 postcondition is what covers it, and it covers it only as a postcondition. A report that says "the window is closed" at phase 4 is describing the pointer-following readers only.

If the publication moves to an OCI registry

The fleet now has its own registry (cr.meshweaver.cloud) and a program to push plugin bundles into it as OCI artifacts. It is worth writing down exactly what that does and does not close, because "content-addressed" is easy to read as "the race is gone".

What it closes by construction. Blobs and manifests are addressed by the digest of their own bytes. Two publications pushing byte-identical content collide benignly — same digest, a no-op — and two publications pushing different content get different blobs, which cannot overwrite each other. A blob is immutable once pushed; there is no partial overwrite to interleave. So the mix — a set holding some of each publisher's bytes — becomes unrepresentable at the byte level, which is the same property the generation directory buys, obtained more cheaply.

🚨 What it does NOT close: a tag is a mutable, last-writer-wins pointer. If the publication is addressed by tag, the defect simply moves from a directory prefix to a tag, and the migration inherits it. Three conditions close it, and they are the rule already in force for images via MW_IMAGE_DIGEST:

  1. Every bundle is pushed and recorded by DIGEST, never by tag alone.
  2. Each publication also gets an immutable, identity-qualified tag — so a publication can be named without that name being reassignable to different bytes later.
  3. The sealed set is a list of (package, digest) pairs, so a mixed set cannot be written at all: the set names exact bytes, and a publisher that did not assemble those bytes cannot produce that list.

🚨 Content addressing does NOT make two bakes of one commit converge. The bundle compile is not byte-reproducible — measured 2026-09-08, 40 of 45 files differed between two bakes of the same source commit — so two publications of one commit produce different blobs and therefore different manifest digests. OCI does not merge them; what it does is make each one a complete, immutable, self-consistent object, so the only contention left is which one the reference names. That is a last-writer-wins between two correct sets, which is a different and far weaker thing than a mix.

🚨 Two invariants the migration must carry across, or it reopens something worse than it closed:

And the refusal must not be weakened, in either world. On the share it reads "never seal a sentinel over another publication's bundles". In OCI terms it is the same sentence one level up: never publish a reference to a set you did not assemble. That refusal is the only reason this was ever visible instead of silently shipping a mixed set.

Where this stands

Verification

Related: Sealed Publication Reads · CI Content Bake · Plugin Build Contract · Bake Identity Mismatch

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.