## Unreleased ### Added - A new `create-cairn-site` package (`packages/create-cairn-site`, published separately from the engine) carries the local half of the scaffolding tool: argument parsing, an action runner that makes `--dry-run` a property of the frame rather than a per-feature flag, an out-of-scaffold state store at `~/.config/cairn/sites/.json` (mode `0600`, so no tool state or secret ever lands under a project directory), credential-free pre-flight checks (Node floor, loopback bindability, proxy detection, a Windows execution-policy note), a pack-time bake of the Waymark template through the existing emitter, and a fail-loud substitution pass that personalizes the site name, description, and brand color. The description is written under `site.config.yaml`'s own `description` key rather than an invented `tagline` key, since the engine validates its top-level keys and rejects an unrecognized one. The brand substitution rotates the hue of all four `--color-primary` declarations and holds each one's lightness and chroma, following the theme file's own re-skin recipe, so a scaffolded site keeps the contrast both its light and dark blocks were tuned for. The command is wired end to end: pre-flight, prompts, a copy of the baked template, the package rename, the substitution pass, and a state save, all through the action runner, so `--dry-run` describes the whole run and performs none of it. It refuses before writing anything when the target directory exists and holds files, or when the template was never baked, and every exit path prints a next step. A new `create-site.yml` workflow packs the CLI, installs that tarball into a scratch directory, runs the command, and then installs, typechecks, and builds the site it produced. Consumers must: nothing. The engine's own `files` array, exports, and tarball are untouched, and this package ships separately. The printed hand-over block is the output of a recorded setup walk rather than a guess. That walk found a bare `npm run dev` never reached the admin, because the scaffolded `dev` script was bare `vite dev` and the dev backend needed `CAIRN_DEV_BACKEND=1` at runtime on top of its build-time define. A later fix in this same entry, below, closes that gap at its source. The block also says, in the walk's own terms, that the local admin is a stand-in that touches no GitHub repository and sends no real email, and it names what going live will cost, including the Workers Paid plan that arbitrary-recipient sign-in email requires. A test locks the switch into the copy. The bake refuses to run while `@glw907/cairn-cms-dev` is unpublished, naming the package and the fix, rather than emit a template whose devDependency reads `^0.0.0`. A scaffolded site needs the dev backend to build at all, so publishing it is a prerequisite for shipping the tool. The scaffold now leads into a GitHub chapter. It is manifest-first: each site's admin gets their own GitHub App, created through GitHub's manifest flow, so there is no shared or standing OAuth client and nothing to register ahead of time. The whole flow takes two browser trips. The manifest page creates the App, and the install page, visited once more, both installs it and authorizes it via `request_oauth_on_install`. The chapter creates a private repository, links it to the App's installation, and pushes the scaffold as one commit on top of GitHub's own `auto_init` seed. No `git` binary is used; every call goes through the REST and Git Data APIs behind one fetch wrapper, and the suite exercises the whole flow against a fake GitHub server. Every hop's result, the App's credentials, the installation, the repository, is saved to the site's local state record as soon as it lands, so a later `create-cairn-site --dir ` resumes from the next unfinished hop instead of repeating it, and `--start-over` sets a previous run's record aside and starts the chapter over. An organization that requires an owner's approval before installing a new App parks the run instead of failing it: the tool saves its progress and exits cleanly, and the next run picks up where it left off once an owner approves. The scaffold's target-directory claim is now atomic, using an exclusive `wx` write, so two concurrent runs against the same directory fail loudly instead of racing, and a run interrupted mid-scaffold leaves a directory the next run recognizes and recovers instead of treating as a stray non-empty directory. `npm run dev` now reaches the scaffolded site's local admin with no environment variable, because the scaffold's own `dev` script sets the dev backend's runtime flag itself. Consumers must: nothing (the tool is unpublished; no engine surface changed). A Cloudflare chapter now follows the GitHub one and takes the site the rest of the way to live. It installs the site's dependencies, signs wrangler in, builds, and deploys to the admin's free `workers.dev` hostname, creating one Worker, two D1 databases, and one R2 bucket on their own account; the free plan covers all of it and the tool asks for no payment method. The chapter holds no Cloudflare credential of its own, riding wrangler's own session, which a spike against the real API confirmed reaches every call chapter one makes. The scaffold's `wrangler.jsonc` is named after the site and ships **without** `database_id`s, because a deploy provisions an id-less binding by name and binds it by name on every later deploy, so nothing has to be written back into the file and a re-run that meets existing resources reuses them. Both databases are migrated by binding name after the first deploy, and a second deploy follows so the running site carries its own real origin. The GitHub App's private key then moves from the local state record into a Worker secret and is deleted locally, but only once Cloudflare confirms the upload, so a failed upload leaves the only recoverable copy where it was; after that hop the key lives in exactly one place. Finally the tool seeds the owner's row and one ten-minute magic-link token straight into the deployed database and opens the site's own confirm page, so the first sign-in takes one click and needs no email at all. This rides the engine's own tables, hashing, and confirm route with no engine change. The App's own identity (owner, repo, app id, installation id) is written into the scaffold's `cairn.config.ts` before the GitHub chapter pushes it, so the repository is born able to publish rather than carrying the showcase's placeholder. New flags: `--deploy` to opt in unattended, `--owner-email` for the sign-in address, and `--sign-in` to issue a fresh link for a site that is already live. Every hop persists, so a resumed run skips the deploy it already did, and `--dry-run` still prints the whole chapter while spawning nothing. Consumers must: nothing (the tool is unpublished; no engine surface changed). A domain chapter now follows the Cloudflare one and connects a scaffolded site to an owner's own domain. It creates a Cloudflare zone for the domain, or adopts one the account already holds, and offers to copy the domain's current DNS records into it behind a carry-over gate that writes nothing until confirmed and preserves MX priority intact. Nameserver delegation parks: the tool exits `0`, prints the assigned nameservers and the exact command to run again, and a resumed run re-detects delegation through pending, propagating, and active. The cutover attaches a Workers Custom Domain, confirms the site answers on the new hostname before touching anything else, and restores the site's own address to `workers.dev` if the closing redeploy fails. A new `--domain ` flag both supplies the domain and opts into the chapter; `--yes` alone skips it with a hint. The chapter's one credential is a Cloudflare API token, prefilled with the permissions it needs. It lives only in the site's local `0600` state record, never lands in the project directory or on the command line, and is deleted once the chapter, email included, reaches a state with nothing left to do with it. Consumers must: nothing, since the tool is unpublished and the engine's runtime library is untouched by this pass. An email chapter now finishes what the domain one started, taking a site from serving on its own domain to sending its own sign-in mail. It onboards the domain for Cloudflare Email Sending through the REST API, sends one real message to the owner's address to prove the path, then writes `no-reply@` into the scaffold's `cairn.config.ts` and redeploys once so the running Worker carries it. The test send runs before the rewrite and deploy deliberately: it proves the sending path without depending on the deployed Worker, and a broken path should not buy a deploy. The deploy itself happens only when the address actually changed, so a resumed run does not buy a second one. The chapter opens with the price rather than discovering it: a cost preamble now heads a fresh run, before the site name is even asked, covering what is free, what a domain costs at a registrar, and Cloudflare's Workers Paid plan at $5 US per month billed once per account. Every figure carries its date and a link. A new `--email` flag opts in unattended, and `--yes` alone declines rather than committing an owner to a subscription, naming the flag that would not have. Declining is a first-class outcome rather than a failure: the catalogue gains a fourth error kind, `declined`, which exits `0` and carries no re-run urgency, and the copy names what still works, what does not, and `--sign-in` as the owner's own way back in. A re-run re-offers. The pasted Cloudflare token now survives until chapter 2 reaches a genuinely terminal state, which is the email half completing or a recorded decline, and a park keeps it; this closes a hole where an owner who declined left a live credential on disk. Consumers must: nothing, since the tool is unpublished and this pass leaves the engine's runtime library untouched. Two platform findings from the pass's live spike are worth recording, because both contradict what the documentation implies. Cloudflare's REST send carries none of the `E_` codes its Workers binding throws, and two codes, `10203` and `10204`, both describe a sender that is not ready, covering a domain that was never onboarded and one whose DNS is still settling alike, so elapsed time since onboarding is the only thing separating them. Deleting a sending subdomain removes the records onboarding added except the `p=reject` DMARC record at the apex, so that policy outlives the feature that wrote it and any later sender for the domain has to be added to it. The closing copy and the deploy guide both say so now. A live end-to-end run of the whole chapter then found four defects its fakes could not reach, and all four are fixed here. Two of them stranded a real owner. A saved Cloudflare API token that still passed validation but lacked a write permission could never be replaced, because validation is a read and nothing ever cleared a token that failed later; a `token-scope-missing` or `token-invalid` failure now deletes the saved token before the error surfaces, so the re-run the message already asks for actually collects a new one. Under `--yes`, a `CAIRN_CF_API_TOKEN` that differs from the saved token now wins, since an operator who sets the variable is expressing an intent a stored value should not override. Separately, an owner whose zone already existed but whose token could not create zones died on a refusal for a create that was never needed: `ensureZone` now lists zones by name before posting a create, adopting on a hit, which is the read-before-write discipline every other non-idempotent call in the tool already followed. The 1061 collision path stays as a race guard. The third fix is a correction, not a repair. The run proved that Cloudflare accepts a message and proved nothing about delivery: every send returned 200 and none arrived, on a domain a day old. Greylisting and SPF were both refuted by experiment, leaving new-domain sending reputation, which a CLI cannot observe. So the tool no longer claims delivery anywhere. The closing copy names what was actually proven, adds the warm-up expectation a brand-new domain should have, and points at `cairn-doctor --send-test` for checking the path later. The stale "Email arrives with a later chapter" line, which this pass's own predecessor made false, now names `--domain` and `--email` instead. Fourth, the `10204` refusal code joins `10203` under the propagation-window classification deliberately, rather than landing correctly by fall-through. Consumers must: nothing. `create-cairn-site` is still unpublished and the engine's runtime library is untouched by this pass. A Builds chapter now follows the email one (or, later, is entered on its own with a new `--connect` flag) and takes a site the rest of the way to push-to-deploy. It connects the repository to Cloudflare Workers Builds, creates a trigger bound to the site's existing Worker, reconciles the two tool-owned config files (`wrangler.jsonc`, `src/theme/cairn.config.ts`) into the repository through an admin-attributed commit whenever they have drifted, and watches the first Builds deploy to success. Connecting and the trigger are both adopted rather than re-created on a re-run, since the connections PUT is a true upsert and a worker's existing triggers each embed the connection they already point at. The build token needs no separate dashboard visit: the chapter registers the admin's own pasted Cloudflare API token as the build token, through the same paste flow the domain chapter already used, using `GET /user/tokens/verify` to learn the id Cloudflare's token-registration route needs. Two things still need a browser: a one-time authorization of Cloudflare's "Workers and Pages" GitHub App, and a sign-in click for the reconcile commit. That sign-in is skipped when nothing has drifted locally, decided by a hash of the two tool-owned files recorded at the last reconcile, because a scaffolded repository is private and reading it to compare needs a credential the tool deliberately does not keep. One narrowing follows from that, stated here rather than discovered later: editing either file in the repository while changing nothing on your machine leaves the drift unnoticed until something local changes too. A first run has no recorded hash, so it always signs in once, and an unattended `--yes` first run parks at that point instead. Both authorization refusals Cloudflare's connections API can return are read from their numeric codes (`8000008`, `8000012`) rather than their platform message, since both messages are Pages-era and factually wrong for this condition; the rows state the real condition in cairn's own words. A push-triggered build is found by matching the reconcile commit's sha; a manual kick, needed only on a genuinely first run whose config already matches, carries no commit metadata to match on and is tracked by its own build id instead. A `builds-live` re-entry (a later `--connect`) is not a no-op: it re-runs the reconcile diff, so a `PUBLIC_ORIGIN` that changed locally after the site went live is not silently left uncommitted. Every park exits `0` and names the exact command to resume; a failed or unrunnable build exits `1` with the build log's last few lines and a dashboard link. Consumers must: nothing. The engine's runtime library is untouched by this pass, and `create-cairn-site` is still unpublished. The machinery behind the public template repo lands next, though the repo itself does not. `scripts/sync-template-repo.mjs` generates `glw907/cairn-waymark-template` wholesale from the same bake the CLI scaffolds from, plus a small repo-only overlay (README, MIT license, `.dev.vars.example`, and the `.gitignore` negation that keeps git from swallowing it). The tree is regenerated on every sync and committed normally, never force-pushed, so a hand edit to the template repo survives at most one sync and history stays intact. GitHub Actions drives it three ways: a job gated on the npm publish succeeding, a manual dispatch, and a weekly `--dry-run` compare that fails on drift, which is the tripwire for a hand edit, an expired credential, or a sync gone silently inert. Write access rides a fine-grained PAT that reaches the script only as `TEMPLATE_REPO_TOKEN` in the environment, carried to git as an injected `http.extraheader` rather than embedded in the remote URL, so it appears in no subprocess argv and in no clone's persisted `.git/config`. The sync refuses to push a tree it cannot prove builds. Registry resolvability alone was not enough, and finding out why is what split this pass: the bake emits the showcase's **current** tree while the emitted engine dependency resolves to the last **published** version, so a template generated between releases can import engine symbols the registry does not serve. It did, against `0.94.0`. The gate installs and builds the composed tree before committing, and runs only when a real sync is about to push, so a no-op run and a `--dry-run` never pay for it. `docs/guides/deploy-to-cloudflare.md` gains the three-door framing this implies: the manual door, `create-cairn-site --connect`, and the Deploy to Cloudflare button, which is named as not yet available. Consumers must: nothing; none of this ships in the engine tarball. **Two release-checklist obligations ride this entry, and `cairn-release` must not miss them.** (1) Drop `--strip-dev-backend` from `sync-template.yml`'s drift cron and from its dispatch input default, in the same edit; the release path never strips, so leaving the flag on makes the weekly compare measure a stripped bake against an unstripped repo and go red every Monday forever. (2) Drop the pre-release notice from `packages/create-cairn-site/template-repo/README.md`. The remaining T5 work (creating the public repo, its first sync, the live button spike, the spike-derived completion checklist, and the C3 `--template` verification) belongs to the same cut, since the repo cannot build from a clean clone until the engine it names is on the registry. - Every entry gains a publish history and a revert-as-draft: the `history` admin view (`/admin///history`), reachable from the edit screen's overflow menu, lists the entry's most recent 25 publishes off `Backend`'s new `listCommits(path, ref, limit)` member (a new `BackendCommit` export; both the GitHub App backend and the packaged dev backend implement it identically), each row naming who published it and when, plus a synthetic row for an open draft. The list shows every publish that touched the file, including ones made outside cairn, and its own label, "recent versions," never claims completeness: a rename restarts the commits API's path filter cairn reads from. Choosing Revert beside a listed version starts a fresh draft holding that version's content, through the unchanged Publish gate; nothing goes live at revert time. Revert refuses fail-closed (a new `RevertFailure` union, `ActionFailure` on the new `revert` facade action key gated to the `history` view) when a pending draft already blocks the entry, when the posted target isn't a member of a freshly re-read history list, or when the default branch moved since the history page rendered; a reverted version carrying a since-retired field or vocabulary tag rides forward as a warn-only advisory rather than blocking the revert. `Backend.createBranch`'s existing-branch collision is now a typed `BranchExistsError` (both backends throw it identically); the packaged dev backend used to silently overwrite the branch on a name collision and now throws instead. See [SvelteKit](docs/reference/sveltekit.md), [the canonical admin mount](docs/reference/admin-routes.md), and [Publish and discard](docs/guides/publish-and-discard.md#see-an-entrys-history-and-restore-an-earlier-version). Consumers must: nothing, for a site that only imports the packaged backends and admin facade; this is purely additive, and a site that mounts the single-mount admin facade picks up the History link with no code change. A site implementing its own `Backend` must add the new `listCommits(path, ref, limit)` member and change `createBranch`'s return type from `Promise` to `Promise`, returning the sha it created the branch at. Undelete stays out of scope for this release (see ROADMAP.md). - An editor can now hand a draft to someone who isn't an editor: the edit screen's new "Share preview" group mints an opaque link (`previewMint`/`previewRevoke` facade actions, backed by the new `previewMintAction`/`previewRevokeAction` route-factory members and `mintPreviewToken`, all `/sveltekit`), and the site mounts a new, never-prerendered `previewLoad` route that renders the shared draft through the exact composition its public entry page runs (`composeEntryData`/`EntryDataOverrides`, newly exported from `/delivery`), so the preview and its eventual public page can't structurally drift: full CSS, working components, hydrating islands. The link carries `PreviewData` (`EntryData` plus a `preview` field: `state`, `expiresAt`, and the live `permalink` once published), which the new `PreviewBanner` component (`/components`) renders as a small default banner a site may replace. A new bundled migration, `migrations/0003_preview.sql`, adds the opt-in `preview_tokens` table the feature needs; a link dies with its branch (publish and discard both delete it), rename/delete/discard clear a never-published entry's outstanding rows, removing an editor clears theirs, and the edit screen's Revoke-all button clears every row for an entry on demand. TTL defaults to seven days and configures through `CairnAdminOptions.preview`/`ContentRoutesOptions.preview` (`PreviewTokenConfig`). Four new log events (`preview.token.minted`, `preview.token.revoked`, `preview.cleanup_failed`, `preview.rejected`) never carry the token itself. See [SvelteKit](docs/reference/sveltekit.md#public-preview), [Components](docs/reference/components.md#previewbanner), [Delivery](docs/reference/delivery.md#composeentrydata), [Log events](docs/reference/log-events.md), and [Share a draft preview](docs/guides/share-a-draft-preview.md) for the full adopter walkthrough, including the operational notes on caching, rate limiting, and the URL-as-credential exposure a preview link carries. Consumers must: nothing to keep building and deploying as before; every addition here is purely additive and unmounted by default. **A site that adopts the feature** (an adopter-only step, not a required upgrade action) copies and applies `migrations/0003_preview.sql` to its `AUTH_DB` and mounts a `+page.server.ts`/`+page.svelte` pair at `/preview/[token]`, inside the same layout group as its entry pages; the edit screen's Share preview group ships automatically to every site mounting the single-mount admin facade, but minting produces a working link only once the route is mounted and the migration is applied. - Vertical alignment is now an engine-owned mechanic on the admin surface, off a measured inventory of every admin screen at three widths in both themes. Three recipes land: a new [`FieldRow`](docs/reference/admin-toolkit.md#fieldrow) export on `/admin-toolkit` that levels a row mixing a stacked field with a bare control on their bottom edges, and two new classes in the shipped admin sheet, `cairn-icon-label` (a glyph-plus-word label that reports its own text baseline, so a row declaring `items-baseline` levels the word rather than the icon) and `cairn-line-slot` (a one-line-tall slot that levels a painted chip on the line it labels). Three admin rows were re-composed onto the recipes: the tidy settings screen's three developer-tier rows (a 2.5px baseline miss), its "Set by your developer" pill (5px low against the heading it labels), and the editor's Write tab glyph (2.33px above its own label's cap centre). The mechanics, and the `text-box-trim` default that was measured and declined, are written up in `docs/internal/admin-design-system.md`. Consumers must: nothing. The new export and the two new classes are additive, and no existing class changed. - `create-cairn-site` gained a localhost console over its two longest waits. DNS propagation and the first Workers build now hold in place on an interactive run instead of parking, printing a `127.0.0.1` URL that serves a live view of what the run is waiting on, and resuming by themselves the moment the wait clears. The propagation view reads the answer from the zone's own nameservers beside the machine resolver's, which splits the old single `hostname-propagating` outcome into `hostname-records-absent` and `hostname-resolver-lagging`: a live run measured 27 minutes parked on a stale negative cache while the authoritative answer served the whole time, and that case now names itself and clears without a re-run. The marker pair remains the only authority for a live verdict, so the DNS answer upgrades the diagnosis and never the verdict. The builds view shows discovery, then the build's own state from queued to running to an outcome, and the commit it matched. Each page carries a chapter and hop header, refreshes on a per-class cadence, and renders from a per-view field allowlist, so no token or secret reaches a rendered byte or a printed line. The console mounts under a fresh unguessable path per run, refuses any request not addressed to the local machine, and exists only while a run is waiting; nothing is left listening between runs. A run that is `--yes`, non-TTY, or under CI never holds and parks exactly as before. An interrupt during a wait closes the console, saves what the loop learned, prints the same park row the expiry path prints, and exits `0`, where an interrupt previously died at 130 with nothing saved. A console that cannot bind degrades to the old behavior rather than failing the run. Three internal reuse moves came with it: the loopback listener extracted into a routing core that adds a Host allowlist and per-mount secret prefixes (the GitHub chapter's `/callback` and `/manifest` stay at their fixed paths, since GitHub's loopback leniency is port-only and the App's callback URLs are baked at creation), the authoritative-versus-recursive DNS machinery lifted out of the records module into a shared helper, and the fake HTTP plumbing shared by the GitHub and Cloudflare test fakes collapsed into one. Consumers must: nothing. The engine's runtime library, exports, and tarball are untouched, and this package ships separately. ### Changed - The docs now say plainly that a production site belongs on the Workers Paid plan, and why. A new "Choose a Workers plan" section in [Configure auth and D1](docs/guides/configure-auth-and-d1.md) states that Cloudflare Email Sending reaches arbitrary recipients only on the paid plan, that every editor is an arbitrary recipient, and that the free tier's verified-destination path does not fit an editor roster, since each recipient has to click a Cloudflare verification link and the sending domain has to run Email Routing, which takes over the domain's root MX records. It also names the way out for a site that would rather not upgrade: the `SendMagicLink` seam takes any delivery function, so another provider's API can carry the mail with the rest of auth unchanged. The readiness guide and the tutorial's prerequisites both point at it, so the cost surfaces where a reader decides rather than only where a send fails. Prices stay linked to Cloudflare rather than copied, per this repo's rule that a vendor's specifics go stale on the vendor's schedule. Consumers must: nothing, though a site relying on magic-link sign-in should confirm its account's plan. - The template emitter no longer copies showcase-only material into a scaffolded site. Three paths join `.cairn-template.json`'s exclude list (`.claude`, which carried seven tracked agent-memory notes, `scripts`, a design-lab tool, and the showcase's own `README.md`, whose relative links point back into the engine repo), and the `create-cairn-site` bake additionally prunes the `pretest:e2e`, `test:e2e`, and `design:probe` scripts and the two Playwright devDependencies, which survived the path exclusions because the manifest can drop files but not lines inside a kept `package.json`. The prune is a rot gate: it throws when an expected key is already gone, so a rename in the showcase surfaces here instead of silently shipping. `cairn:manifest` is deliberately kept, since a real site runs it. Consumers must: nothing. This changes the scaffolded output only, never the installed package. - `@anthropic-ai/sdk` is now an optional peer dependency instead of a plain dependency, and the tidy action reaches it through a dynamic import at call time rather than a static import at module load. A site that never turns tidy on stops installing the SDK and its six transitive packages, 13 MB and roughly 1,980 files off a production install. A site that does use tidy behaves exactly as before once the package is present, and the injectable `ContentRoutesOptions.tidy.client` seam keeps its signature, so an injected client (the packaged dev backend's deterministic stub included) is untouched. With tidy enabled and the SDK absent, the action answers `fail(503)` naming the package and the install command instead of failing the build, and logs `tidy.failed` with a new `reason: 'sdk_missing'`; the key-health cache stays untouched, since no key was ever tried. See [Log events](docs/reference/log-events.md). Consumers must: a site using the tidy action adds `@anthropic-ai/sdk` to its own dependencies (`npm install @anthropic-ai/sdk`), because npm does not auto-install an optional peer; a site not using tidy does nothing, and its install gets lighter. ## 0.94.0 ### Consumers must Four sites depend on the package today, each on its own `0.x` caret range (907-life `^0.84.4`, cairn-pub `^0.87.4`, aksailingclub-org `^0.91.1`, ecxc-ski `^0.93.0`), and a caret admits only its own minor, so a site more than one minor behind this window crosses several earlier `Consumers must:` lists on the way here. Read every list your version range crosses, per [the upgrade guide](docs/guides/upgrade-cairn.md); this list covers only this window, not the older ones. The steps below are ordered so a rename that has to compile before another change lands first; each links to its full entry under Changed for the reasoning and the exact old and new names. 1. **Toolchain.** Be on Node 22 or later for the build (`"engines"` now states it). 2. **Imports and types.** - Replace `AuthEnv`/`BackendEnv` with [`CairnEnv`](docs/reference/sveltekit.md#cairnenv); drop any reliance on `PlatformContext.ctx`/`.context`. - Replace `EventBase`, `RequestContext`, `ContentEvent`, `AdminEvent`, or `AdminActionEvent` with [`CairnEvent`](docs/reference/sveltekit.md#the-event-shape); a hand-built event fixture (a test, a script) now needs `params` and `route` alongside its existing fields. - Drop any imported `Role` type and use `string`; drop a `CairnRolesRegister` `app.d.ts` augmentation and any cast that forced a custom role name past the old union. - Replace `CairnAdminDeps`, `ContentRoutesDeps`, `AdminActionDeps`, or `PublicRoutesDeps` with its renamed `*Options`/`*Config` counterpart; replace `makeMediaResolver` with `buildMediaResolver`; replace `OrphanScan` with `MediaOrphanScanResult` and `AdminNavIcon` with `NavIcon`; drop any imported `MakeIcon` or `ConceptUrlPolicy`. - Replace any imported `AdminActionError` with [`UnauditedActionError`](docs/reference/sveltekit.md#refusal-channels) and remove any `handleError` mapping of it; `adminAction`'s two refusals now answer with a `303` redirect and a `403` error instead of the old `500`, so a monitor or an alert keyed on that status needs the same update. - Replace any `@glw907/cairn-cms/admin-fields` import with [`@glw907/cairn-cms/admin-toolkit`](docs/reference/admin-toolkit.md#fields), renaming `TextField`/`SelectField`/`SelectFieldOption` to `TextInput`/`SelectInput`/ `SelectInputOption`; replace `@glw907/cairn-cms/components`'s `OfficeList` import with the same `/admin-toolkit` subpath. - Rename any read of [`TidyResult`](docs/reference/sveltekit.md#createcontentroutes)'s `usage` field to `tokens`; the old name collided with `MediaDeleteRefusal.usage` inside SvelteKit's generated `ActionData` union for the admin route, which is why `` failed to typecheck. - Rename any read of [`AdminActionAuditRecord`](docs/reference/sveltekit.md#types)'s `editor` field to `actor`, in a hand-rolled `AdminActionAuditSink` or a custom `App.Locals` augmentation that duplicates the type; a site wiring no audit sink does nothing. 3. **`locals` reads.** Rename every `event.locals.editor`, `.backend`, and `.auditSink` read or write in your `hooks.server.ts` or a custom admin route to `event.locals.cairnEditor`, `.cairnBackend`, and `.cairnAuditSink`, including in a hand-duplicated `App.Locals` augmentation. See [Ambient types](docs/reference/ambient.md). 4. **Action names and posted `?/` keys.** Rename any route-factory member your site imports directly in its own `+page.server.ts` files (see [SvelteKit](docs/reference/sveltekit.md#createcontentroutes)); change the posted `?/` action string on any form or `fetch` call that targets a renamed facade action, for example `?/saveSettings` to `?/settingsSave` (see [admin routes](docs/reference/admin-routes.md#the-actions-vocabulary)), since a stale name now 404s at submit time with no compile-time warning. 5. **Adapter config.** Replace `editor.adminNav` with `editor.navLayout` on the adapter, a flat entry or section carrying over unchanged in shape (see [the navLayout seam](docs/reference/sveltekit.md#the-navlayout-seam)), and replace any imported `AdminNavEntry`/`AdminNavSection`/`AdminNavConfig`/`ResolvedNavSection`/`ResolvedNavItem` with its `NavLayoutEntry`/`NavLayoutSection`/`NavLayout`/`ResolvedLayoutSection`/ `ResolvedLayoutNode` counterpart. Rekey any access map behind a parameterized or catch-all [`createSectionAction`](docs/reference/sveltekit.md#createsectionaction) route from the concrete request path to the bracket-form route id (`/admin/posts/[id]`); a map still keyed by path now fails closed and refuses every session, including owner. 6. **Log-consumer changes.** Update any Workers Logs saved query, alert, or dashboard filter naming `admin.audit.sink_failed`, `admin.action.audit_sink_failed`, `tidy.done`, `tidy.error`, `media.orphan_reconcile`, or `content.field_behavior_error` to its renamed form; update a filter on a kebab-case media upload `reason` or on `github.unreachable`'s `scope: 'layout'` (which never fired) to the corrected snake_case value. See [Log events](docs/reference/log-events.md). None of this breaks at compile time; these are runtime values, not exported types. 7. **`requireAccess`'s default target.** Rekey any access map behind a [`requireAccess`](docs/reference/sveltekit.md#requireaccess) call on a parameterized or catch-all admin route from the concrete request path to the bracket-form route id (`/admin/posts/[id]`), the same rekey step 5 already covers for `createSectionAction`; a map still keyed by the concrete path now fails closed and refuses every session, including owner. A site with no parameterized or catch-all route behind `requireAccess` sees no change. Two more steps, neither gated by a compiler: a call to `formatCivilDate` that relied on its old `'Not yet'` default for a missing date now renders an empty string unless you pass `{ fallback: 'Not yet' }` explicitly (see [Admin toolkit](docs/reference/admin-toolkit.md#formatts)); and any monitoring keyed on `adminAction`'s old `500` for a missing-editor or CSRF refusal should watch for the new `303`/`403` instead, the same change step 2 already carries, restated here because a status-code dashboard is easy to miss on a code review. Every other entry below (the `/cloudflare` subpath, `createD1AuditSink`, the export-rule sweep's roughly ninety new exports, `/components`'s two new views, the newly named `AuthGuardOptions` and `EditorRoutesOptions` bags, the newly named factory return types) is additive: no rename, no removal, nothing this list needs to carry. ### Added - A new server-only export subpath, `@glw907/cairn-cms/cloudflare`, publishes the Cloudflare-native platform primitives two sites already copy by hand: `verifyTurnstile(token, secret, opts?)`, the siteverify fetch, fail-closed on every failure mode (a malformed or over-length token or secret, a fetch that throws or times out, a non-200 response, an unparseable or malformed body, a `success: false` result, or a `hostname`/`action` mismatch all return `false`, never throw); and `checkRateLimit(binding, key)` plus `checkRateLimitKeys(binding, keys)`, the Workers `RateLimit` binding wrapper, degrade-to-open on an absent binding. `RateLimitLike`, already public on `./sveltekit` for `createSectionAction`, moves to this subpath as its one declaration and is re-exported from `./sveltekit` unchanged. See [Cloudflare](docs/reference/cloudflare.md). Consumers must: nothing. - A new factory on `@glw907/cairn-cms/sveltekit`, `createD1AuditSink(db, waitUntil)`, is the first implementation of the `AdminActionAuditSink` seam: a fire-and-forget, parameterized insert into a packaged `audit_log` table (`migrations/0002_audit.sql`, opt-in, applied the same way as the auth migrations), fail-open so a persist failure never fails the audited action, with every bound field truncated to a documented maximum so an oversized `detail` can't suppress its own row. `waitUntil` is a required parameter that explicitly accepts `undefined`, so a site chooses the drop risk rather than defaulting into it. A rejected insert logs the whole truncated record and the error as `audit.sink.write_failed`. See [SvelteKit](docs/reference/sveltekit.md#created1auditsink). Consumers must: nothing. - The export-rule sweep (C2 breaking-window pass, R4 ruling) adopts a standing doctrine: every type named in a public signature is exported from a subpath the consumer already imports, so an AI coding agent or a developer can always name a value it holds instead of inventing a structural duplicate. Roughly ninety previously unreachable types become named, documented exports, closed under their own structural bodies (a type re-exported from a subpath now pulls every type its own shape names along with it): the field-descriptor union's fifteen arms (`TextField`, `SelectField`, `ArrayField`, and the rest) from the root barrel; the facade/action result and plan types (`TidyResult`, `DictionaryAddResult`, `MediaBulkDeleteResult`, `MediaOrphanPurgeResult`, `MediaOrphanScanResult`, `MediaReplacePreviewPlan`, `MediaAltPreviewPlan`, and their own nested shapes) and the tidy, content, and media-preview supporting types (`TidyClient`, `TidyConfig`, `TidyConventions`, `TidyKeyProbeResult`, `FragmentTarget`, `LinkTarget`, `InboundLink`, `UsageEntry`, `ReferenceEdge`, `MarkdownReferenceRow`, `MediaLibraryEntry`, `ResolvedPreview`, `CookieSetOptions`, `GettingStarted`, `ResolveOptions`) from `./sveltekit` and, where a signature there names it, the root barrel. Three recurring anonymous inline load-payload shapes are named and exported from `./sveltekit`: `LoginData` (`loginLoad`), `ConfirmData` (`confirmLoad`), and `EditorsData` (`editorsLoad`), replacing the anonymous object literals `AdminData`'s `'login'`/`'confirm'`/ `'editors'` members used to inline. See [Core](docs/reference/core.md#types), [SvelteKit](docs/reference/sveltekit.md#types), and [Delivery data](docs/reference/delivery-data.md#types). Consumers must: nothing; every addition is a new named export with no renamed or removed symbol. - Five new refusal-shape exports on `./sveltekit` (pass C2b, the refusal-channel convergence), one per action the pass moved off the query channel onto `fail()`: `CreateFailure` (a refused create), `NavSaveFailure`, `SettingsSaveFailure`, and `VocabularySaveFailure` (a refused nav, settings, or vocabulary save), and `MediaUploadFailure` (a refused upload, including the Media Library's own direct-commit conflict bounce). Each is the bare `{ error: string }` one-line summary the engine's other single-field refusals already use. See [SvelteKit](docs/reference/sveltekit.md#createcontentroutes). Consumers must: nothing; every addition is a new named export. - A new server-only export subpath, `@glw907/cairn-cms/auth-channel`, publishes `createAuthChannel(config)`: a factory for a site's own second-audience login channel (members, athletes, boosters), an 8-digit-by-default OTP code requested and confirmed over any transport the site's own `deliver` function sends, backed by the site's own D1 binding rather than `AUTH_DB`. The factory owns every security discipline behind the channel: salted identity derivation, atomic sliding-window rate budgets, the nonce-bound code row, session issuance and revocation, and its own D1 schema (`CHANNEL_SCHEMA_SQL`, `CHANNEL_SCHEMA_VERSION`). Every denying control keys on the requester, never the victim's identity; an identity-keyed control either escalates through a `challenge-required` retry or only logs. Also exports `devDelivery`, a dev-only transport that refuses outside `CAIRN_DEV_BACKEND=1`. See [Auth channel](docs/reference/auth-channel.md), [Add a login channel](docs/guides/add-a-login-channel.md), and [the security model](docs/explanation/auth-channel-security-model.md). Consumers must: nothing; this is a new subpath with no bearing on the engine's own editor magic-link auth, which is unchanged. - A new optional `CairnAdapter.aiPosture` field (`AiPosture`, `'invite' | 'decline'`), read by `buildRobots`/`robotsResponse` (`@glw907/cairn-cms/delivery`). `'decline'` adds `Content-Signal: ai-train=no` and a `Disallow: /` group per token in a new first-party-verified training-crawler table, `AI_CRAWLERS` (plus its review date, `AI_CRAWLERS_REVIEWED`); `'invite'` adds `Content-Signal: search=yes, ai-train=yes` and no `Disallow`. Declining is a request that named crawlers say they honor, not enforcement: robots.txt has no mechanism to block a fetch, and OpenAI's `ChatGPT-User` and Perplexity's `Perplexity-User` are exempt from robots.txt by their own operators' first-party design. See [Delivery data](docs/reference/delivery-data.md). Consumers must: nothing; `aiPosture` is optional and unset on every site today, so this window's robots.txt output is byte-identical to before for all four. - A nineteenth `cairn-doctor` check, `ai.posture-effective`: a plain, credential-free `GET /robots.txt` against the deployed origin, reporting what the live file actually carries rather than what the adapter declares. It distinguishes no posture stated, a stated posture the live file contradicts, and a managed layer (Cloudflare's AI Crawl Control or its managed robots.txt) prepending directives cairn did not write, a shape measured live on three of the four estate zones. Only the middle case fails, since a stated stance crawlers never read is a broken deployment. Stating no posture passes, and so does the managed layer, since whether that is wanted belongs to the zone's owner. The report never asserts why a zone is configured as it is. See [`cairn-doctor`](docs/reference/doctor.md#the-checks). Consumers must: nothing today, since the failing case needs a declared `aiPosture` and no site declares one. A site that adopts a posture and sees this check go red has a deployment that does not carry the stance it states. - A raw-markdown twin for every routable entry (`@glw907/cairn-cms/delivery`): `markdownResponse` wraps a body in `text/markdown; charset=utf-8`, a sibling of `robotsResponse`/`sitemapResponse`, and `createPublicRoutes` gains `markdownEntries()` and `markdownLoad(event)`, enumerating and serving one `.md`-suffixed path per entry whose frontmatter `robots` field doesn't carry `noindex`. `markdownLoad` returns the entry's stored markdown body unrendered, reading only through the injected `SiteResolver`, so it can serve only what that resolver carries. The engine ships the builder and the enumerator; a site wires one small prerendered route to serve the set, and the build runs against committed `main` content, which is what keeps a pending `cairn/*` edit branch structurally out of reach. A runtime route reopens that question. See [Delivery](docs/reference/delivery.md) and [Delivery data](docs/reference/delivery-data.md#markdownresponse). Consumers must: nothing; no site has wired the route yet, so this ships no new response on any deployed site. ### Changed - The env-genericity sweep audited every exported event and config type pinned to `AuthEnv` (`AdminEvent`, `RequestContext`, `HandleInput`, `ContentEvent`, `adminAction`'s own event type, and the rest of the route-factory surface) against a compile-only fixture proving each one still assigns into a real site's own `App.Platform['env']`. No exported type changed: every pin compiles clean as is, on the grounds `CairnPlatformBindings` documents, and each now carries a doc comment recording the pin as deliberate rather than an oversight. The sweep's fixtures also proved a sharper consequence the pins had never surfaced: a site whose `App.Platform['env']` is a bare `wrangler types`-generated `Env`, with no `CairnPlatformBindings` intersection, failed to compile `export const actions = admin.actions` (and every other route factory assignment), since `@cloudflare/workers-types`' `SendEmail.send` returns `Promise` while `AuthEnv['EMAIL'].send` declared `Promise`. **This incompatibility dissolved in the breaking-window pass below** (`EmailSender.send` now returns `Promise`), so `CairnPlatformBindings` demotes back to a recommended convenience preset rather than a requirement; see that entry for the current claim. - `AuthEnv` and `BackendEnv` collapse into one all-optional `CairnEnv` (`AUTH_DB`, `PUBLIC_ORIGIN`, `CAIRN_DEV_BACKEND`, `EMAIL`, `GITHUB_APP_PRIVATE_KEY_B64`), exported from both the root barrel and `./sveltekit`; the split's only purpose, typing which routes need what, is now reference-page prose rather than a second declaration. `EmailSender` is named once (`{ send(message: MagicLinkMessage): Promise }`) and referenced from both `CairnEnv` and `CairnPlatformBindings`; the widened `Promise` return (not `Promise`) structurally accepts `@cloudflare/workers-types`' `SendEmail.send`, whose return is `Promise`, dissolving the env-genericity sweep's incompatibility recorded above: a bare `wrangler types`-generated env now assigns cleanly into `CairnPlatformBindings` with no intersection required, so `CairnPlatformBindings` demotes from a requirement back to a recommended convenience preset (it still catches a forgotten binding at compile time). Route factory event params (`BackendProvider.connect`, `SendMagicLink`, `healthLoad`, `appCredentials`) now read against `CairnEnv`. `PlatformContext` narrows to `{ env?: Env }` (the engine never read `ctx`/`context`) and is now exported from `./sveltekit`. See [SvelteKit](docs/reference/sveltekit.md#cairnenv) and [Core](docs/reference/core.md#cairnenv). **Consumers must:** replace any imported `AuthEnv`/`BackendEnv` with `CairnEnv` on the same subpath; a site relying on `PlatformContext.ctx` or `.context` (the engine never read either) removes that reliance, since the type no longer carries them. - `EventBase`, `RequestContext`, the content routes' `ContentEvent`, the admin facade's `AdminEvent`, and `adminAction`'s own `AdminActionEvent` collapse into one `CairnEvent`, exported from `./sveltekit`. It adds `params: Record` and `route: { id: string | null }`, ending the documented anti-idiom of reading route identity out of a form body and giving `SectionActionOptions.target` an honest derivation, and makes `cookies` and `setHeaders` unconditionally required, since a real SvelteKit event always carries all four. `locals`'s key names are renamed by the entry below; `requireSession`, `requireOwner`, `requireEditor`, and `requireAccess` now take `CairnEvent` in place of their old minimal inline shapes. See [SvelteKit](docs/reference/sveltekit.md#the-event-shape). **Consumers must:** replace any imported `EventBase`, `RequestContext`, `ContentEvent`, `AdminEvent`, or `AdminActionEvent` with `CairnEvent` on the same subpath; a hand-built event fixture (a test, a script) now needs `params` and `route` alongside the fields it already carried. - `locals`'s four keys take the flat `cairn` prefix, with no alias and no fallback read of the old names: `editor` becomes `cairnEditor`, `backend` becomes `cairnBackend`, and `auditSink` becomes `cairnAuditSink` (`cairnAccess` already carried the prefix and stays as is). A flat key costs a site one optional hop (`event.locals.cairnEditor`) instead of a nested `locals.cairn.editor`, and a grep for `cairnEditor` now finds every engine read of the field in any repo. `docs/reference/ambient.md` is rewritten from `src/lib/ambient.ts`, which fixes a drift the rewrite surfaced: the page never documented `cairnAccess` at all. See [Ambient types](docs/reference/ambient.md) and [SvelteKit](docs/reference/sveltekit.md#the-event-shape). **Consumers must:** rename every `event.locals.editor`, `event.locals.backend`, and `event.locals.auditSink` read or write in your own `hooks.server.ts` and any custom admin route to `event.locals.cairnEditor`, `event.locals.cairnBackend`, and `event.locals.cairnAuditSink`; a custom `App.Locals` augmentation that duplicates the old field names (rather than importing `@glw907/cairn-cms/ambient`) needs the same rename. - Every role-*name* position (`Editor.role`, `EditorRow.role`, `insertEditor`'s and `setEditorRole`'s `role` parameters, `AccessMap`'s values, `NavLayoutEntry.roles`, `NavLayoutSection.roles`, `AdminShellData.user.role`) widens from the implicit `'owner' | 'editor'` union to `string` / `string[]`: role names are open (a site names its own vocabulary with `defineRoles`), and the literal union was a type lie the moment a site declared a role like `webmaster`. `Capability` (`'owner' | 'editor' | 'none'`) is unchanged and stays the one closed role-shaped union, since that vocabulary is genuinely fixed. The `Role` export (root barrel and `/auth-store`) is removed, along with the now-dead `CairnRolesRegister` registry interface it existed solely to narrow: with `Role` gone, no code anywhere declares the augmentation, so it carried no remaining read-side to serve. `editors-routes.ts`'s two `role as Role` casts, forced by the old narrow union, are now plain assignments. See [Core](docs/reference/core.md#roles) and [Auth store](docs/reference/auth-store.md). **Consumers must:** drop any imported `Role` type (root barrel or `/auth-store`) and use `string` in its place; drop any `interface CairnRolesRegister { roles: typeof roles }` augmentation from `app.d.ts`, since it no longer narrows anything; a hand-built `AccessMap`, `NavLayoutEntry`/`NavLayoutSection.roles`, or `Editor`/`EditorRow` fixture that cast a custom role name to `Role` drops the cast. - The root `package.json` now declares `"engines": { "node": ">=22" }`, giving npm's own install check the Node floor the tutorial has always stated. This is a build-toolchain floor, not a runtime claim (the runtime is workerd, never Node); npm's own `EBADENGINE` is a warning that still lets the install finish, unless a consumer's own `.npmrc` sets `engine-strict=true`. See [Supported toolchain](docs/reference/supported-toolchain.md). **Consumers must:** be on Node 22 or later for the build toolchain. - `adminAction`'s two refusals, a missing signed-in editor (authentication) and a CSRF mismatch, now ride SvelteKit's own `redirect()` and `error(403, ...)`, the same shapes `requireOwner`, `requireEditor`, `requireAccess`, and `requireSession` throw for their own authorization checks, so neither of `adminAction`'s two needs a site `handleError` mapping. `adminAction` itself still performs no authorization: a `none`-capability editor's session passes both checks and reaches the wrapped handler unchanged. The old `AdminActionError` class renames to `UnauditedActionError` and now means exactly one thing, the dev-only unaudited-action defect signal (a build-time check, never a production response); the new name states that plainly instead of describing a refusal channel that no longer exists. The CSRF branch logs the new `admin.action.csrf_rejected` event before it throws. See [SvelteKit](docs/reference/sveltekit.md#refusal-channels) and [log events](docs/reference/log-events.md). **Consumers must:** replace any imported `AdminActionError` with `UnauditedActionError` and remove any `handleError` mapping of that class; `adminAction`'s two refusals are now SvelteKit's own `redirect()` and `error(403)`, which need no mapping. A site that alerted on the old `500` these refusals used to produce now sees a `303` and a `403` instead, and both now navigate away from the submitting page (the redirect to `/admin/login`, the 403 to the nearest error boundary), discarding any unsaved form input, where the old `500` left the page itself intact and recoverable with Back. - The route-factory members and the admin facade's `actions` keys now follow one grammar: a member that is a SvelteKit `load` ends in `Load`, a member that is a form action ends in `Action`, and a facade `actions` key is its member name minus the `Action` suffix. Twelve route-factory members rename: `createContentRoutes`'s `settingsSave` to `settingsSaveAction`, `vocabularySave` to `vocabularySaveAction`, `shellPayload` to `shellLoad`, `indexRedirect` to `indexLoad`, `addDictionaryWordAction` to `dictionaryAddAction`, `mediaPurgeOrphansAction` to `mediaOrphanPurgeAction`, `mediaReplaceApplyAction` to `mediaReplaceAction`, and `mediaAltApplyAction` to `mediaAltPropagateAction`; `createNavRoutes`'s `navSave` to `navSaveAction`; and `createEditorRoutes`'s `addEditorAction`, `removeEditorAction`, and `setRoleAction` to `editorAddAction`, `editorRemoveAction`, and `editorSetRoleAction`. Seven facade `actions` keys on `createCairnAdmin` (`src/lib/sveltekit/cairn-admin.ts`) rename to match: `saveSettings` to `settingsSave`, `saveVocabulary` to `vocabularySave`, `addDictionaryWord` to `dictionaryAdd`, `addEditor` to `editorAdd`, `removeEditor` to `editorRemove`, `setRole` to `editorSetRole`, and `mediaPurge` to `mediaOrphanPurge`. The engine's own admin components (`CairnTidySettings`, `VocabularyAdmin`, `ManageEditors`, `EditPage`, `CairnMediaLibrary`) post the renamed facade keys in the same diff. See [SvelteKit](docs/reference/sveltekit.md#createcontentroutes) and [admin routes](docs/reference/admin-routes.md#the-actions-vocabulary). **Consumers must:** rename every renamed route-factory member in a site's own `+page.server.ts` files (per-route mounting only; the single-mount `createCairnAdmin` facade needs no source change). **Any site that posts to a renamed facade action by name, in a form's `action="?/oldName"` attribute or a programmatic `fetch('/admin/...?/oldName')` call, must change the posted `?/` action string to the new name** (for example `?/saveSettings` to `?/settingsSave`, `?/addEditor` to `?/editorAdd`, `?/mediaPurge` to `?/mediaOrphanPurge`); this is a runtime-only failure (a 404 on submit), since the action name is a string literal a type gate cannot catch. - Every injectable-dependency bag renames from `*Deps` to `*Config` (a factory's primary bag) or `*Options` (a secondary or per-call bag): `CairnAdminDeps` to `CairnAdminOptions`, `ContentRoutesDeps` to `ContentRoutesOptions`, `AdminActionDeps` to `AdminActionOptions`, and `PublicRoutesDeps` to `PublicRoutesConfig` (`createPublicRoutes`'s only bag, so it takes the primary-bag name). `createAuthGuard`'s and `createEditorRoutes`'s previously anonymous inline option bags are now named and exported as `AuthGuardOptions` and `EditorRoutesOptions`. `CairnAdminOptions.auth` stops re-declaring `AuthRoutesConfig`'s shape inline and references it directly (`Partial`). Every `create*` factory's return type is now named and exported: `CairnAdminRoutes`, `ContentRoutes`, `AuthRoutes`, `EditorRoutes`, `NavRoutes`, `PublicRoutes`, and `Renderer`. `makeMediaResolver` renames to `buildMediaResolver` (`make` is retired; `build` derives pure data from an already-resolved config). The media orphan-scan result type renames from `OrphanScan` to `MediaOrphanScanResult`, and the custom-nav icon-name type from `AdminNavIcon` to `NavIcon`. `NavLayoutEngineRef.hidden` widens from `hidden?: true` to `hidden?: boolean`, so a computed flag is as valid as the literal; the runtime already treated a falsy `hidden` as visible. The `MakeIcon` re-export is removed from `@glw907/cairn-cms/render` (the type stays internal). `ConceptUrlPolicy` is removed from the root barrel (it stays internal, used by `defineConcept`). See [SvelteKit](docs/reference/sveltekit.md). **Consumers must:** replace any imported `CairnAdminDeps`, `ContentRoutesDeps`, `AdminActionDeps`, or `PublicRoutesDeps` with its renamed `*Options`/`*Config` counterpart; replace `makeMediaResolver` with `buildMediaResolver`; replace any imported `OrphanScan` with `MediaOrphanScanResult` and `AdminNavIcon` with `NavIcon`; and drop any imported `MakeIcon` or `ConceptUrlPolicy`. - `adminNav` retires entirely: `CairnAdapter.editor.adminNav`, `CairnRuntime.adminNav`, `AdminNavConfig`, `AdminNavSection`, `normalizeAdminNav`, `filterNavByRole`, `ResolvedNavItem`, and `ResolvedNavSection` are removed, and `ResolveNavLayoutOptions.adminNav` and `validateNavLayout`'s `hasAdminNav` ctx member go with them. `navLayout` is now the one nav seam: `AdminNavEntry`'s members (`label`, `icon`, `href`, `ownerOnly?`) fold into `NavLayoutEntry`, which stops extending it and stands self-contained. The behavioral objection that kept `adminNav` alive, that it was additive (declare one link) where `navLayout` replaced the whole sidebar, is answered by `navLayout`'s own fallback: an engine screen a declared layout never references still lands in the trailing fallback group, so adding one link is still a one-entry declaration. See [the navLayout seam](docs/reference/sveltekit.md#the-navlayout-seam) and [Organize your admin nav](docs/guides/organize-your-admin-nav.md#omission-falls-back-hiding-is-explicit). **Consumers must:** replace `editor.adminNav` on the adapter with `editor.navLayout`; a flat `adminNav` entry becomes a top-level `NavLayoutEntry` in the `navLayout` array, unchanged in shape (`{ label, icon, href, ownerOnly? }`), and an `adminNav` section becomes a `NavLayoutSection` (`{ label, children }`) the same way. A site whose whole reason for `adminNav` was adding one extra link declares that single entry in `navLayout` and nothing else: every one of cairn's own screens the declaration omits still renders, in the same trailing fallback group the zero-config sidebar already uses for Help. Replace any imported `AdminNavEntry`, `AdminNavSection`, `AdminNavConfig`, `ResolvedNavSection`, or `ResolvedNavItem` type with `NavLayoutEntry`, `NavLayoutSection`, `NavLayout`, `ResolvedLayoutSection`, or `ResolvedLayoutNode` respectively. - `/admin-fields` merges into `/admin-toolkit` and is removed from the exports map: two subpaths stating one charter ("primitives for a site building its own admin screens") is one subpath. The merged toolkit's form components rename to resolve a name collision with the root barrel's field *descriptor* arms (the export rule elsewhere in this window makes `TextField`/`SelectField` importable at the root as content field descriptors): `TextField` becomes `TextInput`, `SelectField` becomes `SelectInput`, and `SelectFieldOption` becomes `SelectInputOption`. `FieldLabel` is unchanged. `OfficeList` also moves from `/components` to `/admin-toolkit`, beside `PageHeader`, its own later generalization; both stay, since they cover different shapes, a header primitive versus a full list-screen scaffold. See [The admin toolkit](docs/reference/admin-toolkit.md#fields). **Consumers must:** replace any `@glw907/cairn-cms/admin-fields` import with `@glw907/cairn-cms/admin-toolkit`; rename `TextField` to `TextInput`, `SelectField` to `SelectInput`, and `SelectFieldOption` to `SelectInputOption`; replace `@glw907/cairn-cms/components`'s `OfficeList` import with `@glw907/cairn-cms/admin-toolkit`. - `/components` completes its per-view seam: `VocabularyAdmin` and `WelcomeView` join the barrel, the two views `CairnAdmin` already rendered internally but a site on the advanced per-route mounting could not reach directly. The membership rule is now exact: every view `CairnAdmin` can render is individually mountable. See [Components](docs/reference/components.md#vocabularyadmin). Consumers must: nothing; both additions are additive. - `createSectionAction`'s `SectionActionOptions.target` now defaults to `event.route.id`, never `event.url.pathname` (C2 breaking-window pass, R9 ruling): on a catch-all route the path is attacker-chosen and the route id is not. **On a parameterized or catch-all admin route, an access map must key its rule by the bracket-form route id (`/admin/posts/[id]`), never the concrete request path (`/admin/posts/hello-world`); a map still keyed by the concrete path stops matching and the section fails closed (every session refused, including owner).** A static route's id and path are the same string, so a site with no parameterized or catch-all admin section sees no change. The derived target drops route-group segments (`/admin/(app)/roster` reads as `/admin/roster`), the one place a route id and its URL differ on every site, so an access map stays keyed by URL shape; a `target` the call site declares is matched verbatim. A null `route.id` (unreachable for a dispatched form action) falls back to a fixed constant that matches no access-map key, never to the path. A wrapped handler's `ctx.audit` call now also defaults `action`/`entity` from the call site's own `SectionActionOptions`, so the common call names only `entityId`/`detail` (`ctx.audit({ entityId })`); a handler may still override either verb. See [SvelteKit](docs/reference/sveltekit.md#createsectionaction). **Consumers must:** for any parameterized or catch-all route behind `createSectionAction`, rekey the site's access map from the concrete path to the bracket-form route id, or the section silently refuses every session. - `requireAccess`'s own `target` parameter now defaults the same way (pass C2b, the refusal-channel convergence): `event.route.id`, never `event.url.pathname`, closing the asymmetry the C2 breaking-window post-mortem flagged between the load-side helper and `createSectionAction`'s already-corrected default. The shared derivation (`targetFromRouteId`, internal to the engine) moves out of `section-action.ts` into `auth/access.ts`, next to `canReach` and `hasAccessRule`, so both call sites import one copy rather than keeping a second in sync by hand. Behavior is otherwise identical to `createSectionAction`'s own R9 change: route-group segments drop (`/admin/(app)/roster` reads as `/admin/roster`), a parameterized route id resolves verbatim (`/admin/posts/[id]`), and a null `route.id` falls back to a fixed constant that matches no access-map key, never to the request path. See [`requireAccess`](docs/reference/sveltekit.md#requireaccess). **Consumers must:** for any `requireAccess`-guarded parameterized or catch-all route, rekey the site's access map from the concrete request path to the bracket-form route id, or the helper fails closed and refuses every session, including owner. A static route's id and path are the same string, so a site with no parameterized or catch-all `requireAccess` route sees no change. - The admin refusal channel converges on `fail()` (pass C2b, the refusal-channel convergence, R10 ruling): every content, media, settings, vocabulary, and nav action's own validation and commit-conflict refusal now answers `fail(...)` in place, keeping the editor on the page with the submitted body intact, rather than throwing a redirect that discarded it. The shared `commitFailure` helper (reached from every read-modify-commit action) stops throwing and returns its caller's own typed `ActionFailure` instead. `settingsSaveAction` widens from `Promise` to `Promise>`, and `vocabularySaveAction`, `navSaveAction`, and `createAction` widen the same way to their own new failure type (`VocabularySaveFailure`, `NavSaveFailure`, `CreateFailure`). `NavTree`, `CairnTidySettings`, and `VocabularyAdmin` now receive `form` from the shell, the same way `ManageEditors` always has, so their conflict and validation refusals render. Every same-route save-validation bounce on the edit and list screens converts the same way; `ConceptList` and `EditPage` already received `form`, so no shell wiring was needed there. See [Refusal channels](docs/reference/sveltekit.md#refusal-channels) and [`createContentRoutes`](docs/reference/sveltekit.md#createcontentroutes). **Consumers must:** update the return-type annotation of any hand-duplicated wrapper around `settingsSaveAction`, `vocabularySaveAction`, `navSaveAction`, or `createAction` that assumed `Promise`. A site that renders these built-in screens through `createCairnAdmin` or `createContentRoutes` / `createNavRoutes` directly, with no such wrapper, sees no change beyond the behavior note below. - The admin facade's `viewAction` wrapper drops its `scriptPosted` branch (pass C2b, R10 ruling): every action's own unexpected failure now answers `fail(500, { error })` in place, form-posted or script-posted alike, instead of a form-posted action redirecting while a script-posted one (tidy, a dictionary word, an upload) got the `fail()` treatment already. The `scriptPosted` and `carriesNewFlag` facade options, and the request clone that supported reconstructing a dropped `new=1` flag on the redirect path, are removed; they had no other reader. Because this arm is shared, the facade's `confirm`, `logout`, `discard`, and `publishAll` actions each widen their own call-site return annotation to include `ActionFailure<{ error: string }>` alongside the `never` their underlying delegate still declares (each delegate only ever throws on its own deliberate success or expected-refusal path; the wrapper is what can now also return): a failed discard, sign-in confirm, logout, or publish-all answers in place with the same calm retry copy instead of navigating away on an unexpected failure. See [Refusal channels](docs/reference/sveltekit.md#refusal-channels). Consumers must: nothing; `scriptPosted` and `carriesNewFlag` were never public, since `viewAction` is internal to the facade, and the behavior change touches only an unexpected-failure path, not a deliberate success or a validated refusal. - Every refusal that genuinely navigates (three survive: an expired sign-in link, publish-all's two outcomes, and the `/admin` landing relay that forwards one onward) now carries a bounded internal `RefusalCode` on `?error=` instead of a redirect target's own free-form string, resolved server-side against a small closed vocabulary that drops any value it doesn't recognize (pass C2b, R10 ruling; this closes a link-crafting surface documented in [the security model](docs/explanation/security-model.md#who-may-edit)). `RefusalCode` stays internal to the engine; no exported signature names it, so `ListData.formError`, `LoginData.error`, and `ConfirmData.error` keep their existing `string | null` type. Six other `?error=` readers now sit orphaned, since the redirect that used to fill them converted to `fail()` earlier in this pass, and are removed outright along with their data field: `EditData.error`, `EditorsData.error`, `NavLoadData.error`, `SettingsData.error`, `VocabularyLoadData.error`, and `MediaLibraryData.flashError`. See [Refusal channels](docs/reference/sveltekit.md#refusal-channels). **Consumers must:** drop any read of `EditData.error`, `EditorsData.error`, `NavLoadData.error`, `SettingsData.error`, `VocabularyLoadData.error`, or `MediaLibraryData.flashError`; each field is gone, not renamed, since its own producing refusal now answers through `fail()` instead of a redirect that carried it. A site that never read one of these fields directly (the common case, since cairn's own components already handled them) sees no change. - The log-event vocabulary settles on one grammar (`area[.subject].verb_phrase`, past-tense for an occurrence or a state adjective for a detected condition) and every `reason`/`scope` value goes snake_case. Six renames: `admin.audit.sink_failed` to `audit.sink.write_failed`; `admin.action.audit_sink_failed` to `admin.action.sink_threw`; `tidy.done` to `tidy.succeeded`; `tidy.error` to `tidy.failed`; `media.orphan_reconcile` to `media.orphans_reconciled`; and `content.field_behavior_error` to `content.field_behavior_failed`. `guard.rejected`'s `reason: 'csrf'` and `admin.action.csrf_rejected` stay distinct on purpose (different layers). The media upload `reason` family goes snake_case, reaching the upload popover's own failure-card mapping in the same change. `github.unreachable`'s `scope` values correct to `shell`, `help`, and `publish_advisories` (the documented `layout` scope never fired). `config.invalid` gains a `scope` (`nav`, `settings`, or `vocabulary`) so its three emit sites and four call paths stop sharing one indistinguishable log line. See [Log events](docs/reference/log-events.md). **Consumers must:** update any Workers Logs saved query, alert, or dashboard filter naming one of the six old event names, a kebab-case media upload `reason`, or `github.unreachable`'s `scope: 'layout'`, to the corrected value. Nothing breaks at compile time, since these are runtime log values, not exported types. - `/admin-toolkit`'s formatters settle on one nullish rule (C2 breaking-window pass, RN ruling): every display formatter, `formatMoney`, `formatCivilDate`, `formatTimestamp`, and `formatPhone`, accepts a nullish input and takes a `fallback?: string` option defaulting to `''`, so a caller never has to remember which formatter tolerates a missing value and which throws. `formatMoney`, `formatTimestamp`, and `formatPhone` widen their first parameter to `T | null | undefined` and gain the `fallback` option (`formatPhone` gains its first options parameter, `FormatPhoneOptions`, exported from `./admin-toolkit`); `formatCivilDate` already accepted nullish and keeps its shape, but its `fallback` default drops from the opinionated `'Not yet'` to `''`. `ageFromBirthdate` is unaffected: it returns `number | null`, not a display string, and stays outside this rule on its own documented grounds. See [Admin toolkit](docs/reference/admin-toolkit.md#formatts). **Consumers must:** a site that calls `formatMoney`, `formatTimestamp`, or `formatPhone` with a value that is statically `number`/ `string` (never nullish) sees no change; a site relying on `formatCivilDate`'s old `'Not yet'` default now renders an empty string for a missing date and must pass `{ fallback: 'Not yet' }` explicitly to keep the old copy. This is a silent visual change, not a compile error, since the parameter type was already nullable. - The admin `Strict-Transport-Security` header no longer sends `includeSubDomains` by default (the AI-posture pass, HSTS rider). `applySecurityHeaders` (`sveltekit/admin-response.ts`) previously set `max-age=63072000; includeSubDomains` on every `/admin` response unconditionally: one editor visit pinned the site's apex and every sibling subdomain to https in that browser for two years, including on a zone whose owner had left edge HSTS off, with no way for the operator to un-pin an already-pinned editor except by serving a corrective header from the same host. `max-age` still sends on every admin response the guard returns, since the admin surface is the one place the engine has standing to insist on https; only `includeSubDomains` becomes conditional, since pinning subdomains the engine knows nothing about is a decision that belongs to whoever owns the domain. `AuthGuardOptions` gains `includeSubDomains?: boolean`, threaded from `createAuthGuard`'s single call to `applySecurityHeaders`; omitted or `false` is the new default and matches the shape of every other `AuthGuardOptions` field, so a zero-config site sees only the header change, no code change. The doctor's zone-level `edge.hsts` check is reconciled to say so: a failing zone setting no longer reads as though nothing is protected, since cairn's own admin responses already carry their own header regardless of that zone setting. See [SvelteKit](docs/reference/sveltekit.md#createauthguard). The guard's rejection pages and its login redirect now send no `Strict-Transport-Security` at all. RFC 6797 section 8.1 has a browser replace its cached policy on every header it receives, so a rejection page sending `max-age` alone is not a weaker default but a policy write that clears the `includeSubDomains` an opted-in site's guarded responses asserted. The CSRF rejection is reachable by any cross-site POST carrying no session, so leaving it would have handed any page on the web a repeatable way to unpin an opted-in site's sibling subdomains. Omitting the header writes nothing, and the guarded path still pins the admin host. **Consumers must:** a site that wants the previous domain-wide pinning back sets `createAuthGuard({ includeSubDomains: true })` in its `hooks.server.ts`; a site that takes no action keeps `max-age` on the admin surface but stops pinning its sibling subdomains. Check the zone before deciding: where edge HSTS already sends `includeSubDomains` for the admin's host, which is the measured state on more than one site running this engine, set the option so the engine states the same policy rather than a weaker one. - Site code calling `createD1AuditSink` directly with its own domain events (a roster change, a season rollover, anything append-only worth a durable trail) is now a sanctioned pattern, not only an implementation detail of `adminAction` and `createSectionAction` (the 2026-08-05 engine-harvest sitting, ruling 1). The sink was already generic: it binds whatever `{ actor, action, entity, entityId, detail }` record it receives into the `actor, action, entity, entity_id, detail` columns with no admin-specific behavior. A namespaced action vocabulary (`roster.add`, not a bare `add`) keeps a domain row distinguishable from an admin-action row in the shared table; the existing fail-open, truncation, and `waitUntil` promises apply to a direct call unchanged. See [SvelteKit](docs/reference/sveltekit.md#created1auditsink). Sanctioning direct use means `AdminActionAuditRecord`'s identity field no longer names only a cairn editor, so it renames from `editor` to `actor`, matching the column it has always landed in: `AdminActionAuditRecord` is now `AdminActionAudit & { actor: string }`. `adminAction`'s own composition and the packaged sink's own read follow. Two log events follow the actor they can report: `admin.action.audited` (which spreads the record) and `audit.sink.write_failed` (composed by the sink itself, which can now fire for a non-editor actor) both key their identity field `actor`. `admin.action.sink_threw` fires only from inside `adminAction`, where the actor is always a verified cairn editor, and keeps `editor`, as do the auth events. `AdminActionAudit` (what a handler passes to `ctx.audit`) carries no identity field and is unchanged. See [SvelteKit](docs/reference/sveltekit.md#types) and [log events](docs/reference/log-events.md). **Consumers must:** rename any read of `record.editor` to `record.actor` in a hand-rolled `AdminActionAuditSink`, or in a custom `App.Locals` augmentation that duplicates `AdminActionAuditRecord`'s shape rather than importing it; a site that wires no audit sink does nothing. ### Fixed - `@glw907/cairn-cms/auth-crypto` and `@glw907/cairn-cms/cloudflare` failed to start on Cloudflare Workers: both subpaths' `exports` entries published a `browser` condition pointing at a client-only stub that throws at import, and Wrangler's own esbuild re-bundle of the adapter output for `workerd` resolves that same `browser` condition, so the *server* bundle got the throwing stub and the Worker never started. `/auth-crypto` has carried the defect since `0.93.0`, the release that shipped the subpath, and it stayed latent because nothing consumed the subpath until this window; `/cloudflare` carried it from its first publish in `0.94.0-rc.1`. A consumer migration found it at an end-to-end gate, after `svelte-check`, `vitest`, and `vite build` had all passed; see [the defect filing](docs/internal/feedback/2026-08-05-rc1-worker-condition-defect.md). The fix adds a `worker` condition ahead of `browser` in both entries, pointing at the same module `default` does, so a Workers build resolves the real module while a browser build still hits the throwing stub. A packaging check now fails the build when a `browser`-conditioned export declares no `worker` condition ahead of it, and a resolver probe runs the built package under Wrangler's own condition set to confirm the real module resolves. **Consumers must:** nothing. - The dev-backend build fence taught by `@glw907/cairn-cms-dev` did not eliminate. A site gating the dev backend on a shared exported constant (`export const devBackendEnabled = dev && ...`, the shape the package's README and the tutorial both taught) ships the whole dev backend in its deployed Worker: SvelteKit's SSR build folds the constant to `false` inside its own chunk and never propagates that value across the module boundary, so every consuming `if` and its dynamic `import()` survive. The code is unreachable behind the literal `false`, and cairn's own 503 `dev_backend_in_prod` tripwire still backstops it, so nothing is exploitable; the fake auth store, the R2 and Anthropic doubles, and the seed editor identity ride into the bundle regardless. The README, the tutorial, and the showcase now name a build-time flag at each call site (a Vite `define`) so each branch folds where it is written. **Consumers must:** if you followed the old shape, replace the shared constant with a `define` named directly in every `if` that guards a dev-backend import, and verify with `npx wrangler deploy --dry-run --outdir=` plus a grep of that output for `devBackendHandle`. Grepping `.svelte-kit/cloudflare` proves nothing under adapter-cloudflare 7: it holds a loader and assets, not the server code wrangler bundles. - `scripts/check-reference-signatures.mjs`'s `normalizeSignature` stripped every `| undefined` unconditionally, so the exported surface's own nullability (`ContentIndex.byId`, `SiteResolver.byPermalink`, `CookieJar.get`, the access-map helpers, and more) never showed up in a signature diff; the comment it carried claimed no deliberately-required `T | undefined` existed on the surface, which stopped being true once `createD1AuditSink`'s `waitUntil` and the rate-limit helpers' `binding` shipped. It now strips a `| undefined` only where the enclosing `?:` makes it an optional-parameter artifact, so a required union survives. `docs/internal/api-surface.md` regenerates with nullability visible for the first time, across all 16 export subpaths (27 entries changed). Internal tooling only; the reference pages themselves were already correct. Consumers must: nothing. - `adminAction`'s `ctx.audit` now holds the audit sink's advertised fail-open promise at its own call site, not only by a sink's own discipline: a hand-rolled `event.locals.cairnAuditSink` that throws synchronously, or one that returns a rejecting promise (the seam's `(record) => void` type admits an async sink through void-return bivariance), no longer fails the wrapped action either way; the failure logs a new `admin.action.sink_threw` event instead. The same catch also rethrows SvelteKit's own `redirect()`/`error()` untouched, so a sink built on one of those control-flow primitives is never swallowed into a log line the site never sees. See [SvelteKit](docs/reference/sveltekit.md#adminaction) and [log events](docs/reference/log-events.md). Consumers must: nothing; a throwing or rejecting sink previously failed the action and now does not. - `TidyResult.usage` renames to `TidyResult.tokens`: the token-usage field collided with `MediaDeleteRefusal.usage` (the where-used rows) inside SvelteKit's generated `ActionData` for the admin route once every content action carried a precise `ActionFailure` (the sharpening landed earlier in this window), which made `` fail `svelte-check` on every consumer site. A type-level assertion in the library's own test suite now checks every facade action's awaited return, successes included, against `CairnAdmin`'s `form` prop type, so a future field-name collision across actions fails a compile rather than shipping unnoticed. See [SvelteKit](docs/reference/sveltekit.md#createcontentroutes). **Consumers must:** rename any read of `TidyResult.usage` to `TidyResult.tokens`. - `requireAccess`'s default `event.route.id` derivation (and `createSectionAction`'s own copy) silently fell back to a shallower access-map key when a deeper key sat under a dynamic route segment: a route id's `[id]`/`[...rest]` segment can never equal a deeper key's own literal text, so a site that keyed both `/admin/money` and the stricter `/admin/money/payroll` under a dynamic `/admin/money/[report]` route saw the deeper rule silently never apply, admitting the shallower role instead (a review finding on this pass's own `route.id` change, caught before release). The match now refuses outright rather than falling back whenever a map holds a key deeper than a dynamic segment, so `requireAccess`/`createSectionAction` 403 and force an explicit `target` for a route this ambiguous, the same posture the fail-closed unmatched case already takes. A `defineAccess` screen-id key may no longer contain `(` or `)` either, closing off the shape of the internal fail-closed sentinel a null or all-groups route id resolves to. See [`requireAccess`](docs/reference/sveltekit.md#requireaccess). Consumers must: nothing for a correctly configured map (one concrete key per route); a site relying on the shallow-fallback behavior under a dynamic segment now sees a 403 and should declare `target` explicitly. - A refused first save or publish of a brand-new entry rendered a 404 and lost the draft: `?/save` and `?/publish` resolve as query-only references against the page's own URL, replacing the whole query (RFC 3986 §5.3), so a validation `fail()`'s load re-run lost `?new=1` and `editLoad` 404'd on the not-yet-committed entry instead of rendering the refusal. `EditPage`'s form now carries `&new=1` on its own POST target for a new entry, so the flag survives the re-run. Two related refusal-visibility gaps close alongside it, review findings on this pass's own `fail()` conversion: `publishAllAction`'s unexpected (non-conflict) commit failure and a failed session logout both post to the bare `/admin`, whose own load unconditionally redirects away before it can render a `fail()`'s payload, so each now answers on its own redirect channel instead (publish-all gets a new bounded `publish_failed` code; logout clears the cookie before, not after, its own best-effort session-row delete, so a D1 fault never leaves both the row and the cookie valid). A `?/mediaUpdate` or `?/mediaAltPropagate` conflict lost its visible channel entirely when the query-error relay it used to ride was removed earlier in this pass; `MediaUpdateFailure`/`MediaAltPropagateFailure` now carry the asset's `hash` (optional; a pre-hash failure in the alt-preview fetch action still has none), so the Library's existing re-surface effect can re-open the right slide-over, and a hash-less refusal (no asset to re-home to) now renders in a new top-level banner instead of nowhere. The login and confirm pages likewise now render an unexpected action failure's own message instead of silently doing nothing (`ConfirmPage` gains a `form` prop for this). See [Refusal channels](docs/reference/sveltekit.md#refusal-channels). Consumers must: nothing; every changed surface (`EditPage`, `LoginPage`, `ConfirmPage`, `CairnMediaLibrary`) is an internal component behavior fix, and `MediaUpdateFailure.hash`/`MediaAltPropagateFailure.hash` are additive optional fields. ### Documentation - [Add a login channel](docs/guides/add-a-login-channel.md) gains a "Prove your channel" section: `createChannelDb` (`@glw907/cairn-cms-dev`, the in-memory `node:sqlite` double for a channel's own D1 binding, with its Node.js 22.13 floor), the capture-transport pattern for a `deliver` that records instead of sends, the fixture-roster-only rule for a capture transport's readback route, the warning against wrapping a dev-only transport in a deployed Worker with observability turned on, and the origin-check boundary a site keeps in mind if it disables SvelteKit's own built-in check elsewhere. The showcase's own member fixture (`examples/showcase/src/members/`, `examples/showcase/src/routes/members/`, and `examples/showcase/e2e/members.spec.ts`) is the section's worked exemplar, with its two deliberate divergences from a real site named. No consumer action. - The `./sveltekit` reference documents the admin action surface's refusal channels: the framework-native `error()`/`redirect()` shared by `requireOwner`/`requireEditor`/`requireAccess`/ `requireSession` and `adminAction`'s own authorization guards, `fail(...)` from `createSectionAction`'s own branches, and, internal to the engine only, the built-in actions' own redirect and the guard's pre-routing raw `Response`. It rules on the seams whose sync-or-async color was previously undocumented (`ComponentDef.build`, `FieldsetOptions.refine`, `RendererOptions.sanitizeSchema`, and the resolver family). A new reference page, [Supported toolchain](docs/reference/supported-toolchain.md), states the package's minimum-supported and proven-against versions for `@sveltejs/kit`, `svelte`, `vite`, `typescript`, `node`, and TypeScript module resolution. No consumer action. - Every one of the package's fifteen export subpaths now carries a stated membership rule, in its barrel header comment and in its reference page's opening: what belongs, and a plausible candidate ruled out. [Auth crypto](docs/reference/auth-crypto.md#tokensmatch)'s `tokensMatch` section also gains a fourth caller precondition: `TextEncoder` maps a lone (unpaired) surrogate to the replacement character, so two distinct strings differing only in a lone surrogate encode to the same bytes and compare equal, harmless for the CSPRNG tokens and hex hashes the function is for. No consumer action. ## 0.93.0 ### Added - A new server-only export subpath, `@glw907/cairn-cms/auth-store`, re-exports the D1 editor-provisioning functions the engine's own `editors-routes` already uses: `listEditors`, `insertEditor`, `deleteEditor`, `setEditorRole`, `removeOwnerIfNotLast`, `insertOwnerIfEmpty`, and `demoteOwnerIfNotLast`, plus the `EditorRow` and `Role` types. A site that provisions or manages editors from its own server code, a setup script, or a migration now reads and writes the same rows the `ManageEditors` screen does, instead of reimplementing the D1 statements. See [Auth store](docs/reference/auth-store.md). Consumers must: nothing. - A new server-only export subpath, `@glw907/cairn-cms/auth-crypto`, re-exports the token and session-id generators, the token hash, the constant-time compare, and a new `__Host-` cookie-naming primitive: `generateToken`, `generateSessionId`, `generateCsrfToken`, `hashToken`, `tokensMatch`, and `cookieName`. A site authenticating a second audience, member magic-link sessions, offer tokens, an OTP flow, reuses the same cryptography primitives the engine's own login proves in production instead of copying them by hand. See [Auth crypto](docs/reference/auth-crypto.md). Consumers must: nothing. - A new factory on `@glw907/cairn-cms/sveltekit`, `createSectionAction`, composes onto `adminAction` the enforcement every site-built admin section otherwise hand-rolls: an optional per-action rate limit (degrade-to-open, structurally typed as `RateLimitLike`, so no dependency on `@cloudflare/workers-types`), the section's own database binding, and the same access-map check `requireAccess` runs (`hasAccessRule` then `canReach`, audited on every refusal, with all three 403 branches emitting the existing `auth.access.denied` and both 500 branches the new `admin.action.misconfigured`). Authorization runs before the database-binding check, so a session the access map refuses never learns whether the section's binding is deployed, and `resolveDb` returning `null` fails closed the same as `undefined`. A rate limit's `key()` or `limit()` call throwing still degrades to open, but now logs a distinct `admin.action.rate_limit_failed`, so a transient limiter error reads apart from `admin.action.rate_limit_absent`'s "no binding at all". `AdminActionEvent` becomes generic over the site's platform env (`Env = AuthEnv` by default, so every existing call site keeps today's meaning) and `App.Locals` (via `@glw907/cairn-cms/ambient`) gains `cairnAccess?: AccessMap`, the map `createAuthGuard` already attaches. See [SvelteKit](docs/reference/sveltekit.md#createsectionaction) and [log events](docs/reference/log-events.md). Consumers must: nothing. - The content manifest gains `ManifestEntry.publishedAt`, an ISO 8601 UTC stamp a publish action writes once, at the commit that first lands an entry non-draft, and never overwrites or clears afterward. An entry already non-draft and unstamped before this release stays unstamped; only a future transition into published stamps. A new pure helper, `newlyPublishedEntries(before, after)` on `@glw907/cairn-cms/delivery/data`, diffs two manifests down to the entries that just carried that transition and are still currently live, so a site can detect a first publish and fan out its own notification with no engine networking or scheduling involved. The same subpath also re-exports `Manifest` and `parseManifest`, so a consumer can name and validate the manifest it fetches to build `newlyPublishedEntries`'s `before`/`after` pair without hand-casting JSON. See [Announce on publish](docs/guides/announce-on-publish.md) and [Delivery data](docs/reference/delivery-data.md#newlypublishedentries). Consumers must: nothing; the field is additive and optional, and the stamp only ever appears on a publish that happens after the upgrade. ### Changed - CodeMirror dependencies updated to the latest 6.x releases within the existing ranges (`@codemirror/state` 6.6.0 to 6.7.1, `@codemirror/view` 6.43.0 to 6.43.7, plus patch bumps to `autocomplete`, `commands`, `language`, and `lang-markdown`). Lockfile-only; the full gate (`check` 0/0, 4552 tests, `check:cm-internals`) passes on the new versions. Consumers must: nothing. ## 0.92.0 ### Added - `FieldLabel`, `SelectField`, and `TextField` (`@glw907/cairn-cms/admin-fields`) gain a `register: 'inline' | 'stacked'` prop (design ratchet Task 3, closes finding 3): `'stacked'` puts the label on its own line above the control, the individual-field-label recipe already proven inside the engine's own `FieldInput`; `'inline'` keeps the prior label-beside-control layout. A `.cairn-field-stacked` sheet hook fills a stacked control to its container, so a consumer cannot forget `w-full`, and a stacked field in a wide multi-column grid track fills its own cell edge-to-edge instead of clamping to daisyUI's fixed 20rem default. The hook matches only a control that's a direct child of the label (fix A2, item 1), not any descendant, so a consumer composing a compact row, a flex wrapper holding two or more narrower controls side by side, inside a stacked field can nest it and keep the row's own width choice instead of every nested control being forced to fill the row. **`'stacked'` is now the default**, a deliberate breaking change (ratified by Geoff 2026-07-30): defaulting to inline reproduces the harvest's trap shape, where the register that staircases in a grid stays the effortless path. `docs/reference/admin-fields.md` documents both registers and the compact-row escape; `skills/cairn-admin-screens/references/form-anatomy.md`'s composition-width guidance now points at `register="stacked"` by name. **Consumers must:** pass `register="inline"` on any `FieldLabel`/`TextField`/`SelectField` call whose inline label-beside-control layout should survive the upgrade; every other call renders the new stacked default. A stacked field composing a compact row needs no extra care: the width hook already reaches only its direct child. - `cairn-audit`'s rendered rule set gains three geometry rules (design ratchet Task 5, the mechanical halves of findings 1, 3, and 6), the cap the plan set at three: `form-font-parity` asserts every rendered `input`/`select`/`textarea`/`button` computes the same first `font-family` as the admin root, the UA reset layer's own regression tripwire; registered **provisionally at advisory**, promoted to error only once a CI re-check confirms the rendered suite is green against cairn's own admin and showcase on the CI runner. `field-edge-alignment` (advisory) is the staircase detector: two or more form controls in the same visual column of a grid or flex-column container must share a left edge within 1.5px, the shape an `inline`-register field with a varying label width produces. `container-inset-asymmetry` (advisory) is the phantom-gutter detector: a `.card-shell`, `.list`, or `.modal-box` container whose content sits more than 24px closer to one side than the other. `docs/reference/cairn-audit.md`'s rule table gains all three rows. Consumers must: nothing; both `field-edge-alignment` and `container-inset-asymmetry` are advisory and `form-font-parity` is provisionally advisory, so none changes the exit code yet. ### Changed - `cairn-audit`'s `one-filled-action` rendered rule narrows the landmarks that partition a surface from `
,