Skip to content

fix(nextly): a Single's publish re-judges the draft it promotes - #1826

Merged
mobeenabdullah merged 3 commits into
mainfrom
fix/a-single-publish-revalidates-its-draft
Sep 12, 2026
Merged

mobeenabdullah merged 3 commits into
mainfrom
fix/a-single-publish-revalidates-its-draft

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Closes the ledger's task:singles-promotion-revalidation and finding:singles-promotion-never-revalidates.

The defect

A Single's publish folds its held pending change into the live row. Field access and validation both ran when the caller's payload arrived, and for a publish that payload is just { status: "published" }. So the draft's content reached the live row having been judged only when it was saved.

Measured on current main (the finding was recorded before #1659, so I re-verified rather than trusting it):

guarded: access.update = only boss@x.test
BOSS saves a draft with guarded = "secret"
CLERK publishes
live row: guarded = "secret"      <-- a value the publisher may not write

Two things can change between saving and publishing: the publisher may not be the author, and the schema may have tightened under a value that was legal when it was held.

The fix

Both gates now run over the merged document the promotion will actually persist, in the shared promotion, so all three Singles publish routes are covered by one change.

They run inside the transaction on the draft it has already read, rather than on an advisory copy read beforehand as the collection path does. That costs no extra query and judges the exact snapshot about to be written rather than one that could have moved since.

Scoped to the promoted snapshot and never to the live document, so a schema change cannot block someone from fixing and republishing unrelated content.

Validation refuses, naming the field

NextlyError.validation with the existing { path, code, message } issues, which the admin already renders inline. No new error shape.

Access refuses too, and this departs from the ordinary write

On an ordinary write a denied field is stripped, which is right: the value is the caller's own input, so dropping it costs them nothing they did not already have.

A promotion is not that. The value belongs to whoever saved the draft, and a successful publish consumes the draft. I measured it: after such a publish, zero draft rows remain. So stripping would publish everything else, delete the pending change, and take the one edit this publisher may not write with it, leaving the author no draft, no value and no message.

So a denied change is refused, the draft is kept, and the message says so:

The pending change edits this field and you do not have permission to write it, so it cannot be published. The change is kept.

Judged on what would change, not on what the snapshot holds: a snapshot is a full copy of the document, so a denied field appears in every one of them. Only a denied field whose promoted value differs from the live row counts, which is why "publishes normally when the draft changes nothing the publisher is denied" passes.

Research

Product Re-validates at publish Re-checks write access at publish
Payload v3 Yes, the publish transition enforces what a draft save skips Via the same update pipeline
Strapi 5 Yes, some rules deferred to publish Yes, Publish is its own RBAC action, field-level in Enterprise
Directus Yes, at promote, against the current schema No, there is no distinct promote permission (#20372)
Sanity Only in Studio, the server never validates No server-side concept
WordPress No native field schema Yes, publish_posts is re-checked at the transition, distinct from edit

Two cautions taken from their bug trackers and honoured here: Sanity's #11456, where the publish gate leaked onto the live document and blocked legitimate republishing, and Strapi's #15636, where a check deferred to publish fell through to a raw database constraint instead of a field-scoped message. This routes everything through validateEntryData and applyFieldWriteAccess.

Behaviour change, stated plainly

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. That is the intended trade: publishing content that violates the current contract is worse, and the refusal names what to fix.

Evidence

single-publish-revalidates-its-draft.integration.test.ts, four cases:

  • a change the publisher may not write is refused, the issue is { path: "guarded", code: "FORBIDDEN" }, and the draft is still there with the value intact;
  • a publisher who MAY write it still promotes it (positive control);
  • a draft the schema has since made invalid is refused, naming the field and carrying the rule's message, and nothing is published (the schema tightening is real: two boots against one database file, the second with the stricter rule);
  • a draft that changes nothing the publisher is denied publishes normally.

Control run against origin/main: the two defect cases fail, the two controls pass on both sides. All 33 Singles and versions integration suites pass.

Follow-up filed

finding:promote-strips-a-denied-field-and-consumes-the-draft: the collection promote does the same strip at collection-mutation-service.ts:6549. Whether it also consumes its working draft on success, and so has the same silent-loss shape, is not measured and should be before anything there changes. If it does, the same refusal belongs there and the two paths should agree.

A Single's publish folds its held pending change into the live row. Field
access and validation both ran when the caller's payload arrived, and for a
publish that payload is just `{ status: "published" }`, so the draft's own
content reached the live row having been judged only when it was SAVED. The
collection publish path already gates its promotion; this is the same gate on
the Singles path, and it covers all three publish routes because it sits in the
shared promotion rather than in any one of them.

Two things can have changed between saving and publishing. The publisher may
not be the author, so 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.

Both gates run on the merged document the promotion will actually persist, and
never on the live row: a schema change must not block someone from fixing and
republishing content that has nothing to do with it.

Validation refuses, naming each field and carrying the rule's own message.

Access refuses too, which is a departure from the ordinary write, where a
denied field is stripped. Stripping is right there, because the value is the
caller's own input and dropping it costs them nothing. A promotion is not that:
the value belongs to whoever saved the draft, and a successful publish CONSUMES
the draft. Stripping would publish everything else, delete the pending change,
and take the one edit this publisher may not write with it, leaving the author
no draft, no value and no message. Measured: zero draft rows remained after
such a publish. Refusing keeps the draft for someone who can write the field.

Judged on what would CHANGE, not on what the snapshot holds. A draft snapshot
is a full copy of the document, so a denied field appears in every one of them;
only a denied field whose promoted value differs from the live row is a change
this publisher is not allowed to make.

Researched first. Payload enforces at the publish transition what a draft save
skips; Strapi makes Publish its own RBAC action; WordPress re-checks
`publish_posts` at the transition rather than reusing the edit check. Two
cautions came from their bug trackers and are honoured here: Sanity's #11456
shows what happens when the check leaks onto the live document, and Strapi's
#15636 shows a deferred check falling through to a raw database constraint
instead of a field-scoped message.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 23803140-558b-43b1-9376-9495d4ad73a1

📥 Commits

Reviewing files that changed from the base of the PR and between b287a61 and 6ff7223.

⛔ Files ignored due to path filters (1)
  • .changeset/a-single-publish-revalidates-its-draft.md is excluded by !.changeset/**
📒 Files selected for processing (5)
  • packages/nextly/src/domains/singles/__tests__/single-publish-revalidates-its-draft.integration.test.ts
  • packages/nextly/src/domains/singles/services/promote-gate.ts
  • packages/nextly/src/domains/singles/services/single-entry-service.ts
  • packages/nextly/src/domains/singles/services/single-mutation-service.ts
  • packages/nextly/src/domains/singles/services/single-publish-all.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added scope: core nextly type: docs Documentation only labels Sep 12, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-12T10:00:43.072196Z 6ff7223 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@github-actions

github-actions Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Whole-Repository Code Hygiene Summary

Full dead-code, duplication, and complexity report for the PR branch as it stands now. Playground is excluded. Quality gate enforcement on introduced issues is performed by the Changed files job.

🌿 Fallow

Warning

Review needed

⚠️ 73 code issues · ⚠️ 681 clone groups · ⚠️ 1033 health findings

See inline review comments for per-finding details.

Code issues (73)
Category Count
Unused files 2
Unused exports 5
Unused dependencies 19
Unused devDependencies 6
Unresolved imports 2
Unlisted dependencies 1
Circular dependencies 38
Duplication (681 groups · 28663 lines · 4%)
Locations Lines Tokens
schemas/_dialect-bundles/mysql.relations.ts:40-134
schemas/_dialect-bundles/postgres.relations.ts:40-134
schemas/_dialect-bundles/sqlite.relations.ts:40-134
95 593
cli/commands/db-sync-demote.ts:70-75
cli/commands/db-sync-promote.ts:38-43
cli/commands/dev-build.ts:100-105
cli/commands/dev-build.ts:179-184
cli/commands/dev-build.ts:299-304
cli/commands/dev-build.ts:411-416
cli/commands/dev-build.ts:552-557
cli/commands/dev-server.ts:575-580
cli/commands/dev-server.ts:840-845
cli/commands/dev-server.ts:1143-1148
cli/commands/migrate-field-groups.ts:110-115
6 70
entries/EntryList/EntryTableSkeleton.tsx:74-98
collection/components/CollectionTableSkeleton.tsx:94-118
field-group/components/FieldGroupTableSkeleton.tsx:90-114
plugins/components/PluginsTableSkeleton.tsx:86-110
singles/components/SinglesTableSkeleton.tsx:77-101
src/components/table-skeleton.tsx:100-124
25 89
collections/config/validate-config.ts:380-433
field-groups/config/validate-field-group.ts:185-238
singles/config/validate-single.ts:190-243
54 152
dispatcher/handlers/collection-dispatcher.ts:925-967
field-groups/services/field-group-table-provisioning.ts:186-236
singles/services/reconcile-single-companion.ts:110-160
51 149

… and 676 more groups.

Across 423 files.

Complexity (1033 functions above threshold)
File Function Severity Cyclomatic Cognitive CRAP Lines
singles/services/single-mutation-service.ts:1009 <arrow> critical 245 ! 307 ! 13210.4 ! 1589
collections/services/collection-mutation-service.ts:6117 <arrow> critical 169 ! 160 ! 6338.2 ! 1261
src/init/reload-config.ts:1417 applyReload critical 137 ! 208 ! 4191.1 ! 1421
shared/lib/entry-validation.ts:223 validateFieldValue critical 109 ! 157 ! 2675.3 ! 432
blocks-engine/src/measure-bytes.ts:646 surveyDocument critical 102 ! 250 ! 137.1 ! 658

5052 files, 77939 functions analyzed (thresholds: cyclomatic > 20, cognitive > 15, CRAP >= 30)

Codebase health

Metric Value
Maintainability 91.7 / 100
Avg complexity 1.8

Tip

Run fallow fix --dry-run to preview auto-fixes.
Add /** @public */ above exports to preserve them.

@pkg-pr-new

pkg-pr-new Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@9b987f4

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@9b987f4

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@9b987f4

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@9b987f4

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@9b987f4

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@9b987f4

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@9b987f4

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@9b987f4

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@9b987f4

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@9b987f4

@nextlyhq/eslint-plugin

npm i https://pkg.pr.new/@nextlyhq/eslint-plugin@9b987f4

nextly

npm i https://pkg.pr.new/nextly@9b987f4

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@9b987f4

@nextlyhq/plugin-mcp

npm i https://pkg.pr.new/@nextlyhq/plugin-mcp@9b987f4

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@9b987f4

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@9b987f4

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@9b987f4

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@9b987f4

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@9b987f4

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@9b987f4

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@9b987f4

commit: 9b987f4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b3e3e2ef3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/nextly/src/domains/singles/services/single-mutation-service.ts Outdated
Comment thread packages/nextly/src/domains/singles/services/single-mutation-service.ts Outdated
Comment thread packages/nextly/src/domains/singles/services/single-mutation-service.ts Outdated
Comment thread packages/nextly/src/domains/singles/services/single-mutation-service.ts Outdated
…ogical snapshot

Codex found four defects in the first revision, three of them P1, and all four
were real. This reworks it rather than patching around them.

The gate ran INSIDE the write transaction. Both a field's `access` rule and its
`validate` are user code, and resolving the caller's grants issues its own
pooled queries, so on a small pool those queries wait for the connection the
transaction is holding and the publish hangs. It now runs before the
transaction opens, which is where the collection publish path has always run
its own, and for this reason.

It judged the snapshot as stored. A snapshot holds a JSON-backed value (a
group, a repeater, `chips`, `json`) as the string that was written, and the
validator for those fields expects the object. Measured: publishing an
ordinary Single that merely HAS a group field was refused with "seo must be an
object". The snapshot is now read in its logical shape first. My tests used
text fields only, which is why this reached review.

It covered one publish path. `SinglePublishAllService.promotePendingChanges`
loops every language's pending change, writes each and deletes them all, and
had no gate of its own, so publishing every language still promoted content
forbidden to the publisher or invalid under the current schema. Both paths now
call one shared `promote-gate` module: two gates would be two answers to one
question, and the second is the one nobody remembers to update.

It compared a denied field against the main-table row, which omits every
localized value. An unchanged translation therefore compared against
`undefined` and read as an edit, so a publisher without write access to that
field could be refused a publish they were entitled to make. The comparison now
reads the live document for the language the pending change belongs to.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b987f42ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/nextly/src/domains/singles/services/promote-gate.ts
Comment thread packages/nextly/src/domains/singles/services/single-publish-all.ts
Comment thread packages/nextly/src/domains/singles/services/promote-gate.ts
Comment thread packages/nextly/src/domains/singles/services/promote-gate.ts
Comment thread packages/nextly/src/domains/singles/services/single-publish-all.ts
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b987f42ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/nextly/src/domains/singles/services/single-publish-all.ts
@mobeenabdullah
mobeenabdullah merged commit ebd0b24 into main Sep 12, 2026
12 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ff72234c6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

gateLocale
);
if (held) {
await assertDraftsMayBePromoted(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Authorize publishing before evaluating the held draft

When a caller may update the Single but lacks its publish permission, this gate runs before the later checkSingleAccess publish check. If the held draft is invalid or contains a denied change, the caller receives a detailed 400 validation response instead of the expected 403, and custom access/validation callbacks execute over draft content for an unauthorized publish attempt. Resolve the post-hook publication decision and its authorization before invoking the promotion gate.

AGENTS.md reference: AGENTS.md:L309-L312

Useful? React with 👍 / 👎.

"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include plugin-mcp in the lockstep changeset

Because this commit's parent already includes @nextlyhq/plugin-mcp in the repository's fixed release group, closing this frontmatter without that package leaves one of the 26 lockstep packages out. The fixed group will still bump it, but it receives no changelog entry, and node scripts/release/check-changesets.mjs .changeset/a-single-publish-revalidates-its-draft.md rejects this changeset as incomplete.

AGENTS.md reference: AGENTS.md:L329-L331

Useful? React with 👍 / 👎.

Comment thread packages/nextly/src/domains/singles/services/promote-gate.ts
Comment thread packages/nextly/src/domains/singles/services/promote-gate.ts
mobeenabdullah added a commit that referenced this pull request Sep 12, 2026
* feat(builder): edit an instance's exposed link and visibility in the inspector (#1798)

* feat(builder): offer the site's components in the insert panel, placed as one linked node

The third tier of the palette, beside blocks and patterns. A component is
offered from the definitions a host supplies — read nothing, as the pattern
tier reads nothing — and placing one writes a SINGLE instance node carrying the
definition's id. Nothing of the definition's content is copied into the page;
the renderer inlines it at read time, which is what makes an edit to the
definition reach every page that placed it. That is the opposite promise from
a pattern's copy, so the tile says which: a visible badge for a sighted author
choosing between look-alikes, and the same promise leading the description a
screen reader hears after the name — not in the name, which stays exactly the
visible label so a spoken command matches what is written on the tile.

Placement is judged by the definition's ROOTS, through the rule patterns are
already judged by. The instance node's own type is not a registered block and
the nesting source answers "no restriction" for a type it cannot resolve, so
judging the node by its type would let a component whose root may only live
inside columns be placed at the page root — and render there.

Rows the palette has nothing to place for are skipped rather than refused: a
published definition saved without content, and a row holding something other
than a component. The kind check is pinned by a fixture that HAS roots, since
one without would be dropped by the empty-roots check first and the case would
pass whether or not the kind was ever read.

Click to place only, for now. The drag path judges a placement by one block
name, and a component is judged by its roots; giving it the instance type would
bypass that judgement, so it stays with the click path as a pattern does.

* feat(plugin-page-builder): component definitions reach the editor, and a placed one renders

The second half of placing a component. The builder could offer a component
and write an instance node for it, and the canvas then drew the
could-not-be-loaded marker: the renderer resolves an instance against a map of
definitions, and the editor handed it none — every definition read lived on
the served-page path. A component placed in the builder rendered on the site
and nowhere an author could see while editing.

The library route now carries the site's component definitions beside its
patterns, and the document comes from a BY-ID read rather than the listing.
That is the point, not a detail: the working-draft overlay lives in the
service's by-id path and nowhere else, so a definition built from the listing
would show an author a stale component beside the draft they just saved — a
defect this repository has fixed once already on the served-page path. The
plugin-facing collection service forwards no draft flag on its by-id read, so
the route binds the Direct API's `findByID({ draft: true })` as the user:
access rules decide what the caller may read, and the overlay is gated on the
caller being allowed to edit, so a reader who may only read sees the live one.

Two readers with different timing share the one route. The insert panel reads
the whole library only while it is open, as before. The canvas needs
definitions on every editor mount, and the pattern tier can run to the whole
byte ceiling, so a `tier=components` query narrows the read the editor makes
on mount. The route's paging walk and its admission under the ceiling are one
implementation for both tiers, each handing in only how a listed row becomes an
item; the two tiers spend one ceiling, patterns first.

The editor reads the tier once and hands the same response to both consumers:
the canvas gets a map by id, the panel a list — so the tile an author places
is built from the very document the canvas then resolves the instance against.

* fix(plugin-page-builder): definitions are read as the user, on their own route, at every state

The component tier moves from a `?tier=` query on the pattern library to
`GET …/library/components`, gated by the components collection's read
permission and answering the canonical `{ items, meta }` envelope. One
walk and one admission still serve both routes; a tier is a completion
function and a permission, not a copy of the loop.

Both component reads now say `overrideAccess: false`. `requireNextly()`
defaults every call to `true`, under which naming a `user` narrows
nothing: the by-id read overlaid the working draft without the
update-capability probe and skipped every field-level read rule, so an
author who could only read a component was handed its pending draft. The
listing asks for `status: "all"` — the plugin-facing service forwards no
lifecycle scope, and an untrusted read that states none is bounded to
public states, so a never-published component could not be placed on a
draft page. An API key's own scope travels on both reads.

`LibraryComponent` extends `SavedComponent`, and the client hands the
rows to the panel as received; a type test pins that the wire carries
every key the panel reads.

`pageRenderInputs` demands `definitions`, and the entry form's resting
render reads the component library too, so a placed component stays
drawn in the miniature after Done rather than becoming the
could-not-be-loaded marker.

The panel resolves each definition through the canvas's own lookup
before offering it: a root that is itself an instance is judged by what
it draws, and one the resolver had to leave standing is withheld. And
the panel says, in a status region above the tiles, when the library was
too large to load whole — naming the tier, and for components what a
left-out one looks like on the page.

* feat(builder): a selected component instance opens its own inspector, over its exposed properties

An instance's type is not in the block registry, so the block inspector
answered "select a block" to an author who had just clicked a
component. `inspectInstance` now describes the selection — the
library's title and usage for the definition, the node's own name and
lock, and one row per exposed property read back from the resolver's
own result, so a row shows what the page shows: the value in force,
which layer supplied it, whether it was cleared, and which exposure
shadows it when one does. Values stored for properties the definition
no longer exposes are carried separately, and only those this instance
holds.

`InstanceInspectorPanel` draws that in place of the tabs: text and
choice rows edit in place, an emptied text field clears the property
with the `$unset` sentinel rather than writing `""`, every override
carries a visible Reset that removes it (and drops the record with its
last entry, never writing an empty one), rows of a type with no control
yet show their value and say so, and the orphaned values are listed
with a Discard. `overridesPatch` writes the whole props object so
`componentId` survives every edit.

The name and lock fields move to `identity-fields.tsx` so both
inspectors draw one control, and every text field's draft follows the
stored value through one `useStoredDraft`. `InspectorPanel` takes the
host's component library as ONE prop — the lookup the canvas resolves
against and the rows that name the definitions — and the editor hands
it the same read the canvas gets.

* fix(plugin-page-builder): the component read overlaps, reaches drafts, and says when it failed

Every component's completion was a database round trip awaited one
after another — three thousand in a row at the library's ceiling. The
walk now completes rows in batches of eight, admitted in listing order
so the ceiling cuts the same rows it always did, and starts no batch
once it has.

The by-id read asks for `status: "all"` as the listing does: an
untrusted read that states nothing is bounded to public states, so the
never-published row the listing found answered 404 and the component
stayed unplaceable until published. The Direct API's `findByID` did not
forward `status` at all — a caller passing it was silently ignored — so
it does now, with `FindByIDArgs.status` declared and pinned by a test.

Every field of an item now comes from the by-id row, the one with the
working draft overlaid: a draft can rename a component or move it to
another category, and an item labelled from the live listing was named
by one version and drawn from another.

The identity the reads run as is built by `buildUserContext`, now
exported from `nextly/runtime` — the one builder of the user an access
rule is evaluated against — so the caller's verified claims travel and
the canonical identity wins. Roles are not on the route context; the
service resolves them from the account.

The component read's `pending`, `error` and `refetch` no longer vanish
behind an empty map. The canvas and the resting miniature wait for the
read and say when it failed, with a Try again; the insert panel tells a
tier that was cut from one that could not be read, and offers the retry.

The palette resolves a definition under the site's own document caps,
handed through `documentLimits`, and withholds one whose root resolves
to an empty forest.

* fix(plugin-page-builder): a failed component read no longer takes the canvas away

The component route refuses a role that may edit pages but not read
components, and the gate added for a pending read treated that failure
the same way — a least-privilege page editor lost the canvas for every
page, block-only pages included, and the resting card refused to
preview them. Only a PENDING component read gates now; a failed one
draws the page with its instances as could-not-be-loaded and says so
beside the canvas and beneath the miniature, with the retry.

A component's own content field no longer offers the definition it is
editing: placed, that instance points at the definition it sits in, and
the resolver draws the loop as a cycle after the save. `withoutSelf`
judges it on the opening document being a component and the form naming
the row — a page's field is never inside a component, and a create form
names no row.

Placing a component now asks the resolver whether the PAGE has room for
it, with the node in place: the apply counts stored nodes and an
instance is one, but the composed tree spends one budget across every
expansion, so a page near its cap accepted the node and rendered it
unresolved. `compositionRefusal` composes the candidate document under
the site's caps and turns a `budget` or depth reason into a sentence
the panel raises as a notice, leaving reasons that are the tile's
concern alone.

* fix(builder): the style trace composes instances through the definitions the canvas draws with

The builder's trace wrapper forwarded the site tier, the fetch policy,
the resolver and the caps to the renderer's trace, and not the
definitions — so on a page holding instances the cascade the inspector
reads its provenance from described a tree with every instance left
unresolved, while the canvas drew the composed one. The wrapper takes
the map and the editor hands it the canvas's own.

* fix(builder): a choice exposure with an empty option no longer crashes the inspector

The validator accepts an empty string as an option value — a "none"
beside class names — and the select control throws at render on an
item whose value is the empty string, which it reserves for no
selection. The empty value wears a NUL sentinel in the control and is
taken off again on the way back to the document, so the option is
offered and written as empty.

* fix(builder): judge a component's room under the site's limits, for every instance left standing

The insert preflight composed the candidate page the way the canvas does,
but dry-ran the insert itself under the engine's default limits, so a page
legal only under a raised cap threw out of the click before the resolver
ran; and it refused only when the PLACED instance was left unresolved, so
a nested instance the page had no room for, or an instance already on the
page that the new one took the budget from, was placed and drew a
placeholder.

The dry run now takes the same limits the composition does, a refusal the
apply itself makes is left to the editor's apply, and the refusal reads
every room-reason instance the placement introduces — compared against the
page composed without it, since an instance the page could not hold before
the click is not the click's doing.

* fix(builder): say when a library tier could not be reloaded, apart from one never read

A host that reads its library afresh on every open keeps the last answer
while it does, so a failed read can leave tiles standing — and beside them
"none are offered" was false. A stale tier gets its own sentence: the tiles
offered may be out of date, and a component on the page draws as it was.

* fix(plugin-page-builder): keep the definitions map still across renders, and name a failed refresh

The component read memoised its map and list on the route hook's refetch
wrapper too, which the hook mints per render, so the canvas was handed a
new map — and re-resolved every instance — on every render, keystrokes
included. The map and the list are now derived from the data alone, and the
retry keeps one identity for the life of the hook.

And a read that answered once and then failed to answer again was called
ready: the cached definitions drew with no sentence and no retry. Both
library reads now tell that state apart as `stale` — the page still draws,
from an answer an edit elsewhere may have overtaken — and the canvas note,
the entry card and the insert panel say so with the retry, rather than the
never-read sentences, which are false beside tiles that stand.

* fix(plugin-page-builder): leave out every component that reaches the one being edited

A component's content field left out only the row it was editing, so a
component holding an instance of that row — or one holding that — was still
offered, and placing it closed a loop nothing sees until the row is saved.
Every candidate is now resolved through the canvas's own lookup, and one
that reaches the row being edited at any depth is left out.

* fix(plugin-page-builder): count the classes linked components render on the page

The classes manager's on-this-page filter walked the stored document, where
a linked component is one instance node, so a class applied inside the
definition the canvas draws was missing from the filter. The walk now
composes through the same map the canvas draws with, under the same limits;
the usage record still asks what the document itself references.

* fix(plugin-page-builder): list components in pages of one completion batch

Every listed component is read again by id, so the listing's content is
never used — and the service reads whole rows however a caller narrows the
answer. A page of a hundred rows held while its batches completed was a
hundred documents of content in memory at once. The component listing now
pages by one completion batch, and the bound on reads is derived per tier
from its page size so the tier still reaches the item ceiling.

* fix(builder): offer the reset on the row that holds the override, not on the row it shadows

Two exposures aimed at one target share a value, and the resolver reports
the winning exposure's source on both — so a reset keyed on the source
stood on the shadowed row too, and pressing it removed nothing. Each row
now carries whether THIS instance's record holds a value under its id, and
the reset is offered on that.

* fix(builder): judge a moved component instance by the roots of the definition it draws

A pointer move judged the dragged node by its type, and an instance's type is
not a registered block, so the nesting rule answered "no restriction": a
component whose root belongs only inside a Columns, refused at the insert,
could be dragged into a paragraph afterwards. The drop query now carries the
types the placement is judged by — a block's own, or an instance's resolved
roots through the canvas's lookup under its caps — and the editor hands the
drag the same map and caps the canvas draws with.

* fix(builder): read an instance's definition under the resolver's own rule, published from the engine

The inspector judged a supplied definition by its kind alone, where the
resolver also refuses a format this build does not read and a list of nodes
that is not one. A definition the canvas left standing as a placeholder
therefore drew editable rows, and malformed nodes reached the exposure. The
engine now publishes `readableDefinition`, the rule the resolver applies
before inlining, and the inspector reads through it.

* feat(builder): edit an instance's exposed link and visibility in the inspector

Two more of the six exposed types get a control. A link exposure holds what
its url prop holds, a string, and is edited in the same field a url prop gets
in the block inspector, keyed for an address and clearing the property when
emptied. A visibility exposure is a checkbox writing `true` or `false` — the
two values the resolver reads there — with an inherited row reading as shown,
the component's own rule until the definition gates the node itself. Rich text
and image rows keep showing their value and source without a control.

* fix(plugin-page-builder): read components from the store the plugin was told they live in

A host may keep its definitions in a collection of its own and render from
it (`createBlocksPage`'s `componentCollection` and `componentField`), and the
plugin already lets the readiness notice follow that store through
`componentReadiness.collection` and `.field`. The editor's library route
read the plugin's own store regardless, so it drew a different definition
for the same id than the page did, or none. One derivation of the store now
feeds the route, its permission gate and the notice; a host whose
definitions come from no collection at all still gets the plugin's store,
which is the one thing a collection read can follow, and the option says so.

* fix(plugin-page-builder): build the editor under the site's caps, as the preflight and canvas are

The editor was built with no limits and so judged every apply under the
engine's defaults, while the insert's preflight and the canvas judged under
the site's. On a site that raised its cap, a placement the preflight and the
canvas accepted was refused silently by the apply. The caps are read once,
before the editor is built, and handed to it.

* fix(builder): read a tile's roots without composing its definition

The palette judged where a component may be placed by composing the whole
definition through the canvas's lookup and reading the roots of the result,
once per catalogue row, so a library of small wrappers around one large
definition cloned that definition once per wrapper every time the panel
opened — three thousand one-node wrappers around a five-thousand-node
definition was fifteen million nodes. The engine now publishes
`composedRootTypes`, which follows a root instance into the definition it
names through the resolver's own reader and its own four refusals, reading
each definition once and cloning nothing; the tile, the moved-instance rule
and the component's own field's exclusion all read through it or through
the stored graph. Room stays the click's question, asked of the resolver
with the page in hand.

* fix(builder): preflight a moved instance for room, and judge every edit under the editor's own caps

A move applied without asking whether the page still composed with the node
there: an instance carried ahead of another takes the budget that one had,
and one carried into another's slot content nests a composition deeper, so
the drop left a placeholder where a component stood. The preflight now takes
an op rather than a placed node, the drop asks it for a move and says why
when it refuses, and the caps it asks under are the editor's own — exposed
on the editor state, since they are the ones its apply enforces — which the
insert panel now reads too in place of a second reading handed in as a prop.

* feat(nextly,plugin-sdk): a plugin route resolves the caller's identity once, roles included

`PluginRouteCaller.identity()` resolves `readCaller(auth)` once per request —
the user context with the roles a stored read rule reads, its claims, and an
API key's scope — and `can()` reads through the same memo. The SDK publishes
`PluginRouteIdentity` as its answer.

A route that built its Direct API user from `ctx.user` carried no roles, so a
role-based read rule on the collection it read refused the very caller the
route's own gate had admitted, and the caller saw an empty library with
nothing to say why. The `nextly/runtime` export of `buildUserContext` added
for that route is gone again: the identity is the caller's to give.

* fix(nextly): findByID on users offers no status argument

`FindUserByIDArgs` omits `status` beside `collection` and `draft`. The users
read has no lifecycle scope to narrow by, and an argument the call accepts
and ignores reads as one it honours. Pinned by a type test the checker
evaluates (`users.test-d.ts`, under `tsconfig.tests.json`), with `depth` as
the control that still is offered.

* fix(blocks-engine,builder): the tile offers only a definition the resolver would read

The engine publishes `readableDefinition`, the resolver's own rule for a
supplied definition — a plain record whose nodes are a list, of kind
`component`, in this build's format — and `offerableDefinition` reads
through it, so the tile and the canvas read one rule.

Judged by kind alone, a definition in a format this build does not read was
offered, placed and drawn as a placeholder; one whose nodes were not a list
crashed the catalogue on `.length`.

* fix(plugin-page-builder): the style trace compiles under the canvas's caps

`pageStyleTrace` takes `canvasRender.limits` beside the canvas's definitions,
so the cascade the inspector reads describes the composed tree on screen —
neither one with every instance left unresolved, nor one composed past a cap
the canvas stopped at. Handed the map alone, the trace defaulted to the
engine's caps and reported declarations for nodes the canvas drew as a
placeholder.

* fix(plugin-page-builder): the component tier reads as the caller, failing on all but a missing row

`directComponentReads` reads through `caller.identity()` on both reads —
the user context with the roles a stored read rule reads, the verified
claims, and an API key's own scope — so a role-based read rule sees the
roles the route's gate resolved rather than an identity built from the
account alone, which carried none and refused the caller the gate admitted.

The by-id read no longer asks for `disableErrors`. It catches only not-found
and forbidden — a row gone between the two reads, or one THIS caller may not
read, which the walk reports as a cut library — and lets anything else fail
the route, which the client reports as unavailable with a retry. Swallowed,
a database fault read as a static ceiling with every instance on the page
drawn as missing and nothing to retry.

* fix(plugin-page-builder): a library row without a title is labelled by its id rather than dropped

A page's renderer keeps a component by its id and its document field; a
custom collection with no title field, or one whose title is redacted by
field-level access, renders every instance on the public page. The editor
dropped such rows — omitted from the component tier, counted as a cut —
and drew a placeholder where the page draws the component.

`identityOf` requires an id alone; a title only labels the row, and one
without a readable title is labelled by its id, the one name a row is sure
to have. One reader for both tiers, as before.

* fix(builder,plugin-page-builder): room is judged at the editor's apply; the host owns the notices

`useEditorState({ definitions, onRefused })` asks, after a fresh group
applies (`apply` and `applyAll` alike, never an undo or a redo), whether the
page still composes with the edit in — `compositionRefusal(before, after,
definitions, limits)` compares the resolver's unresolved sets by instance id
— and refuses with the sentence, committing nothing. The insert panel's and
the drag's own preflights are removed: every surface reaches the page through
this apply, so a duplicate, a paste, a pattern and a keyboard move are judged
exactly as a drop is, and no surface can forget to ask.

`BuilderShell` takes the host's `NoticeQueue`, and `useNoticeQueue`,
`NoticeQueue` and `RaiseNotice` are published from the shell entry. The
shell provides its sink only to what it renders, and the editor is built
above it, so a raise made there reached nothing in production; the host
builds the queue, hands it to the shell to draw, and raises into it.

* fix(builder,plugin-page-builder): a keyboard move judges an instance by the roots it draws

`nestingRefusalForMove` asks `placementVerdict(placementTypesOf(moving,
definitions), …)` — the function and the types a drop is judged by —
where it asked `blockAllowedAt(moving.type, …)`. An instance node's own type
is not a registered block, so the nesting rule restricted it nowhere, and
Alt+Arrow, the toolbar and the command palette could lift a component whose
root belongs only inside a container up to the root, where the same
instance's drop is refused.

`BlockKeyboardActions` takes the definitions the canvas draws with, as it
takes `nesting`, and the page-builder field passes its map. Without one an
instance is judged by its own type, which is how a placeholder stays movable.

* fix(plugin-page-builder): what a candidate reaches is read under the site's cap, failing closed

`withoutSelf` leaves out of a component's own field every component whose
stored graph reaches the one being edited. The walk read each definition with
`componentIdsIn` — the engine's default node cap, and no word on whether the
read was whole — so under a site cap other than the default, or a definition
longer than the cap, it answered a prefix, and a candidate whose reference
sat past it was cleared and could close a loop once saved.

Every read in the walk now goes through `componentUsageIn` under the SITE's
`maxNodes`, and a read the cap ends early counts as reaching: the candidate
is left out. That refuses no legitimate offer — a definition the cap cannot
read whole is one the resolver cannot inline under that cap either — and it
is the direction to err in, because the other is a saved loop every page
then draws as a placeholder.

* fix(blocks-engine,builder): composed root types are distinct and each definition is walked once

`composedRootTypes` walked a definition once per instance pointing at it and
concatenated the answers, so a definition of two hundred instances of another
holding two hundred more answered forty thousand roots, and walked the leaf
as many times — on every insert-panel open, for every component in the
library.

It now answers the DISTINCT types at the composed roots, in the order first
met, which is all `placementVerdict` reads, so the tile and the drop are
judged as before and the answer is bounded by the registry rather than the
forest. And it remembers each definition's answer for the query, reused at
the same depth or a shallower one only: the composition cap refuses by depth,
so what fit under an instance at depth one can be refused under one at depth
four. A refusal is never remembered — a cycle or the cap is a property of
where an instance sits — and the resolver's own refusals are still asked
first, per instance.

* fix(plugin-page-builder): the component tier is read in the language the document is edited in

A component's document field can be localized, and the public renderer reads
definitions in the page's locale; the editor's read named none, so an author
editing German drew a canvas of English components, and the resting
miniature too, from one locale-independent cache entry.

`useComponentLibrary` reads the surrounding form's `useDocumentLocale()` and
asks for `componentLibraryPath(code)`: `?locale=<code>` for a non-default
language, the bare path for the default or where the language cannot be
known — how an absent `?locale=` reads everywhere in the admin. The path is
the read hook's cache key, so a switch of language is a different read. The
route reads the parameter, spelled once in the shared contract, into BOTH
Direct API reads: a listing in one language completed by rows in another
would label one version and draw the other. An empty value is none, and an
unknown code resolves to the default in the Direct API.

The changeset now describes the caller's resolved identity, the title rule
and the locale, and no longer the `nextly/runtime` export this branch had
added and then removed.

* refactor(builder): the editor's room check is its own function, and its undo case can fail

The room question moves out of `run` into `refusedForRoom`, which asks
only when definitions were given and tells the host why when it refuses;
`run` keeps one condition, a fresh edit. One branch fewer took `run` back
under the complexity bound the audit holds this package to.

Re-breaking it showed the undo-and-redo case could not fail: the library
never changed between an edit and its replay, so a replay asked the room
question would have been allowed anyway. The library now grows between them
— a definition of three nodes becomes ten, which no longer fits — and a
fresh insert under the grown library is the control that refuses. Asking on
undo, on redo, or on both now fails the case.

* refactor(blocks-engine): a definition's remembered root types are read by their own function

`rootTypesOfNode` keeps the resolver's refusals — a malformed or gated
instance, a missing or unreadable definition, a cycle, the cap — and hands
the rest to `definitionRootTypes`, which serves an answer already given at
the same depth or a deeper one, or walks the definition and remembers it.
One branch fewer took the function back under the complexity bound the
audit holds this package to; the four ways the memo can be wrong — never
remembering, remembering across depths, answering before the refusals, and
answering the forest's width — each still fail a named test.

* fix(builder): the instance inspector reads a malformed instance and a definition's options safely

Every read of an instance's props goes through one guard, as the resolver
reads them: a stored instance whose `props` is missing or not a record — an
import, a hook, a hand edit — is drawn on the canvas as a malformed
placeholder, and selecting it threw on `node.props.componentId` where it now
reads as naming no component. The override patch spreads the same guarded
record.

A row's `options` and `label` are normalised where they are consumed. The
resolver checks the fields its own writer reads — id, node, path, type — so
a stored or hook-shaped definition could hand the panel options that were
not a list (`row.options.map` threw on selection), entries without a string
value, a value offered twice (one choice drawn twice under one key), or a
label that is an object. Options keep string values and labels, each value
once; a label that is not a string reads as the property's id.

* fix(builder): the instance panel spells each option apart and says what a structured value holds

Every option value wears a one-character prefix in the select control,
where only `""` wore a NUL sentinel. The control cannot take `""` — the
primitive reserves it for "no selection" — but a definition's option is free
text, so a sentinel for one value collides with the option that IS the
sentinel: the two items shared one value, the control drew the later of them
for either, and choosing one while the other was held changed nothing. A
prefix on every value is injective and never empty.

A read-only row and an orphaned override now summarise a structured value
instead of reading it through the text field's reader, which turned a value
that is there into the same nothing as one that is not: rich text as its
words through the engine's reader, a list by its count, other data as its
JSON, a cleared override as "(cleared)".

* fix(builder): a rename that differs only in spaces hands the field back the stored name

The name field compared a trimmed draft with the stored name and, when they
matched, returned — leaving the field showing a draft with spaces the
document does not hold. It now trims once, compares and stores the same
value, and hands the field the stored name back when nothing changes. The
field is the one both inspectors draw, so the block inspector's name
behaves the same; the comparison moved here from it unchanged.

* refactor(builder): the instance inspector's small text uses the type-scale token

Three font sizes in the instance inspector's rules were written as
`0.75rem` where the rest of the stylesheet names `var(--text-xs)`, the
theme's step of exactly that size — so the rendering is unchanged and the
rules follow the scale if it moves.

* fix(builder): a component is offered only when the canvas holds its definition, judged by that copy

`componentEntriesFrom` judged and previewed a tile from the row's own
document, while the placed instance is resolved by the canvas against its
lookup. A host of the published `InsertPanel` that passed `components`
without `componentDefinitions` was offered tiles whose every placement
landed as a missing component; and a row whose copy differed from the
lookup's was judged by roots the canvas does not draw. The offer now reads
the lookup's definition for the row — the drag and the keyboard already
judge a placed instance through the lookup — and a row the lookup does not
hold is not offered. The row still supplies the label, category and usage.

* fix(plugin-page-builder): a component's own field fails closed on a reference a cut library omits

The walk that keeps a component's own field from offering anything that
reaches it treated an id the canvas's lookup does not hold as a dead end.
From a library the ceiling or a permission cut, that id may be a component
the store holds and the read left out — and it may name the definition being
edited, so the candidate stayed offerable and a placement closed the loop
once saved. The field now passes whether its read was whole; out of a cut
library such an id counts as reaching and the candidate is left out, while
out of a whole one it is a component nobody supplied and names nothing.

`withoutSelf` takes the graph as one value (`ComponentGraph`: the lookup,
the site's caps, and whether the read was whole), and each walk starts from
the lookup's copy of the candidate, which is what the canvas draws.

* fix(blocks-engine): a root the inliner would drop stands for no type

`cloneDefinitionForest` skips a node it cannot clone — one that is not a
record, or whose id is not a string — so such a root never lands on the
page. `composedRootTypes` counted its type anyway, so a component was
offered, and refused, by a root the page never gets; one whose every root is
dropped was offered as placing something and placed nothing. The query now
passes over exactly what the inliner drops, and still answers nothing for a
root it keeps but cannot name.

* fix(builder): a pattern's roots are resolved through the lookup, as a component's are

A pattern is copied into the page as it stands, and one of its roots may be
a component instance — saving a placed component as a pattern stores exactly
that node. `rootsAllowedAt` judged the root's own type, which is not a
registered block and which the nesting source therefore restricts nowhere,
so a pattern wrapping a component whose root belongs only inside a Columns
was offered at the page root while the component's own tile was refused
there. Each root is now read through `placementTypesOf`, and the panel hands
its lookup to `allowedEntries`.

* fix(plugin-page-builder): a completed component keeps the id the listing named

The by-id read returns a PRESENTATION of the row — an `afterRead` hook may
rewrite or drop its `id` — while stored instances reference the id the
collection holds, which is the one the listing named. Completed from the
presentation, the client keyed its definitions by a name no instance uses,
or dropped the component, and every instance of it drew as missing. The
completion carries the listed id and takes the title, category, description
and content from the by-id row; a row with no readable title is still
labelled by that id, and a read that answered nothing is still a cut tier.

* fix(builder): every inspector control takes an id of its own, minted per mount

An exposed row's control and the block name and lock fields carried fixed
ids. A page can hold two blocks fields, each with its own editor, and both
labels then resolved to the FIRST editor's control: it carried two labels
and a doubled accessible name, while the second editor's input had none and
its label focused the other editor's field. Each control mints its id with
`useId`, so a label points at the control beside it.

The block inspector's own assertion no longer pins the literal id; it tells
the identity field from an attribute's by the placeholder only it has.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* test(builder): the visibility checkbox reflects a gate the definition itself carries

The rule is the engine's — a `visibility` exposure with no override now reads
the definition's own decision — and this is the surface it was wrong on: the
box drew checked and said the node was shown on this page while the renderer's
hidden-node pass withheld it. Asserted here as well as in the engine because
the panel is where an author reads it, and because the click that turns it on
writes the one value that removes the component's gate for this instance.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* chore: a changeset for the component library's review round

The round's own entry, since the PR it was raised against merged while this was
being written and its changeset went with it.

* fix(blocks-engine): a malformed slot entry refuses a placement, it does not raise

A stored forest reaches the internal-nesting walk unvalidated — its own comment
says a slot may hold anything — and asking what a node DRAWS reads one field
further than asking its type did: an entry typed as an instance but carrying no
`props` had its `componentId` read, where before only its type was.

Read defensively now, and an entry with no readable type stands for NO type
rather than for one spelled out of whatever was there: `placementVerdict`
compares these against a registry, and a string like "undefined" is a name a
slot's admissions list could be made to accept. The planners shape-check their
forest before they reach this, so it is a boundary rather than the whole
defence — what it must not be is a raise where a refusal belongs.

* fix(plugin-page-builder,blocks-engine): a by-id row's own id must be the one asked for

Four things a reader established about its own instruments rather than about the
data.

A row with no `id` KEY is the redaction case and is keyed by the listing. A row
that HAS the key answered with an identity, and `null`, a number or an empty
string is not "no id" — it is an answer, and not this one. Read as absence its
content travelled under a name it never claimed.

The lookup is now read INSIDE the roots query's exception boundary. It is a
caller-supplied object rather than a map the engine builds, so a `get` that
raises escaped to exactly the caller the boundary exists for.

The listing's cursor is taken from the greatest READABLE id on a page, and the
test stub now selects it the same way. Inspecting only the final row made the
stub fall back to an offset where the route carries a cursor, so the pagination
cases could pass about a walk that does not run — and the route's own selection
had no test at all, because the component tier's listing is injected. It is
asserted on the pattern tier, where the route's `page()` actually runs.

And the two `withoutSelf` suites share one set of fixture builders. Two copies of
what a component, an instance and the walk's inputs are is a second definition
of the thing under test.

* fix(builder): a value the inspector's control cannot hold is shown, not offered for editing

`OverrideValue` is unconstrained, so a host block may declare a text or link prop
as an object. A text field renders one as a blank — there is no string to show —
and the first edit then replaces the whole value with whatever was typed into
that blank, losing the rest of it with nothing reporting the loss.

Such a row now takes the same note a type this panel cannot edit yet takes: the
value is summarised and nothing overwrites it.

A select and a visibility row always stay editable. Neither carries the old value
into what it writes, so a value they cannot show is repairable by using the
control, and hiding it would strand the property instead.

* fix(nextly): a Single's publish re-judges the draft it promotes (#1826)

* fix(nextly): a Single's publish re-judges the draft it promotes

A Single's publish folds its held pending change into the live row. Field
access and validation both ran when the caller's payload arrived, and for a
publish that payload is just `{ status: "published" }`, so the draft's own
content reached the live row having been judged only when it was SAVED. The
collection publish path already gates its promotion; this is the same gate on
the Singles path, and it covers all three publish routes because it sits in the
shared promotion rather than in any one of them.

Two things can have changed between saving and publishing. The publisher may
not be the author, so 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.

Both gates run on the merged document the promotion will actually persist, and
never on the live row: a schema change must not block someone from fixing and
republishing content that has nothing to do with it.

Validation refuses, naming each field and carrying the rule's own message.

Access refuses too, which is a departure from the ordinary write, where a
denied field is stripped. Stripping is right there, because the value is the
caller's own input and dropping it costs them nothing. A promotion is not that:
the value belongs to whoever saved the draft, and a successful publish CONSUMES
the draft. Stripping would publish everything else, delete the pending change,
and take the one edit this publisher may not write with it, leaving the author
no draft, no value and no message. Measured: zero draft rows remained after
such a publish. Refusing keeps the draft for someone who can write the field.

Judged on what would CHANGE, not on what the snapshot holds. A draft snapshot
is a full copy of the document, so a denied field appears in every one of them;
only a denied field whose promoted value differs from the live row is a change
this publisher is not allowed to make.

Researched first. Payload enforces at the publish transition what a draft save
skips; Strapi makes Publish its own RBAC action; WordPress re-checks
`publish_posts` at the transition rather than reusing the edit check. Two
cautions came from their bug trackers and are honoured here: Sanity's #11456
shows what happens when the check leaks onto the live document, and Strapi's
#15636 shows a deferred check falling through to a raw database constraint
instead of a field-scoped message.

* refactor(nextly): one promote gate, outside the transaction, over a logical snapshot

Codex found four defects in the first revision, three of them P1, and all four
were real. This reworks it rather than patching around them.

The gate ran INSIDE the write transaction. Both a field's `access` rule and its
`validate` are user code, and resolving the caller's grants issues its own
pooled queries, so on a small pool those queries wait for the connection the
transaction is holding and the publish hangs. It now runs before the
transaction opens, which is where the collection publish path has always run
its own, and for this reason.

It judged the snapshot as stored. A snapshot holds a JSON-backed value (a
group, a repeater, `chips`, `json`) as the string that was written, and the
validator for those fields expects the object. Measured: publishing an
ordinary Single that merely HAS a group field was refused with "seo must be an
object". The snapshot is now read in its logical shape first. My tests used
text fields only, which is why this reached review.

It covered one publish path. `SinglePublishAllService.promotePendingChanges`
loops every language's pending change, writes each and deletes them all, and
had no gate of its own, so publishing every language still promoted content
forbidden to the publisher or invalid under the current schema. Both paths now
call one shared `promote-gate` module: two gates would be two answers to one
question, and the second is the one nobody remembers to update.

It compared a denied field against the main-table row, which omits every
localized value. An unchanged translation therefore compared against
`undefined` and read as an edit, so a publisher without write access to that
field could be refused a publish they were entitled to make. The comparison now
reads the live document for the language the pending change belongs to.

* fix(builder,blocks-engine,plugin-page-builder): a component is judged by what it draws (#1824)

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it wheneve…
mobeenabdullah added a commit that referenced this pull request Sep 13, 2026
…t write (#1821)

* fix(nextly): a function default cannot read a field the caller may not write

Field write access now runs before the defaults as well as after the hooks, the
two passes the read path already runs. A function `defaultValue` receives the
data built so far, and it ran while a field this caller is denied was still in
the record, 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, which is the field rule defeated by
proxy. Both passes share one grants resolver, so the caller's roles and
permissions are still read once.

A config reload re-registers the live config's field functions. The 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. Wrong in the direction that matters most for
an access rule, since a rule tightened in the config was not the one enforced.
Applied on the same optimistic terms as the field-type registry, and put back by
the same undo when a reload is abandoned.

The registry keyed only named entries, so every rule, hook and validator
declared inside an unnamed presentational group was dropped. `defineCollection`
refuses a field with no name, but the plugin declaration gate checks field TYPES
and not their names, so the shape reaches the live config. Flattened through the
repository's own addressable-fields walk, which already knows an unnamed group
stores its children at its own level while an unnamed repeater does not.

A Single's first read no longer invents an incomplete group: filling one for the
sake of a defaulted child while a required sibling has no value stored a
document the next write would refuse, and that insert runs no validation pass.

The docs no longer claim a field group's children take their defaults before the
entry's hooks; they are written in their own pass afterwards.

* feat(nextly): a field-group child takes a function default (#1822)

A field group's fields are read from its stored definition on every write, and a
function does not survive being stored, so only a constant default applied
there. A `defaultValue` written as a function on a field-group child was
silently dropped on every write, which #1809 documented as an exception rather
than closed.

The field-level registry already solves exactly this for collections and
Singles: it captures the function-bearing half of the live config at boot, and
the write path reads the function from there. Field groups now join it. The
configs were already in hand at the point of registration, so nothing new is
loaded: `NextlyServiceConfig` carries `fieldGroups`, and the same function that
registers every collection and every single registers them beside it. The
config-reload path re-registers them too, so an edit is picked up without a
restart.

`prepareInstanceForWrite` takes the field group's slug, available at all six of
its call sites, and hands the function map to `applyFieldDefaults` exactly as a
collection create does.

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 entity kind's comment says
so, rather than leaving the next reader to infer it.

* fix(nextly): judge a create's field rules on a copy, and swap the registry at the commit

Three review findings on the access and reload changes.

The pre-defaults access pass DELETED denied fields, and a create rule may
depend on a sibling the caller omitted precisely BECAUSE it has a default
(`data.kind === "public"` where `kind` defaults to `public`). The rule was
judged before that default existed, denied, and the caller's value was gone
before the pass that decides correctly could keep it. Measured: a supplied
`note` came back null on a create that succeeded before.

The rules now run over a COPY, and the copy is what the function defaults read.
`applyFieldDefaults` takes that view as an argument and threads it through
groups and repeater rows, so a nested default reads the nested view. The record
itself is untouched until the authoritative pass, which runs after the defaults
and therefore judges the condition they establish.

The reload installed the new field functions as soon as the config was read,
with an undo on the abandon paths. A reload whose DDL succeeds and whose later
metadata sync fails does not commit and does not run those undos, so a rule
from a config the process refused was deciding writes while every other service
ran the previous one. It is now staged and installed by `commitReload`, the
same boundary the config's hooks and the retention policies are published from.

That install replaces the registry wholesale rather than overwriting the slugs
it finds. A collection dropped from the config keeps its registry row and its
table so an orphan sweep can find them later, so it stays addressable, and its
entry here would have gone on running its old access rules, hooks, validators
and defaults until the process restarted.

* fix(nextly): the promote gate judges the draft the write commits (#1833)

* feat(builder): edit an instance's exposed link and visibility in the inspector (#1798)

* feat(builder): offer the site's components in the insert panel, placed as one linked node

The third tier of the palette, beside blocks and patterns. A component is
offered from the definitions a host supplies — read nothing, as the pattern
tier reads nothing — and placing one writes a SINGLE instance node carrying the
definition's id. Nothing of the definition's content is copied into the page;
the renderer inlines it at read time, which is what makes an edit to the
definition reach every page that placed it. That is the opposite promise from
a pattern's copy, so the tile says which: a visible badge for a sighted author
choosing between look-alikes, and the same promise leading the description a
screen reader hears after the name — not in the name, which stays exactly the
visible label so a spoken command matches what is written on the tile.

Placement is judged by the definition's ROOTS, through the rule patterns are
already judged by. The instance node's own type is not a registered block and
the nesting source answers "no restriction" for a type it cannot resolve, so
judging the node by its type would let a component whose root may only live
inside columns be placed at the page root — and render there.

Rows the palette has nothing to place for are skipped rather than refused: a
published definition saved without content, and a row holding something other
than a component. The kind check is pinned by a fixture that HAS roots, since
one without would be dropped by the empty-roots check first and the case would
pass whether or not the kind was ever read.

Click to place only, for now. The drag path judges a placement by one block
name, and a component is judged by its roots; giving it the instance type would
bypass that judgement, so it stays with the click path as a pattern does.

* feat(plugin-page-builder): component definitions reach the editor, and a placed one renders

The second half of placing a component. The builder could offer a component
and write an instance node for it, and the canvas then drew the
could-not-be-loaded marker: the renderer resolves an instance against a map of
definitions, and the editor handed it none — every definition read lived on
the served-page path. A component placed in the builder rendered on the site
and nowhere an author could see while editing.

The library route now carries the site's component definitions beside its
patterns, and the document comes from a BY-ID read rather than the listing.
That is the point, not a detail: the working-draft overlay lives in the
service's by-id path and nowhere else, so a definition built from the listing
would show an author a stale component beside the draft they just saved — a
defect this repository has fixed once already on the served-page path. The
plugin-facing collection service forwards no draft flag on its by-id read, so
the route binds the Direct API's `findByID({ draft: true })` as the user:
access rules decide what the caller may read, and the overlay is gated on the
caller being allowed to edit, so a reader who may only read sees the live one.

Two readers with different timing share the one route. The insert panel reads
the whole library only while it is open, as before. The canvas needs
definitions on every editor mount, and the pattern tier can run to the whole
byte ceiling, so a `tier=components` query narrows the read the editor makes
on mount. The route's paging walk and its admission under the ceiling are one
implementation for both tiers, each handing in only how a listed row becomes an
item; the two tiers spend one ceiling, patterns first.

The editor reads the tier once and hands the same response to both consumers:
the canvas gets a map by id, the panel a list — so the tile an author places
is built from the very document the canvas then resolves the instance against.

* fix(plugin-page-builder): definitions are read as the user, on their own route, at every state

The component tier moves from a `?tier=` query on the pattern library to
`GET …/library/components`, gated by the components collection's read
permission and answering the canonical `{ items, meta }` envelope. One
walk and one admission still serve both routes; a tier is a completion
function and a permission, not a copy of the loop.

Both component reads now say `overrideAccess: false`. `requireNextly()`
defaults every call to `true`, under which naming a `user` narrows
nothing: the by-id read overlaid the working draft without the
update-capability probe and skipped every field-level read rule, so an
author who could only read a component was handed its pending draft. The
listing asks for `status: "all"` — the plugin-facing service forwards no
lifecycle scope, and an untrusted read that states none is bounded to
public states, so a never-published component could not be placed on a
draft page. An API key's own scope travels on both reads.

`LibraryComponent` extends `SavedComponent`, and the client hands the
rows to the panel as received; a type test pins that the wire carries
every key the panel reads.

`pageRenderInputs` demands `definitions`, and the entry form's resting
render reads the component library too, so a placed component stays
drawn in the miniature after Done rather than becoming the
could-not-be-loaded marker.

The panel resolves each definition through the canvas's own lookup
before offering it: a root that is itself an instance is judged by what
it draws, and one the resolver had to leave standing is withheld. And
the panel says, in a status region above the tiles, when the library was
too large to load whole — naming the tier, and for components what a
left-out one looks like on the page.

* feat(builder): a selected component instance opens its own inspector, over its exposed properties

An instance's type is not in the block registry, so the block inspector
answered "select a block" to an author who had just clicked a
component. `inspectInstance` now describes the selection — the
library's title and usage for the definition, the node's own name and
lock, and one row per exposed property read back from the resolver's
own result, so a row shows what the page shows: the value in force,
which layer supplied it, whether it was cleared, and which exposure
shadows it when one does. Values stored for properties the definition
no longer exposes are carried separately, and only those this instance
holds.

`InstanceInspectorPanel` draws that in place of the tabs: text and
choice rows edit in place, an emptied text field clears the property
with the `$unset` sentinel rather than writing `""`, every override
carries a visible Reset that removes it (and drops the record with its
last entry, never writing an empty one), rows of a type with no control
yet show their value and say so, and the orphaned values are listed
with a Discard. `overridesPatch` writes the whole props object so
`componentId` survives every edit.

The name and lock fields move to `identity-fields.tsx` so both
inspectors draw one control, and every text field's draft follows the
stored value through one `useStoredDraft`. `InspectorPanel` takes the
host's component library as ONE prop — the lookup the canvas resolves
against and the rows that name the definitions — and the editor hands
it the same read the canvas gets.

* fix(plugin-page-builder): the component read overlaps, reaches drafts, and says when it failed

Every component's completion was a database round trip awaited one
after another — three thousand in a row at the library's ceiling. The
walk now completes rows in batches of eight, admitted in listing order
so the ceiling cuts the same rows it always did, and starts no batch
once it has.

The by-id read asks for `status: "all"` as the listing does: an
untrusted read that states nothing is bounded to public states, so the
never-published row the listing found answered 404 and the component
stayed unplaceable until published. The Direct API's `findByID` did not
forward `status` at all — a caller passing it was silently ignored — so
it does now, with `FindByIDArgs.status` declared and pinned by a test.

Every field of an item now comes from the by-id row, the one with the
working draft overlaid: a draft can rename a component or move it to
another category, and an item labelled from the live listing was named
by one version and drawn from another.

The identity the reads run as is built by `buildUserContext`, now
exported from `nextly/runtime` — the one builder of the user an access
rule is evaluated against — so the caller's verified claims travel and
the canonical identity wins. Roles are not on the route context; the
service resolves them from the account.

The component read's `pending`, `error` and `refetch` no longer vanish
behind an empty map. The canvas and the resting miniature wait for the
read and say when it failed, with a Try again; the insert panel tells a
tier that was cut from one that could not be read, and offers the retry.

The palette resolves a definition under the site's own document caps,
handed through `documentLimits`, and withholds one whose root resolves
to an empty forest.

* fix(plugin-page-builder): a failed component read no longer takes the canvas away

The component route refuses a role that may edit pages but not read
components, and the gate added for a pending read treated that failure
the same way — a least-privilege page editor lost the canvas for every
page, block-only pages included, and the resting card refused to
preview them. Only a PENDING component read gates now; a failed one
draws the page with its instances as could-not-be-loaded and says so
beside the canvas and beneath the miniature, with the retry.

A component's own content field no longer offers the definition it is
editing: placed, that instance points at the definition it sits in, and
the resolver draws the loop as a cycle after the save. `withoutSelf`
judges it on the opening document being a component and the form naming
the row — a page's field is never inside a component, and a create form
names no row.

Placing a component now asks the resolver whether the PAGE has room for
it, with the node in place: the apply counts stored nodes and an
instance is one, but the composed tree spends one budget across every
expansion, so a page near its cap accepted the node and rendered it
unresolved. `compositionRefusal` composes the candidate document under
the site's caps and turns a `budget` or depth reason into a sentence
the panel raises as a notice, leaving reasons that are the tile's
concern alone.

* fix(builder): the style trace composes instances through the definitions the canvas draws with

The builder's trace wrapper forwarded the site tier, the fetch policy,
the resolver and the caps to the renderer's trace, and not the
definitions — so on a page holding instances the cascade the inspector
reads its provenance from described a tree with every instance left
unresolved, while the canvas drew the composed one. The wrapper takes
the map and the editor hands it the canvas's own.

* fix(builder): a choice exposure with an empty option no longer crashes the inspector

The validator accepts an empty string as an option value — a "none"
beside class names — and the select control throws at render on an
item whose value is the empty string, which it reserves for no
selection. The empty value wears a NUL sentinel in the control and is
taken off again on the way back to the document, so the option is
offered and written as empty.

* fix(builder): judge a component's room under the site's limits, for every instance left standing

The insert preflight composed the candidate page the way the canvas does,
but dry-ran the insert itself under the engine's default limits, so a page
legal only under a raised cap threw out of the click before the resolver
ran; and it refused only when the PLACED instance was left unresolved, so
a nested instance the page had no room for, or an instance already on the
page that the new one took the budget from, was placed and drew a
placeholder.

The dry run now takes the same limits the composition does, a refusal the
apply itself makes is left to the editor's apply, and the refusal reads
every room-reason instance the placement introduces — compared against the
page composed without it, since an instance the page could not hold before
the click is not the click's doing.

* fix(builder): say when a library tier could not be reloaded, apart from one never read

A host that reads its library afresh on every open keeps the last answer
while it does, so a failed read can leave tiles standing — and beside them
"none are offered" was false. A stale tier gets its own sentence: the tiles
offered may be out of date, and a component on the page draws as it was.

* fix(plugin-page-builder): keep the definitions map still across renders, and name a failed refresh

The component read memoised its map and list on the route hook's refetch
wrapper too, which the hook mints per render, so the canvas was handed a
new map — and re-resolved every instance — on every render, keystrokes
included. The map and the list are now derived from the data alone, and the
retry keeps one identity for the life of the hook.

And a read that answered once and then failed to answer again was called
ready: the cached definitions drew with no sentence and no retry. Both
library reads now tell that state apart as `stale` — the page still draws,
from an answer an edit elsewhere may have overtaken — and the canvas note,
the entry card and the insert panel say so with the retry, rather than the
never-read sentences, which are false beside tiles that stand.

* fix(plugin-page-builder): leave out every component that reaches the one being edited

A component's content field left out only the row it was editing, so a
component holding an instance of that row — or one holding that — was still
offered, and placing it closed a loop nothing sees until the row is saved.
Every candidate is now resolved through the canvas's own lookup, and one
that reaches the row being edited at any depth is left out.

* fix(plugin-page-builder): count the classes linked components render on the page

The classes manager's on-this-page filter walked the stored document, where
a linked component is one instance node, so a class applied inside the
definition the canvas draws was missing from the filter. The walk now
composes through the same map the canvas draws with, under the same limits;
the usage record still asks what the document itself references.

* fix(plugin-page-builder): list components in pages of one completion batch

Every listed component is read again by id, so the listing's content is
never used — and the service reads whole rows however a caller narrows the
answer. A page of a hundred rows held while its batches completed was a
hundred documents of content in memory at once. The component listing now
pages by one completion batch, and the bound on reads is derived per tier
from its page size so the tier still reaches the item ceiling.

* fix(builder): offer the reset on the row that holds the override, not on the row it shadows

Two exposures aimed at one target share a value, and the resolver reports
the winning exposure's source on both — so a reset keyed on the source
stood on the shadowed row too, and pressing it removed nothing. Each row
now carries whether THIS instance's record holds a value under its id, and
the reset is offered on that.

* fix(builder): judge a moved component instance by the roots of the definition it draws

A pointer move judged the dragged node by its type, and an instance's type is
not a registered block, so the nesting rule answered "no restriction": a
component whose root belongs only inside a Columns, refused at the insert,
could be dragged into a paragraph afterwards. The drop query now carries the
types the placement is judged by — a block's own, or an instance's resolved
roots through the canvas's lookup under its caps — and the editor hands the
drag the same map and caps the canvas draws with.

* fix(builder): read an instance's definition under the resolver's own rule, published from the engine

The inspector judged a supplied definition by its kind alone, where the
resolver also refuses a format this build does not read and a list of nodes
that is not one. A definition the canvas left standing as a placeholder
therefore drew editable rows, and malformed nodes reached the exposure. The
engine now publishes `readableDefinition`, the rule the resolver applies
before inlining, and the inspector reads through it.

* feat(builder): edit an instance's exposed link and visibility in the inspector

Two more of the six exposed types get a control. A link exposure holds what
its url prop holds, a string, and is edited in the same field a url prop gets
in the block inspector, keyed for an address and clearing the property when
emptied. A visibility exposure is a checkbox writing `true` or `false` — the
two values the resolver reads there — with an inherited row reading as shown,
the component's own rule until the definition gates the node itself. Rich text
and image rows keep showing their value and source without a control.

* fix(plugin-page-builder): read components from the store the plugin was told they live in

A host may keep its definitions in a collection of its own and render from
it (`createBlocksPage`'s `componentCollection` and `componentField`), and the
plugin already lets the readiness notice follow that store through
`componentReadiness.collection` and `.field`. The editor's library route
read the plugin's own store regardless, so it drew a different definition
for the same id than the page did, or none. One derivation of the store now
feeds the route, its permission gate and the notice; a host whose
definitions come from no collection at all still gets the plugin's store,
which is the one thing a collection read can follow, and the option says so.

* fix(plugin-page-builder): build the editor under the site's caps, as the preflight and canvas are

The editor was built with no limits and so judged every apply under the
engine's defaults, while the insert's preflight and the canvas judged under
the site's. On a site that raised its cap, a placement the preflight and the
canvas accepted was refused silently by the apply. The caps are read once,
before the editor is built, and handed to it.

* fix(builder): read a tile's roots without composing its definition

The palette judged where a component may be placed by composing the whole
definition through the canvas's lookup and reading the roots of the result,
once per catalogue row, so a library of small wrappers around one large
definition cloned that definition once per wrapper every time the panel
opened — three thousand one-node wrappers around a five-thousand-node
definition was fifteen million nodes. The engine now publishes
`composedRootTypes`, which follows a root instance into the definition it
names through the resolver's own reader and its own four refusals, reading
each definition once and cloning nothing; the tile, the moved-instance rule
and the component's own field's exclusion all read through it or through
the stored graph. Room stays the click's question, asked of the resolver
with the page in hand.

* fix(builder): preflight a moved instance for room, and judge every edit under the editor's own caps

A move applied without asking whether the page still composed with the node
there: an instance carried ahead of another takes the budget that one had,
and one carried into another's slot content nests a composition deeper, so
the drop left a placeholder where a component stood. The preflight now takes
an op rather than a placed node, the drop asks it for a move and says why
when it refuses, and the caps it asks under are the editor's own — exposed
on the editor state, since they are the ones its apply enforces — which the
insert panel now reads too in place of a second reading handed in as a prop.

* feat(nextly,plugin-sdk): a plugin route resolves the caller's identity once, roles included

`PluginRouteCaller.identity()` resolves `readCaller(auth)` once per request —
the user context with the roles a stored read rule reads, its claims, and an
API key's scope — and `can()` reads through the same memo. The SDK publishes
`PluginRouteIdentity` as its answer.

A route that built its Direct API user from `ctx.user` carried no roles, so a
role-based read rule on the collection it read refused the very caller the
route's own gate had admitted, and the caller saw an empty library with
nothing to say why. The `nextly/runtime` export of `buildUserContext` added
for that route is gone again: the identity is the caller's to give.

* fix(nextly): findByID on users offers no status argument

`FindUserByIDArgs` omits `status` beside `collection` and `draft`. The users
read has no lifecycle scope to narrow by, and an argument the call accepts
and ignores reads as one it honours. Pinned by a type test the checker
evaluates (`users.test-d.ts`, under `tsconfig.tests.json`), with `depth` as
the control that still is offered.

* fix(blocks-engine,builder): the tile offers only a definition the resolver would read

The engine publishes `readableDefinition`, the resolver's own rule for a
supplied definition — a plain record whose nodes are a list, of kind
`component`, in this build's format — and `offerableDefinition` reads
through it, so the tile and the canvas read one rule.

Judged by kind alone, a definition in a format this build does not read was
offered, placed and drawn as a placeholder; one whose nodes were not a list
crashed the catalogue on `.length`.

* fix(plugin-page-builder): the style trace compiles under the canvas's caps

`pageStyleTrace` takes `canvasRender.limits` beside the canvas's definitions,
so the cascade the inspector reads describes the composed tree on screen —
neither one with every instance left unresolved, nor one composed past a cap
the canvas stopped at. Handed the map alone, the trace defaulted to the
engine's caps and reported declarations for nodes the canvas drew as a
placeholder.

* fix(plugin-page-builder): the component tier reads as the caller, failing on all but a missing row

`directComponentReads` reads through `caller.identity()` on both reads —
the user context with the roles a stored read rule reads, the verified
claims, and an API key's own scope — so a role-based read rule sees the
roles the route's gate resolved rather than an identity built from the
account alone, which carried none and refused the caller the gate admitted.

The by-id read no longer asks for `disableErrors`. It catches only not-found
and forbidden — a row gone between the two reads, or one THIS caller may not
read, which the walk reports as a cut library — and lets anything else fail
the route, which the client reports as unavailable with a retry. Swallowed,
a database fault read as a static ceiling with every instance on the page
drawn as missing and nothing to retry.

* fix(plugin-page-builder): a library row without a title is labelled by its id rather than dropped

A page's renderer keeps a component by its id and its document field; a
custom collection with no title field, or one whose title is redacted by
field-level access, renders every instance on the public page. The editor
dropped such rows — omitted from the component tier, counted as a cut —
and drew a placeholder where the page draws the component.

`identityOf` requires an id alone; a title only labels the row, and one
without a readable title is labelled by its id, the one name a row is sure
to have. One reader for both tiers, as before.

* fix(builder,plugin-page-builder): room is judged at the editor's apply; the host owns the notices

`useEditorState({ definitions, onRefused })` asks, after a fresh group
applies (`apply` and `applyAll` alike, never an undo or a redo), whether the
page still composes with the edit in — `compositionRefusal(before, after,
definitions, limits)` compares the resolver's unresolved sets by instance id
— and refuses with the sentence, committing nothing. The insert panel's and
the drag's own preflights are removed: every surface reaches the page through
this apply, so a duplicate, a paste, a pattern and a keyboard move are judged
exactly as a drop is, and no surface can forget to ask.

`BuilderShell` takes the host's `NoticeQueue`, and `useNoticeQueue`,
`NoticeQueue` and `RaiseNotice` are published from the shell entry. The
shell provides its sink only to what it renders, and the editor is built
above it, so a raise made there reached nothing in production; the host
builds the queue, hands it to the shell to draw, and raises into it.

* fix(builder,plugin-page-builder): a keyboard move judges an instance by the roots it draws

`nestingRefusalForMove` asks `placementVerdict(placementTypesOf(moving,
definitions), …)` — the function and the types a drop is judged by —
where it asked `blockAllowedAt(moving.type, …)`. An instance node's own type
is not a registered block, so the nesting rule restricted it nowhere, and
Alt+Arrow, the toolbar and the command palette could lift a component whose
root belongs only inside a container up to the root, where the same
instance's drop is refused.

`BlockKeyboardActions` takes the definitions the canvas draws with, as it
takes `nesting`, and the page-builder field passes its map. Without one an
instance is judged by its own type, which is how a placeholder stays movable.

* fix(plugin-page-builder): what a candidate reaches is read under the site's cap, failing closed

`withoutSelf` leaves out of a component's own field every component whose
stored graph reaches the one being edited. The walk read each definition with
`componentIdsIn` — the engine's default node cap, and no word on whether the
read was whole — so under a site cap other than the default, or a definition
longer than the cap, it answered a prefix, and a candidate whose reference
sat past it was cleared and could close a loop once saved.

Every read in the walk now goes through `componentUsageIn` under the SITE's
`maxNodes`, and a read the cap ends early counts as reaching: the candidate
is left out. That refuses no legitimate offer — a definition the cap cannot
read whole is one the resolver cannot inline under that cap either — and it
is the direction to err in, because the other is a saved loop every page
then draws as a placeholder.

* fix(blocks-engine,builder): composed root types are distinct and each definition is walked once

`composedRootTypes` walked a definition once per instance pointing at it and
concatenated the answers, so a definition of two hundred instances of another
holding two hundred more answered forty thousand roots, and walked the leaf
as many times — on every insert-panel open, for every component in the
library.

It now answers the DISTINCT types at the composed roots, in the order first
met, which is all `placementVerdict` reads, so the tile and the drop are
judged as before and the answer is bounded by the registry rather than the
forest. And it remembers each definition's answer for the query, reused at
the same depth or a shallower one only: the composition cap refuses by depth,
so what fit under an instance at depth one can be refused under one at depth
four. A refusal is never remembered — a cycle or the cap is a property of
where an instance sits — and the resolver's own refusals are still asked
first, per instance.

* fix(plugin-page-builder): the component tier is read in the language the document is edited in

A component's document field can be localized, and the public renderer reads
definitions in the page's locale; the editor's read named none, so an author
editing German drew a canvas of English components, and the resting
miniature too, from one locale-independent cache entry.

`useComponentLibrary` reads the surrounding form's `useDocumentLocale()` and
asks for `componentLibraryPath(code)`: `?locale=<code>` for a non-default
language, the bare path for the default or where the language cannot be
known — how an absent `?locale=` reads everywhere in the admin. The path is
the read hook's cache key, so a switch of language is a different read. The
route reads the parameter, spelled once in the shared contract, into BOTH
Direct API reads: a listing in one language completed by rows in another
would label one version and draw the other. An empty value is none, and an
unknown code resolves to the default in the Direct API.

The changeset now describes the caller's resolved identity, the title rule
and the locale, and no longer the `nextly/runtime` export this branch had
added and then removed.

* refactor(builder): the editor's room check is its own function, and its undo case can fail

The room question moves out of `run` into `refusedForRoom`, which asks
only when definitions were given and tells the host why when it refuses;
`run` keeps one condition, a fresh edit. One branch fewer took `run` back
under the complexity bound the audit holds this package to.

Re-breaking it showed the undo-and-redo case could not fail: the library
never changed between an edit and its replay, so a replay asked the room
question would have been allowed anyway. The library now grows between them
— a definition of three nodes becomes ten, which no longer fits — and a
fresh insert under the grown library is the control that refuses. Asking on
undo, on redo, or on both now fails the case.

* refactor(blocks-engine): a definition's remembered root types are read by their own function

`rootTypesOfNode` keeps the resolver's refusals — a malformed or gated
instance, a missing or unreadable definition, a cycle, the cap — and hands
the rest to `definitionRootTypes`, which serves an answer already given at
the same depth or a deeper one, or walks the definition and remembers it.
One branch fewer took the function back under the complexity bound the
audit holds this package to; the four ways the memo can be wrong — never
remembering, remembering across depths, answering before the refusals, and
answering the forest's width — each still fail a named test.

* fix(builder): the instance inspector reads a malformed instance and a definition's options safely

Every read of an instance's props goes through one guard, as the resolver
reads them: a stored instance whose `props` is missing or not a record — an
import, a hook, a hand edit — is drawn on the canvas as a malformed
placeholder, and selecting it threw on `node.props.componentId` where it now
reads as naming no component. The override patch spreads the same guarded
record.

A row's `options` and `label` are normalised where they are consumed. The
resolver checks the fields its own writer reads — id, node, path, type — so
a stored or hook-shaped definition could hand the panel options that were
not a list (`row.options.map` threw on selection), entries without a string
value, a value offered twice (one choice drawn twice under one key), or a
label that is an object. Options keep string values and labels, each value
once; a label that is not a string reads as the property's id.

* fix(builder): the instance panel spells each option apart and says what a structured value holds

Every option value wears a one-character prefix in the select control,
where only `""` wore a NUL sentinel. The control cannot take `""` — the
primitive reserves it for "no selection" — but a definition's option is free
text, so a sentinel for one value collides with the option that IS the
sentinel: the two items shared one value, the control drew the later of them
for either, and choosing one while the other was held changed nothing. A
prefix on every value is injective and never empty.

A read-only row and an orphaned override now summarise a structured value
instead of reading it through the text field's reader, which turned a value
that is there into the same nothing as one that is not: rich text as its
words through the engine's reader, a list by its count, other data as its
JSON, a cleared override as "(cleared)".

* fix(builder): a rename that differs only in spaces hands the field back the stored name

The name field compared a trimmed draft with the stored name and, when they
matched, returned — leaving the field showing a draft with spaces the
document does not hold. It now trims once, compares and stores the same
value, and hands the field the stored name back when nothing changes. The
field is the one both inspectors draw, so the block inspector's name
behaves the same; the comparison moved here from it unchanged.

* refactor(builder): the instance inspector's small text uses the type-scale token

Three font sizes in the instance inspector's rules were written as
`0.75rem` where the rest of the stylesheet names `var(--text-xs)`, the
theme's step of exactly that size — so the rendering is unchanged and the
rules follow the scale if it moves.

* fix(builder): a component is offered only when the canvas holds its definition, judged by that copy

`componentEntriesFrom` judged and previewed a tile from the row's own
document, while the placed instance is resolved by the canvas against its
lookup. A host of the published `InsertPanel` that passed `components`
without `componentDefinitions` was offered tiles whose every placement
landed as a missing component; and a row whose copy differed from the
lookup's was judged by roots the canvas does not draw. The offer now reads
the lookup's definition for the row — the drag and the keyboard already
judge a placed instance through the lookup — and a row the lookup does not
hold is not offered. The row still supplies the label, category and usage.

* fix(plugin-page-builder): a component's own field fails closed on a reference a cut library omits

The walk that keeps a component's own field from offering anything that
reaches it treated an id the canvas's lookup does not hold as a dead end.
From a library the ceiling or a permission cut, that id may be a component
the store holds and the read left out — and it may name the definition being
edited, so the candidate stayed offerable and a placement closed the loop
once saved. The field now passes whether its read was whole; out of a cut
library such an id counts as reaching and the candidate is left out, while
out of a whole one it is a component nobody supplied and names nothing.

`withoutSelf` takes the graph as one value (`ComponentGraph`: the lookup,
the site's caps, and whether the read was whole), and each walk starts from
the lookup's copy of the candidate, which is what the canvas draws.

* fix(blocks-engine): a root the inliner would drop stands for no type

`cloneDefinitionForest` skips a node it cannot clone — one that is not a
record, or whose id is not a string — so such a root never lands on the
page. `composedRootTypes` counted its type anyway, so a component was
offered, and refused, by a root the page never gets; one whose every root is
dropped was offered as placing something and placed nothing. The query now
passes over exactly what the inliner drops, and still answers nothing for a
root it keeps but cannot name.

* fix(builder): a pattern's roots are resolved through the lookup, as a component's are

A pattern is copied into the page as it stands, and one of its roots may be
a component instance — saving a placed component as a pattern stores exactly
that node. `rootsAllowedAt` judged the root's own type, which is not a
registered block and which the nesting source therefore restricts nowhere,
so a pattern wrapping a component whose root belongs only inside a Columns
was offered at the page root while the component's own tile was refused
there. Each root is now read through `placementTypesOf`, and the panel hands
its lookup to `allowedEntries`.

* fix(plugin-page-builder): a completed component keeps the id the listing named

The by-id read returns a PRESENTATION of the row — an `afterRead` hook may
rewrite or drop its `id` — while stored instances reference the id the
collection holds, which is the one the listing named. Completed from the
presentation, the client keyed its definitions by a name no instance uses,
or dropped the component, and every instance of it drew as missing. The
completion carries the listed id and takes the title, category, description
and content from the by-id row; a row with no readable title is still
labelled by that id, and a read that answered nothing is still a cut tier.

* fix(builder): every inspector control takes an id of its own, minted per mount

An exposed row's control and the block name and lock fields carried fixed
ids. A page can hold two blocks fields, each with its own editor, and both
labels then resolved to the FIRST editor's control: it carried two labels
and a doubled accessible name, while the second editor's input had none and
its label focused the other editor's field. Each control mints its id with
`useId`, so a label points at the control beside it.

The block inspector's own assertion no longer pins the literal id; it tells
the identity field from an attribute's by the placeholder only it has.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* test(builder): the visibility checkbox reflects a gate the definition itself carries

The rule is the engine's — a `visibility` exposure with no override now reads
the definition's own decision — and this is the surface it was wrong on: the
box drew checked and said the node was shown on this page while the renderer's
hidden-node pass withheld it. Asserted here as well as in the engine because
the panel is where an author reads it, and because the click that turns it on
writes the one value that removes the component's gate for this instance.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* chore: a changeset for the component library's review round

The round's own entry, since the PR it was raised against merged while this was
being written and its changeset went with it.

* fix(blocks-engine): a malformed slot entry refuses a placement, it does not raise

A stored forest reaches the internal-nesting walk unvalidated — its own comment
says a slot may hold anything — and asking what a node DRAWS reads one field
further than asking its type did: an entry typed as an instance but carrying no
`props` had its `componentId` read, where before only its type was.

Read defensively now, and an entry with no readable type stands for NO type
rather than for one spelled out of whatever was there: `placementVerdict`
compares these against a registry, and a string like "undefined" is a name a
slot's admissions list could be made to accept. The planners shape-check their
forest before they reach this, so it is a boundary rather than the whole
defence — what it must not be is a raise where a refusal belongs.

* fix(plugin-page-builder,blocks-engine): a by-id row's own id must be the one asked for

Four things a reader established about its own instruments rather than about the
data.

A row with no `id` KEY is the redaction case and is keyed by the listing. A row
that HAS the key answered with an identity, and `null`, a number or an empty
string is not "no id" — it is an answer, and not this one. Read as absence its
content travelled under a name it never claimed.

The lookup is now read INSIDE the roots query's exception boundary. It is a
caller-supplied object rather than a map the engine builds, so a `get` that
raises escaped to exactly the caller the boundary exists for.

The listing's cursor is taken from the greatest READABLE id on a page, and the
test stub now selects it the same way. Inspecting only the final row made the
stub fall back to an offset where the route carries a cursor, so the pagination
cases could pass about a walk that does not run — and the route's own selection
had no test at all, because the component tier's listing is injected. It is
asserted on the pattern tier, where the route's `page()` actually runs.

And the two `withoutSelf` suites share one set of fixture builders. Two copies of
what a component, an instance and the walk's inputs are is a second definition
of the thing under test.

* fix(builder): a value the inspector's control cannot hold is shown, not offered for editing

`OverrideValue` is unconstrained, so a host block may declare a text or link prop
as an object. A text field renders one as a blank — there is no string to show —
and the first edit then replaces the whole value with whatever was typed into
that blank, losing the rest of it with nothing reporting the loss.

Such a row now takes the same note a type this panel cannot edit yet takes: the
value is summarised and nothing overwrites it.

A select and a visibility row always stay editable. Neither carries the old value
into what it writes, so a value they cannot show is repairable by using the
control, and hiding it would strand the property instead.

* fix(nextly): a Single's publish re-judges the draft it promotes (#1826)

* fix(nextly): a Single's publish re-judges the draft it promotes

A Single's publish folds its held pending change into the live row. Field
access and validation both ran when the caller's payload arrived, and for a
publish that payload is just `{ status: "published" }`, so the draft's own
content reached the live row having been judged only when it was SAVED. The
collection publish path already gates its promotion; this is the same gate on
the Singles path, and it covers all three publish routes because it sits in the
shared promotion rather than in any one of them.

Two things can have changed…
mobeenabdullah added a commit that referenced this pull request Sep 13, 2026
…locale it judges (#1836)

* fix(nextly): a function default cannot read a field the caller may not write

Field write access now runs before the defaults as well as after the hooks, the
two passes the read path already runs. A function `defaultValue` receives the
data built so far, and it ran while a field this caller is denied was still in
the record, 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, which is the field rule defeated by
proxy. Both passes share one grants resolver, so the caller's roles and
permissions are still read once.

A config reload re-registers the live config's field functions. The 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. Wrong in the direction that matters most for
an access rule, since a rule tightened in the config was not the one enforced.
Applied on the same optimistic terms as the field-type registry, and put back by
the same undo when a reload is abandoned.

The registry keyed only named entries, so every rule, hook and validator
declared inside an unnamed presentational group was dropped. `defineCollection`
refuses a field with no name, but the plugin declaration gate checks field TYPES
and not their names, so the shape reaches the live config. Flattened through the
repository's own addressable-fields walk, which already knows an unnamed group
stores its children at its own level while an unnamed repeater does not.

A Single's first read no longer invents an incomplete group: filling one for the
sake of a defaulted child while a required sibling has no value stored a
document the next write would refuse, and that insert runs no validation pass.

The docs no longer claim a field group's children take their defaults before the
entry's hooks; they are written in their own pass afterwards.

* feat(nextly): a field-group child takes a function default (#1822)

A field group's fields are read from its stored definition on every write, and a
function does not survive being stored, so only a constant default applied
there. A `defaultValue` written as a function on a field-group child was
silently dropped on every write, which #1809 documented as an exception rather
than closed.

The field-level registry already solves exactly this for collections and
Singles: it captures the function-bearing half of the live config at boot, and
the write path reads the function from there. Field groups now join it. The
configs were already in hand at the point of registration, so nothing new is
loaded: `NextlyServiceConfig` carries `fieldGroups`, and the same function that
registers every collection and every single registers them beside it. The
config-reload path re-registers them too, so an edit is picked up without a
restart.

`prepareInstanceForWrite` takes the field group's slug, available at all six of
its call sites, and hands the function map to `applyFieldDefaults` exactly as a
collection create does.

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 entity kind's comment says
so, rather than leaving the next reader to infer it.

* fix(nextly): judge a create's field rules on a copy, and swap the registry at the commit

Three review findings on the access and reload changes.

The pre-defaults access pass DELETED denied fields, and a create rule may
depend on a sibling the caller omitted precisely BECAUSE it has a default
(`data.kind === "public"` where `kind` defaults to `public`). The rule was
judged before that default existed, denied, and the caller's value was gone
before the pass that decides correctly could keep it. Measured: a supplied
`note` came back null on a create that succeeded before.

The rules now run over a COPY, and the copy is what the function defaults read.
`applyFieldDefaults` takes that view as an argument and threads it through
groups and repeater rows, so a nested default reads the nested view. The record
itself is untouched until the authoritative pass, which runs after the defaults
and therefore judges the condition they establish.

The reload installed the new field functions as soon as the config was read,
with an undo on the abandon paths. A reload whose DDL succeeds and whose later
metadata sync fails does not commit and does not run those undos, so a rule
from a config the process refused was deciding writes while every other service
ran the previous one. It is now staged and installed by `commitReload`, the
same boundary the config's hooks and the retention policies are published from.

That install replaces the registry wholesale rather than overwriting the slugs
it finds. A collection dropped from the config keeps its registry row and its
table so an orphan sweep can find them later, so it stays addressable, and its
entry here would have gone on running its old access rules, hooks, validators
and defaults until the process restarted.

* fix(nextly): the promote gate judges the draft the write commits (#1833)

* feat(builder): edit an instance's exposed link and visibility in the inspector (#1798)

* feat(builder): offer the site's components in the insert panel, placed as one linked node

The third tier of the palette, beside blocks and patterns. A component is
offered from the definitions a host supplies — read nothing, as the pattern
tier reads nothing — and placing one writes a SINGLE instance node carrying the
definition's id. Nothing of the definition's content is copied into the page;
the renderer inlines it at read time, which is what makes an edit to the
definition reach every page that placed it. That is the opposite promise from
a pattern's copy, so the tile says which: a visible badge for a sighted author
choosing between look-alikes, and the same promise leading the description a
screen reader hears after the name — not in the name, which stays exactly the
visible label so a spoken command matches what is written on the tile.

Placement is judged by the definition's ROOTS, through the rule patterns are
already judged by. The instance node's own type is not a registered block and
the nesting source answers "no restriction" for a type it cannot resolve, so
judging the node by its type would let a component whose root may only live
inside columns be placed at the page root — and render there.

Rows the palette has nothing to place for are skipped rather than refused: a
published definition saved without content, and a row holding something other
than a component. The kind check is pinned by a fixture that HAS roots, since
one without would be dropped by the empty-roots check first and the case would
pass whether or not the kind was ever read.

Click to place only, for now. The drag path judges a placement by one block
name, and a component is judged by its roots; giving it the instance type would
bypass that judgement, so it stays with the click path as a pattern does.

* feat(plugin-page-builder): component definitions reach the editor, and a placed one renders

The second half of placing a component. The builder could offer a component
and write an instance node for it, and the canvas then drew the
could-not-be-loaded marker: the renderer resolves an instance against a map of
definitions, and the editor handed it none — every definition read lived on
the served-page path. A component placed in the builder rendered on the site
and nowhere an author could see while editing.

The library route now carries the site's component definitions beside its
patterns, and the document comes from a BY-ID read rather than the listing.
That is the point, not a detail: the working-draft overlay lives in the
service's by-id path and nowhere else, so a definition built from the listing
would show an author a stale component beside the draft they just saved — a
defect this repository has fixed once already on the served-page path. The
plugin-facing collection service forwards no draft flag on its by-id read, so
the route binds the Direct API's `findByID({ draft: true })` as the user:
access rules decide what the caller may read, and the overlay is gated on the
caller being allowed to edit, so a reader who may only read sees the live one.

Two readers with different timing share the one route. The insert panel reads
the whole library only while it is open, as before. The canvas needs
definitions on every editor mount, and the pattern tier can run to the whole
byte ceiling, so a `tier=components` query narrows the read the editor makes
on mount. The route's paging walk and its admission under the ceiling are one
implementation for both tiers, each handing in only how a listed row becomes an
item; the two tiers spend one ceiling, patterns first.

The editor reads the tier once and hands the same response to both consumers:
the canvas gets a map by id, the panel a list — so the tile an author places
is built from the very document the canvas then resolves the instance against.

* fix(plugin-page-builder): definitions are read as the user, on their own route, at every state

The component tier moves from a `?tier=` query on the pattern library to
`GET …/library/components`, gated by the components collection's read
permission and answering the canonical `{ items, meta }` envelope. One
walk and one admission still serve both routes; a tier is a completion
function and a permission, not a copy of the loop.

Both component reads now say `overrideAccess: false`. `requireNextly()`
defaults every call to `true`, under which naming a `user` narrows
nothing: the by-id read overlaid the working draft without the
update-capability probe and skipped every field-level read rule, so an
author who could only read a component was handed its pending draft. The
listing asks for `status: "all"` — the plugin-facing service forwards no
lifecycle scope, and an untrusted read that states none is bounded to
public states, so a never-published component could not be placed on a
draft page. An API key's own scope travels on both reads.

`LibraryComponent` extends `SavedComponent`, and the client hands the
rows to the panel as received; a type test pins that the wire carries
every key the panel reads.

`pageRenderInputs` demands `definitions`, and the entry form's resting
render reads the component library too, so a placed component stays
drawn in the miniature after Done rather than becoming the
could-not-be-loaded marker.

The panel resolves each definition through the canvas's own lookup
before offering it: a root that is itself an instance is judged by what
it draws, and one the resolver had to leave standing is withheld. And
the panel says, in a status region above the tiles, when the library was
too large to load whole — naming the tier, and for components what a
left-out one looks like on the page.

* feat(builder): a selected component instance opens its own inspector, over its exposed properties

An instance's type is not in the block registry, so the block inspector
answered "select a block" to an author who had just clicked a
component. `inspectInstance` now describes the selection — the
library's title and usage for the definition, the node's own name and
lock, and one row per exposed property read back from the resolver's
own result, so a row shows what the page shows: the value in force,
which layer supplied it, whether it was cleared, and which exposure
shadows it when one does. Values stored for properties the definition
no longer exposes are carried separately, and only those this instance
holds.

`InstanceInspectorPanel` draws that in place of the tabs: text and
choice rows edit in place, an emptied text field clears the property
with the `$unset` sentinel rather than writing `""`, every override
carries a visible Reset that removes it (and drops the record with its
last entry, never writing an empty one), rows of a type with no control
yet show their value and say so, and the orphaned values are listed
with a Discard. `overridesPatch` writes the whole props object so
`componentId` survives every edit.

The name and lock fields move to `identity-fields.tsx` so both
inspectors draw one control, and every text field's draft follows the
stored value through one `useStoredDraft`. `InspectorPanel` takes the
host's component library as ONE prop — the lookup the canvas resolves
against and the rows that name the definitions — and the editor hands
it the same read the canvas gets.

* fix(plugin-page-builder): the component read overlaps, reaches drafts, and says when it failed

Every component's completion was a database round trip awaited one
after another — three thousand in a row at the library's ceiling. The
walk now completes rows in batches of eight, admitted in listing order
so the ceiling cuts the same rows it always did, and starts no batch
once it has.

The by-id read asks for `status: "all"` as the listing does: an
untrusted read that states nothing is bounded to public states, so the
never-published row the listing found answered 404 and the component
stayed unplaceable until published. The Direct API's `findByID` did not
forward `status` at all — a caller passing it was silently ignored — so
it does now, with `FindByIDArgs.status` declared and pinned by a test.

Every field of an item now comes from the by-id row, the one with the
working draft overlaid: a draft can rename a component or move it to
another category, and an item labelled from the live listing was named
by one version and drawn from another.

The identity the reads run as is built by `buildUserContext`, now
exported from `nextly/runtime` — the one builder of the user an access
rule is evaluated against — so the caller's verified claims travel and
the canonical identity wins. Roles are not on the route context; the
service resolves them from the account.

The component read's `pending`, `error` and `refetch` no longer vanish
behind an empty map. The canvas and the resting miniature wait for the
read and say when it failed, with a Try again; the insert panel tells a
tier that was cut from one that could not be read, and offers the retry.

The palette resolves a definition under the site's own document caps,
handed through `documentLimits`, and withholds one whose root resolves
to an empty forest.

* fix(plugin-page-builder): a failed component read no longer takes the canvas away

The component route refuses a role that may edit pages but not read
components, and the gate added for a pending read treated that failure
the same way — a least-privilege page editor lost the canvas for every
page, block-only pages included, and the resting card refused to
preview them. Only a PENDING component read gates now; a failed one
draws the page with its instances as could-not-be-loaded and says so
beside the canvas and beneath the miniature, with the retry.

A component's own content field no longer offers the definition it is
editing: placed, that instance points at the definition it sits in, and
the resolver draws the loop as a cycle after the save. `withoutSelf`
judges it on the opening document being a component and the form naming
the row — a page's field is never inside a component, and a create form
names no row.

Placing a component now asks the resolver whether the PAGE has room for
it, with the node in place: the apply counts stored nodes and an
instance is one, but the composed tree spends one budget across every
expansion, so a page near its cap accepted the node and rendered it
unresolved. `compositionRefusal` composes the candidate document under
the site's caps and turns a `budget` or depth reason into a sentence
the panel raises as a notice, leaving reasons that are the tile's
concern alone.

* fix(builder): the style trace composes instances through the definitions the canvas draws with

The builder's trace wrapper forwarded the site tier, the fetch policy,
the resolver and the caps to the renderer's trace, and not the
definitions — so on a page holding instances the cascade the inspector
reads its provenance from described a tree with every instance left
unresolved, while the canvas drew the composed one. The wrapper takes
the map and the editor hands it the canvas's own.

* fix(builder): a choice exposure with an empty option no longer crashes the inspector

The validator accepts an empty string as an option value — a "none"
beside class names — and the select control throws at render on an
item whose value is the empty string, which it reserves for no
selection. The empty value wears a NUL sentinel in the control and is
taken off again on the way back to the document, so the option is
offered and written as empty.

* fix(builder): judge a component's room under the site's limits, for every instance left standing

The insert preflight composed the candidate page the way the canvas does,
but dry-ran the insert itself under the engine's default limits, so a page
legal only under a raised cap threw out of the click before the resolver
ran; and it refused only when the PLACED instance was left unresolved, so
a nested instance the page had no room for, or an instance already on the
page that the new one took the budget from, was placed and drew a
placeholder.

The dry run now takes the same limits the composition does, a refusal the
apply itself makes is left to the editor's apply, and the refusal reads
every room-reason instance the placement introduces — compared against the
page composed without it, since an instance the page could not hold before
the click is not the click's doing.

* fix(builder): say when a library tier could not be reloaded, apart from one never read

A host that reads its library afresh on every open keeps the last answer
while it does, so a failed read can leave tiles standing — and beside them
"none are offered" was false. A stale tier gets its own sentence: the tiles
offered may be out of date, and a component on the page draws as it was.

* fix(plugin-page-builder): keep the definitions map still across renders, and name a failed refresh

The component read memoised its map and list on the route hook's refetch
wrapper too, which the hook mints per render, so the canvas was handed a
new map — and re-resolved every instance — on every render, keystrokes
included. The map and the list are now derived from the data alone, and the
retry keeps one identity for the life of the hook.

And a read that answered once and then failed to answer again was called
ready: the cached definitions drew with no sentence and no retry. Both
library reads now tell that state apart as `stale` — the page still draws,
from an answer an edit elsewhere may have overtaken — and the canvas note,
the entry card and the insert panel say so with the retry, rather than the
never-read sentences, which are false beside tiles that stand.

* fix(plugin-page-builder): leave out every component that reaches the one being edited

A component's content field left out only the row it was editing, so a
component holding an instance of that row — or one holding that — was still
offered, and placing it closed a loop nothing sees until the row is saved.
Every candidate is now resolved through the canvas's own lookup, and one
that reaches the row being edited at any depth is left out.

* fix(plugin-page-builder): count the classes linked components render on the page

The classes manager's on-this-page filter walked the stored document, where
a linked component is one instance node, so a class applied inside the
definition the canvas draws was missing from the filter. The walk now
composes through the same map the canvas draws with, under the same limits;
the usage record still asks what the document itself references.

* fix(plugin-page-builder): list components in pages of one completion batch

Every listed component is read again by id, so the listing's content is
never used — and the service reads whole rows however a caller narrows the
answer. A page of a hundred rows held while its batches completed was a
hundred documents of content in memory at once. The component listing now
pages by one completion batch, and the bound on reads is derived per tier
from its page size so the tier still reaches the item ceiling.

* fix(builder): offer the reset on the row that holds the override, not on the row it shadows

Two exposures aimed at one target share a value, and the resolver reports
the winning exposure's source on both — so a reset keyed on the source
stood on the shadowed row too, and pressing it removed nothing. Each row
now carries whether THIS instance's record holds a value under its id, and
the reset is offered on that.

* fix(builder): judge a moved component instance by the roots of the definition it draws

A pointer move judged the dragged node by its type, and an instance's type is
not a registered block, so the nesting rule answered "no restriction": a
component whose root belongs only inside a Columns, refused at the insert,
could be dragged into a paragraph afterwards. The drop query now carries the
types the placement is judged by — a block's own, or an instance's resolved
roots through the canvas's lookup under its caps — and the editor hands the
drag the same map and caps the canvas draws with.

* fix(builder): read an instance's definition under the resolver's own rule, published from the engine

The inspector judged a supplied definition by its kind alone, where the
resolver also refuses a format this build does not read and a list of nodes
that is not one. A definition the canvas left standing as a placeholder
therefore drew editable rows, and malformed nodes reached the exposure. The
engine now publishes `readableDefinition`, the rule the resolver applies
before inlining, and the inspector reads through it.

* feat(builder): edit an instance's exposed link and visibility in the inspector

Two more of the six exposed types get a control. A link exposure holds what
its url prop holds, a string, and is edited in the same field a url prop gets
in the block inspector, keyed for an address and clearing the property when
emptied. A visibility exposure is a checkbox writing `true` or `false` — the
two values the resolver reads there — with an inherited row reading as shown,
the component's own rule until the definition gates the node itself. Rich text
and image rows keep showing their value and source without a control.

* fix(plugin-page-builder): read components from the store the plugin was told they live in

A host may keep its definitions in a collection of its own and render from
it (`createBlocksPage`'s `componentCollection` and `componentField`), and the
plugin already lets the readiness notice follow that store through
`componentReadiness.collection` and `.field`. The editor's library route
read the plugin's own store regardless, so it drew a different definition
for the same id than the page did, or none. One derivation of the store now
feeds the route, its permission gate and the notice; a host whose
definitions come from no collection at all still gets the plugin's store,
which is the one thing a collection read can follow, and the option says so.

* fix(plugin-page-builder): build the editor under the site's caps, as the preflight and canvas are

The editor was built with no limits and so judged every apply under the
engine's defaults, while the insert's preflight and the canvas judged under
the site's. On a site that raised its cap, a placement the preflight and the
canvas accepted was refused silently by the apply. The caps are read once,
before the editor is built, and handed to it.

* fix(builder): read a tile's roots without composing its definition

The palette judged where a component may be placed by composing the whole
definition through the canvas's lookup and reading the roots of the result,
once per catalogue row, so a library of small wrappers around one large
definition cloned that definition once per wrapper every time the panel
opened — three thousand one-node wrappers around a five-thousand-node
definition was fifteen million nodes. The engine now publishes
`composedRootTypes`, which follows a root instance into the definition it
names through the resolver's own reader and its own four refusals, reading
each definition once and cloning nothing; the tile, the moved-instance rule
and the component's own field's exclusion all read through it or through
the stored graph. Room stays the click's question, asked of the resolver
with the page in hand.

* fix(builder): preflight a moved instance for room, and judge every edit under the editor's own caps

A move applied without asking whether the page still composed with the node
there: an instance carried ahead of another takes the budget that one had,
and one carried into another's slot content nests a composition deeper, so
the drop left a placeholder where a component stood. The preflight now takes
an op rather than a placed node, the drop asks it for a move and says why
when it refuses, and the caps it asks under are the editor's own — exposed
on the editor state, since they are the ones its apply enforces — which the
insert panel now reads too in place of a second reading handed in as a prop.

* feat(nextly,plugin-sdk): a plugin route resolves the caller's identity once, roles included

`PluginRouteCaller.identity()` resolves `readCaller(auth)` once per request —
the user context with the roles a stored read rule reads, its claims, and an
API key's scope — and `can()` reads through the same memo. The SDK publishes
`PluginRouteIdentity` as its answer.

A route that built its Direct API user from `ctx.user` carried no roles, so a
role-based read rule on the collection it read refused the very caller the
route's own gate had admitted, and the caller saw an empty library with
nothing to say why. The `nextly/runtime` export of `buildUserContext` added
for that route is gone again: the identity is the caller's to give.

* fix(nextly): findByID on users offers no status argument

`FindUserByIDArgs` omits `status` beside `collection` and `draft`. The users
read has no lifecycle scope to narrow by, and an argument the call accepts
and ignores reads as one it honours. Pinned by a type test the checker
evaluates (`users.test-d.ts`, under `tsconfig.tests.json`), with `depth` as
the control that still is offered.

* fix(blocks-engine,builder): the tile offers only a definition the resolver would read

The engine publishes `readableDefinition`, the resolver's own rule for a
supplied definition — a plain record whose nodes are a list, of kind
`component`, in this build's format — and `offerableDefinition` reads
through it, so the tile and the canvas read one rule.

Judged by kind alone, a definition in a format this build does not read was
offered, placed and drawn as a placeholder; one whose nodes were not a list
crashed the catalogue on `.length`.

* fix(plugin-page-builder): the style trace compiles under the canvas's caps

`pageStyleTrace` takes `canvasRender.limits` beside the canvas's definitions,
so the cascade the inspector reads describes the composed tree on screen —
neither one with every instance left unresolved, nor one composed past a cap
the canvas stopped at. Handed the map alone, the trace defaulted to the
engine's caps and reported declarations for nodes the canvas drew as a
placeholder.

* fix(plugin-page-builder): the component tier reads as the caller, failing on all but a missing row

`directComponentReads` reads through `caller.identity()` on both reads —
the user context with the roles a stored read rule reads, the verified
claims, and an API key's own scope — so a role-based read rule sees the
roles the route's gate resolved rather than an identity built from the
account alone, which carried none and refused the caller the gate admitted.

The by-id read no longer asks for `disableErrors`. It catches only not-found
and forbidden — a row gone between the two reads, or one THIS caller may not
read, which the walk reports as a cut library — and lets anything else fail
the route, which the client reports as unavailable with a retry. Swallowed,
a database fault read as a static ceiling with every instance on the page
drawn as missing and nothing to retry.

* fix(plugin-page-builder): a library row without a title is labelled by its id rather than dropped

A page's renderer keeps a component by its id and its document field; a
custom collection with no title field, or one whose title is redacted by
field-level access, renders every instance on the public page. The editor
dropped such rows — omitted from the component tier, counted as a cut —
and drew a placeholder where the page draws the component.

`identityOf` requires an id alone; a title only labels the row, and one
without a readable title is labelled by its id, the one name a row is sure
to have. One reader for both tiers, as before.

* fix(builder,plugin-page-builder): room is judged at the editor's apply; the host owns the notices

`useEditorState({ definitions, onRefused })` asks, after a fresh group
applies (`apply` and `applyAll` alike, never an undo or a redo), whether the
page still composes with the edit in — `compositionRefusal(before, after,
definitions, limits)` compares the resolver's unresolved sets by instance id
— and refuses with the sentence, committing nothing. The insert panel's and
the drag's own preflights are removed: every surface reaches the page through
this apply, so a duplicate, a paste, a pattern and a keyboard move are judged
exactly as a drop is, and no surface can forget to ask.

`BuilderShell` takes the host's `NoticeQueue`, and `useNoticeQueue`,
`NoticeQueue` and `RaiseNotice` are published from the shell entry. The
shell provides its sink only to what it renders, and the editor is built
above it, so a raise made there reached nothing in production; the host
builds the queue, hands it to the shell to draw, and raises into it.

* fix(builder,plugin-page-builder): a keyboard move judges an instance by the roots it draws

`nestingRefusalForMove` asks `placementVerdict(placementTypesOf(moving,
definitions), …)` — the function and the types a drop is judged by —
where it asked `blockAllowedAt(moving.type, …)`. An instance node's own type
is not a registered block, so the nesting rule restricted it nowhere, and
Alt+Arrow, the toolbar and the command palette could lift a component whose
root belongs only inside a container up to the root, where the same
instance's drop is refused.

`BlockKeyboardActions` takes the definitions the canvas draws with, as it
takes `nesting`, and the page-builder field passes its map. Without one an
instance is judged by its own type, which is how a placeholder stays movable.

* fix(plugin-page-builder): what a candidate reaches is read under the site's cap, failing closed

`withoutSelf` leaves out of a component's own field every component whose
stored graph reaches the one being edited. The walk read each definition with
`componentIdsIn` — the engine's default node cap, and no word on whether the
read was whole — so under a site cap other than the default, or a definition
longer than the cap, it answered a prefix, and a candidate whose reference
sat past it was cleared and could close a loop once saved.

Every read in the walk now goes through `componentUsageIn` under the SITE's
`maxNodes`, and a read the cap ends early counts as reaching: the candidate
is left out. That refuses no legitimate offer — a definition the cap cannot
read whole is one the resolver cannot inline under that cap either — and it
is the direction to err in, because the other is a saved loop every page
then draws as a placeholder.

* fix(blocks-engine,builder): composed root types are distinct and each definition is walked once

`composedRootTypes` walked a definition once per instance pointing at it and
concatenated the answers, so a definition of two hundred instances of another
holding two hundred more answered forty thousand roots, and walked the leaf
as many times — on every insert-panel open, for every component in the
library.

It now answers the DISTINCT types at the composed roots, in the order first
met, which is all `placementVerdict` reads, so the tile and the drop are
judged as before and the answer is bounded by the registry rather than the
forest. And it remembers each definition's answer for the query, reused at
the same depth or a shallower one only: the composition cap refuses by depth,
so what fit under an instance at depth one can be refused under one at depth
four. A refusal is never remembered — a cycle or the cap is a property of
where an instance sits — and the resolver's own refusals are still asked
first, per instance.

* fix(plugin-page-builder): the component tier is read in the language the document is edited in

A component's document field can be localized, and the public renderer reads
definitions in the page's locale; the editor's read named none, so an author
editing German drew a canvas of English components, and the resting
miniature too, from one locale-independent cache entry.

`useComponentLibrary` reads the surrounding form's `useDocumentLocale()` and
asks for `componentLibraryPath(code)`: `?locale=<code>` for a non-default
language, the bare path for the default or where the language cannot be
known — how an absent `?locale=` reads everywhere in the admin. The path is
the read hook's cache key, so a switch of language is a different read. The
route reads the parameter, spelled once in the shared contract, into BOTH
Direct API reads: a listing in one language completed by rows in another
would label one version and draw the other. An empty value is none, and an
unknown code resolves to the default in the Direct API.

The changeset now describes the caller's resolved identity, the title rule
and the locale, and no longer the `nextly/runtime` export this branch had
added and then removed.

* refactor(builder): the editor's room check is its own function, and its undo case can fail

The room question moves out of `run` into `refusedForRoom`, which asks
only when definitions were given and tells the host why when it refuses;
`run` keeps one condition, a fresh edit. One branch fewer took `run` back
under the complexity bound the audit holds this package to.

Re-breaking it showed the undo-and-redo case could not fail: the library
never changed between an edit and its replay, so a replay asked the room
question would have been allowed anyway. The library now grows between them
— a definition of three nodes becomes ten, which no longer fits — and a
fresh insert under the grown library is the control that refuses. Asking on
undo, on redo, or on both now fails the case.

* refactor(blocks-engine): a definition's remembered root types are read by their own function

`rootTypesOfNode` keeps the resolver's refusals — a malformed or gated
instance, a missing or unreadable definition, a cycle, the cap — and hands
the rest to `definitionRootTypes`, which serves an answer already given at
the same depth or a deeper one, or walks the definition and remembers it.
One branch fewer took the function back under the complexity bound the
audit holds this package to; the four ways the memo can be wrong — never
remembering, remembering across depths, answering before the refusals, and
answering the forest's width — each still fail a named test.

* fix(builder): the instance inspector reads a malformed instance and a definition's options safely

Every read of an instance's props goes through one guard, as the resolver
reads them: a stored instance whose `props` is missing or not a record — an
import, a hook, a hand edit — is drawn on the canvas as a malformed
placeholder, and selecting it threw on `node.props.componentId` where it now
reads as naming no component. The override patch spreads the same guarded
record.

A row's `options` and `label` are normalised where they are consumed. The
resolver checks the fields its own writer reads — id, node, path, type — so
a stored or hook-shaped definition could hand the panel options that were
not a list (`row.options.map` threw on selection), entries without a string
value, a value offered twice (one choice drawn twice under one key), or a
label that is an object. Options keep string values and labels, each value
once; a label that is not a string reads as the property's id.

* fix(builder): the instance panel spells each option apart and says what a structured value holds

Every option value wears a one-character prefix in the select control,
where only `""` wore a NUL sentinel. The control cannot take `""` — the
primitive reserves it for "no selection" — but a definition's option is free
text, so a sentinel for one value collides with the option that IS the
sentinel: the two items shared one value, the control drew the later of them
for either, and choosing one while the other was held changed nothing. A
prefix on every value is injective and never empty.

A read-only row and an orphaned override now summarise a structured value
instead of reading it through the text field's reader, which turned a value
that is there into the same nothing as one that is not: rich text as its
words through the engine's reader, a list by its count, other data as its
JSON, a cleared override as "(cleared)".

* fix(builder): a rename that differs only in spaces hands the field back the stored name

The name field compared a trimmed draft with the stored name and, when they
matched, returned — leaving the field showing a draft with spaces the
document does not hold. It now trims once, compares and stores the same
value, and hands the field the stored name back when nothing changes. The
field is the one both inspectors draw, so the block inspector's name
behaves the same; the comparison moved here from it unchanged.

* refactor(builder): the instance inspector's small text uses the type-scale token

Three font sizes in the instance inspector's rules were written as
`0.75rem` where the rest of the stylesheet names `var(--text-xs)`, the
theme's step of exactly that size — so the rendering is unchanged and the
rules follow the scale if it moves.

* fix(builder): a component is offered only when the canvas holds its definition, judged by that copy

`componentEntriesFrom` judged and previewed a tile from the row's own
document, while the placed instance is resolved by the canvas against its
lookup. A host of the published `InsertPanel` that passed `components`
without `componentDefinitions` was offered tiles whose every placement
landed as a missing component; and a row whose copy differed from the
lookup's was judged by roots the canvas does not draw. The offer now reads
the lookup's definition for the row — the drag and the keyboard already
judge a placed instance through the lookup — and a row the lookup does not
hold is not offered. The row still supplies the label, category and usage.

* fix(plugin-page-builder): a component's own field fails closed on a reference a cut library omits

The walk that keeps a component's own field from offering anything that
reaches it treated an id the canvas's lookup does not hold as a dead end.
From a library the ceiling or a permission cut, that id may be a component
the store holds and the read left out — and it may name the definition being
edited, so the candidate stayed offerable and a placement closed the loop
once saved. The field now passes whether its read was whole; out of a cut
library such an id counts as reaching and the candidate is left out, while
out of a whole one it is a component nobody supplied and names nothing.

`withoutSelf` takes the graph as one value (`ComponentGraph`: the lookup,
the site's caps, and whether the read was whole), and each walk starts from
the lookup's copy of the candidate, which is what the canvas draws.

* fix(blocks-engine): a root the inliner would drop stands for no type

`cloneDefinitionForest` skips a node it cannot clone — one that is not a
record, or whose id is not a string — so such a root never lands on the
page. `composedRootTypes` counted its type anyway, so a component was
offered, and refused, by a root the page never gets; one whose every root is
dropped was offered as placing something and placed nothing. The query now
passes over exactly what the inliner drops, and still answers nothing for a
root it keeps but cannot name.

* fix(builder): a pattern's roots are resolved through the lookup, as a component's are

A pattern is copied into the page as it stands, and one of its roots may be
a component instance — saving a placed component as a pattern stores exactly
that node. `rootsAllowedAt` judged the root's own type, which is not a
registered block and which the nesting source therefore restricts nowhere,
so a pattern wrapping a component whose root belongs only inside a Columns
was offered at the page root while the component's own tile was refused
there. Each root is now read through `placementTypesOf`, and the panel hands
its lookup to `allowedEntries`.

* fix(plugin-page-builder): a completed component keeps the id the listing named

The by-id read returns a PRESENTATION of the row — an `afterRead` hook may
rewrite or drop its `id` — while stored instances reference the id the
collection holds, which is the one the listing named. Completed from the
presentation, the client keyed its definitions by a name no instance uses,
or dropped the component, and every instance of it drew as missing. The
completion carries the listed id and takes the title, category, description
and content from the by-id row; a row with no readable title is still
labelled by that id, and a read that answered nothing is still a cut tier.

* fix(builder): every inspector control takes an id of its own, minted per mount

An exposed row's control and the block name and lock fields carried fixed
ids. A page can hold two blocks fields, each with its own editor, and both
labels then resolved to the FIRST editor's control: it carried two labels
and a doubled accessible name, while the second editor's input had none and
its label focused the other editor's field. Each control mints its id with
`useId`, so a label points at the control beside it.

The block inspector's own assertion no longer pins the literal id; it tells
the identity field from an attribute's by the placeholder only it has.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* test(builder): the visibility checkbox reflects a gate the definition itself carries

The rule is the engine's — a `visibility` exposure with no override now reads
the definition's own decision — and this is the surface it was wrong on: the
box drew checked and said the node was shown on this page while the renderer's
hidden-node pass withheld it. Asserted here as well as in the engine because
the panel is where an author reads it, and because the click that turns it on
writes the one value that removes the component's gate for this instance.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* fix(plugin-page-builder): a pattern that reaches the edited component is not offered

Saving a selection as a pattern keeps the instance nodes it held, so a pattern
is a second way to copy a component into itself. The component tier was
filtered and the pattern tier was forwarded whole, and the two tiers are
different rows out of different collections.

Both are now judged by one graph and one rule: which components a candidate
places, followed through the canvas's own lookup until the edited row is met.
A pattern nobody could read whole under the site's node cap is left out rather
than offered on an answer nobody has.

* fix(blocks-engine): a pattern's instances are judged by what they draw

`nextly/component-instance` is not a registered block, so every rule spelled
over node types answered wrong for it in both directions. A parent rule found
no restriction on the instance type and admitted a component whose root belongs
only inside a Columns. A slot naming what it admits did not name the instance
type, so every component was barred from every slot that lists its contents,
including the ones drawing exactly what the slot asks for — the tile was
offered and the click then planned nothing.

The rule the palette already used for a node's drawn types now lives in the
engine, beside the roots query it is built on, and the builder re-exports it.
The two pattern rules — the destination's placement and the forest's own
internal nesting — resolve through it whenever the caller holds a lookup, and
the panel hands the same lookup to the catalogue and to the planner. Callers
holding no library get the answer they got before.

* fix(blocks-engine): a nested instance's own overrides reach its definition's roots

`composedRootTypes` followed a root instance into its definition without asking
what that instance decided about the definition's nodes. A `visibility`
exposure overridden to false removes the node and its subtree from the composed
forest — `cloneDefinitionNode` returns nothing at all for it — so the query
reported a type the page never receives, and a component whose only root is
hidden was offered as placing something and placed nothing. Overridden to true
it deletes the node's own gate, so a gated root the author turned on is
expanded rather than left standing.

The decision comes from the resolver's own planner rather than a second reading
of `props.overrides`, so the chosen variant's presets are folded in under the
instance's answers exactly as the render folds them. A definition's remembered
roots are a property of the definition, so an instance that decides neither
reads that memo nor writes to it.

* fix(plugin-page-builder): a completed component must be the row the listing named, read whole

Two ways a by-id read answers with something that is not the component it was
asked for, both of which the completion took at face value.

A `beforeOperation` hook can redirect the read, so the record answering for one
component may be another's. Keyed by the listing's id, that row's draft was
served under the other component's name: the canvas drew one component's
content wherever the other was placed, with nothing anywhere saying so. A row
naming a different id is now left out and the tier reported cut. A row carrying
NO id is still keyed by the listing — field-level access drops it in
presentation, and that case is why the id comes from the listing at all.

And an absent document field was flattened into the same `null` as a
stored-empty one. The field layer writes an unset non-required field as SQL
NULL and reads it back with the key present, so `null` is an answer and a legal
row; no key at all means this caller did not read the row whole. Told apart,
the second marks the tier cut instead of reporting a complete library the
client has no definition for.

* fix(plugin-page-builder): the library is paged from a row, not from an offset

Both tiers walked their collection as independent offset queries against a
collection other authors are editing. A row inserted or deleted before the next
offset shifts every row after it, so one comes back twice and another never at
all — and a row never listed is never completed, so `omitted` stays false and
the response reports a whole library while the canvas has no definition for it
and draws every instance as missing.

The position is now the greatest id the last page named, and the next page is
the rows after it: ordered by id, that means the same thing however many rows
before it moved. What is left of the offset counts pages since the position
last MOVED, for the one page whose every row arrives without a readable id —
it names no position, and stepping past it is the only way to reach what is
behind it. A page that names an id resets it, so an ordinary walk never carries
an offset a mutation can move.

* fix(blocks-engine): the roots query survives a node whose own fields throw

The resolver composes a definition carrying a throwing property accessor
without raising; this query read the same fields with no boundary. Its one
caller builds the insert panel's catalogue, so a single such definition took
the whole panel down over a component the canvas draws.

The boundary is at the published entry point rather than per definition,
because the document's own roots are read by the same walk. Caught rather than
asked first, which is this module's usual answer to a stored shape: reading the
property IS the raise, so there is no question to ask before it. The answer is
`undefined` — the one already defined for a forest this cannot judge, and one
its callers already withhold the tile for.

* refactor: name the two questions each of these rules was asking

`withDraftDocument` asked three: is this the row that was asked for, was it read
whole, and what is it called. The first and third are now their own — and the
third was spelled twice, once here and once on the listing row, so an unnamed
component could have come to be called two things.

`rootTypesOfNode` asked what a root stands for and, for an instance, what its
definition draws. The second is its own function, reached once the first has
decided it is an instance at all.

Both were over fallow's CRAP bound, whose coverage is estimated and so cannot
be tested down; both are under it by branch count now.

* perf(plugin-page-builder): the resting card reads the component tier only when the page places one

The miniature every entry form draws at rest read the whole component tier on
mount, whatever the page held. That read is a listing over every row plus a
read of its own per row to reach the working draft, bounded at sixteen
mebibytes — so opening an entry whose page places no component paid for a
library it resolves nothing against and draws identically without.

The question is asked of the document, under the SITE's node cap, and an
unread prefix counts as placing one: a document past the cap answers
`complete: false`, and "names nothing" is what an unread document looks like
too, so the read is still made rather than the largest pages on a site drawing
as could-not-be-loaded markers.

A disabled read reports `ready` rather than `pending`. A query that never runs
never stops pending, and the card would have waited on an answer nobody asked
for.

* fix(blocks-engine): a visibility exposure reads the definition's own gate

With no override written, a `visibility` exposure reported nothing in force —
and something was. A definition node carrying entry-field conditions is not
served: `survivesGating` with no plan says so, and the composed forest keeps
the gate for the renderer's hidden-node pass to act on. Read as a blank, the
inspector drew the row checked and told the author the node was shown on this
page while the canvas withheld it.

The row now carries the definition's own decision, with `source: "definition"`
and no own override, so a surface can tell an inherited gate from an author's
own hiding without a vocabulary of its own. A node with no gate still reads as
nothing in force, because nothing is.

* fix(plugin-page-builder): gate a configured component store by the slug it reads

Two rules about a store the plugin was told about rather than owns.

The route resolved its permission through the scope helper, which follows the
host's rename of a collection the PLUGIN declared. The handler reads the
configured slug literally, so the two named different collections exactly when
a host renamed the contributed `components` collection and pointed the store at
another one called `components`: a caller holding read on the collection being
read was refused, and one holding read on the renamed collection was let into a
collection that permission says nothing about. The default store still resolves
through the helper, which is what a rename needs.

And a completed component now carries its `keywords`. `LibraryComponent`
derives from `SavedComponent`, and the catalogue builds a component tile's
search terms from that field exactly as it does a pattern's — through the same
reader here, so an absent field stays absent and a stored NULL arrives as
`null`, which is the shape the palette expects.

* chore: a changeset for the component library's review round

The round's own entry, since the PR it was raised against merged while this was
being written and its changeset went with it.

* fix(blocks-engine): a malformed slot entry refuses a placement, it does not raise

A stored forest reaches the internal-nesting walk unvalidated — its own comment
says a slot may hold anything — and asking what a node DRAWS reads one field
further than asking its type did: an entry typed as an instance but carrying no
`props` had its `componentId` read, where before only its type was.

Read defensively now, and an entry with no readable type stands for NO type
rather than for one spelled out of whatever was there: `placementVerdict`
compares these against a registry, and a string like "undefined" is a name a
slot's admissions list could be made to accept. The planners shape-check their
forest before they reach this, so it is a boundary rather than the whole
defence — what it must not be is a raise where a refusal belongs.

* fix(plugin-page-builder,blocks-engine): a by-id row's own id must be the one asked for

Four things a reader established about its own instruments rather than about the
data.

A row with no `id` KEY is the redaction case and is keyed by the listing. A row
that HAS the key answered with an identity, and `null`, a number or an empty
string is not "no id" — it is an answer, and not this one. Read as absence its
content travelled under a name it never claimed.

The lookup is now read INSIDE the roots query's exception boundary. It is a
caller-supplied object rather than a map the engine builds, so a `get` that
raises escaped to exactly the caller the boundary exists for.

The listing's cursor is taken from the greatest READABLE id on a page, and the
test stub now selects it the same way. Inspecting only the final row made the
stub fall back to an offset where the route carries a cursor, so the pagination
cases could pass about a walk that does not run — and the route's own selection
had no test at all, because the component tier's listing is injected. It is
asserted on the pattern tier, where the route's `page()` actually runs.

And the two `withoutSelf` suites share one set of fixture builders. Two copies of
what a component, an instance and the walk's inputs are is a second definition
of the thing under test.

* fix(builder): a value the inspector's control cannot hold is shown, not offered for editing

`OverrideValue` is unconstrained, so a host block may declare a text or link prop
as an object. A text field renders one as a blank — there is no string to show —
and the first edit then replaces the whole value with whatever was typed into
that blank, losing the rest of it with nothing reporting the loss.

Such a row now takes the same note a type this panel cannot edit yet takes: the
value is summarised and nothing overwrites it.

A select and a visibility row always stay editable. Neither carries the old value
into what it writes, so a value they cannot show is repairable by using the
control, and hiding it would strand the property instead.

* fix(nextly): a Single's publish re-judges the draft it promotes (#1826)

* fix(nextly): a Single's publish re-judges the draft it promotes

A Single's publish folds its held pending change into the live row. Field
access and validation both ran when the caller's payload arrived, and for a
publish that payload is just `{ status: "published" }`, so the draft's own
content reached the live row having been judged only when it was SAVED. The
collection publish path already gates its promotion; this is the same gate on
the Singles path, and it covers all three publish routes because it sits in the
shared promotion rather than in any one of them.

Two things can hav…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core nextly type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant