# 0.0.2-alpha.66

14 Sep 2026 · Before you upgrade: No steps listed

- Saving a published page no longer puts the edit live. Pages now keep a working draft.
- Every sortable table in the admin can be reordered from the keyboard, and a screen reader is told what is being moved.

Published: 14 Sep 2026
Changes: 64 changes
Notes: [On GitHub](https://github.com/nextlyhq/nextly/releases/tag/v0.0.2-alpha.66)

## Before you upgrade

These notes list no steps.

## What's changed

<!-- release-notes -->

Released 21 packages at `0.0.2-alpha.66` in lockstep. Every package below ships at this version.

### Patch Changes

- [#1792](https://github.com/nextlyhq/nextly/pull/1792) [`da323d5`](https://github.com/nextlyhq/nextly/commit/da323d58366d362dfaadb7a1ab9593dbf98d18b2) @mobeenabdullah: The schema builder's drag announcements name every field, and its row ids are only the ones the list mints.

  A field just added to the canvas has no label and no name until its author
  fills them in, and it drags in that state: it was announced as nothing at all
  ("Picked up , row 2") and, beside a named field, as "and Title". It is now
  called "an unnamed field", on the announcement and on the nested drag handle
  alike. A row id that is not `row-` followed by a canonical nonnegative integer
  no longer names a row, so a drop the reorder would refuse is no longer
  announced as a move.

- [#1697](https://github.com/nextlyhq/nextly/pull/1697) [`b87f0d3`](https://github.com/nextlyhq/nextly/commit/b87f0d3d0d6468c17bb9728738a1729b07c74230) @mobeenabdullah: A dashboard card asserted a meaning its own data did not have.

  The status breakdown was generated for any collection carrying a field NAMED `status`, and the schema permits an ordinary field of that name with the publishing lifecycle switched off — so such a collection got a card titled "by status", describing the split "between draft and published", over a column holding whatever that author's field holds. It is now gated on the lifecycle capability itself, which is what the health card beside it already read.
  
  A chart's placeholder rows were told apart by styling alone. A bucket holding no
  value and one holding the literal text `(empty)` were drawn in different colours
  and announced identically, so the readers who most needed the distinction were
  the ones without it. A placeholder now says what it is — "no value stored" —
  rather than relying on italics. Exact separation is not achievable, because no
  string can be reserved from a column of arbitrary text; what is fixed is that
  the placeholder describes itself instead of depending on something a screen
  reader never sees.

- [#1756](https://github.com/nextlyhq/nextly/pull/1756) [`705926b`](https://github.com/nextlyhq/nextly/commit/705926b091c1e6ab17fd4065d89dfc2d29c094b2) @mobeenabdullah: The collections and singles cards no longer decide for themselves whether they have anything to show.

  A fresh dashboard made the same "get started" pitch three times: the setup
  checklist, the demo-content offer, and a third panel the collections card drew
  on its own when it had no counts. The singles card went the other way and
  returned nothing at all when the install had no singles -- and a card that
  renders nothing still holds its place in the grid, so the layout reserved an
  empty slot on every install that never used them.
  
  Both cards now declare a condition, and the host offers them only while it
  holds. Two names join the closed set a conditional widget may use:
  `collections:present` and `singles:present`, each true while this reader may
  read at least one. They are about what exists rather than what is in it, so a
  collection with no entries yet still counts as present -- it is still something
  to list -- which is what separates them from `content:empty`. The
  `GetStartedEmptyState` panel is removed; the checklist and the demo offer, which
  the host withdraws on their own, are the two pitches a new reader meets.

- [#1723](https://github.com/nextlyhq/nextly/pull/1723) [`4c03302`](https://github.com/nextlyhq/nextly/commit/4c03302fd9a8532e033c2cfb16a878a25408b9d0) @mobeenabdullah: The dashboard's setup checklist is answered by the server now, and shown only while there is something left to do.

  It used to derive its own steps in the browser from dashboard statistics, and
  decide for itself whether to appear by reading a dismissal out of
  `localStorage`. Both were wrong in the same direction. The steps were computed
  in two places from the same four counts, so they could drift; and dismissal was
  per BROWSER, which answers a question about a person — the same admin on a
  second machine met a checklist they had already finished, and a colleague met
  one reporting someone else's progress.
  
  The host answers both. It reports which steps this reader has finished, and the
  widget condition deciding whether the card is offered is DERIVED from that same
  answer — so a card showing every row ticked, and a card that will not go away,
  are both unreachable.
  
  Steps are detected rather than self-reported: nothing is ticked by hand, the
  install is asked. Only what can be answered about the reader is included, so an
  editor is never held short of finishing by content they are not allowed to see.
  The first step is complete for everyone, and truthfully so — reaching the card
  means an account exists and they made it. A checklist that opens above zero is
  finished far more often than one that opens empty, and that head start is worth
  having only if it is true.
  
  A transient card can now name SEVERAL conditions, and is offered only while
  every one of them holds.
  
  The get-started card needed two, and neither answers alone: is there nothing to
  look at, and is the offer of demo content still open. Declining that offer
  creates no content, so the card kept its slot on the strength of the install
  still being empty — visible, drawing nothing, for a reader who had already said
  no. The two conditions are scoped differently on purpose: whether there is
  content to see is about the reader, while whether a project took the demo data
  is recorded once for the project, so a second admin is not offered it again
  after the first declined.
  
  The dashboard's last browser-stored dismissal is gone with it. A hook that
  derived onboarding steps in the browser and remembered dismissal in
  `localStorage` had no consumer left once the host started answering, and the
  types beside it described a checklist that no longer exists — including a step
  vocabulary listing five names none of which are detected any more.

- [#1770](https://github.com/nextlyhq/nextly/pull/1770) [`732da46`](https://github.com/nextlyhq/nextly/commit/732da46718b4e1342ad517d05a276bcfaf45ef8b) @mobeenabdullah: An exposure row reports itself cleared when the winning clear erases its target — at its own path or an ancestor's, by any exposure — never when the clear is below it, and a cleared row's value is always absent rather than the clear sentinel.

- [#1773](https://github.com/nextlyhq/nextly/pull/1773) [`716adbd`](https://github.com/nextlyhq/nextly/commit/716adbd7b710559b33d9d568f187b310206e6bd5) @mobeenabdullah: The insert panel offers the site's components beside its blocks and patterns, and placing one writes a single instance node that keeps pointing at the definition — nothing is copied into the page.

  Component definitions now reach the editor through a route of their own, `GET …/library/components`, gated by the components collection's read permission and answering the canonical `{ items, meta }` envelope. It lists every lifecycle state the caller may read, so a never-published component can be placed on a draft page, and reads each definition by id AS THE USER so an author who may edit a component sees its working draft while one who may only read it sees the live definition. The canvas and the entry form's resting miniature both resolve instances against them, so a placed component renders in the builder — and stays rendered after Done — rather than as a could-not-be-loaded placeholder; both wait for the read and say when it failed, with a way to try again. A definition whose own root is another component is judged for placement by what that component draws, under the site's own document caps, and the panel says when a tier of the library was too large to load whole or could not be read.
  
  The component tier is read in the language the surrounding document is being
  edited in, as the public page reads it, so a localized component draws on the
  canvas as it will on the page; and a row without a title — a custom collection
  with none, or one field-level access redacts — is offered labelled by its id
  rather than left out.
  
  The Direct API's `findByID` now forwards `status`, so an untrusted by-id read
  can reach a row that was never published — a caller passing it before was
  silently ignored. A plugin route's caller gains `identity()`, which resolves
  once per request the identity an access rule is evaluated against — the user
  with their roles, and an API key's scope — so a route reading through the
  Direct API on the caller's behalf reads as the caller its own gate admitted;
  `PluginRouteIdentity` names its answer in the SDK.

- [#1827](https://github.com/nextlyhq/nextly/pull/1827) [`a6ea554`](https://github.com/nextlyhq/nextly/commit/a6ea554476000f65d384a2b169802c637154c96f) @mobeenabdullah: A component can no longer be saved into a shape where the library references itself.

  Components may place other components, so the library is a directed graph. A
  loop in it is not a crash — the renderer detects one and draws what it reached —
  but it leaves a gap where the loop closes, on every page placing anything on the
  loop, and nothing said how it got there. The editor already withheld the tiles
  that would close one, but that is a snapshot: an author saving against a library
  read that had since gone stale closed a loop anyway.
  
  The write now refuses, naming the chain to break — `Hero → Banner → Hero` —
  rather than leaving the author to find which placement did it. The check reads
  each referenced component as it currently stands, and refuses rather than
  guessing when it cannot read them all. A component the saving author cannot read
  is named only as `…`, so a refusal does not hand out identifiers.
  
  What it does NOT close is a chain that mixes lifecycle states. The published
  walk reads every state, so a component that has never been published can join a
  chain the public renderer cannot draw — it could not load that row — and a save
  is then refused for a loop no reader sees. Reading only public states is not
  available to a plugin: naming the state matches nothing on a site whose workflow
  calls its public state something else, and dropping the system-level read would
  hide a component the author cannot see, which is a MISSED loop rather than a
  false one. So it over-refuses, which is the recoverable direction.
  
  What it does NOT close is a loop that only an EXISTING placement of the saved
  component completes. A page or component already placing it may carry an override
  keyed for an exposure it does not yet declare, which is inert; declaring that
  exposure activates the override and can point a nested node back at the placer.
  Measured on the renderer: the same library composes cleanly before the exposure is
  declared and reports a cycle after, while the saved component judged on its own
  composes cleanly either way. Seeing it means asking which components place this
  one — the reverse direction, which neither the walk nor the composition of a
  single subject can supply.
  
  What it does NOT close is two authors closing a loop between them at the SAME
  moment. The check runs before its own write commits and takes no lock the other
  write contends for, and a plugin hook has no transaction to enlist in — so two
  saves that each read the other's document before either commits are both
  approved. Closing that needs a boundary the two writes share.
  
  A component's VARIANTS and its PLACEMENTS count as references. A variant may
  preset an exposed `componentId`, and a placement may override one on the
  component it places — so a definition whose stored ids look harmless can still
  resolve back to itself. Both the insert panel and the write judge a definition by
  what it can reach that way, so the editor does not offer a component whose insert
  the save would refuse.
  
  What decides a refusal is the RENDERER, not that scan. Overrides flow down
  through nesting, so a placement can re-point a node two levels below it: the
  scan is short by a level wherever that happens, and long by a stored edge
  wherever an override points one away from the loop. Both directions are real, so
  a save is judged by composing it with the same function that draws the page —
  the only answer guaranteed to match what a reader would see. The scan still runs
  first, because it is what can name the chain to break; it no longer has a vote.
  
  That is asked once per variant the component offers, and once for a placement
  naming none. Only one variant resolves at a time, so a loop that exists under
  one selection is real, and a scan that unions them all would refuse a component
  whose every selection is fine.
  
  A component that names ITSELF is refused from the submitted document alone,
  without reading the library at all — nothing in it can reopen an edge the
  document has already closed, and the reads it skips each run the site's hooks.
  
  A limit the composition reaches is not a report that there is no loop. Where a
  chain runs deeper than components may nest, the renderer stops before the loop
  closes and has seen nothing past that point — so the save is refused on the
  scan's own chain rather than approved on the renderer's silence. The same holds
  for a library it could not finish reading.
  
  Clearing a component's content is not the same as leaving it alone. A publish
  that also empties the field stores nothing, so it can close no loop, and it is
  allowed — where previously the accumulated draft was judged instead and the save
  was refused for a chain that very write removes.
  
  It judges the lifecycle form the write actually changes. An ordinary editor save
  is stored as a working draft and leaves the published row alone, so it is checked
  against what a preview would show; publishing checks the live library as well.
  Publishing an accumulated draft is checked too, even when the request carries
  nothing but the new status — that is the write that brings the draft's content
  live.
  
  It is deliberately a refusal rather than a warning: the author who closes a loop
  is not the person who sees the gap, so a notice would go to someone who has no
  reason to act on it.

- [#1786](https://github.com/nextlyhq/nextly/pull/1786) [`a8f84fd`](https://github.com/nextlyhq/nextly/commit/a8f84fd40143f450577ee49c8262644cdc10c02c) @mobeenabdullah: A component the editor offers, and one the canvas draws, are now judged against the same forest and read from the same row.

  A saved pattern keeps the instance nodes it held, so a pattern that places a
  component is a second way to copy that component into itself; editing a
  component now withholds those patterns by the same graph the component tier is
  filtered by. A pattern's nested instances are judged by what they DRAW rather
  than by the reserved instance type, which a parent rule read as unrestricted
  and a slot's admissions list refused outright — so a component drawing exactly
  what a slot asks for could not be placed in it, and one whose root belongs
  elsewhere could.
  
  The roots query follows a nested instance's own overrides, so a component whose
  only root that instance hides is no longer offered as placing something; and it
  survives a definition whose fields throw rather than taking the insert panel
  down with it.
  
  The library is paged from the last row seen rather than from an offset, so a
  component inserted or deleted by another author while the editor loads it can
  no longer be skipped and reported as a complete library. A completed component
  must be the row the listing named and must have been read whole: a row
  answering under a different id, or one whose document field an access rule
  removed, is left out and the tier reported cut rather than served under another
  component's name. Its keywords travel, so the palette can find it by them, and
  a store the plugin was told about is gated by the slug it actually reads.
  
  The entry form's resting miniature reads the component library only when its
  page places a component, instead of on every mount of every blocks field.
  
  An exposed visibility control now reflects a gate the component itself carries,
  rather than reporting a node as shown on a page the renderer withholds it from.

- [#1683](https://github.com/nextlyhq/nextly/pull/1683) [`fda49c2`](https://github.com/nextlyhq/nextly/commit/fda49c2876a4842ed47d4aa142b5bd6ec7f1eaab) @mobeenabdullah: A dashboard could count and group entries but had no way to show either as a picture.

  Every chart meant writing a React component, which is how a dashboard stops looking like one product.
  
  Two archetypes now draw from a query the way `metric` and `table` already do,
  so an author declares a chart instead of shipping code: `bars` compares the
  buckets of a `groupBy`, and `timeseries` plots the points of a window.
  
  They are drawn here rather than by a charting dependency. The popular one pulls
  eleven packages — a state-management stack in every install's admin bundle for
  two shapes — and renders `role="application"`, which puts a screen reader into
  forms mode over a graphic that has no keyboard interface. Owning the markup is
  what makes the right pattern reachable: each chart is a labelled graphic with a
  text alternative naming what it shows, beside the same numbers as a real table.
  
  The bars run horizontally because their labels are whatever the grouped column
  holds — author names, tags, statuses — and a column chart has only its own
  width for those, which forces rotation or truncation. A capped bucket set says
  so rather than presenting a partial comparison as the whole one, and a
  timeseries labels each point in UTC, the zone its buckets were computed in, so
  the axis cannot name a different day than the server did.
  
  Every collection that can answer them now generates the two cards, beside the
  count, list and table it already generated — so the charts are something an
  install has rather than something a plugin author could build. A timeline is
  withheld from a collection whose only dates the read cannot bucket, and a
  status breakdown from one with no status column, because offering a card whose
  query is then refused is a card that draws an error on every load.

- [#1821](https://github.com/nextlyhq/nextly/pull/1821) [`2fa4696`](https://github.com/nextlyhq/nextly/commit/2fa4696c3e5becc4fc5fc954cb8d429a7ffe34ba) @mobeenabdullah: A function default cannot read a field the caller may not write.

  A `defaultValue` written as a function receives the data built so far, and that data still held every value the caller sent, including one a field rule denies them, so a default could read the forbidden value and carry it into a field the caller IS allowed to write: the denied field was stripped and its value persisted anyway, one column across. The rules are now applied to a COPY, and the copy is what the functions read. The record itself is left alone until the pass that decides what is stored, because a rule may depend on a sibling the caller omitted precisely BECAUSE it has a default, and judging that rule before the defaults exist would deny it and lose the value for good.
  
  That copy is now defaulted and judged a second time before the record reads it. A field the caller may not write still takes its own declared default, and the pass that runs before the defaults has no key to judge because nothing supplied one; left in the copy, a later function default read it and carried it into a field the caller CAN write, so the rule was defeated one column across by the field's own default rather than by the caller's input.
  
  A config reload installs the live config's field functions, replacing them wholesale at the point the reload commits. Replacing rather than adding, so an entity the new config no longer declares stops deciding anything: a collection dropped from the config keeps its registry row and its table so an orphan sweep can find them, so it stays addressable. At the commit point rather than when the config is read, so a reload whose DDL succeeds and whose later sync fails does not leave a rule from a config the process refused deciding writes. The field-level registry holds every field's `access` rules, hooks, `validate` and function `defaultValue`, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned.
  
  An access rule declared inside an unnamed container is captured. The registry keyed only named entries, so every rule, hook and validator inside an unnamed presentational group was dropped. `defineCollection` refuses a field with no name, but a plugin contributing raw config is checked on its field TYPES and not their names, so the shape reaches the live config.
  
  A Single's first read no longer invents an incomplete group. Filling a group for the sake of one defaulted child, while a required sibling has no default and no value, stored a document the next create or update would refuse; that insert runs no validation pass of its own. The group is left absent instead.
  
  The documentation no longer claims a field group's children take their defaults before the entry's hooks. They are written in their own pass afterwards, so the parent's hooks see such a child as absent.
  
  A `defaultValue` written as a function now works on a reusable field group's children. A field group's fields are read from its stored definition on every write, and a function does not survive being stored, so only constants applied there: a function default on a field-group child was silently dropped on every write. Field groups now get the same live-config capture collections and Singles have, so the function form resolves from the config at boot, and it is re-read when the dev server reloads the config. It resolves against the instance being built, so a child may compute from a sibling defaulted before it.
  
  Only the default is wired. The same capture holds a field's `access` rules, hooks and `validate`, and nothing reads those for a field group, so registering them changes nothing about whether they are enforced.
  
  The promote gate judges the draft the write actually commits.
  
  The gate that re-judges a Single's pending change before it is published ran before the write transaction, where it could only judge a copy of the world as it was. It now runs inside that transaction, on the draft the transaction has locked, which closes what a pre-flight check could not: a draft saved by another writer between the check and the commit is now the draft that is judged; a `beforeChange` hook that turns a status-less edit into a publish no longer slips past a check that ran before the hooks; and `publishAllLocales`, which applies every language's snapshot to one row, now judges the combined result rather than each language against its own shared values. Resolving the caller's grants is the one thing that cannot happen inside a transaction, since it queries the pooled connection that transaction holds, so it is resolved beforehand and handed in.
  
  A field rule on a child of a group or a repeater row is enforced. The check copied the snapshot shallowly, so the rules deleted a denied nested value from the copy and the original alike and the comparison then saw an unchanged container. A denied child is now found at its own depth and named at its own path.
  
  A publish is no longer refused over a field nobody touched. The comparison read the live document through a read that expands an upload or a relationship into the document behind it, while the snapshot holds the identifier, so an untouched field of either kind looked like an edit. Both sides now go through one conversion.
  
  A draft older than a newly required field is refused rather than published. The check judged the promoted document as a patch, which skips absent properties by design, so a snapshot written before a field became required reported nothing and published a document that violates the contract.
  
  An API key is judged on the grants stamped on the key, not on the database roles of whoever owns it.

- [#1768](https://github.com/nextlyhq/nextly/pull/1768) [`2bcccdd`](https://github.com/nextlyhq/nextly/commit/2bcccddfbb83b9d9e5a57a555cedb321ed4a85c1) @mobeenabdullah: Every sortable table in the admin can be reordered from the keyboard, and a screen reader is told what is being moved.

  Two tables -- the collection fields list and the user fields page -- accepted
  only a pointer, so a keyboard user could not reorder them at all. They now use
  the same sensors as every other drag surface: Space or Enter picks a row up,
  the arrow keys move it, Space drops it, Escape puts it back.
  
  What a drag says has changed on every surface. The defaults read the
  draggable's id aloud -- "Picked up draggable item 3f9a…" -- and every id here
  is a field name or a uuid. A drag now names the thing and where it sits:
  "Picked up Title, position 1 of 4", "Title is over Body, position 3 of 4",
  "Title moved to position 3 of 4". On the dashboard the same sentences name the
  card and its column, matching what the Move buttons already say, so a card
  moved by keyboard and one moved by button sound the same. Each drag handle is
  also named after its row, so tabbing through them no longer reads as a list of
  identical buttons.

- [#1831](https://github.com/nextlyhq/nextly/pull/1831) [`b25490b`](https://github.com/nextlyhq/nextly/commit/b25490bc4af5bad91bc67a5333ec28295199e211) @mobeenabdullah: A field hook now runs for the fields a write touched, and reads the whole row either way.

  A localized update assembles the complete translation onto the row it returns,
  because the version snapshot and the outgoing events describe a translation
  rather than the one field of it that moved. Field hooks were selected by what
  was present in that row, so an `afterChange` handler on a sibling nobody
  edited started firing for an unchanged value — sending mail, re-indexing and
  calling out for a field the write never named.
  
  Which handlers run and what each handler can see are now separate questions.
  The set of touched fields decides the first; the row stays whole, so a hook
  that derives a search document from an unchanged neighbour can still read it.

- [#1677](https://github.com/nextlyhq/nextly/pull/1677) [`793c9c1`](https://github.com/nextlyhq/nextly/commit/793c9c120dbd3525c6b3be4fcc4cb53059a8891d) @mobeenabdullah: The engine's three whole-document measurements — `countNodes`, `treeDepth` and `documentBytes` — now refuse a forest they cannot afford to walk instead of grinding through it or raising a native error.

  They walk ENTRIES, not objects, and deliberately so: one node object placed in two slots is two elements of the document, and counting it once would report half a real size and pass a cap the document exceeds. That makes the walk exponential in depth for a forest whose branches share a node object — 21 shared objects reach 2,097,151 entries, and every further object doubles it.
  
  `documentBytes` was the sharp edge. `JSON.stringify` expands each shared node into a copy per path that reaches it, so it allocated 132 MB for 21 objects and raised `RangeError: Invalid string length` at 23 — from a document a few kilobytes in memory, in the one function that decides whether a document may be stored.
  
  All three now throw `ForestTooLargeError`, against the ceiling that fits what each one actually spends:
  - `countNodes` and `treeDepth` refuse past `MAX_VALUE_PARTS` (4,194,304) — the ceiling the op layer's preflight **already** refused values at, now shared rather than duplicated.
  - `documentBytes` refuses past `MAX_SERIALIZED_VALUES`, which is derived from the byte cap it protects: every serialized value contributes at least one byte, so a document that has emitted more values than the cap has bytes cannot come in under it.
  
  Sharing the first number is the point: a second, lower ceiling for the structural readers would let a dry run accept a document the apply then refuses, and would sit below `maxNodes` on a site that legitimately raised it. The serializer keeps its own because it bounds a different resource — a structural walk reads, while serialization BUILDS as it goes, so one numeral buys different amounts of work in each. Both are exported from the package root, and `@nextlyhq/blocks-engine/format` exports the error with `MAX_SERIALIZED_VALUES`, the only one bounding anything that entry exposes.
  
  The messages name the routes to the ceiling without claiming which one a caller hit, because no reader compares object identity and so none can tell. Inside `applyOp` the refusal arrives as an `OpError` like every other, so the `...Refusal` helpers still return a reason rather than throwing at their caller. Composition and the builder's deletion metadata degrade rather than propagate it: an unmeasurable subtree refunds nothing and reports no descendant count, so a page still renders and a block can still be deleted.
  
  Nothing a site can store is affected. `JSON.parse` produces fresh objects and cannot express sharing, so a stored document is never such a forest; the bound is reachable only by a forest built in memory by code.

- [#1706](https://github.com/nextlyhq/nextly/pull/1706) [`1bfc12e`](https://github.com/nextlyhq/nextly/commit/1bfc12eb4c3122d6e4984d69195f45061d281e5b) @mobeenabdullah: A spacing handle stopped following the pointer when a drag took a margin past zero.

  A margin dragged below zero is drawn on the other side of the block's edge, and
  its two sides swap roles when that happens. The handle kept the side it was given
  when the drag began, so once the value crossed zero it sat on the edge that no
  longer moves and the block stopped responding — the drag had to be released and
  started again to carry on.
  
  The handle now takes the side the band is drawn with as it is drawn, so a stroke
  that runs a margin from positive to negative keeps moving the block the whole way.

- [#1863](https://github.com/nextlyhq/nextly/pull/1863) [`6e22e58`](https://github.com/nextlyhq/nextly/commit/6e22e58f389affa7f922afb65cc4db8f3db5d63c) @mobeenabdullah: The Fonts panel no longer warns that a font stack will be dropped when a `var()` names its custom property with a CSS hex escape.

  `var(--\62 rand)` is `var(--brand)` to a browser, and is now read as the same working substitution.

- [#1667](https://github.com/nextlyhq/nextly/pull/1667) [`48967c8`](https://github.com/nextlyhq/nextly/commit/48967c89417abfe6ad1e474eb7ea476b473274e0) @mobeenabdullah: A hook could not tell whether the write it was running on came from a browser or from a seed script, so a rule that only makes sense for a visitor, a rate limit or a honeypot, could not be written at the write seam at all.

  It had to live in one route, and a collection that grants public create has more than one door into it.
  
  Collection operations now accept the HTTP request that produced them, and the
  core resolves it into the facts hooks read as `ctx.req`: the headers, and a
  client address judged against this deployment's `security.trustProxy` and
  `TRUSTED_PROXY_IPS` rather than read raw off `x-forwarded-for`, which is
  whatever the sender chose to claim. `ctx.req.http` is absent when no request
  produced the write, and that absence is what tells a request-scoped rule to
  stand down instead of judging a server-side import as a visitor.
  
  Both HTTP doors pass it down, and `ctx.services.collections` takes it too, so a
  plugin serving its own route can hand over the request it was given. The form
  route's audit column now records the resolved address instead of the leftmost
  forwarded hop.

- [#1733](https://github.com/nextlyhq/nextly/pull/1733) [`cadd25b`](https://github.com/nextlyhq/nextly/commit/cadd25b48744ccc5a2658678b4164776e8c22d69) @mobeenabdullah: A job handler is told which job it is running.

  Delivery is at-least-once, so one queued job can reach a handler more than
  once, and the documentation says to write handlers that survive that. It did
  not say how. The context carried the identity, the clock, a content API and the
  tick deadline, and nothing that identified the queued row, so a handler had
  nothing stable to key an external side effect on.
  
  It now receives `jobId`, the same on every attempt, which is the key to hand a
  payment provider's idempotency header or the unique column an upsert targets.
  It also receives `attempt`, counting from 1, which is written to the row before
  the handler starts, so a handler that dies part-way still leaves the count
  advanced. It is not the deduplication signal: `attempt > 1` says an earlier run
  began, not what it finished.
  
  The same page told operators to size `leaseMs` to the work. Nobody could:
  the built-in route passes only a batch size and a duration, and the public
  configuration takes job definitions alone. It was also the wrong advice, since
  the lease is renewed while a handler is alive and so already covers work that
  merely takes a long time. The page now documents the fixed lease as a stall
  tolerance and points at the key a handler can actually use.

- [#1814](https://github.com/nextlyhq/nextly/pull/1814) [`83a2e49`](https://github.com/nextlyhq/nextly/commit/83a2e4925aa0c1de40011bbcce25018ee7b9db72) @mobeenabdullah: A many-to-many field's junction table is now followed by the table itself, not by the field that names it.

  A junction name reused for a field pointing at another collection is dropped and created again — before, `CREATE TABLE IF NOT EXISTS` kept the old table and its old link column. Changing a field's `junctionTable` renames its table with the links in it, and pointing a field at another collection gives it a new table instead of none. Two many-to-many fields cannot store their links in one table, whether the name is the author's or the generated one, and a save that would move a junction off a table another field still uses, or onto one that already exists, is refused by name.

- [#1676](https://github.com/nextlyhq/nextly/pull/1676) [`419d5f2`](https://github.com/nextlyhq/nextly/commit/419d5f2b7666b42e3c2b02639835b44257401ff3) @mobeenabdullah: A scoped API key is judged on its own grants at every access gate, not only the first one.

  Transaction writes, field-level access rules and the coarse collection gate all resolved the permissions of the key's OWNER when the key's own scope did not reach them, so a key issued to read could act with the authority of whoever created it.
  
  A permission now reaches a code-defined `access` rule in the spelling those
  rules are documented to receive (`posts:read`), so a rule written as
  `({ permissions }) => permissions.includes("posts:read")` decides the same way
  for an API key as it does for a session.
  
  The scope a plugin route handler receives is its own copy. Narrowing it in place
  now affects only that request, where before it edited the key's real grants for
  every request for the next five minutes.

- [#1656](https://github.com/nextlyhq/nextly/pull/1656) [`9893b11`](https://github.com/nextlyhq/nextly/commit/9893b119dbdd3fbc1f171b4ed9cf6146388d94bf) @mobeenabdullah: A plugin route received the caller's account and nothing else.

  For a request authenticated with an API key that account is the key's OWNER, so the access check resolved the owner's roles and a key scoped to read was authorized to write whatever its owner could — reaching, for a key minted by a super-admin, the unconditional super-admin allow.
  
  `AuthenticatedScope` already existed for exactly this and is honoured by the
  collection access services; the plugin route path was the one surface it was
  never wired into. The key's own grants now travel with the account, from the
  dispatcher through `ServiceOpts` and `RequestContext` to the collection facade.
  
  A session caller is unaffected: it carries no key scope and resolves the same
  way it always has, super-admin bypass included.

- [#1695](https://github.com/nextlyhq/nextly/pull/1695) [`89f515b`](https://github.com/nextlyhq/nextly/commit/89f515b0d6a9e12a669a4a02ef173264b439a1e9) @mobeenabdullah: A scoped API key is judged on the scope the request is CURRENTLY under, for the whole of an operation rather than at the one call that names it.

  A route that narrowed its own scope before a transactional write had the
  narrowing discarded: the transaction methods sit outside the plugin facade's
  wrapper, so the scope reached the argument and never a gate, and the write was
  judged on the grant the route had given up. The transaction params carry it now,
  including the delete path, where the owner predicate was resolved without it —
  so a key owned by a super-admin took a bypass that belongs to a session.
  
  A release operation resolves its target before it acts, and that lookup ran
  with no scope at all, so it read on the key OWNER's grants. The scope is pinned
  for the operation instead of handed to half of it.
  
  `narrowScope` accepts a caller that has none. A signed-in person reaches the
  same routes an API key does, and requiring each call site to guard that is how
  a `!` reached the documentation — where it was a crash for every session
  caller.
  
  The API-key scope built by the Single detail route named `actorType` and
  `permissions` only, so a documented `permissions.includes("site:publish")` was
  handed the stored slugs and denied a key that held the grant.
  
  Hand-writing that scope is refused rather than corrected again, and the refusal
  runs in two places because one is not enough. A lint selector rejects the
  literal — in either key order, shorthand, with a nested object between the keys,
  and with a spread standing in for the second half. The one shape it cannot see
  is a spread of an existing scope that replaces only `permissions`, which keeps
  every original row and so keeps naming a grant the caller has just given up;
  nothing in the syntax separates that from an ordinary config overlay, so
  `ruleFacingPermissions` refuses it where the disagreement decides the answer.

- [#1663](https://github.com/nextlyhq/nextly/pull/1663) [`5aae8fc`](https://github.com/nextlyhq/nextly/commit/5aae8fcf9386e47a94de8974c92a76b5727e0e21) @mobeenabdullah: Deleting a component that a Layout still uses is now refused, and the message names the Layouts to go and edit.

  A Layout wraps every page assigned to it, so removing a component it uses would leave a gap on all of them at once — unlike an ordinary page, where the renderer draws one visible, recoverable placeholder.
  
  Draft Layouts count. A component named only by an unpublished Layout is on no
  page yet, and deleting it would break that Layout the moment someone publishes,
  by which time the cause is a deletion nobody remembers.
  
  The database could not have caught this: a Layout's areas are stored as one
  JSON column, so the reference to the component emits no foreign key.

- [#1772](https://github.com/nextlyhq/nextly/pull/1772) [`c80d0da`](https://github.com/nextlyhq/nextly/commit/c80d0dafd69f9825b75e1a33b97aeb7891575c2e) @mobeenabdullah: The collections and singles listings show exactly what the reader may open.

  Both lists scoped themselves by the stored `{slug}:read` grants alone, while
  every other read -- opening a document, the dashboard's own scope, a version
  read -- also consults the entity's code-defined `access.read`. The two
  disagreed in both directions: a collection or Single authorised entirely in
  code has no grant row, so it was left out of the list a reader could open it
  from, and on the dashboard the singles card was offered for it and then drew
  nothing; a grant the code rule refuses was listed anyway.
  
  Both listings now take the same read decision as everything else. One
  consequence for API keys: a key owned by a super admin no longer lists every
  collection and Single. It is judged on its own stamped scope, which is the rule
  every other read path already applied to it.

- [#1707](https://github.com/nextlyhq/nextly/pull/1707) [`9c8d64d`](https://github.com/nextlyhq/nextly/commit/9c8d64d5e41a85d7cf5c76d4e5b7fc66b3134f7c) @mobeenabdullah: An author who opened a document somebody else was already editing had exactly one thing they could do about it: take the document over, displacing a colleague mid-sentence.

  The polite option was to close the tab and hope.
  
  The lock strip now offers "Request edit access" beside "Take over", and the
  holder is told — passively, in the same strip — that someone is waiting. Nothing
  else changes: the request moves no claim, asks the holder for no answer, cannot
  be refused, and the lease expiring stays the only thing that transfers a
  document. Every action the holder had, they keep, including the Save they are
  being given time to reach.
  
  The ask is a standing one rather than a single message. The editor that is
  locked out is already polling for the document on every beat, so the request
  rides that poll and is re-stated for as long as the person is still there and
  still waiting. Close the tab and it lapses, so a holder is never nudged on
  behalf of a colleague who has gone.
  
  Pressing the button is answered. The strip replaces it with "We have let Bob
  know you are waiting" — text, not a disabled button, because a control that
  stays on screen having stopped doing anything is what a broken one looks like.
  The confirmation is shown only when the SERVER has the ask on record, so a
  request that landed nowhere cannot be reported as delivered.
  
  The holder's notice is a `status` region and not a dialog. There is no APG basis
  for one here — that pattern is for urgent interruptions a person must answer —
  and it is spoken once when it appears rather than on every heartbeat.

- [#1856](https://github.com/nextlyhq/nextly/pull/1856) [`f6ddafa`](https://github.com/nextlyhq/nextly/commit/f6ddafa0c3ed698309cd9c1f42449955955718d4) @mobeenabdullah: A field added to a collection now becomes the same column it would have become when the table was created.

  Three implementations answered "what column is this field?", and the one the
  product READS through — the canonical descriptor, used by the runtime Drizzle
  table and by the schema diff — was not the one the Schema Builder wrote with.
  So the table a user got was not the table the rest of the system believed it
  had, and the failure surfaced far from its cause: a diff proposing the same
  change on every run, or a write rejected by a column whose type nobody
  declared.
  
  Both paths that CREATE a column now read the descriptor. The path that alters
  an existing column deliberately does not, and that boundary is the whole safety
  argument: restating a live column under a mapping it was not built with is a
  narrowing `MODIFY` on MySQL that truncates stored values for an edit that
  touched nothing but a flag. No existing table is altered by this change.
  
  Two of the fixed disagreements were losing data outright on a column added to a
  table that already existed:
  - A number field set to `float` was added as a whole-number column on all three
    databases, silently discarding every fraction written to it. It is now
    `float8` / `double` / `real`, which is what the same field gets when its table
    is created.
  - A text field declared short was added unbounded on PostgreSQL and MySQL,
    losing the width the field asked for.
  
  Both happened because the ADD COLUMN path rendered a column from the field's
  type and length alone and never saw its options or validation.
  
  One family of disagreements is deliberately NOT converged here. The descriptor
  stores a field holding many values (`hasMany` numbers, uploads, relationships)
  and a repeater or group as a JSON array where these generators emit a scalar.
  That changes the column's storage class rather than its spelling, and the type
  is not the only thing that would have to move with it: indexability is decided
  from the old rendering, a relationship attaches a scalar foreign key, validation
  bounds are emitted as a comparison against the column, and a required column's
  backfill derives a scalar default from the declared type. Taking the
  descriptor's type alone would emit `CREATE INDEX` on a JSON column, a foreign
  key from an array to a scalar id, and `json NOT NULL DEFAULT 0`. Those stay
  recorded as known disagreements until the consumers move in one change.
  
  A MySQL `MODIFY` issued only to change a column's nullability now restates the
  column's LIVE type, read from the catalog, rather than a rendered one. MySQL
  restates the whole definition on every `MODIFY`, so such an edit has to name a
  type — and once new columns come from the descriptor, no renderer is right for
  both: rendering the old mapping narrows a float created as `double` to
  `decimal(10,2)`, and rendering the descriptor narrows a `select` created as
  unbounded `text` to `varchar(255)`. Both truncate, for an edit that asked for
  nothing but a required flag. The database knows which it is, so it is asked. A
  caller that does not read the live table keeps the previous behaviour.
  
  Some column types are spelled differently as a result — `int4` for `integer`
  and `bool` for `boolean` on PostgreSQL, `tinyint(1)` for `boolean` on MySQL.
  These are the same types under the names the descriptor and the database's own
  catalog use, not storage changes.
  
  One consequence worth stating plainly: on MySQL a `select` or `radio` column
  created from now on is `varchar(255)` where it used to be unbounded `text`,
  because that is what the descriptor says. Nothing existing is affected and
  nothing can be truncated, since only columns that do not yet exist are rendered
  this way. Whether the descriptor should instead move to `text` for these types
  is a separate open question.
  
  The conformance matrix that pins these three implementations against each other
  lost 23 accepted disagreements, all of them on the collection generator's
  create and add-column paths. Its ratchet is checked in both directions, so an
  entry describing a disagreement that no longer happens fails the suite — the
  list could not have been left stale.

- [#1817](https://github.com/nextlyhq/nextly/pull/1817) [`1ca2733`](https://github.com/nextlyhq/nextly/commit/1ca273325ff393fc3dead74febc678985014e16a) @mobeenabdullah: A new package, `@nextlyhq/plugin-mcp`, for exposing an install to AI agents over the Model Context Protocol.

  Experimental, read-only when it arrives, and inert in this release: it contributes no route, no field and no permission, so installing it today changes nothing. It is published now so that the protocol surface lands as additions to a package that already exists rather than as one drop, and so its release path is proven before anything depends on it.
  
  `enabled` defaults to `false` and stays that way while the surface is
  experimental. What the endpoint will expose is an install’s schema and content
  to any client that can reach it, so a version bump must never be what starts
  serving it.

- [#1882](https://github.com/nextlyhq/nextly/pull/1882) [`dbebaf7`](https://github.com/nextlyhq/nextly/commit/dbebaf7fb786e7a14c1998267662d12b6245c014) @mobeenabdullah: Saving a published page no longer puts the edit live.

  Pages now keep a working draft, as components and Layouts already do: Save stores the draft while the site keeps serving what was published, and Publish changes puts the draft live. A new page offers Save draft and Publish.
  
  Existing sites see this on their published pages from the first save after
  upgrading, with no migration. Pages also gain autosave recovery points while an
  author works in the page editor.

- [#1860](https://github.com/nextlyhq/nextly/pull/1860) [`ab3791d`](https://github.com/nextlyhq/nextly/commit/ab3791d667180008cb83be4ef0acb514b3d2f130) @mobeenabdullah: An edit made to a collection that has been saved but not yet deployed no longer narrows the column its own creation migration is about to write.

  Such a collection has a registry record and no table, and the two artefacts
  replay in order: the create runs first, then the edit. Everything the edit
  believes about that table is therefore a prediction, and the indexes and
  foreign keys were already predicted for exactly this reason. The column TYPES
  were not — they were reported as unknown, which sent MySQL's `MODIFY` to the
  legacy renderer.
  
  The two renderers now disagree by design, so that fallback had a consequence: a
  float field is created as `double`, and a follow-up requiredness edit in the
  same window emitted `MODIFY COLUMN ... decimal(10,2)`. Both artefacts are
  correct read alone, and applying them in order narrows and rounds a column the
  deployment had just built.
  
  The planned types are predicted by the same class that emits the CREATE, beside
  the planned indexes and keys, so a prediction and the statement it predicts
  cannot describe different columns.

- [#1823](https://github.com/nextlyhq/nextly/pull/1823) [`5992aab`](https://github.com/nextlyhq/nextly/commit/5992aabbae4e25b1a3a672e883c231f1c79336d7) @mobeenabdullah: A plugin can contribute a dashboard-widget DATA SOURCE.

  `contributes.widgetSources` takes a source and the server-side function that answers it, together: the source declares its queryable fields and supported ops the way every other source does, and the resolver is handed the query, the caller, and its own plugin context to read through.
  
  What the validation bounds is the SHAPE of the question -- field names,
  operators and operand shapes, all against the source's own declaration. It does
  not constrain operand VALUES, so a resolver must treat every value in a query
  as caller-controlled and must not use one as an outbound URL, a path, or a
  table or column name without checking it against a closed set of its own.
  
  Both halves travel in one value, so a source that nothing can answer is not a
  state a plugin can reach. A contributed id must sit in the `plugin:` namespace;
  `collection:`, `single:` and `system:` are refused at boot, as is a duplicate id
  or a missing resolver, each naming the plugin that declared it. A disabled
  plugin contributes nothing, and a boot starts from an empty store, so a source
  never outlives the plugin that published it.
  
  The dashboard query endpoint now bounds each slot. The batch answers with
  `Promise.all`, so it was only ever as fast as its slowest query: one that never
  settled held every other card behind it and the reader saw nothing at all. A
  slot that exceeds its budget now fails on its own and its siblings still answer.
  
  `WidgetSourceResolver`, `PluginWidgetSource` and `ReadCaller` are published from
  `@nextlyhq/plugin-sdk`, so a resolver can be written as a named function rather
  than only inline. They are `@experimental` on the same ladder as the rest of the
  widget contract.
  
  A contributed resolver is handed its plugin's own context, so it can read data
  to answer with. A plugin's services are reachable through nothing else, so the
  first shape of this contract could return constants and little more.
  
  The resolver type a plugin author writes against is published as
  `PluginSourceResolver`. The existing `WidgetSourceResolver` is core's own
  two-argument shape and rejects the context parameter a contributed resolver
  needs, so typing one with it made the contract reachable only inline.
  
  A plugin's managed read keeps the claims the caller's token proved. The
  identity was rebuilt from id, name, email and roles alone, so a collection's
  code-defined access rule reading a tenant, plan or entitlement was judged on a
  different caller than the request authenticated -- denying a positive check,
  and granting an absence-tolerant one.

- [#1684](https://github.com/nextlyhq/nextly/pull/1684) [`a2cd0f3`](https://github.com/nextlyhq/nextly/commit/a2cd0f30c363c10409d6872d799f832422f567ce) @mobeenabdullah: A plugin can now serve a route at a top-level path instead of under `/plugins/<plugin-name>`, by declaring `mount: "root"`.

  That is what lets a plugin take over an endpoint the core stops shipping without every caller having to change the URL they already use.
  
  It cannot take a path the core serves. Root routes are matched only after the
  built-in router has declined, so the core's answer always comes first: a plugin
  declaring `/collections` gets the collections API like anyone else, not control
  of it. The ordering is the guard rather than a list of reserved prefixes, which
  would have to be updated every time the core gained a route and would fail
  silently when it was not.
  
  Two plugins still cannot claim one address: rooting a route keeps the collision
  check that the namespace used to make unnecessary. That check asks within a
  mount, because the two are matched in separate passes and a root route that
  merely resembles a namespaced one never competes with it for a request.
  
  A route is also refused at boot when it is rooted somewhere the request never
  arrives, rather than registering and silently answering nothing. That covers
  `/auth`, `/plugins`, `/admin-meta` and the development endpoints, all of which
  are answered before the point a root route is consulted. A `mount` outside
  `"plugin" | "root"` is refused for the same reason: only an untyped caller can
  produce one, and it would register under one name and be looked up under
  another.
  
  `PluginRouteMount` is exported from `nextly` and `@nextlyhq/plugin-sdk` beside
  `PluginRoute`, so a plugin author writing a reusable route builder can name it.
  
  A request that could reach a plugin route always waits for initialisation to
  finish, not merely for the routes to appear. The route registry fills partway
  through startup, so a second request arriving in that window could previously
  run a handler before permissions were seeded and migrations had settled.
  
  Nothing changes for a route that does not ask. The default is unchanged, so
  every existing plugin route stays where it is.

- [#1819](https://github.com/nextlyhq/nextly/pull/1819) [`39c96f3`](https://github.com/nextlyhq/nextly/commit/39c96f3f1248cb5a56bddf8f4d0ae76d9972d577) @mobeenabdullah: A plugin can update many entries in one call.

  `ctx.services.collections` ended at `createMany`: plugin code could write many rows in one call and then had no way to change them in one, elevated or not, so the only batch update available to it was a loop of `updateEntry` calls, each with its own transaction, its own access pass and its own cache flush. `updateMany(slug, entries, opts?)` takes one `{ id, data }` per row, so a single call can apply a different patch to each row, and returns the same `BatchOperationResult` `createMany` returns: partial success, with `errors[].index` indexing the array the caller passed. There is deliberately no by-filter form; `listEntries` and this method compose to the same thing with the rows named, and a filter that matches more than its author meant is the failure a batch write cannot take back. A `locale` is refused by name, as on `createMany`, because the bulk pipeline writes in one pass and cannot store a translation. `@experimental` on the plugin surface until a first-party plugin exercises it.

- [#1769](https://github.com/nextlyhq/nextly/pull/1769) [`4de79a2`](https://github.com/nextlyhq/nextly/commit/4de79a2137618d5541d45ee0bec95fe4d7e791a2) @mobeenabdullah: A plugin can now name the locale it reads or writes in.

  `ServiceOpts` — the options every `ctx.services.collections` call takes — gains `locale` and `fallbackLocale`, spelled as the request context and the REST API already spell them, and both are carried through to the collection services. Until now the request context could hold the pair and every read accepted it, but nothing on the plugin path could say it and the facade's forwarding seam dropped it even when a context did: every plugin read and write on a localized site was in the default language, silently. A code that is not a configured locale resolves to the default on a read, as it does on the wire, and is refused on a write, so a typo cannot overwrite the default language's content. `createMany` refuses any locale by name rather than filing the rows under the default language silently; its bulk pipeline cannot store a translation yet. `deleteEntry` refuses one likewise: a delete removes every translation with the document, and a locale on it would read as removing one. The selectors `*` and `all` are refused outright, from one classifier now exported as `isLocaleSelector` from `@nextlyhq/plugin-sdk`: a value forwarded from a query string must never be able to publish every translation of a document. The same spellings are reserved at localization configuration, together with `none`, the wire's spelling of no fallback — a site can no longer name a language `all` or `none`, neither of which could ever be read, written or chosen as one. An empty locale reads as none named.
  
  The form builder uses it for the one place a visitor could see the gap. A
  form that redirects to a picked page read that page with no locale, so a page
  whose slug is `thanks` in English and `merci` in French sent every visitor to
  `/thanks`. `submitForm` takes `locale`, and the built-in
  `POST /api/forms/:slug/submit` reads it from `?locale=` — the same place
  core's own routes take a locale — so a French submission is answered with the
  French URL, and one that names no language is answered as before. The target
  is read as the visitor rather than as the system, so a translation still in
  draft is never the URL a visitor is sent to; the published default answers
  until it is published. The read wildcards (`all`, `*`) are not a language and
  read as none.

- [#1673](https://github.com/nextlyhq/nextly/pull/1673) [`69b9aa5`](https://github.com/nextlyhq/nextly/commit/69b9aa535d9073c5ab693fdb0a86d09f743276ac) @mobeenabdullah: A plugin route can now ask what its caller may do, not only who they are.

  `ctx.caller` carries `authMethod`, the authenticating `apiKeyId`, the token's
  verified `claims`, and `can(action, resource)` — answered through the same
  machinery that decides a route's own `requiredPermission`: a scoped API key on
  its own stamped grants and the code-defined rule evaluated against them, and a
  session through the RBAC service with its super-admin bypass. A super admin does
  not bypass an API key's scope.
  
  This is additive and changes no existing behaviour. It exists because
  `ctx.authenticatedScope` answers only for API keys — a session caller's grants
  are resolved on demand and it holds no stamped scope — so a route could not ask
  "may this signed-in author create in collection X", which is the question an
  admin panel needs in order to gate a create action before showing it.
  
  Deliberately not a permission array: a session's is empty by design, so a route
  reading one would refuse every signed-in user while appearing to check
  something. The write remains the enforcement point; this is a UX and routing
  aid.

- [#1679](https://github.com/nextlyhq/nextly/pull/1679) [`113f82c`](https://github.com/nextlyhq/nextly/commit/113f82c827874ebace3f8cb4e2a120d6c695883a) @mobeenabdullah: A plugin-contributed route reached the collections through the managed facade without carrying the request it was serving, so a hook underneath one read a browser's write as background work.

  Every route written before that field existed names no request, which is all of them: the page-builder's save route and the form-builder's export routes among them. A request-scoped rule, a rate limit or an audit, stood down for exactly the traffic it exists to judge.
  
  The route dispatcher pins the request once, beside the caller scope it already
  pins, so a contributed route inherits it without naming it.

- [#1807](https://github.com/nextlyhq/nextly/pull/1807) [`348ceb9`](https://github.com/nextlyhq/nextly/commit/348ceb93a51cf8fca0ae55ee5dcbb132baeaa015) @mobeenabdullah: A database error from the adapters now says which operation failed on which table on every dialect.

  The context was attached only when the message did not already contain the operation's name, and on PostgreSQL and MySQL the driver's message quotes the failed statement — which Drizzle spells in lower case — so an `update`, `insert` or `delete` there, or any table or column whose name contains the word, arrived without it. The error still carries the table as before; only the message changes, and only where the context was missing.

- [#1854](https://github.com/nextlyhq/nextly/pull/1854) [`e700886`](https://github.com/nextlyhq/nextly/commit/e7008868e8b631c7e2ba4bc74b6e166b72d6eb41) @mobeenabdullah: A reader can send a first-run card away from the card itself, and dismissing one no longer stops later widgets from reaching them.

  A widget declaring `dismissible` draws a control on the card, outside edit mode: a card that addresses somebody who has just arrived should not require discovering the dashboard editor first. Framed cards carry it in their header; unframed cards float it in the free corner. It is offered only from the width at which editing -- the one route to bringing a card back -- is available, and `dismissible` must be a boolean on both declaration channels.
  
  Dismissing hides the placement rather than deleting it. The write has its own channel, so its failures never reach the editor's chrome, it locks every other layout write while it is in flight, it confirms against the refreshed dashboard before announcing, and focus moves into the widgets region when the card holding it goes.
  
  A layout row now records whether its reader ARRANGED it. A row written only by dismissals still follows the live registry -- a widget declared later is placed, and positions track the declared order -- with the reader's dismissals applied. The editor's save takes charge of the arrangement, and a row once arranged stays arranged whatever a later write says. Every row written before this reads as arranged, so existing dashboards are unchanged; the flag lives in the stored JSON, so nothing migrates on any dialect.
  
  `toggleHidden` and `remove` now announce through the grid's live region, where both used to change the dashboard in silence.

- [#1840](https://github.com/nextlyhq/nextly/pull/1840) [`16efd9c`](https://github.com/nextlyhq/nextly/commit/16efd9cf7383708209c5b8306f73efbac7b246fb) @mobeenabdullah: A function default cannot read a denied sibling from inside a group or a repeater row either.

  The create path judges the field rules on a copy of the request and hands that copy to the function defaults, so a default cannot read a value its writer was not allowed to send. When the rules removed a whole container, the copy had no counterpart for it, and the walk read that as no copy having been given at all: it fell back to the caller's own row, which is exactly the unfiltered data the copy exists to replace. A nested default could then carry a denied sibling into a child the caller may write, while the pass that decides what is stored removed only the field it came from. A container missing from the copy is now read as a container the rules emptied, which is the only way one goes missing.
  
  A Single's first read judges a required nested child by the rule the write validator will apply. `required: true` on the field and `validation: { required: true }` beside its other rules are both supported spellings, and this read tested only the first, so a group invented for one defaulted child was stored with a required sibling empty and the next write refused the document. The validator's own predicate is shared now rather than restated.

- [#1753](https://github.com/nextlyhq/nextly/pull/1753) [`08bce33`](https://github.com/nextlyhq/nextly/commit/08bce33d147922909894f2b00e245aae0e54e9f2) @mobeenabdullah: A class rename the host refuses with an empty or whitespace-only reason is now reported in the same words a rename that threw already used, rather than as an alert with nothing written in it.

  The refusal's shape is the documented one and the host did refuse, so silence would report a failed rename as a success; what was missing was only the words, and those are supplied.

- [#1752](https://github.com/nextlyhq/nextly/pull/1752) [`cde0bd0`](https://github.com/nextlyhq/nextly/commit/cde0bd0b8bf2425c2afe753c4c058060fcfefd9d) @mobeenabdullah: A class rename refused by a host is shown to the author only when the refusal carries the reason the panel is about to print.

  The guard that recognised an outcome read one field of it — whether `ok` was a boolean — and then narrowed to a type promising `reason: string`, so any unrelated failure-shaped result a host handed back was accepted as a refusal. `{ ok: false, error }`, which is what an ordinary mutation helper answers with, reported `undefined`.
  
  The notice renders on the reported reason not being `null`, and `undefined` is
  not `null`, so the author got an error box with nothing written in it and a
  screen reader announced an alert carrying no text — a rename declared failed
  with no way to learn why. A result this cannot vouch for is silence again, as
  it always was for a host that answers with nothing.

- [#1757](https://github.com/nextlyhq/nextly/pull/1757) [`9ca0cfd`](https://github.com/nextlyhq/nextly/commit/9ca0cfda21f4c3ddb57e970570b3aead8e36af90) @mobeenabdullah: `DropRegion.at` is now `DropRegion.target`.

  The module spelled two ideas with one word: on a region, `at` was the placement a block would be nested into, while on a `DropTarget`, `at` was a position and `target` was that placement. A host reading both had to remember which `at` it held. The region now names the placement as the target does, and `at` means a position everywhere in the module. A value still spelling `at` on a region is a type error rather than a silent fall-through; the package is `@experimental` throughout, so no alias is kept.

- [#1834](https://github.com/nextlyhq/nextly/pull/1834) [`c52aa59`](https://github.com/nextlyhq/nextly/commit/c52aa5970405a62f3bbd13df67722afa444a9d77) @mobeenabdullah: An edit to what a relationship does when the row it points at is deleted now reaches the database.

  A foreign key carried the actions the statement that CREATED it wrote, and
  nothing else ever changed them. Moving \`posts.author\` from cascade to restrict
  saved successfully and recorded restrict, while the database went on cascading
  — so deleting an author still destroyed their posts. The same held for a
  many-to-many, whose junction kept the actions it was built with.
  
  Both are emitted now, through one implementation. PostgreSQL and MySQL drop
  the constraint and declare it again under its own name, as two statements:
  MySQL rejects a drop and an add of one name in a single \`ALTER TABLE\`, and on
  PostgreSQL a single statement would depend on the order the server applies its
  subcommands in. A junction has both of its foreign keys rebuilt, and the table
  itself is left alone — rebuilding it would destroy every link it holds for a
  change that never needed to touch one.
  
  SQLite refuses the edit by name rather than performing it. It cannot alter a
  constraint at all, and the table rebuild that would be required has caused
  real data loss in three independent tools that automated it; refusing is what
  this dialect already does for a foreign-key drop and for a unique constraint
  it cannot enforce.
  
  Three things the statements meet on the way to the database are handled with
  them, because emitting the right SQL is only half of arriving:
  - They are emitted one statement per chunk. The runner splits a migration on
    its breakpoint markers and never on semicolons, and the MySQL driver is
    configured to refuse a query carrying more than one statement — so a
    semicolon-joined pair was rejected whole, and this edit did nothing at all
    on MySQL.
  - Turning a link optional now relaxes its column as well as its key, in that
    order, and turning one required replaces the key before tightening the
    column. A relationship's requiredness never moved its column before: the
    descriptor calls the column nullable whichever way `required` is set, so an
    optional link kept a NOT NULL column while its key moved to `SET NULL` —
    which MySQL rejects outright, and PostgreSQL accepts and then fails on the
    first delete, in production.
  - The key that is dropped is the one the table actually carries, read from it
    rather than derived from a naming convention. A column whose key was
    installed under another name, or that carries none because it was edited
    from a scalar into a relationship, no longer aborts the migration.
  
  The edit is also paired the way the rest of the save pairs, rather than by
  name alone: a relationship renamed in the same save carries its action edit
  (it previously emitted the rename and left the old action enforced), and a
  field whose storage moved leaves its key to the path that creates it rather
  than declaring the same constraint twice. Many-to-many junctions are paired
  once for both their table move and their action edit, so a save that did both
  can no longer fall between the two.
  
  Two referential actions are now refused rather than emitted for a server to
  reject halfway. `onUpdate: "set null"` on a required relationship is the pair
  `onDelete` has always refused, reached through the other half. And `set null`
  on a many-to-many cannot hold at all: both link columns are `NOT NULL`,
  because a link naming nothing on one side is not a link.
  
  One behaviour changed on SQLite. A junction action edit was refused by name
  when the junction kept its table and silently ignored when the same save also
  renamed it — so whether the edit was refused or lost depended on whether you
  happened to rename. It is refused in both cases now. SQLite still cannot
  change a junction's referential actions; renaming one on its own is
  unaffected.
  
  Two further things the column's METADATA cannot answer are now asked of the
  column itself:
  - Installing `SET NULL` states that the column accepts nulls rather than
    inferring it from requiredness. A database migrated before a requiredness
    toggle relaxed anything still carries whatever `CREATE TABLE` gave the
    column, so a relationship both definitions call optional can be sitting on
    a `NOT NULL` column right now — and the statement is idempotent, so this
    also repairs the ones the old behaviour left behind.
  - Making a field required is refused, before any statement is written, when
    entries still leave that column empty. The server rejects the tightening,
    and by then the statements ahead of it have run — auto-committed on MySQL,
    including the foreign-key replacement a relationship's tightening is
    ordered behind, which would leave the table carrying no key at all while
    the save was recorded as made. The check reads the live rows; a caller that
    does not supply them keeps the behaviour it had.
  
  A definition already stored is read rather than judged. The previous creation
  path accepted a required relationship declaring `onUpdate: "set null"`, and
  refusing to READ that combination would have frozen the collection holding it:
  the repair is itself an edit, and every save visits every relationship the
  collection keeps, so one legacy field would have blocked unrelated changes to
  its neighbours. What a save ASKS FOR is still refused — including a save that
  flips requiredness onto a declaration that was legal before.
  
  What a field was is now answered in ONE place for every pass in a save. The
  action pass carried a renamed field's edit while the column pass, keyed on the
  new name, skipped the same field — so a link renamed and turned optional in
  one save had its key moved to `SET NULL` and its column left `NOT NULL`.
  
  The SQLite refusal in the schema templates is a `NextlyError` rather than a
  bare `Error` subclass, so it reaches a caller as the typed envelope every
  other refusal in the package uses. The class and its message are unchanged;
  callers and tests identify it by type.
  
  Two things about the check above, both found before it shipped:
  - It asks only about columns that are ON this table. A localized collection
    keeps its translatable columns in a companion, so probing the main table for
    one asked for a column it does not have — and that error arrives before any
    migration is generated, which would have failed every save on a localized
    collection with an optional translatable field.
  - Relaxing a column for a `SET NULL` key keeps the default it carries. MySQL
    restates the entire column definition on `MODIFY`, so a narrower rendering
    silently removed the relationship's configured default for future inserts.
    Both callers now render that statement through one function.
  
  A column the live table does not have YET is its own answer, not a clean one.
  Its `ADD` is queued, so it holds no nulls today — and reading that as "no
  nulls" let a save make the field required, after which the deployment creates
  the column holding NULL in every existing row and fails on the next statement.
  Absence is reported separately now and refused only where the table already
  has entries, with its own message: deploy the change that adds the field
  first, fill the entries in, then make it required.
  
  Which columns exist is read from the database rather than predicted from the
  field definitions. Predicting it was wrong twice — a localized collection
  keeps its translatable columns in a companion table, and a deployment holding
  an unapplied migration has a field whose column is not there yet — and each
  repair only covered the reason someone had just met. The reader now narrows to
  the columns the catalog reports before it probes anything, so it cannot be
  asked about a column that is not there whatever the caller believed. The
  caller's list is a query-reduction narrowing and says so.
  
  The requiredness precondition uses the save's own rename pair, like every
  other pass in a save. A field renamed and made required in one go read as
  newly added, skipped the refusal, and had the tightening emitted anyway; the
  nulls are recorded against the column the live table still has, so that is the
  name the check reads.
  
  The live column read is resolved to the catalog's own spelling of the table.
  MySQL under `lower_case_table_names=1` answers a query for a mixed-case table
  by reporting the name it folded, so the map came back keyed as `dc_posts` for a
  table asked about as `Dc_Posts` and an exact lookup missed the very table it had
  just described. That miss reported no nulls and no absent columns, which is
  indistinguishable from a clean table, so the refusal above withdrew itself on
  exactly the server whose DDL auto-commits. The folding rule is given rather than
  queried, for the reason the apply pipeline already records: the only name being
  matched is the one this read just asked the server to describe, so a
  case-insensitive match cannot select a different object. PostgreSQL is left
  case-sensitive, where the two spellings are two different tables.
  
  Singles ask the same question of their own table. The precondition is keyed
  entirely on facts the caller supplies, so a caller that reads none does not get
  a weaker check — it gets no check: the refusal returns at its first guard. The
  singles path read whether the table had rows and which columns carried keys and
  indexes, and never read the null state, so a single that tightened a field over
  an entry leaving it empty, or over a column an undeployed migration has not
  added yet, still had the nullability statement written for PostgreSQL and MySQL
  to reject. It reads both now, through the same reader the collection path uses,
  narrowed to the columns this pass will actually diff — a localized single keeps
  its translatable columns in a companion table and they are not this pass's to
  ask about.

- [#1796](https://github.com/nextlyhq/nextly/pull/1796) [`10ef8d9`](https://github.com/nextlyhq/nextly/commit/10ef8d95573eebad5bfdd8bd2daebdfb7193f141) @mobeenabdullah: A many-to-many field's junction table now follows the field through its life in the migrations the builder writes.

  Adding a many-to-many relationship field has always created its junction table; nothing removed it. A removed field left the table standing with every link in it — unread, and inherited by any field added later under the same name, because the creation runs `CREATE TABLE IF NOT EXISTS`. A renamed field was worse: the rename detector only pairs fields that have a column, so a many-to-many rename fell to the add/drop loops, which created a fresh, empty junction for the new name and left the old one, links and all, orphaned. Dropping a collection dropped its table and its `_locales` companion and left its junctions behind.
  
  Now:
  - **Removing** a many-to-many field emits `DROP TABLE IF EXISTS <junction>`, the table resolved by the same rule the creation uses (the author's `junctionTable` name when set, the generated name otherwise). Moving the field to a storage class that has a column does the same, then adds the column.
  - **Renaming** a many-to-many field — one removed and one added that point at the same target under the same relation kind — emits `ALTER TABLE <old> RENAME TO <new>`, spelled the same on PostgreSQL, MySQL and SQLite, so the links travel with the name, and then renames every index and constraint whose generated name embedded the old table name (PostgreSQL renames them; MySQL renames the indexes and re-declares the foreign keys under their new names; SQLite rebuilds the indexes and needs nothing for its per-table constraints). Without that, a later field reusing the old field name would find its index and constraint names already taken. A junction the author named keeps its name and emits nothing. More than one such pairing in a single save is refused by name (`MANY_TO_MANY_RENAME_AMBIGUOUS`), as two field-group renames already are: a wrong pairing would hand one field the other's links. Removals and additions that pair with nothing are plain drops and creates.
  - The junction lifecycle is decided on the **full** field lists, so a `localized: true` many-to-many — which the column diff of a localized collection never sees — is created, renamed and dropped like any other; and a save that renames a many-to-many and a field group together carries both.
  - **Dropping** a collection drops the junctions its own fields own before the companion and the main table. A junction that another collection's field points at this table through is that field's, and stays with it; what should happen to such a field when its target collection is dropped is a separate question, filed for a decision.
  
  `generateDropTableMigration` takes the collection's fields (defaulting to none) so it can name those junctions; the collections delete path passes them.

- [#1868](https://github.com/nextlyhq/nextly/pull/1868) [`c5f8fe8`](https://github.com/nextlyhq/nextly/commit/c5f8fe82e43e86e29760a434129b68b524670192) @mobeenabdullah: Saving part of a page as a pattern no longer gives a block an id it never had.

  When a pattern is inserted beside an element that already uses one of its ids, the insert renames the copy (`pricing` becomes `pricing-4985ccb3`) and records the rename, so a later save can store the pattern under its own name again. That record said what an id became but not which block it belonged to, so a block added afterwards and given the renamed id was also saved as `pricing`.
  
  An insert now also records which blocks it renamed, as `renamedNodes` beside
  `renamed` on the inserted root's `origin`. A save restores an id only on a block
  listed there that still exists once on the page and still carries the renamed
  id. A block that was deleted, whose id was changed, or that was added later
  keeps the id it has. Hiding a block behind a visibility condition does not
  change this.
  
  Existing pages behave as they do today. A pattern inserted before this release
  has no such list, and saving from it restores by id exactly as before, including
  the case above. Inserting the pattern again writes the list, and saving over a
  pattern keeps whichever form its record already has. `patternRenameRecord` reads
  a stored record's renames and its list together, in one pass.

- [#1857](https://github.com/nextlyhq/nextly/pull/1857) [`9bac152`](https://github.com/nextlyhq/nextly/commit/9bac152475a676d84d360bef7a6f999cbc9986f1) @mobeenabdullah: A rename of a site class survives the panel it was started from.

  The class manager lives in a rail that unmounts it on every switch, and that
  switch is exactly what makes two renames of one class overlap: the author
  renames, moves away, comes back, renames again, and the first write is still on
  the network. Two things were decided from state that the unmount destroyed or
  that the wrong attempt cleared.
  
  The name a class is HEADING FOR is now released only by the rename that is still
  the live one. An earlier attempt finishing under a later one used to release it
  for both — and the panel then judges the next edit against the name on screen
  rather than the one being written, so typing the original back reads as "nothing
  changed" and is silently dropped while the later rename goes on to persist a
  different name.
  
  And whether an answer still describes the rename being attempted is now decided
  from an identity the HOST owns. Kept inside the field, it was destroyed by the
  unmount and started again from zero, so the first answer to arrive after a switch
  passed as the current one: a refusal for a rename the author had already replaced
  was raised from the shell, naming an edit that no longer existed.
  
  Both decisions now read the same identity, because they are the same question
  asked twice.

- [#1692](https://github.com/nextlyhq/nextly/pull/1692) [`dc38f47`](https://github.com/nextlyhq/nextly/commit/dc38f4758422964a2ff34fe5556020d552e4b3dc) @mobeenabdullah: A plugin route can compute the permission it requires.

  `requiredPermission` took a fixed slug, and a permission slug spells a
  collection — which the host can rename. A route gating on one of its own
  plugin's collections therefore had to choose between demanding a grant nobody
  was seeded on the installs that renamed it, or declaring no permission at all.
  The page builder's save-pattern route chose the second, so a write was reachable
  by any authenticated caller.
  
  It now also accepts a function of the plugin's own resolved names:
  
  ```ts
  requiredPermission: ({ collection }) => collection("patterns", "create"),
  ```
  
  The scope composes the slug through the same helper core seeds with, so a route
  never spells one itself, and the demanded grant follows a rename. A resolver
  that throws refuses the request rather than falling through to the ungated path.

- [#1699](https://github.com/nextlyhq/nextly/pull/1699) [`c293917`](https://github.com/nextlyhq/nextly/commit/c293917acd1e74d5d8722696589c31ba69d90bc2) @mobeenabdullah: A dashboard chart's table draws its row separators at full strength.

  At half alpha they measured 1.11:1 against the page surface, where WCAG asks
  3:1. A separator in a data table is structural rather than decorative — it is
  what tells a reader which number belongs to which row — so it carries the
  contrast a reader needs, and it now matches the shared table primitive every
  other table already uses.
  
  The plugin-route documentation's scope-narrowing example is a complete route
  rather than a fragment. It referenced `ctx`, `id` and `data` with nothing
  declaring them, so a reader copying it got three errors and could not see where
  those values come from. It now shows the handler they arrive in.

- [#1875](https://github.com/nextlyhq/nextly/pull/1875) [`3d60b11`](https://github.com/nextlyhq/nextly/commit/3d60b11362c0faf2b55685ae08a6b3513fb871d7) @mobeenabdullah: Saving a component could do far more work than its bound said.

  Before a save, each of the component's variants is composed to check that the component does not end up referencing itself, and that work was charged only for the component's own nodes. A small component placing a large one composed the large one again under every variant, and a large component naming itself was not checked at all on a save made without the Direct API.
  
  Each composition is now charged, by the composer itself, for every entry it
  examines, including expansions it abandons and nodes an override hides, against
  one allowance per save. A save that spends it is refused, and the message says
  what the author can change. `resolveComponentInstances` takes that allowance as
  the `work` option, and reports `loopsClosed`, every component a reference loop
  closed on, so a loop found just before the allowance ran out is still named as a
  loop rather than as spent work.

- [#1803](https://github.com/nextlyhq/nextly/pull/1803) [`4e25eb2`](https://github.com/nextlyhq/nextly/commit/4e25eb229a30824e620457bdf24ffe527ad716be) @mobeenabdullah: A `single:<slug>` widget source is executable.

  `WIDGET_SOURCE_KINDS` named the kind and nothing published or ran it: a widget
  query over a single was refused as "not executable yet". Every single the
  install has is now published as a source beside the collections, from the
  singles registry at request time, and a `list` query over it reads the one
  document through the same access-controlled read as the API -- the single's
  own rules, code-defined ones included, decide the answer -- and returns it as
  a list of one row projected to the selection. A single answers a fixed
  question, so `list` is the one op it supports: a `where`, a `sort`, a `count`
  or a bucketing over it is refused by name rather than answered.
  
  The dashboard offers one status card per single a reader may read -- whether
  it is published, and when it last changed, linking to the single -- the way it
  offers cards per collection: never placed, only offered. The `singles:present`
  and `collections:present` conditions now answer from the registries' own
  readable listing -- what the management cards they gate list -- so a
  collection or single whose migration is still pending keeps its card on the
  dashboard instead of disappearing exactly when it needs attention. A single
  whose DDL a reload refused is withheld from the widget sources the way a
  collection's is, from one shared store of deferred entities.
  
  A Single's not-found answer now says which Single it is about: the error's
  `data` carries `{ single: <slug> }`, the slug the caller named. A draft-only
  Single and a nonexistent one still answer identically.
  
  A single's widget query projects OWN properties, so a field the read removed
  for a caller -- one named `toString` or `constructor`, which a single may
  legally declare -- stays removed instead of answering with the value
  `Object.prototype` carries.
  
  A collection or single whose metadata sync could not store its new field list
  is withheld from the widget sources for the rest of the process, beside the
  ones whose DDL a reload refused: in both cases the registry's description and
  the table are known to disagree, and a card drawn from one queries a shape the
  database does not have.
  
  A single the boot cannot register -- because a collection already holds its
  slug, say -- now fails the boot naming it, as a collection in the same state
  already did, instead of leaving the app running without a single its config
  declares.
  
  A collection, single or field group can no longer take a slug another kind
  already holds. Slugs are one namespace across kinds -- as
  `defineConfig` already enforced for an app's own config -- and before, such a
  boot succeeded while the registry silently refused one of the two at sync, so
  an app's single could vanish behind a plugin's collection of the same slug.
  The boot now fails with `NEXTLY_SCHEMA_SLUG_COLLISION`, naming both owners in
  the log. The same rule is applied to the entities the Schema Builder owns,
  once the registry is readable -- at the runtime boot and on the CLI -- so a
  Builder single and a plugin collection under one slug are refused up front
  rather than at registration, where one of the two was rejected by a message
  naming neither the other kind nor its owner.
  
  Under `next dev`, a single whose fields you edit keeps its source and its
  status card: the reload re-marks an edited single's migration as applied from
  the sync's own report, as it already did for an edited collection, instead of
  leaving the row `pending` for the rest of the session.
  
  A widget query asking for `draft` or `published` from a source that has no
  publish lifecycle is now refused, instead of being accepted and answered as if
  no state had been named. Nothing downstream could apply such a selector, so
  the two mutually exclusive questions came back with one answer and the card
  said nothing about having been ignored. `status: "all"` is unaffected: it
  claims no lifecycle, and it is what the generated count, recent and timeline
  cards send.
  
  A single whose metadata a `next dev` reload could not store now keeps its
  widget sources withheld even when the rest of its kind synced cleanly. The
  singles sync reports a per-single refusal without failing the scope, so a
  reload that refused one single had been clearing the whole deferral set and
  republishing that single's stale field list.
  
  A `next dev` reload whose metadata sync fails now withholds that kind's widget
  sources rather than publishing them over tables the apply has already moved,
  and a single the sync refused no longer has its migration recorded as applied
  -- a label that persists, so the old field list came back against the new
  table on the next restart. A boot also starts with nothing withheld: the
  refusals one boot recorded are its own, and a slug held over from a previous
  boot kept its source and cards hidden for the life of the process.
  
  A collection or single whose registry row was written and whose permission
  seeding then failed keeps its widget source and has its migration marked
  applied. Both registries report such an entity in `errors` as well as in
  `created`, and reading the error alone withheld a source whose stored metadata
  was in fact current -- permanently, since every later pass read the same report
  the same way.

- [#1826](https://github.com/nextlyhq/nextly/pull/1826) [`ebd0b24`](https://github.com/nextlyhq/nextly/commit/ebd0b243cd812d63d5cd5f90aa054f6648e671e8) @mobeenabdullah: Publishing a Single now re-judges the pending change it is about to promote, against the schema and the permissions as they stand at that moment.

  A Single's publish folds its held draft into the live row. Both gates ran when the caller's payload arrived, and for a publish that payload is just the new status, so the draft's own content reached the live row having been judged only when it was SAVED. Two things could have changed since. The publisher may not be the author, and a field rule can deny them a value the author was allowed to write. And the schema can have tightened under a value that was legal when it was held.
  
  BEHAVIOUR CHANGE, and it is visible. A draft written under an older, looser schema is now REFUSED at the moment someone hits Publish, having raised no complaint when it was saved. The refusal names each field and carries the rule's own message, so the author can see what to fix. Publishing content that violates the current contract is the worse outcome.
  
  A pending change that edits a field the PUBLISHER may not write is refused too, rather than being quietly dropped from the write. A successful publish consumes the pending change, so dropping the value would have published everything else, deleted the draft, and destroyed the author's edit with it. Refusing keeps the draft intact for someone who can write that field. Only a field whose promoted value actually differs from the live row counts: a draft snapshot is a full copy of the document, so a denied field appears in every one of them.
  
  Both gates run over the snapshot being promoted and never over the live document, so a schema change cannot block someone from fixing and republishing unrelated content. They run over that snapshot in its logical shape, so a Single that merely HAS a group, repeater or JSON field is not refused for holding one.
  
  Both publish paths are covered by one gate: the ordinary publish and `publishAllLocales`, which promotes every language's pending change in its own loop and had no such check at all.

- [#1800](https://github.com/nextlyhq/nextly/pull/1800) [`e8e8d78`](https://github.com/nextlyhq/nextly/commit/e8e8d7855ca06ce444c9dfaba4ed23b45a64ceb4) @mobeenabdullah: A Single read now applies field-level `access.read` rules before its field-level `afterRead` hooks, and again after them, the way a collection read always has.

  Before, a denied field's own hook ran and saw the value, and a hook on an allowed sibling could read the denied value; the response was redacted, but app code had already seen it. Access decides on what is stored; hooks shape what is returned.

- [#1687](https://github.com/nextlyhq/nextly/pull/1687) [`adfafa3`](https://github.com/nextlyhq/nextly/commit/adfafa36762a24745b69ca1e14367f310940fbce) @mobeenabdullah: A spacing drag handle sat on the wrong edge for most margins, and dragged backwards there.

  Which edge of a band moves when its value grows is a property of the layout
  rather than of the box. Measured in Chromium, growing `margin-top` drives the
  block's border edge down while its outer edge stays pinned by whatever precedes
  it; `margin-left` does the same against the container, and so does `margin-right`
  on a block whose width is auto — while `margin-bottom`, and `margin-right` on a
  fixed width, do the opposite. The editor now asks the element on every side, for
  margins as it already did for paddings, so the handle sits on the edge that
  responds and the drag follows the pointer.
  
  It asks each time it draws, so the handle follows whatever the block is doing
  whenever anything prompts a fresh look: an edit that settles a height, a
  breakpoint that swaps the width model, a container query answering to a sibling,
  a state being previewed. None of those has to be anticipated for the handle to
  be right. A margin that changes only under a real `:hover` is still described at
  rest, which is what the editor has always done, because nothing reports that.
  
  The measurement also leaves the page alone properly now. It works by pushing a
  value onto the block and putting it back, and on a block with no inline style of
  its own the attribute was left behind empty — invisible on screen, and visible to
  everything in the editor that watches the page for edits. Nothing is left behind.
  
  Two smaller corrections come with it. A handle on a negative margin now grows
  the value in the direction the edge actually travels, instead of committing a
  larger negative number and running the block away from the pointer. And a
  transformed block is measured against the scale its margins are laid out in
  rather than the one it renders at, which had inverted the handle on any block
  carrying a transform of its own.

- [#1859](https://github.com/nextlyhq/nextly/pull/1859) [`812f167`](https://github.com/nextlyhq/nextly/commit/812f16767fec5aecb0acf41711ddc1d7047c8e26) @mobeenabdullah: A publish that also edits a group, repeater or JSON field no longer fails validation.

  When a pending change existed, publishing it together with an edit to any group, repeater or JSON field was refused as "must be an object", with no field rules involved at all. The ordinary write encodes those fields to their column strings before the publish is judged, and the check on the document being published then read a group as text. Both checks now read the document in its logical shape, through the same conversion that reads the live row, and the write encodes it once.
  
  A field declared inside a group or a repeater is judged as content, however it is named.
  
  The publish gate skips the store's own bookkeeping columns, such as `id` and `updatedAt`, and defers to the field names the schema declares so that a real field with one of those names is still judged. Those names were collected in a way that stopped at the first named container, so a declared field nested inside a group or repeater was still skipped, and a Single's publish was not given the names at all. The names are collected at every depth now, for collections and Singles alike.

- [#1832](https://github.com/nextlyhq/nextly/pull/1832) [`9ad1744`](https://github.com/nextlyhq/nextly/commit/9ad17448d2176b786041efae68a86d73678bf3b4) @mobeenabdullah: The dashboard's onboarding checklist now offers "create your first collection" to every reader who could finish it, and to no reader who could not.

  An administrator who is not a super admin was never shown the step. Creating a
  collection seeds its permissions to the super-admin role, but the built-in role
  presets are re-resolved against the live permission list on every boot, and the
  `admin` preset covers a content collection — so an admin does reach what they
  created, from the next start. The checklist asked only about the immediate seed
  and hid a step that was theirs to take.
  
  An API key was offered it on any grant beginning `read-`. The read decision
  admits a key on an exact `read-<slug>` match, so a key stamped `read-settings`
  could only finish the step by creating a collection called `settings` — a
  reserved name. It is now offered the step only where its grant names a
  collection that could actually be created.
  
  Both answers are computed from the declarations that produce them — the seeder's
  role and the preset predicates, and the collection slug rules themselves — rather
  than restated beside them, so a preset or a reserved name that changes carries
  the checklist with it.
  
  A widget source contributed by a plugin is now keyed from the record the registry
  published rather than by reading the plugin's object a second time. A JavaScript
  `id` may be an accessor or a proxy, and a second read that answered differently
  filed the resolver under an id no source claimed, leaving the published source
  failing every query as unanswerable.
  
  A widget source's resolver now receives the host's `ResolverOptions` — a
  cancellation signal today — beside the question it is answering. The dashboard's
  per-slot budget already stopped waiting for a resolver that hung, but a promise
  has no cancellation, so the work went on running and further requests started
  more of it. The signal is aborted when that budget expires, so a resolver that
  passes it to whatever it calls (`fetch` takes one directly) stops rather than
  merely stops being awaited.
  
  The parameter is optional and arrives in an options object, so every resolver
  written before it keeps working unchanged, and anything the host offers later
  becomes a field there rather than another positional argument. `ResolverOptions`
  is published through `nextly` and `@nextlyhq/plugin-sdk` alongside the resolver
  types.

- [#1830](https://github.com/nextlyhq/nextly/pull/1830) [`1532b2e`](https://github.com/nextlyhq/nextly/commit/1532b2e28b095a859c02501c4a4b785b2ac01146) @mobeenabdullah: The setup checklist offers "create your first collection" only to a reader who would be able to READ what they create.

  A new collection's permissions are seeded to the super-admin role alone, so a caller holding the definition grant and nothing else created the collection, gained no read on it, and found the step outstanding permanently -- with the card pinned to their dashboard.
  
  An API key stamped with a read grant is offered the step it can finish: a key
  never gains a permission, but a pre-seeded `read-<slug>` lets it create that
  collection and read it afterwards.

- [#1783](https://github.com/nextlyhq/nextly/pull/1783) [`cd95546`](https://github.com/nextlyhq/nextly/commit/cd9554690379d1620eded8b7d58107ea43f6a669) @mobeenabdullah: An API key created by a Super Admin copies the permission catalogue rather than the role's rows.

  A Super Admin's power is a bypass, and the role only holds the permissions that existed when the install was set up, so a key of theirs held a stale subset at best and, where the first user came before the grant, nothing: every request refused, starting with the first key an operator minted to try an integration with. A `read-only` key of theirs now reads every collection the install declares, including one added later, and still cannot write; a `full-access` key holds every permission. A permission a package stopped declaring is not inherited. Whether the creator is a Super Admin is asked of the same resolver as the session bypass, so a role built on top of Super Admin counts here exactly as it does everywhere else.
  
  A plugin calling `ctx.services` as a user now sees that user's roles. The
  caller was built with an empty role, so a code-defined rule such as
  `req.user?.role === "editor"` refused every caller on the plugin path while
  the same caller's own request passed it, and a negative rule granted what it
  was written to refuse. The roles are resolved and the caller is built by the
  one constructor every other authenticated path uses.
  
  Changing a permission row now retires the cached answers derived from it, and
  retiring them is no longer a separate step a writer has to remember: every
  write goes through one place that does both. None of the methods writing those
  rows invalidated anything, and neither of the existing invalidations can
  express the change, because a permission row belongs to no user and no role. So
  a role-based key kept a renamed slug, and a Super Admin's key kept a deleted
  grant and missed a new one, until their entries aged out.
  
  A cached answer resolved before an invalidation is no longer written after it.
  Every cache here is filled from an asynchronous read, so a lookup that began
  before a role changed could complete afterwards and put the old answer back into
  a cache that had just been cleared.
  
  A call whose caller's roles could not be read is refused rather than run as a
  caller with none, with a typed error rather than the driver's own. The resolver
  behind it degraded a failed query to an empty set, which is safe for a rule that
  grants on a role and wrong for one that withholds on it: `user.role !==
  "suspended"` admitted a caller the database declined to answer for.
  
  This covers an API KEY's roles as well as a plugin call's. A read-only or
  full-access key resolves its owner's roles, and those populate the scope every
  later role rule reads directly, so a failed lookup arriving as an empty set was
  indistinguishable there from an owner who holds no roles. Both paths now ask one
  resolver that refuses, rather than each deciding for itself what an unanswerable
  question means.
  
  Losing the Super Admin role now takes effect at once. The cached answer to
  "is this user a super admin" was not cleared when roles changed, so a demoted
  user kept the session bypass until the entry aged out, and an API key's grants
  resolved through that answer could be cached for five minutes of their own on
  top of it. Role and permission invalidation clears it, and an API key's cached
  grants are retired with it: they are derived from the same rows, and nothing
  retired them when a ROLE changed, so revoking a role's inherited Super Admin
  left a key holding the whole catalogue and changing a role's permissions left a
  role-based key holding the old set.
  
  A caller that arrived on an API key is judged on the KEY's roles, not its
  owner's, the way the REST path already judges one. A stored role rule reads
  the caller's roles directly, so the owner's roles let a viewer-scoped key
  minted by an administrator satisfy an administrators-only rule, and refused a
  key holding the very role a rule names because its owner did not hold it.
  
  Stored permission answers now expire after five minutes rather than a day.
  `PERMISSION_CACHE_TTL_SECONDS` still sets it. The stored tier is shared between
  instances and the signal that retires it is held in memory, so an instance that
  did not handle a role change goes on serving what it stored until the entry
  expires; the default was a day, which is not a bound worth having on a revoked
  grant. Installs running a single instance see slightly more cache misses and no
  change in behaviour.
  
  A batch of permission writes no longer defers an unrelated revocation. Seeding
  retires the caches once at the end rather than per row, and the batch is now
  scoped to the operation that opened it, so a revocation raised while a seeder
  happens to be running takes effect immediately instead of waiting for the
  seeder to finish.
  
  A permission answer computed while the caches are being cleared is no longer
  stored. Clearing the shared tier is itself a write, and a check that both began
  and finished during it could read a row the clearing had not yet reached and
  promote it, which put a retired answer back into a faster tier that outlives
  the clearing.

- [#1747](https://github.com/nextlyhq/nextly/pull/1747) [`d29c54d`](https://github.com/nextlyhq/nextly/commit/d29c54daca0076cd0c41192946d2056046c66ac4) @mobeenabdullah: Renaming a class in the page builder could be refused without the author being told.

  A host that answers immediately — because the site style is locked, or the name is one it already knows is taken — had its refusal discarded, so the row cleared and the rename looked like it had worked until the next read. A host that answered a moment later was always shown correctly; only the immediate answer was lost.

- [#1782](https://github.com/nextlyhq/nextly/pull/1782) [`e1605f8`](https://github.com/nextlyhq/nextly/commit/e1605f8b03c0c4111716d426fe0a125e3f174b2c) @mobeenabdullah: A `text` widget declares its prose and the dashboard draws it.

  The archetype existed in the contract with nothing to carry the prose and no
  renderer behind it: a plugin declaring one got a card reading "not rendered
  yet". A text widget now declares `content` as markdown -- headings, paragraphs,
  lists, emphasis, inline code, block quotes and links -- and the host draws it
  through the same rich-text stack the editor uses, read-only, loaded only when a
  text card is on the dashboard.
  
  Three things the markdown cannot do, by design. Raw HTML is shown as the text it
  is. A link may point at `http`, `https`, `mailto`, `tel`, or a path on this site
  written as `/...`, `./...` or `#...`; one to anything else is left on screen as
  the markdown it was written in, so the author can see it was refused. And the
  content is bounded at registration, and refused over the bound rather than cut:
  it travels inside every dashboard load for every reader offered the card. An
  external link opens in a new tab.
  
  `content` is required for `text` and refused on every other archetype, on both
  the registry and the plugin channel through one rule.
  
  The admin workspace payload now carries only the widget declarations its reader
  may see, and only the parts of them. A declaration is its whole content -- a
  `text` widget's prose, an `actions` widget's shortcuts -- so the gate a widget
  declares through `requiredPermission`, and the gate each of its actions
  declares, is applied on the server before the declaration ships, from the
  plugin channel and the registry alike, by the same decision the dashboard
  layout endpoint places cards with. Where two plugins contribute the same widget
  id, only the first declaration ships, which is the one the dashboard draws.
  Previously every authenticated caller received every declaration whole and the
  browser hid the gated cards and shortcuts.
  A registered widget the payload cannot carry -- one JSON drops or rewrites --
  no longer wins a collision with a contributed one; the contribution stands, as
  itself, with the gate it declared. And a numeric character reference outside
  Unicode's range (`&#1114112;` and up) in a text widget's markdown now draws as
  the replacement character, as it would on a web page, instead of blanking the
  card.
  
  The dashboard layout's `scope` token now also covers which shortcuts inside
  the visible cards the reader may see, so the admin re-reads its workspace when
  a reader gains or loses a shortcut's permission and not only when a card
  appears or disappears.

- [#1867](https://github.com/nextlyhq/nextly/pull/1867) [`0d5f838`](https://github.com/nextlyhq/nextly/commit/0d5f838701b6ef77b390fa79eaf985f50b449dca) @mobeenabdullah: Importing a design-token file reports what the reader passed over from the reader itself, rather than from a second walk of the file.

  Two of the format's own fields now say what was lost: `$extends` (the group it would inherit from is not followed, so those tokens are not imported) and `$deprecated`. A token the importer refuses no longer also claims its value was read from `$value`.

- [#1885](https://github.com/nextlyhq/nextly/pull/1885) [`0708ec6`](https://github.com/nextlyhq/nextly/commit/0708ec68cd6785776f073f8e4453ffd95d8ccaca) @mobeenabdullah: A design-token import no longer calls an `$extends` that points through a `$`-prefixed key an inheritance, and a token refused for its name or identity still says when its `$type` was ignored.

- [#1788](https://github.com/nextlyhq/nextly/pull/1788) [`9e59229`](https://github.com/nextlyhq/nextly/commit/9e59229d43ae0a57b2d14bf8c30c651f45bbab52) @mobeenabdullah: A bulk create (`createMany`) and a create or update made inside a caller's transaction now write a `fieldGroup` field's value to its component table, as the ordinary create and update do.

  Before, a bulk create of any collection that embeds a field group failed every row with `no column named <field>`, and a transactional update reported success while leaving the component's old value in place. A transactional update held as a working draft now carries the field group in the draft.

- [#1801](https://github.com/nextlyhq/nextly/pull/1801) [`0121364`](https://github.com/nextlyhq/nextly/commit/0121364ce39b6f2780b3500b1a35aff700769af5) @mobeenabdullah: `updateEntries` on the collection entry service now takes `overrideAccess`, as `createEntries` already did: the collection gate, the publish-transition pre-resolve and every per-entry write skip access when it is set.

  Before, a trusted batch update had no trusted path and was judged as an anonymous caller, so every row was refused on a collection whose update rule wants a user.

- [#1728](https://github.com/nextlyhq/nextly/pull/1728) [`06f192f`](https://github.com/nextlyhq/nextly/commit/06f192f9b0546085ca3bd700cff0b97fe94206e1) @mobeenabdullah: `nextly --version` reports the version that shipped.

  The constant behind it was typed by hand under a comment saying it "should
  match package.json version". It did not: the CLI answered `0.1.0` while the
  package shipped `0.0.2-alpha.65`, so anyone asking the tool which Nextly they
  were working against got a confident wrong answer — and telemetry attributed
  every CLI event to a version that has never been published.
  
  It is asked of the same resolver the plugin system already uses to validate a
  plugin's `nextly` compatibility range, so a reported version and a
  compatibility answer can no longer disagree. A test holds the two together.
  
  The scaffolded agent guide now says how to find that version and where the
  authoritative documentation is, including the two machine-readable indexes at
  `/llms.txt` and `/llms-full.txt`. An agent working in a Nextly project would
  otherwise answer from whatever it remembers of some other version.

- [#1702](https://github.com/nextlyhq/nextly/pull/1702) [`2a6f497`](https://github.com/nextlyhq/nextly/commit/2a6f497fe51cb6bc2403bf77b1e929ceeb45b4a0) @mobeenabdullah: A dashboard card that only matters sometimes had to hide itself.

  The get-started card was placed in the grid, given an order, and then rendered nothing once seeding was done — so the arrangement reserved a slot for a card drawing nothing, and the reason lived in a component rather than in the declaration.
  
  A widget can now declare that it is transient: it names the condition it shows
  under, and the host stops offering the card once that condition lapses.
  Pinning and reader dismissal are not part of this release — a card cannot yet
  ask to sit above the ordinary order, and a reader cannot yet end one early —
  a reader who declines the get-started offer keeps its slot until the install
  has content.
  
  The condition is a NAME from a closed set the host owns, never a predicate or
  callback a widget supplies, so an onboarding surface cannot become the kind of
  unconstrained notice channel that other admin ecosystems have never managed to
  contain. A name this release cannot answer is refused when the widget is
  registered, where the author can still be told.
  
  The first condition asks whether THIS reader can see any content, and is
  deliberately about the reader rather than the install: answering across
  everything would report on rows the reader is not allowed to know exist. A
  draft counts as content — someone who has written one post and not published it
  is not looking at an empty install.

- [#1735](https://github.com/nextlyhq/nextly/pull/1735) [`8653c19`](https://github.com/nextlyhq/nextly/commit/8653c19201992cd0243b2927ad017efe1da9a288) @mobeenabdullah: An API key's write appears in the activity trail.

  It recorded nothing before — not mislabelled, absent. The recorder refused any
  actor that was not a signed-in person, because a row's identity column is
  joined to the accounts table and a key's own id would find no account and be
  filed as an already-erased person. So every write made with an API key was
  invisible, and nothing about it is recoverable after the fact.
  
  The row now carries the KIND of caller its identity column refers to, and the
  admin's Recent Activity names the key rather than showing a blank actor.
  `user_id` is unchanged and still required: it is already documented as the
  actor's opaque reference, so one nullable `actor_type` is the whole schema
  change and no dialect needs a nullability rebuild.
  
  A NULL kind means a row written before this existed. Those are all user writes,
  because no other kind was recordable.
  
  Writes with NO initiating actor — seeds, migrations, imports and jobs — are
  still not recorded, and the reason has changed rather than gone away. They run
  while the schema is being created, and a failure to write the trail fails the
  surrounding write: a trail insert against a table that does not exist yet would
  fail the seed that was creating it. That needs its own answer.

> Notes truncated to stay within GitHub's release body limit. The complete changelog for every package is in its `CHANGELOG.md` at [v0.0.2-alpha.66](https://github.com/nextlyhq/nextly/tree/v0.0.2-alpha.66).

### Packages

- `@nextlyhq/adapter-drizzle`
- `@nextlyhq/adapter-mysql`
- `@nextlyhq/adapter-postgres`
- `@nextlyhq/adapter-sqlite`
- `@nextlyhq/admin`
- `@nextlyhq/admin-css`
- `@nextlyhq/blocks-engine`
- `@nextlyhq/blocks-react`
- `@nextlyhq/builder`
- `@nextlyhq/eslint-plugin`
- `@nextlyhq/plugin-form-builder`
- `@nextlyhq/plugin-mcp`
- `@nextlyhq/plugin-page-builder`
- `@nextlyhq/plugin-sdk`
- `@nextlyhq/plugin-seo`
- `@nextlyhq/storage-s3`
- `@nextlyhq/storage-uploadthing`
- `@nextlyhq/storage-vercel-blob`
- `@nextlyhq/ui`
- `create-nextly-app`
- `nextly`

<!-- /release-notes -->

Next release: [0.0.2-alpha.67](https://nextlyhq.com/changelog/0.0.2-alpha.67)

## Earlier releases

Releases before 0.0.2-alpha.66 are listed in [Nextly's changelog on GitHub](https://github.com/nextlyhq/nextly/blob/main/packages/nextly/CHANGELOG.md).
