On this page
  1. Upgrade
  2. Adopt the admin type grammar
  3. How cairn versions
  4. When something breaks anyway
  5. 0.94.0-rc.1: an auth-channel export, a cloudflare export, an AI posture, a packaged audit sink, and a breaking convergence of the event, locals, role, nav, and refusal seams
  6. 0.93.0: an auth-store export, an auth-crypto export, a section-action factory, a first-publish stamp, and a CodeMirror dependency bump (non-breaking)
  7. 0.92.0: a UA reset layer, a tightened one-filled-action, an exported stacked field register, and a skill-exemplar compile gate
  8. 0.91.1: the admin sheet classes 0.91.0 dropped come back (non-breaking)
  9. 0.91.0: the cairn-audit design gate, and the type scale closes (non-breaking)
  10. 0.90.1: ListToolbar select sizing and menu-facet disclosure/a11y (non-breaking)
  11. 0.90.0: ExpandableRow graduates, a menu filter facet, formatPhone (non-breaking)
  12. 0.89.1: grammatical number on toolkit count lines (non-breaking)
  13. 0.89.0: the admin toolkit, and the header idiom converges (non-breaking)
  14. 0.88.3: a blessed daisyUI safelist for the admin (non-breaking)
  15. 0.88.2: the template’s nav wiring and docs fixes (non-breaking)
  16. 0.88.1: mermaid passthrough and real dev-backend fixtures (non-breaking)
  17. 0.88.0: the access map, collapse defaults, icon overrides, attention badges (non-breaking)
  18. 0.87.4: docs in the tarball, renderDocument, a help default (non-breaking)
  19. 0.87.3: the docs-register sweep (non-breaking)
  20. 0.87.2: honest image dimensions and srcset (non-breaking)
  21. 0.87.1: the admin polish window (non-breaking at runtime)
  22. 0.87.0: fragments, and the embedded-routing promise enforced
  23. 0.86.0: navLayout, the whole-sidebar seam, and the widened navFilter
  24. 0.85.0: site-declared role vocabulary and capability levels (non-breaking)
  25. 0.84.3: the editor lifecycle rounded out (non-breaking)
  26. 0.84.2: the admin hang after login fixed (non-breaking)
  27. 0.84.1: the local-dev media fix completed (non-breaking)
  28. 0.84.0: cairn-media-seed and a media route that works under vite dev (non-breaking)
  29. 0.83.0: a publishActions config renders next-step links on the publish-success moment (non-breaking)
  30. 0.82.1
  31. 0.82.0
  32. 0.81.0

Upgrade cairn

To upgrade cairn you bump the version range, read the Consumers must: steps for the versions you cross, and run your own gates. The CHANGELOG records those steps per version; a version with no Consumers must: list is a drop-in bump.

Upgrade

  1. Bump the version range.

    npm install @glw907/cairn-cms@^0.79.0
    
  2. Read every Consumers must: list between your old version and the new one in the CHANGELOG. Each breaking release states its own list, so a run from 0.76.0 to 0.78.2 means reading 0.78.0’s and 0.78.2’s lists in order. A version with no Consumers must: list changed nothing you need to act on.

  3. Apply each listed change to your adapter, your routes, or your wrangler.jsonc, as that version’s list names.

  4. Run npx cairn-doctor against your site. It catches a binding, a config key, or a dependency floor the new version now expects that your site hasn’t caught up to yet. See the cairn-doctor reference for what it checks.

  5. Run your own site’s build and test gate before you deploy. cairn’s gates only exercise the package. They can’t reach your adapter, your render, or your routes.

Adopt the admin type grammar

When you cross 0.91.0, the release that ships the admin grammar tokens, cross straight to 0.91.1. 0.91.0 alone dropped nineteen utility classes from the shipped admin sheet, the named type steps among them, so custom admin markup riding any of them rendered unstyled; 0.91.1 restores the full set, and on it your custom admin screens render as they did on 0.90.1. npx cairn-audit’s static type-scale rule starts reporting named Tailwind steps (text-sm, text-xs, bracketed sizes) in your admin markup, and most of those reports are a mechanical rename with zero visual change. On the first consumer admin measured, 265 of 298 findings were pure renames.

  1. Run npx cairn-audit over your site. Each type-scale finding names the class you wrote and the size it resolves to: class "text-sm" sets font-size to "0.875rem", which resolves to no --cairn-type-* token.
  2. Match that size to a grammar role in Admin grammar tokens and rename the class to the role’s utility. 0.875rem is --cairn-type-body, so text-sm body copy becomes type-body. A role renders at the same size and leading the named step did, so the screen doesn’t move. The grammar utilities are safelisted into cairn’s shipped admin sheet, so the renamed class resolves with no Tailwind configuration change on your side.
  3. Move an off-scale size onto a role. Where no role carries the size a finding reports, the text is off the scale. Decide what it is (body, meta, label) and move it onto that role, or keep it deliberately and add a counted suppression directive with its reason. The skill’s derivation ladder covers the case where none of the roles fits.
  4. Re-run the audit. The static gate reports zero type-scale findings when the adoption is complete.

How cairn versions

cairn is 0.x, and until it reaches 1.0, the number tracks scale. A minor version means a new subsystem or public surface; everything else is a patch, whether or not it breaks you. Whether a version breaks your site is stated in its Consumers must: list, not signaled by the version number. Check the exact number that’s free to publish next with npm view @glw907/cairn-cms versions --json rather than assuming the next one in sequence.

That scheme lasts only through 0.x. At 1.0, cairn moves to compatibility SemVer: a major version signals a breaking change, and the number finally carries the compatibility promise the Consumers must: line carries now. The beta that precedes 1.0 publishes under an npm beta dist-tag as 1.0.0-beta.1, iterating -beta.N and still carrying a Consumers must: line on any bump that breaks something, until the 1.0.0 cut. npm install @glw907/cairn-cms keeps resolving to the latest 0.x release until then.

When something breaks anyway

Only the latest published minor gets fixes. There’s no backport branch, so check npm view @glw907/cairn-cms version before assuming a bug is still open. If it’s open, file a GitHub issue against glw907/cairn-cms with the version, what you expected, and what happened. Attach the structured log record if the failure logged one. cairn’s runtime emits one for every commit, auth, and guard failure: Log events names each event and its fields, and Read cairn’s logs covers querying them on a deployed Worker.

0.94.0-rc.1: an auth-channel export, a cloudflare export, an AI posture, a packaged audit sink, and a breaking convergence of the event, locals, role, nav, and refusal seams

This is a release candidate on the next dist-tag, not a stable release. It is the largest breaking window so far, and until a real site crosses it the only proof it’s had is cairn’s own examples/showcase. A caret range never resolves a prerelease, so pin the exact version to test against it:

"@glw907/cairn-cms": "0.94.0-rc.1"

Move back to a caret (^0.94.0) once 0.94.0 publishes. The steps below are the same either way; the stable release carries this identical list.

A new server-only export subpath, @glw907/cairn-cms/cloudflare, publishes the Cloudflare-native platform primitives two sites already copy by hand: verifyTurnstile, the Turnstile siteverify fetch, fail-closed on every failure mode; and checkRateLimit and checkRateLimitKeys, the Workers RateLimit binding wrapper, degrade-to-open on an absent binding. Reach for it when you build your own Turnstile-guarded form or your own rate-limited endpoint, so you reuse the same primitives instead of copying them by hand. RateLimitLike moves to this subpath as its one declaration in the source tree; if you imported it from @glw907/cairn-cms/sveltekit, that import keeps working unchanged. See Cloudflare.

A new factory on @glw907/cairn-cms/sveltekit, createD1AuditSink(db, waitUntil), is the first packaged implementation of the AdminActionAuditSink seam: apply the bundled migrations/0002_audit.sql and wire the factory to persist every ctx.audit record into one audit_log table, instead of hand-rolling your own sink module. It’s opt-in, fail-open, and truncates every bound field to a documented maximum. See createD1AuditSink.

Consumers must: nothing. Both additions are additive, and the RateLimitLike re-export keeps its existing shape and location on /sveltekit.

A review pass across the whole seam contract settled a handful of long-standing questions, none of which changed an exported type or a route contract. It found that a bare wrangler types-generated Env, with no CairnPlatformBindings intersection, failed to compile export const actions = admin.actions (and every other route factory assignment), because @cloudflare/workers-types’ SendEmail.send returns Promise<EmailSendResult> while cairn’s own EMAIL.send declared Promise<void>. This incompatibility dissolved in the C2 breaking-window pass (see that section below): cairn’s EmailSender.send now returns Promise<unknown>, which structurally accepts the wider Cloudflare return type, so CairnPlatformBindings stays a recommended convenience preset rather than a requirement. If your app.d.ts already follows Deploy to Cloudflare, nothing changes for you either way.

adminAction’s audit sink now holds its advertised fail-open promise at the engine’s own call site: a hand-rolled event.locals.cairnAuditSink that throws, or one that rejects asynchronously, no longer fails the action it audited, and the failure logs the new admin.action.sink_threw event instead of disappearing.

The package now declares "engines": { "node": ">=22" }, and a new reference page, Supported toolchain, states the versions cairn promises against and proves against, for @sveltejs/kit, svelte, vite, typescript, node, and TypeScript module resolution.

adminAction’s two refusals, a missing signed-in editor (authentication) and a CSRF mismatch, now throw SvelteKit’s own redirect() and error(403, ...) instead of the dev-only error class, the same framework-native shapes requireOwner, requireEditor, and requireAccess throw for their own authorization checks (requireSession throws the same redirect for its own authentication check). adminAction itself still performs no authorization: a none-capability editor’s session passes both of its checks and reaches your handler unchanged; add requireAccess inside the handler, or build on createSectionAction, for a capability check. If your hooks.server.ts defines a handleError only to map the old AdminActionError into a legible response for these two refusals, remove that mapping: it does nothing useful now, and a site relying on the old 500 these refusals produced for alerting now sees a 303 (the redirect) and a 403 (the SvelteKit-native error) instead. 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 class itself renames to UnauditedActionError: after this refusal channel convergence it means exactly one thing, the dev-only unaudited-action defect signal, a build-time check that never reaches a production response, and the new name states that plainly. See Refusal channels.

Consumers must: be on Node 22 or later for your build toolchain (already the tutorial’s stated requirement, now a declared one too); replace any imported AdminActionError with UnauditedActionError; and remove any handleError mapping of that class for adminAction’s two authentication refusals (they need no mapping anymore). Nothing else in this window changes an exported type, a route contract, or a behavior you’d observe without hitting one of those two: a throwing or rejecting audit sink previously failed the action it audited and now does not.

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. EmailSender is named once ({ send(message): Promise<unknown> }) and referenced from both CairnEnv and CairnPlatformBindings; the widened Promise<unknown> return dissolves the SendEmail.send incompatibility above, so CairnPlatformBindings demotes from a requirement back to a recommended convenience preset. PlatformContext narrows to { env?: Env } (the engine never read ctx/context) and is now exported from /sveltekit. See SvelteKit.

Consumers must: replace any imported AuthEnv/BackendEnv with CairnEnv on the same subpath, and drop any reliance on PlatformContext.ctx/.context (the engine never read either).

EventBase, RequestContext, the content routes’ ContentEvent, the admin facade’s AdminEvent, and adminAction’s own AdminActionEvent collapse into one CairnEvent<Env = CairnEnv>. It adds params and route: { id: string | null } (ending the 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. The entry below renames locals’s key names. requireSession, requireOwner, requireEditor, and requireAccess now take CairnEvent in place of their old minimal inline shapes. See 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: editor becomes cairnEditor, backend becomes cairnBackend, and auditSink becomes cairnAuditSink (cairnAccess already carried the prefix and is unchanged). 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. There is no alias and no fallback read of the old names: the rename applies everywhere at once. See the ambient types reference for the full four-key shape and the event shape for CairnEvent['locals'].

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, and a navLayout entry’s or section’s roles) widens from the implicit 'owner' | 'editor' union to a plain string (or string[]): role names are open, since you name your own vocabulary with defineRoles, and the old literal union stopped being true the moment you declared a role like webmaster. Capability ('owner' | 'editor' | 'none') is unchanged. The Role type is removed from the root barrel and /auth-store, along with the CairnRolesRegister registry interface it existed solely to narrow. See Roles and Auth store.

Consumers must: replace any imported Role type with string; if your app.d.ts augments interface CairnRolesRegister { roles: typeof roles }, remove that block, since it no longer narrows anything; drop any cast you added to force a custom role name past the old Role union (an AccessMap value, a navLayout entry’s roles, or an Editor/EditorRow fixture).

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 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. See SvelteKit and admin routes.

Consumers must: rename every renamed route-factory member in your own +page.server.ts files if you mount routes per-surface rather than through the single-mount createCairnAdmin facade (the facade itself needs no source change). If your own markup, a form’s action="?/oldName" or a programmatic fetch('/admin/...?/oldName') call, posts to a renamed facade action by name, change the posted ?/ action string to the new name (for example ?/saveSettings to ?/settingsSave, ?/addEditor to ?/editorAdd, ?/mediaPurge to ?/mediaOrphanPurge); a mismatched name fails at runtime as a 404 on submit, not at compile time, since the action name is a string literal.

Every injectable-dependency bag renames from *Deps to *Config (the factory’s primary bag) or *Options (a secondary or per-call bag), the parameter-bag half of the same grammar: 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 too, as AuthGuardOptions and EditorRoutesOptions; a site typing its own wrapper around either factory can now name the parameter instead of writing the shape out. CairnAdminOptions.auth also stops re-declaring AuthRoutesConfig’s shape inline and references it directly (Partial<AuthRoutesConfig>), so the two stay in lockstep. Every create* factory’s return type is named and exported too: CairnAdminRoutes, ContentRoutes, AuthRoutes, EditorRoutes, NavRoutes, PublicRoutes, and Renderer, so a site can name a variable or a wrapper function’s return type instead of writing the returned shape out by hand. makeMediaResolver renames to buildMediaResolver, matching the four-verb grammar (build derives pure data from an already-resolved config; make is retired). 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 the literal hidden?: true to hidden?: boolean, so a computed flag (a feature switch, a role check) 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; it named a site’s icon factory signature with zero real callers). ConceptUrlPolicy is removed from the root barrel (it stays internal, used by defineConcept); a site never constructed one directly, since defineConcept’s own permalink/datePrefix config builds it. See SvelteKit.

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 (or inline their shape, since both stay usable as unexported internals only through their owning modules’ public factories).

adminNav retires entirely. navLayout is now the one nav seam: 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. 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 and Organize your admin nav.

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.

@glw907/cairn-cms/admin-fields merges into @glw907/cairn-cms/admin-toolkit and is removed: 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 (elsewhere in this window, TextField/SelectField become 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. /components also completes its per-view seam: VocabularyAdmin and WelcomeView join the barrel, so a site on the advanced per-route mounting can now mount every view CairnAdmin renders internally. See The admin toolkit and Components.

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.

The export rule adopted as standing doctrine: every type named in a public signature is exported from a subpath you already import, so you can always name a value a route factory or the adapter contract hands you instead of writing your own structural duplicate (the trap an AI coding agent especially falls into). Roughly ninety previously unreachable types become named, documented exports this window, closed under their own structural bodies: the field-descriptor union’s fifteen arms (TextField, SelectField, ArrayField, and the rest, mentioned above) from the root barrel; the facade and action result and plan types (TidyResult, DictionaryAddResult, MediaBulkDeleteResult, MediaOrphanPurgeResult, MediaOrphanScanResult, MediaReplacePreviewPlan, MediaAltPreviewPlan) and their supporting shapes (TidyClient, TidyConfig, TidyConventions, TidyKeyProbeResult, FragmentTarget, LinkTarget, InboundLink, UsageEntry, ReferenceEdge, MarkdownReferenceRow, MediaLibraryEntry, ResolvedPreview, CookieSetOptions, GettingStarted, ResolveOptions) from /sveltekit and, where a root signature names it, the root barrel too. Three recurring anonymous inline load-payload shapes are named and exported from /sveltekit: LoginData, ConfirmData, and EditorsData, replacing the anonymous object literals the admin facade’s AdminData union used to inline for its 'login', 'confirm', and 'editors' views. See Core, SvelteKit, and Delivery data.

Consumers must: nothing. Every addition is a new named export; nothing already imported changed shape or name.

createSectionAction’s SectionActionOptions.target now defaults to event.route.id, never event.url.pathname: on a catch-all route the request path is attacker-chosen while the route id isn’t. resolveDb’s shape stays ratified unchanged ((env: Env | undefined) => Db | undefined); the engine can’t conjure an absent platform. 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 that touches more than one entity in one call can still override either verb. See createSectionAction.

Consumers must: for any createSectionAction-guarded parameterized or catch-all route, rekey the site’s access map from the concrete request path to the bracket-form route id (/admin/posts/[id], never /admin/posts/hello-world); a map still keyed by the concrete path stops matching, and the section fails closed, refusing every session including owner, with no thrown error to surface the mistake. A static route’s id and path are the same string, so a site with no parameterized or catch-all section behind createSectionAction needs no change. Route groups need no change either: the derived target drops group segments, so /admin/(app)/roster keeps matching a map keyed /admin/roster.

requireAccess’s own target parameter now defaults the same way: event.route.id, never event.url.pathname. This closes the asymmetry between the two halves of the same authorization story that the C2 breaking-window post-mortem flagged. createSectionAction already made this change in the preceding entry, and requireAccess had not. The internal derivation the two share moves out of section-action.ts into auth/access.ts, invisible from a site. See 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, the same rekey step described for createSectionAction in the preceding entry, 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 needs no change.

The admin refusal channel converges on fail(): every built-in content, media, settings, vocabulary, and nav action’s own validation and commit-conflict refusal now answers fail(...) in place, keeping your submitted body on the page, instead of throwing a redirect that discarded it. settingsSaveAction, vocabularySaveAction, navSaveAction, and createAction each widen from Promise<never> to Promise<ActionFailure<T>> (SettingsSaveFailure, VocabularySaveFailure, NavSaveFailure, and CreateFailure respectively). If you mount these route-factory members yourself, through createContentRoutes or createNavRoutes rather than the single-mount createCairnAdmin facade, and you hand-annotated one of their return types as Promise<never>, widen it to match. If you mount through the facade, or through these factories with no such annotation, you see no change beyond the new failure shapes reaching your own form prop, the same way saveAction’s SaveFailure already does. See Refusal channels.

Consumers must: update any hand-annotated Promise<never> return type on settingsSaveAction, vocabularySaveAction, navSaveAction, or createAction. Nothing else here needs a code change.

The facade’s viewAction wrapper drops its scriptPosted branch: every action’s own unexpected failure, whether the request came from a form post or a fetch call, now answers fail(500, { error }) in place instead of a form-posted action redirecting away. A failed discard, sign-in confirm, logout, or publish-all now shows the same calm retry message on the page instead of bouncing you elsewhere when something unexpected goes wrong; a validated refusal or a deliberate success on any of these four is unchanged. The scriptPosted and carriesNewFlag facade options are gone, but neither was ever public. See Refusal channels.

Consumers must: nothing. viewAction is internal to the facade, and the behavior change only touches an unexpected-failure path.

Every refusal that genuinely navigates now carries a bounded internal code on ?error= instead of a free-form string, resolved server-side against a small closed vocabulary; an unrecognized value resolves to nothing. Only three refusals still navigate at all (an expired sign-in link, publish-all’s two outcomes) plus the /admin landing relay that forwards one of those two onward, so six other ?error= readers and the data field each one filled are removed outright: EditData.error, EditorsData.error, NavLoadData.error, SettingsData.error, VocabularyLoadData.error, and MediaLibraryData.flashError. See Refusal channels and the security model.

Consumers must: drop any read of EditData.error, EditorsData.error, NavLoadData.error, SettingsData.error, VocabularyLoadData.error, or MediaLibraryData.flashError in your own code; each field is gone outright. If you never read one of these fields directly, the common case since cairn’s own components already handled them, you see no change.

TidyResult.usage renames to TidyResult.tokens. The name collided with MediaDeleteRefusal.usage (the where-used rows a refused media delete carries) inside SvelteKit’s generated ActionData union for the admin route, once every content action carried a precise ActionFailure<T> rather than the old, looser ActionFailure<unknown>; the collision made <CairnAdmin {form} /> fail your own svelte-check. See tidyAction.

Consumers must: rename any read of TidyResult.usage to TidyResult.tokens.

The log-event vocabulary settles on one grammar (area[.subject].verb_phrase, a past-tense verb phrase for an occurrence or a state adjective for a detected condition) and every reason/scope value goes snake_case. Six events rename to fit: admin.audit.sink_failed to audit.sink.write_failed (the packaged D1 sink’s own persist failure, engine infrastructure, not the action layer); admin.action.audit_sink_failed to admin.action.sink_threw (a site-supplied sink throwing at the engine’s call site); tidy.done to tidy.succeeded and tidy.error to tidy.failed (matching the commit.* pattern); media.orphan_reconcile to media.orphans_reconciled (matching media.orphans_purged); 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 pre-resolve guard versus the action wrapper’s own defense in depth. The media upload reason family goes snake_case (media_disabled, length_required, too_large, session_expired, access_denied, unsupported_type, binding_missing, hash_collision), reaching the upload popover’s own failure-card mapping in the same change. github.unreachable’s scope values become 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.

Consumers must: update any Workers Logs saved query, alert, or dashboard filter that names admin.audit.sink_failed, admin.action.audit_sink_failed, tidy.done, tidy.error, media.orphan_reconcile, or content.field_behavior_error to the new name, and any filter on a kebab-case media upload reason (media-disabled, length-required, too-large, session-expired, access-denied, unsupported-type, binding-missing, hash-collision) or on github.unreachable’s scope: 'layout' (which never fired) to the corrected snake_case value. Nothing breaks at compile time, since these are runtime log values, not exported types.

/admin-toolkit’s formatters settle on one nullish rule: every display formatter, formatMoney, formatCivilDate, formatTimestamp, and formatPhone, accepts a nullish input and takes a fallback?: string option defaulting to '', so you never have to remember which formatter tolerates a missing value and which throws. formatMoney, formatTimestamp, and formatPhone widen their first parameter to accept null/undefined and gain the fallback option (formatPhone gains its first options parameter, FormatPhoneOptions); formatCivilDate already accepted a nullish date, and keeps that shape, but its fallback default drops from the opinionated 'Not yet' to ''. ageFromBirthdate doesn’t change: it returns number | null, not a display string, and stays outside this rule on its own documented grounds. See Admin toolkit.

Consumers must: if you call formatCivilDate and relied on its old 'Not yet' default for a missing date, pass { fallback: 'Not yet' } explicitly; otherwise that cell now renders an empty string. This is a silent visual change, not a compile error, since formatCivilDate’s date parameter was already nullable. Calls to formatMoney, formatTimestamp, or formatPhone with a value that’s statically number/string (never nullish) need no change.

Your adapter can now carry aiPosture, set to 'decline' or 'invite', read by buildRobots/robotsResponse (@glw907/cairn-cms/delivery). Set 'decline' and your robots.txt adds a Disallow: / group per token in a new first-party-verified training-crawler table, AI_CRAWLERS (and its review date, AI_CRAWLERS_REVIEWED), plus Content-Signal: ai-train=no. Set 'invite' and it adds Content-Signal: search=yes, ai-train=yes and no Disallow lines at all, since there’s no robots directive that invites a crawler. 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 Choose an AI posture and Delivery data.

Consumers must: nothing. aiPosture is optional and unset on your site today, so this window’s robots.txt output is byte-identical to before.

cairn-doctor gains a nineteenth check, ai.posture-effective: a plain, credential-free GET /robots.txt against your deployed origin, reporting what the live file actually carries rather than what your adapter declares. It distinguishes stating no posture, stating a posture your live site contradicts, and a managed layer, Cloudflare’s AI Crawl Control or its managed robots.txt, prepending directives cairn didn’t write, a shape measured live on three of the four sites in cairn’s own operator estate. Only the middle case fails; stating no posture passes, and so does a managed layer, since whether that’s wanted is your call as the zone’s owner. See cairn-doctor.

Consumers must: nothing today, since the failing case needs a declared aiPosture and you haven’t declared one. If you adopt a posture and this check goes red afterward, your deployment doesn’t carry the stance you stated.

Every routable, non-noindex entry can now serve a raw-markdown twin of its own page. markdownResponse (@glw907/cairn-cms/delivery) wraps a body in text/markdown; charset=utf-8, a sibling of robotsResponse/sitemapResponse, and createPublicRoutes gains markdownEntries() and markdownLoad(event) to enumerate and serve one .md-suffixed path per entry. The twin reads only through the injected site resolver, so it can serve only what that resolver carries. Wire it through a prerendered route, the same way you already wire robots.txt and the sitemap, 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 Wire the delivery surface and Choose an AI posture.

Consumers must: nothing. No site wires this route today, so it ships no new response on your deployed site until you add it.

The admin Strict-Transport-Security header no longer sends includeSubDomains by default (the AI-posture pass, the HSTS rider). Every /admin response previously pinned your site’s apex and every sibling subdomain to https in the visiting editor’s browser for two years, including on a zone whose owner had left edge HSTS off, and you had no way to un-pin an already-pinned editor except by serving a corrective header. max-age still sends unconditionally; the admin surface is the one place cairn has standing to insist on https for itself. Only includeSubDomains becomes conditional, since pinning subdomains cairn knows nothing about is your call, not the engine’s. AuthGuardOptions gains includeSubDomains?: boolean, alongside roles and access. cairn’s doctor also reconciles the zone-level edge.hsts check’s wording: a failing zone setting no longer reads as though nothing is protected, since your admin responses already carry their own header regardless of the zone. See createAuthGuard.

The guard’s rejection pages and its login redirect now send no Strict-Transport-Security at all. RFC 6797 has a browser replace its cached policy on every header it receives, so a rejection page sending max-age alone would have cleared the includeSubDomains your guarded responses asserted, and the CSRF rejection is reachable by any cross-site POST with no session at all.

Consumers must: if you want the previous domain-wide pinning back, set createAuthGuard({ includeSubDomains: true }) in your hooks.server.ts. If you take no action, your admin responses keep max-age but stop pinning sibling subdomains. Check your zone first: if edge HSTS already sends includeSubDomains for the admin’s host, which is the case on more than one site running this engine, set the option so cairn states the same policy rather than a weaker one on the same host.

Site code can now call createD1AuditSink directly with its own domain events, a roster change or a season rollover, not only through adminAction and createSectionAction (the 2026-08-05 engine-harvest sitting, ruling 1). The sink was already generic; the change is sanction, not new code. Namespace your action names (roster.add, not a bare add) so a domain row stays distinguishable from an admin-action row in the shared table.

Sanctioning direct use means the record’s identity field can no longer be a cairn editor specifically, so AdminActionAuditRecord’s field renames from editor to actor, matching the column it has always landed in. adminAction’s own composition follows, as does the packaged sink’s own read. Two log events rename their identity field to actor to match: admin.action.audited and audit.sink.write_failed. admin.action.sink_threw keeps editor, since it fires only from inside adminAction, where the actor is always a verified cairn editor. See SvelteKit and log events.

Consumers must: rename any read of record.editor to record.actor in a hand-rolled AdminActionAuditSink (the custom admin screen guide’s example is the shape to check against), or in a custom App.Locals augmentation that duplicates AdminActionAuditRecord’s shape rather than importing it. A site wiring no audit sink does nothing.

0.93.0: an auth-store export, an auth-crypto export, a section-action factory, a first-publish stamp, and a CodeMirror dependency bump (non-breaking)

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. Reach for it when you need to provision or manage editors from your own server code, a setup script, or a migration, outside the ManageEditors screen. See Auth store.

A second 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. Reach for it when you build your own login flow for a second audience, member magic-link sessions, offer tokens, an OTP flow, so you reuse the same cryptography the engine’s own login proves in production instead of copying it by hand. See Auth crypto.

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 forever, unless you take it back to Hidden and publish it again, which stamps it as though it were newly published; only a future transition into published stamps otherwise. 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 you can detect a first publish and fan out your own notification with no engine networking or scheduling involved. The same subpath also re-exports Manifest and parseManifest, so you can name and validate the manifest you fetch to build the before/after pair. See Announce on publish.

A third new export on @glw907/cairn-cms/sveltekit, createSectionAction, packages the form-action guard every site-built admin section otherwise hand-rolls: SvelteKit dispatches a matched action directly, with no ancestor layout load run first, so a section’s own POST needs its own check. The factory composes adminAction’s editor resolution, CSRF, and audit contract with an optional rate limit (degrade-to-open) and the same access-map check requireAccess runs, then hands your handler its resolved database binding; authorization runs before that binding check, so a refused session never learns whether the binding is deployed. AdminActionEvent becomes generic over your platform env, defaulting to CairnEnv (named AuthEnv before the C2 breaking-window pass) so no existing call site changes, and App.Locals gains the cairnAccess map the guard already attaches. See SvelteKit.

The @codemirror/* editor dependencies moved to their latest 6.x releases within cairn’s existing version 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.

Consumers must: nothing. All four new seams are additive, and the publishedAt stamp only ever appears on a publish that happens after the upgrade.

0.92.0: a UA reset layer, a tightened one-filled-action, an exported stacked field register, and a skill-exemplar compile gate

The packaged admin sheet now ships a base cascade layer, so a bare form control, dialog, fieldset/legend, or daisyUI’s own .list container renders the admin’s own face instead of the browser’s UA default: a bare <textarea> no longer falls back to the browser’s monospace font and resizes vertically only, a native <dialog class="modal">, the shape every cairn dialog renders, loses Chrome’s UA border frame, and daisyUI’s .list loses its 40px bullet-marker gutter. The dialog rule is scoped to dialog:where(.modal), so a bare <dialog> in your own custom admin route keeps its UA border rather than losing its only visual boundary. Cascade layers merge by name across stylesheets, so cairn’s base layer merges with a base layer your own Tailwind build declares. Your import order decides which rule wins within that merged layer.

The cairn-admin-screens skill’s own reference docs are now checked against the built admin sheet: every class token a worked example teaches has to actually compile. form-anatomy.md’s two-column form-grid recipe and exemplar-detail.md’s divided-list row rhythm both needed a small labeled addition to the shipped sheet’s compatibility safelist so the taught recipes render as written.

cairn-audit’s rendered one-filled-action rule now partitions a screen only at nav, aside, and the topmost open dialog layer. header, footer, and main no longer partition it, so a filled action in a page header and a filled action in a card beneath it now count as one surface. The same change raises the dark theme’s .btn-active selected-state fill to a visible lightness step off a plain .btn, since the ruling pushes a segmented control’s selected state onto btn-active rather than btn-primary.

Consumers must: treat a screen with a filled header action and a filled card action beneath it as a finding, and demote the non-primary fill to btn-ghost or btn-outline. Never loosen the rule to pass a screen instead.

FieldLabel, SelectField, and TextField (@glw907/cairn-cms/admin-fields) gain a register: 'inline' | 'stacked' prop. 'stacked' puts the label on its own line preceding the control and fills the control to its container, so a field composed inside a multi-column form grid renders with no extra markup. 'stacked' is now the default, replacing the prior label-beside-control layout: inline stays available for a genuinely control-adjacent composition, but it is now an explicit choice rather than the default. See The admin toolkit (this content’s current home; the admin-fields subpath these components shipped on at the time merged into /admin-toolkit in a later release, see that section above).

Consumers must: pass register="inline" on any FieldLabel, TextField, or SelectField call whose label-beside-control layout should survive the upgrade. Every other call renders the new stacked default. Nothing else here requires action.

0.91.1: the admin sheet classes 0.91.0 dropped come back (non-breaking)

0.91.0 dropped nineteen utility classes from the shipped admin sheet when cairn’s own tree stopped using them: the named type steps (text-sm, text-xs, text-lg, text-base, text-2xl, text-3xl), gap-6, tracking-tight, badge-ghost, and ten bracketed arbitrary sizes. Custom admin markup riding any of them rendered unstyled on 0.91.0, with no build error to point at it. This release restores the full set through a labeled compatibility safelist, and the shipped sheet’s class inventory is now a tested contract: a class can leave the sheet only as a deliberate act carried in the changelog.

Consumers must: nothing, coming from 0.90.1 or earlier; the sheet again carries every class it did there. Coming from 0.91.0, upgrade and your custom admin screens style again with no markup change on your side.

0.91.0: the cairn-audit design gate, and the type scale closes (non-breaking)

A new bin, cairn-audit, audits an admin surface against cairn’s design language. Static mode parses your components and the built admin stylesheet. Rendered mode drives Chromium against a running admin and measures what it actually paints. Six rendered rules gate and five report only. Run npx cairn-audit on your own admin routes, or skip it entirely: nothing in the engine calls it, and rendered mode takes a Playwright dependency only when you run it. See The cairn-audit CLI.

The grammar token inventory grows to eighteen custom properties. Every type role now carries a paired --cairn-type-<role>--leading token, and a seventh role, type-heading, unifies the admin’s two heading recipes. A leading-* utility still composes over a role. See Admin grammar tokens.

npx cairn-audit norms <role> answers from a manifest of the admin’s measured norms, so you can read a control height, a padding ratio, or a border treatment instead of inferring one from a screenshot.

Consumers must: expect the header stack on any screen mounting PageHeader to render tighter, a shorter title-to-meta gap, and its meta line one step smaller. No prop, type, or route contract changed. If your own screens use the admin grammar tokens, nothing you wrote moves. The new leading tokens match the sizes the roles already rendered at.

0.90.1: ListToolbar select sizing and menu-facet disclosure/a11y (non-breaking)

ListToolbar’s 'select' facets now size to their own content instead of daisyUI’s fixed 320px clamp, and share the 'menu' facet’s border treatment so the two read as one control family. Both dropdown disclosures (a 'menu' facet’s option list and the overflow panel) now show only when dropdown-open is present, so aria-expanded always matches what is visible, and the menu options carry role="menuitemradio" with aria-checked plus a roving-tabindex keyboard model.

Consumers must: nothing. Every change is inside ListToolbar’s own markup and styling.

0.90.0: ExpandableRow graduates, a menu filter facet, formatPhone (non-breaking)

ExpandableRow joins the admin-toolkit subpath (its second consumer, carrying three zebra/hover/panel-depth fixes from the graduation), ListToolbar gains a display: 'menu' filter variant, and formatPhone joins the toolkit’s formatters. ListToolbar’s controls row also recomposes to a wrapped flex row and StatusChip’s border demotes to a 35% currentColor hairline; OfficeList’s header stack and mobile action sizing get two proven fixes; cairn’s own ConceptList create-button label now reads through the shared itemNoun grammar.

Consumers must: nothing. ExpandableRow and formatPhone are new, additive exports; the 'menu' display value widens an existing string union; every other change is a visual refinement inside cairn’s own admin-toolkit and built-in admin screens.

0.89.1: grammatical number on toolkit count lines (non-breaking)

itemNoun and ItemLabel join the admin-toolkit formatters, and Pagination’s and ListToolbar’s itemLabel prop now also accepts an { one, many } pair, so a count of exactly 1 reads its singular form while every other count reads the plural. A plain-string itemLabel renders exactly as before.

Consumers must: nothing. The widening is additive.

0.89.0: the admin toolkit, and the header idiom converges (non-breaking)

A new public subpath, @glw907/cairn-cms/admin-toolkit, packages the general-purpose admin components and formatters aksailingclub-org’s own admin build proved first: PageHeader, ListToolbar, AdminTable, StatusChip, Pagination, EmptyState, and the formatMoney/formatCivilDate/formatTimestamp/ageFromBirthdate formatters. Build your own /admin/ screen on it instead of hand-rolling a bespoke parallel; see the admin-toolkit reference.

cairn’s own built-in admin screens now build on that toolkit too. ConceptList, CairnMediaLibrary, ManageEditors, VocabularyAdmin, CairnTidySettings, NavTree, and HelpHome all render their page header through the toolkit’s PageHeader, converging five ad hoc header markups into one visible idiom, and ConceptList and CairnMediaLibrary converge their search, filter, count, table, and pager markup the same way.

Consumers must: nothing. The new subpath is additive, and the header convergence touches only cairn’s own built-in admin screens; you may notice their rhythm settle to one shape, but no prop or route contract changed.

0.88.3: a blessed daisyUI safelist for the admin (non-breaking)

The admin CSS build now compiles a curated blessed set of daisyUI 5 classes no shipped cairn admin component references yet: stats/stat-*, table-zebra/table-xs, toast with its placement modifiers, the indicator/status/join placement and orientation modifiers, and badge-soft/badge-outline/badge-dash. A site-authored admin screen can now use this vocabulary directly; previously an unreferenced daisy class silently compiled to nothing.

Consumers must: nothing. This changes only the compiled cairn-admin.css output, adding classes, never removing or restyling any that already shipped.

0.88.2: the template’s nav wiring and docs fixes (non-breaking)

A template-and-docs window. The showcase’s public header now renders menus.primary from site.config.yaml through a root layout server load, so /admin/nav edits reach the rendered site, and the tutorial teaches the same server-load shape instead of importing the config module in a client script.

Consumers must: nothing. An existing site keeps its own chrome; the new wiring lands in newly scaffolded or copied sites. If your site copied the showcase’s header, consider adopting the same pattern so your editors’ nav changes take effect.

0.88.1: mermaid passthrough and real dev-backend fixtures (non-breaking)

A mermaid fence now leaves the build-time highlighter untouched with its language-mermaid class intact, so a site’s client-side mermaid renderer can key on the class without a marker plugin. The @glw907/cairn-cms-dev seed also grew: two published fragments and real decodable thumbnail PNGs, so the fragment picker and the Media Library both work under vite dev.

Consumers must: nothing. If your site added a marker plugin to recover mermaid fences, you can delete it after this upgrade.

0.88.0: the access map, collapse defaults, icon overrides, attention badges (non-breaking)

A site can now declare defineAccess(roles, map), one per-role map over cairn’s own admin screens and its own /admin routes, enforced at the route through requireAccess and the engine’s own gates, and read by the sidebar resolver for visibility, so the two can never say two different things: see Restrict admin access by role. NavLayoutSection gains a declared collapsed starting state, NavLayoutEngineRef gains an icon override, the bundled icon allowlist widens from nine names to twenty-seven, and a new attention dependency renders per-session pending-work pills on the sidebar: see Organize your admin nav.

Consumers must: nothing. Every addition here is additive, off by default, and a site that declares none of it sees no behavior change.

0.87.4: docs in the tarball, renderDocument, a help default (non-breaking)

A drop-in bump. createRenderer gains renderDocument, which also returns the page’s heading list for tables of contents; the published docs tree now ships inside the npm tarball; and the admin’s Get Help hand-off gains a default destination, cairn’s hosted editor help at cairn.pub/help. One behavior note: a site that never set editor.supportContact now shows that hosted-help link instead of the self-serve empty state. Keep it, set your own contact, or set an explicit empty string to restore the prior no-link state.

0.87.3: the docs-register sweep (non-breaking)

A drop-in bump with no code changes. Every published docs page now conforms to the banked register standard: two factual errors in the arm indexes corrected, marketing phrasing and internal plan citations removed, and the editor-facing guides keep git vocabulary out. No consumer action.

0.87.2: honest image dimensions and srcset (non-breaking)

A drop-in bump. Rendered managed images now carry their intrinsic width/height when the media manifest records them, and gain an honest srcset/sizes pair when the site’s AssetConfig declares transformations: true. No consumer action; a site that post-processes rendered <img> HTML should expect the new attributes. The rest of the window is gate work inside the repo (check:invisible-craft coverage, two live numeric probes).

0.87.1: the admin polish window (non-breaking at runtime)

This polish pass: about thirty look-preserving refinements across the admin, the include line rendered as an atomic chip naming its fragment, the folded-container chip, a preview-only boundary cue on spliced fragment content, the publish blast-radius line, and a behavior fix. The login and confirm pages now honor the theme cookie, so a dark-mode editor no longer gets a light login card. No consumer action at runtime. The one type-level note: AdminShellData’s public variant now carries a required theme member. The engine produces that value itself, so only a site constructing the public variant by hand in TypeScript needs to add it; neither production site does.

0.87.0: fragments, and the embedded-routing promise enforced

A site can now declare the reserved fragments concept and reuse one piece of markdown across entries with the editor’s “Include a fragment” picker. See Reuse content across entries. Opting in is additive. One thing to check before bumping: a concept declared routing: 'embedded' is now genuinely non-routable, so its entries stop resolving through byPermalink, prerendering through entries(), and appearing in site.all(). If an embedded concept’s entries should have public URLs, declare routing: 'page' instead. If they only ever served as references or includes, you have nothing to change, and cairn stops serving the stray URLs.

0.86.0: navLayout, the whole-sidebar seam, and the widened navFilter

Sites now declare navLayout on the adapter’s editor group to arrange the whole admin sidebar as one tree, mixing cairn’s own screens with their own; see Organize your admin nav. It’s mutually exclusive with adminNav, and a site that declares neither sees no change: the sidebar renders today’s default arrangement through the same resolver.

Consumers must: nothing, for a site that declares neither navLayout nor a custom navFilter. Two narrower changes apply if you do:

  • AdminShellData’s authed arm reshapes: customNav, canManageEditors (as a nav signal), and navLabel collapse into one nav: ResolvedNavLayout field. Update any code that reads AdminShellData’s fields directly (no known consumer does) to the new shape.
  • A declared navFilter widens: it now receives the resolved sidebar’s arranged top-level nodes (ResolvedLayoutNode[], cairn’s own screens included when you declare navLayout), not just your own custom adminNav entries, and returns the same shape. Widen the parameter and return type from ResolvedNavItem[] to ResolvedLayoutNode[]; a filter that only reads an item’s .label needs no other change.

Desk routes (the entry editor) also persist the sidebar at xl and up instead of receding it at every width; no action needed, this changes only what renders wider than 1280px.

0.85.0: site-declared role vocabulary and capability levels (non-breaking)

Sites now declare their own role vocabulary instead of the two names 'owner' and 'editor' the engine used to hard-code. defineRoles on the adapter’s new roles member maps each of your own role names onto one of the engine’s three fixed capability levels, owner, editor, or none (an authenticated identity with no engine content access); a role can also declare a home, the /admin route it lands on. A site that declares no roles gets the implicit { owner: 'owner', editor: 'editor' } pair, so this release changes nothing you’d notice.

Consumers must: nothing, for an existing site. To open a larger vocabulary, declare roles on your adapter with defineRoles, apply the 0001_roles.sql migration the engine ships in the package once you introduce a role name outside owner/editor (see Configure auth and D1 for copying it out of node_modules), and augment CairnRolesRegister in your app.d.ts if you want locals.editor.role narrowed to your declared names. See the roles reference for the full contract and Give a role its own admin area for the worked walkthrough.

0.84.3: the editor lifecycle rounded out (non-breaking)

Four editor changes, no consumer action. The Publish button now shows on every entry, resting dimmed with “Nothing new to publish” until a typed edit, a saved draft, or a new entry gives it something to take live (it used to appear only after a save). A new entry opens with the title typed in the create dialog and its badge reads New instead of Published. The spellchecker knows the standard English contractions and accepts curly apostrophes from pasted prose.

Consumers must: nothing.

0.84.2: the admin hang after login fixed (non-breaking)

On roughly 0.77 and later, a cold Worker isolate could wedge the whole admin for 55 minutes: the first admin request after login canceled an in-flight GitHub token mint, and the token cache kept serving that dead promise to every later request. 0.84.2 caches only a successfully minted token. If editors report the browser waiting forever right after a magic-link login, this is that bug; upgrade and redeploy.

Consumers must: nothing.

0.84.1: the local-dev media fix completed (non-breaking)

0.84.0’s local-dev claim shipped incomplete: a second serialization site (writeHttpMetadata on the returned object) still failed every /media read under vite dev. 0.84.1 completes it, verified end-to-end on a consumer checkout, and cairn-media-seed now stores each object’s Content-Type. Upgrade straight to 0.84.1. The devMediaFallback deletion note below applies as of this version.

Consumers must: nothing. Re-run npx cairn-media-seed after upgrading if you seeded with 0.84.0, so the stored objects gain their content types.

0.84.0: cairn-media-seed and a media route that works under vite dev (non-breaking)

A new bin, cairn-media-seed, seeds wrangler’s local R2 simulator with every media-library object from a deployed site, so local design iteration sees real images. The media delivery route now derives plain onlyIf and range options instead of passing a Headers instance, which fixes the 500 every /media read hit under a consumer’s vite dev. See the reference page and the local design-iteration guide.

Consumers must: nothing. A site that carried a dev-only /media fallback middleware for the vite dev bug can delete it after the bump; the route works locally without it.

A site declares next-step links for the publish-success moment through a new publishActions entry on the adapter’s editor group, the adminNav grammar applied after a publish: a plain {label, href} list, href a template string substituted with the published entry’s concept and id, optionally filtered to specific concepts. The engine validates each entry when the runtime composes, so a blank field or an unknown concept fails the build rather than rendering a broken link. See the publish-actions seam.

Consumers must: nothing. publishActions is opt-in; a site that declares none renders the publish-success moment exactly as it renders today.

0.82.1

No consumer action. Behavior notes for upgraders: the admin shell’s desktop sidebar is now position: fixed (no more scroll drift, and it stays open when navigating to deep custom-nav routes like /admin/club/events); adminAction no longer requires an audit emit from a handler that returns SvelteKit’s fail() before mutating (a handler that writes and then rejects must still emit); the /ambient augmentation now types App.Locals.auditSink.

0.82.0

No consumer action required. The release adds the admin extension surface for sites that build their own /admin/ screens: the admin-fields subpath (SelectField, TextField, FieldLabel), the OfficeList shell in components, the adminAction wrapper and the per-request navFilter dependency in sveltekit (also reachable through CairnAdminDeps), and one-level adminNav sections. All additive; existing sites build unchanged.

0.81.0

No consumer action required. The release adds the renderer’s remarkPlugins/rehypePlugins seam, default-on table scrolling (tableScroll: false opts out), sitemap extraRoutes, CairnHead’s titleTemplate, and the chassis/theme example structure with three ported example themes. All additive; existing sites build unchanged.

Edit this page on GitHub(opens in a new tab)