Capability matrix
Source contract for the unreleased integration candidate, reviewed on 2026-10-18. Each row names the implementation and related tests; listing a test is not a claim that final release acceptance has passed. The current integration additions and remaining acceptance gates are listed below.
Terms:
- CMS — the admin API and the public read API (
/v1). - Generated — a standalone backend produced by
npm run backend:generateornpm run backend:deliver. - Presentation only — the admin screen applies it. No server enforces it.
Model shapes
Section titled “Model shapes”| Capability | CMS | Generated | Where a problem is caught | Evidence |
|---|---|---|---|---|
| Single-entry model (one record only) | A model is a collection or a single (content_model.kind). A single model holds at most one logical entry, counting Trash. A second create is refused (409 single_entry_exists) and names the entry that exists; of two concurrent creates exactly one wins. Adding a language adds to the same entry. The entry keeps its place while in Trash and frees it only on a permanent delete. A collection turns single only while it holds at most one entry, counting Trash (single_entry_conflict); the change moves the model contract, so a create planned against the old kind aborts. A site copy carries the kind; its dry run is blocked when the destination already holds the entry, in Trash too (single_entry_taken), or when the copy carries more than one entry for it. A Strapi single type imports as a single model. |
The model is marked in the schema, a second create is refused by name, and the database holds the table to one row, from the first migration and when the kind changes. | Every create, import and kind change. | backend/src/lib/lifecycle/single-entry.ts. backend/test/single-entry-model.test.ts. backend/test/single-entry-import.test.ts. backend/test/graduate-private-and-single.test.ts. backend/test/import-strapi-components.test.ts |
| Content folders (admin navigation grouping) | A model has an optional folder label, content_model.nav_folder: 1 to 40 characters after trimming, grouped without regard to case, flat, and implicit while a model uses it. It is set by its own write, PUT /api/models/:id/folder, under model:apply, not by Apply, so it never changes the model revision or blocks a save. The Content list shows unfoldered models first, then folders by name; each folder is a button with aria-expanded, the browser remembers which folders are collapsed, and a folder holding the open model opens. Not part of generation input, generated backends or public reads. A site copy carries it onto models it creates. The management API sets it with setModelFolder and lists it as folder. |
Not part of generated backends. | Model folder updates and site copy. | backend/src/lib/model-folder.ts. backend/src/api/model-folder.ts. backend/test/model-folder-route.test.ts. backend/test/model-folder-site-copy.test.ts. backend/test/management-models.test.ts. frontend/src/lib/content/model-folders.ts. frontend/src/routes/content/components/ModelFolderGroups.svelte. frontend/cypress/e2e/journey-model-nav-folders.cy.ts |
| Unique field declared by an author | Individual text/number uniqueness and up to eight named combinations of two to four text/number fields. Combination members use the same shared/translated storage; scope is the model or each language. Empty members do not claim a combination. Save, import, restore, publication and definition changes use atomic guards. See Unique combinations. | Single and compound unique indexes; locale-scoped indexes include lang. |
Generation readiness and the build refuse published duplicates and shared one-to-one targets before anything is generated. | backend/src/lib/lifecycle/unique-values.ts. backend/src/lib/graduate/unique-values.ts. backend/test/field-uniqueness.test.ts. backend/test/graduate-uniqueness.test.ts |
| Private field (hidden from public reads) | Per field (custom_field.private) and per field inside a component. A private field is never served by a public list or item, nor its relations or media, the feed, site search or the generated public client. A public filter, sort, selection or expansion naming one is refused exactly as an unknown field. Cache keys carry the set of private fields, so a field turned private is never served from an older cached response. Turning a field private re-indexes the model’s search rows. A component field whose library cannot be read is removed whole. A saved query on a public route may not name a private column, also through a component column that holds one (422 private_field_on_public_route when binding or editing, saved_query_private_field at generation). Per-model private visibility still needs an in-scope read token. |
Private columns are left out of every reader and write answer and kept for editors. A private list relation’s join table is served to editors only. A generated site shows no private value, relation or media, and removes a component’s private fields from inside its values. Per-model, per-operation access policy (anonymous, session, service) is unchanged. |
Public read and generation. | backend/src/lib/private-fields.ts. backend/test/private-fields.test.ts. backend/test/public-feed.test.ts. backend/test/graduate-private-and-single.test.ts. backend/test/saved-query-graduation.test.ts. backend/test/public-private-routes.test.ts. backend/test/backend-policy.test.ts |
| Conditional field visibility | Typed Boolean, select, text, number and date conditions; AND/OR groups with at most four terms and three levels. The native editor, save and model validation share the same rules. Hidden values stay stored; visibility never grants or removes access. See Show only when. | The same conditions gate required-field validation. | Model save. | frontend/src/lib/eav/field-condition.ts. backend/test/field-conditions.test.ts. backend/test/mixed-components-site-copy.test.ts |
| Mixed component field | Ordinary and mixed components support retirement and retained definition versions. Existing instances stay editable under their pinned definition; retired types cannot be added or duplicated. Upgrade is explicit. Mixed fields keep stable instance ids, enabled state and their allowed component list. See Component definition versions. | Backend generation reads each block by its own component and declares media paths per component. A block of a component the field does not allow, a key its component does not declare, and a lone block where a list is expected are refused. Hidden blocks stay in the rows and are dropped from every reader answer and from a generated site. | Save, model save and generation. | backend/src/lib/component-contract.ts. backend/src/lib/hidden-blocks.ts. backend/test/mixed-components.test.ts. backend/test/mixed-components-site-copy.test.ts. backend/test/hidden-blocks.test.ts |
| Field-level authorization | A role’s limits name, per model or for every model (*), the fields it may read and write; a role without field limits can access every field. Unreadable fields are left out of instance reads, value lists and their totals, searches, version payloads, page reads and the page library, and a preview uses its creator’s read scope. A save keeps every stored value the role could not see. A restore that would rewrite a field it may not write is refused (field_denied). A whole-site export is refused to a role that narrows reading (export_limited_role). A person holding several roles gets the union per operation. A role manager may grant only permissions they hold, and a management credential keeps its person’s limits. Renaming a field key rewrites every role’s field lists for the model. |
Unsupported. You can implement it only through your own hooks in src/owned/backend.ts. |
Save, restore, read and role save. | backend/src/lib/permission-limits.ts. backend/test/permission-limits.test.ts. backend/test/permission-limits-routes.test.ts. backend/test/permission-limits-lifecycle.test.ts. backend/test/permission-limits-pages.test.ts. backend/test/management-roles.test.ts |
| Locale-level authorization | limits also name languages per action: read, create, update, delete, restore, review, publish and unpublish. A save, bulk batch, workflow move, publish or unpublish that touches a language outside the role is refused in its entirety (locale_denied) and writes nothing. A publish override, single or bulk, counts only the languages whose live version it would change, and its refusal names them. Restoring an old version of one language needs restore for that language only and changes that language’s fields, own relation lists and address, never shared values or other languages; restoring the whole entry needs every language. A changed shared field needs update on the primary language; an unchanged one does not. When a role lacks delete permission for another language of the entry, it can move an allowed language to Trash on its own, but cannot move the whole entry. A preview never falls back through a language the maker may not read. An :own key widens a language only for the person’s own rows. A superadmin is never narrowed. |
Unsupported. | Save, workflow, Trash, read and preview. | backend/src/lib/permission-limits.ts. backend/test/permission-limits-lifecycle.test.ts. backend/test/permission-limits-routes.test.ts. backend/test/permission-limits-preview.test.ts. backend/test/permission-limits.test.ts |
Relations
Section titled “Relations”| Surface | One-to-one | Many-to-one | One-to-many | Many-to-many |
|---|---|---|---|---|
| Model editor | Offered, described as “Each linked item belongs to only one <owner>” |
Offered | Offered, described as “Each linked item belongs to only one <owner>”. Component fields do not offer it (relation_type_not_authorable). |
Offered |
| Related from (target editor) | The target lists the entries whose field points at it, by source model and field, with “Live only”, “Draft only” and “View only” marks. An editor who may read the target and update the source adds or removes the link; the change is a new draft version of the source, saved under the source’s own permissions, version check, field rules, cardinality, one-owner rule and hierarchy rule, and never published. Replacing a source’s other target needs a confirmation that names both (409 related_from_replace_unconfirmed). Nothing is stored on the target side. |
Same | Same, without the replacement step | Same, without the replacement step |
| CMS write | One target | One target | Many targets | Many targets |
| CMS write forms | A plain list or { set } replaces the field’s ordered list. { connect, disconnect, move } removes, then connects in the given order (a plain id goes to the end, { id, position } places it), then moves related items; positions are before, after, start, end. On a to-one field a position or move is refused (relation_position_not_ordered) |
Same | Same | Same |
| CMS write checks | On a changed list: the target exists in the site, is of the declared target model (one of the accepted models, when the field accepts several), is readable by the writer (:own read included). It appears once, and a to-one field holds one. A list equal to the stored one is not revalidated. Restore performs the same checks. A target another item points at in this field, live or draft, is refused (409 relation_target_taken) |
Same, without the one-owner refusal | Same as one-to-one | Same, without the one-owner refusal |
| CMS public read | { instance_id, model, slug, display } list in the snapshot. A target of a translated model is served with the address and label of the item’s served language, then the fallback chain; under a strict policy a target with no such language is left out. The tag filter matches that same address |
Same | Same | Same |
| Generated schema | <key>_id column, @unique, foreign key |
<key>_id column, foreign key |
Junction table owned by the declaring model, unique target column, foreign keys | Junction table, foreign keys |
| Generated read | The id column | The id column | Read-only junction route | Read-only junction route |
| Generated write | Sets the id column after checking that the target row exists. Otherwise, it returns 422 relation_target_missing and writes nothing. Single-model output has no target table and refuses every non-null id. |
Same | PUT /api/<owner>/relations/<field> with set, connect, disconnect, validated against the CMS rules in one batch. A target another owner holds is 409 relation_target_taken |
PUT /api/<owner>/relations/<field>, same rules |
| Generated delete of the target | An optional column becomes null. A required one refuses the delete (409 relation_target_referenced) |
Same | The link is removed | The link is removed |
- A missing target, a target of another model and a target the writer may not read are refused the same way (422
relation_target_not_found). The refusal names the refused ids, so the answer does not reveal which ids exist. - Rows written before these checks stay readable. The next save that changes that field must satisfy them.
- Translatable fields are written through the generated translation routes. A locale row names its row and a language the site’s content was generated with, once (
unknown_locale,translation_row_missing,locale_exists); an update changes only translated values. - Deletion: relation rows are removed when either entry is deleted for good. Moving an entry to Trash keeps them, and restore brings them back. A link to an entry in Trash cannot be newly chosen. The other entry stays. There is no per-field setting. In the whole-site generated backend every relation id is a foreign key with the behaviour above. A relation id inside a component JSON value is not a key.
- Different links in each language (
localizedMembership, translated models only, not inside components): each language keeps its own ordered list in rows, versions and the editor, and every relation rule (to-one, one owner, hierarchy, item limits) is checked per language. A public read serves each language its own list; a fallback language serves its own list, never another language’s. Restore brings back each language list, also from a version written before the option changed. Turning the option on copies the shared list to every language; turning it off keeps the primary language list and is blocked when two items would then share a one-owner target or the primary-language parents would loop. Deleting a language for good deletes its list. A site copy carries each language list. A generated backend stores such a field, to-one included, as junction rows carrying the locale, and its relation route reads and writes one language list at a time. Evidence:backend/src/lib/lifecycle/relation-sets.ts.backend/test/relation-localized-membership.test.ts.backend/test/relation-localized-membership-site-copy.test.ts.backend/test/graduate-localized-membership.test.ts.
Evidence: backend/src/lib/lifecycle/service.ts (planRelations, checkRelationLists, resolveRelationWrites). backend/src/api/models.ts. backend/src/lib/relation-type.ts. backend/src/lib/graduate/templates.ts (relationTargetsExist). backend/test/relation-writes.test.ts. backend/test/lifecycle-atomicity.test.ts. backend/test/models-apply.test.ts. backend/test/graduate-relation-writes-worker.test.ts. backend/test/graduate-relation-load.test.ts. backend/test/graduate-relation-schema.test.ts. backend/test/relation-cardinality.test.ts. backend/test/graduate-junction-http.test.ts. frontend/src/routes/model-builder/[id]/tabs/config-types/RelationConfig.svelte.test.ts.
Presentation only versus enforced
Section titled “Presentation only versus enforced”| Control | Enforced by the server? |
|---|---|
| Field “hidden” in the editor | No. The value is still stored and served. |
Show condition (ui.showWhen) |
Partly. A hidden value is still stored and served. The server requires a field only while its condition shows it and refuses a condition it cannot honour. |
| Field “read-only” | Removed from the model editor. Stored values of the old setting are ignored. |
| Field validation: required, length, pattern, number range and step, item counts, dates, select options, on model fields and on every field inside a component value | Supplied values are checked on Save by the editor’s own rule module. Default draft mode defers missing/empty required values; optional strict mode requires them on Save. Publication checks the exact snapshot with full required-value validation (422 field_validation_failed, naming each field by full path: seo.meta_title, faqs[2].question). A component value is validated against the definition its field names. A list where one instance is expected is refused. An instance that is not an object is refused. A key the definition does not declare (beside variant_id, enabled, id, _id) is refused. Media inside a component is taken in every shape the generated standalone backend reads. The shapes are an id, { media_id, url }, a list of either, and a one-item list on a single field. It is stored as an id, or a list of ids. An optional single component holding only empty values counts as left out. Hidden fields, derived fields nobody may edit and a page’s composer-owned fields are skipped, as in the editor. A version restore is validated against the current model and refused with the same 422, writing nothing. Import validation checks the values that each phase writes. Where the job plans relation and media links, counts are checked from the planned links. The dry run reports each problem as a blocking row per path and language before anything is written. A spreadsheet check validates each edited row the same way before the first save. |
| Relation deletion behavior | Removed from the model editor. Deletion is fixed (see Relations). |
Media crop rules on a field (aspectRatio, minWidth, minHeight) |
Yes, for a crop an editor sends. A rule changed later does not block an unrelated save. An import is not revalidated. |
Model settings.permissions, drafts, versioning, default sort |
Legacy settings removed. settings.validation_mode is supported: draft by default, or strict for complete fields on Save. |
Role grants per model, :own ownership, workflow transition permissions, publish and unpublish permissions |
Yes |
Model visibility (public / private) on the CMS public API |
Yes |
| Rich-text size (256 KiB) | Yes |
Evidence: backend/src/lib/lifecycle/field-rules.ts. frontend/src/lib/eav/content-validator.ts. backend/src/lib/import/rule-findings.ts. backend/test/field-rules.test.ts. backend/test/field-rules-save.test.ts. backend/test/component-rules-route.test.ts. backend/test/import-field-rules.test.ts. backend/test/import-strapi-field-rules.test.ts. backend/test/spreadsheet-import.test.ts. frontend/src/routes/content/[id]/component-rules.svelte.test.ts. backend/test/media-framing.test.ts. backend/test/transition-requires-write.test.ts. backend/test/ownership-enforcement.test.ts. backend/test/rich-field-contract.test.ts.
Editorial operations
Section titled “Editorial operations”| Capability | CMS | Evidence |
|---|---|---|
| Locale publication | Each language has its own workflow stage and live version. Publish, unpublish or review one language from the Workflow panel. A group action that publishes or unpublishes moves every language; a group move between two unpublished stages (such as Approve · All languages or Send back · All languages) leaves a language that is already published live, in the Published state, and Unpublish {language} takes it off. Public reads, previews, addresses, redirects and generated sites follow each language’s own published version; a generated site emits each language from its own version and address. When the primary language is not live, generation emits the item from its live languages under the same id, taking shared values from the live version of the first live language in site order (default first). A site copy carries each language’s pointer and the import, its receipts and undo restore it. Scheduling one language needs the plugin capability workflow:locale. |
backend/test/locale-publication.test.ts, backend/test/locale-publication-route.test.ts |
| Search, sharing, redirects and sitemap | Per-language search and sharing overrides live in each version, with a page type for structured data. Site Chrome holds the defaults, who runs the site, search-engine verification codes, the sitemap and robots policy, and up to 256 address and 32 pattern redirects answering 301, 302, 307, 308, 410 or 451. One redirect service refuses loops through any mix of rules, ambiguous patterns, reserved and live addresses; a changed pattern needs a review of the live pages it covers; publishing at an address a redirect holds is refused by name. Both renderers run one matcher, one sitemap writer (an index of 5,000-address chunks with images) and one JSON-LD serializer. | backend/test/redirect-service.test.ts, backend/test/redirect-runtime-parity.test.ts, backend/test/redirect-lifecycle.test.ts, backend/test/sitemap-runtime-parity.test.ts, backend/test/sitemap-public-route.test.ts, backend/test/structured-data.test.ts |
| Live site search | Off until turned on in Settings → Source & delivery → Site search (site:infra). GET /v1/_search answers published pages, posts on a Blog site and up to 16 chosen public models, in the requested language under the site’s fallback chain. Drafts, private models, entries in Trash and languages set to hide from search engines never appear. Every word must match by the type’s matching: whole words (default), English word forms (Porter2 stemming of stored text and query alike, on every site; decided per result by the language it is served in: en, en-* and en_* match word forms, any other language whole words, in one query) or parts of words; title matches come first, then the newest. Every form is stored with each version, so a matching change rebuilds nothing, and cursors and cache keys carry it and the language fallback chain. Under Matching and ranking a type can instead search up to 16 chosen fields, each weighted 1–10 (title 10, others 1 to start); a result ranks by the highest weight among its chosen fields that hold every word, then the newest. Weights are read at search time, so nothing is rebuilt. Each version’s words are stored in the batch that saves it; whether a row is live and its address are decided when searching, so publishing, unpublishing, Trash and address moves take effect at once. Search turned off answers 404 search_off. Managed and generated sites draw /search, /<language>/search and the Site search section from it at request time; nothing is rebuilt to update search. GET /v1/_search/suggest answers up to 10 title-first matches from the same rows, matching, cache tags and purge, the last word matching the start of a word; every search box lists them while the visitor types (keyboard and screen-reader combobox, plain GET form without JavaScript) and records nothing about the query. |
backend/src/lib/search/public-search.ts. backend/src/lib/search/projection.ts. backend/src/lib/search/suggest-script.ts. backend/src/lib/search/english-stem.ts. backend/test/public-search.test.ts. backend/test/search-projection.test.ts. backend/test/english-stem.test.ts. frontend/src/lib/sites/search-suggest-script.test.ts |
| Trash | Delete moves the whole entry, every language, to Trash. Live content is refused until it is unpublished. Trash entries leave ordinary lists, search, relation pickers, dashboards, exports and previews. Each content and page list has a Trash beside it, limited to models the person can read. Restore needs delete on the model, returns the entry as a draft with versions, translations, links and media, and first lists addresses now taken and links that no longer work. Delete permanently is a separate confirmed action. Entries are deleted for good 30 days after they were trashed. One language that does not own the entry’s history can go to Trash alone (“Move this language to Trash”): it leaves the current version, the language switcher and public reads, the rest stays editable and publishable, adding that language again is refused (language_in_trash), and restore puts it back as a draft. Posts have their own Trash beside the list. Content in Trash that links to an item does not block unpublishing that item; it is listed as In Trash. Import refuses an entry whose address belongs to an entry in Trash, a re-import into an entry now in Trash, and a link to one. |
backend/src/lib/lifecycle/trash.ts. backend/src/api/content_lifecycle.ts. backend/test/content-trash.test.ts |
| Bulk actions | Move to a workflow stage, publish or unpublish by override, or move explicit ids to Trash, restore them or delete them for good. A request accepts at most 25 ids. Each entry runs its single route’s checks, including its own revision and reviewed dependents. Results are in input order. A retry with the same key replays committed entries instead of re-running them, even after a request stopped part-way. Keys are bound to the person. Generic /many and /bulk routes answer 410. relate adds or removes up to 10 linked entries in one many-to-many field of each entry (Posts → Categories and tags), as that entry’s own save with its version check; it never publishes, an entry that already has the change is unchanged, and a one-to-many field is refused (batch_relation_exclusive). |
backend/src/api/content_lifecycle.ts (/batch). backend/test/content-batch.test.ts. backend/test/content-batch-relate.test.ts |
| Editing ownership | The first editor of an entry and language holds its editing lease. Others who open it see who holds it, read along in a read-only form that follows the holder’s shared draft, and can Take over in one action. The lease moves at once: the previous holder is told who took over, their next save or move is refused 423 lock_lost, and their unsaved typing is kept as their own recovery draft, offered back when they hold the lease again. Joining or taking over the lease of a language needs the person’s change grant in that language (create, when it has no row yet; otherwise locale_denied), and an own-only role must own that language’s row (not_owner). Saves, adding a language, workflow moves, assignment, version restore, the publish override, Trash and permanent deletion of that entry and language from any other session are refused 423 locked; a list batch refuses only the held entry. The same person’s content list and management credentials are not blocked by their own open editor; another person’s are. Plugins, imports, scheduled publication and spreadsheet apply are never blocked; the version check still protects the editor. The lease is checked when a write is planned and again inside the write’s own batch, so a takeover concurrent with a save causes that save to be refused. A lease lasts two minutes after its last renewal, stops renewing in a hidden tab, ends on tab close, and an expired lease never blocks. While following someone else’s lease the editor asks every 10 seconds (never from a hidden tab) and shows their newer shared draft. People on other languages of the same entry are named on one line. Nothing is logged, counted or kept in history. At most 20 open editors per person and site. A shared-draft write or discard that loses the lease before it commits is refused 423 lock_lost and changes nothing; a refused autosave is kept as the person’s own recovery draft, and a refused discard says Not discarded — taken over. Your work is kept. and keeps the on-screen work as the recovery draft. The page composer works the same way: read-only while someone else holds the page and language, one Take over, and its change-set kept as the person’s own draft when the lease is lost. |
backend/src/lib/presence.ts. backend/src/lib/lifecycle/editing-lease.ts. backend/src/api/presence.ts. backend/test/editing-ownership.test.ts. backend/test/editor-presence.test.ts. frontend/src/lib/presence/presence.svelte.test.ts. frontend/src/lib/drafts/draft-discard-refused.svelte.test.ts. frontend/cypress/e2e/journey-editor-presence.cy.ts |
| Shared draft | Unsaved work on an entry autosaves, after a short pause, as one shared draft per entry and language: written by the lease holder, read by everyone who may read the entry and, for an own-only role, owns that language’s row (not_owner otherwise). Every draft answer, conflicts included, is projected to the fields and languages the person may read; read access is decided on the read grants alone, apart from the write grants, so a person who may write a draft but not read that row or language is never shown the draft (current: null on a conflict, draft: null from a management take over); a holder who cannot read every field writes only the ones they can, and the others are kept. A page draft has an empty title for a role that cannot read the title and is refused (field_denied) to a role that cannot read the sections. The draft shows on any device after a reload. Each write names the revision it replaces; a stale one requires the person to choose how to proceed. The editor says Saving draft…, Draft saved, Offline — kept here (kept on this device and sent on reconnection or Retry), Changed on another device or Draft not saved. A draft is never a version. Save promotes it: the version is written and the draft cleared in one batch. A workflow move or an override publish saves unsaved work first; an override unpublish does not. A shared draft is kept 30 days after its last change. The page composer keeps its change-set as the page’s shared draft the same way. A new item keeps the person’s own draft. |
backend/src/lib/working-drafts.ts. backend/src/api/presence.ts. backend/test/editing-ownership.test.ts. frontend/src/lib/drafts/draft-sync.svelte.test.ts. frontend/src/lib/drafts/DraftStatus.svelte.test.ts. frontend/cypress/e2e/journey-cross-device-drafts.cy.ts |
| Reviewer assignment | One workflow assignee and optional due date per whole item, checked against who can act from the current stage. Independent locale owners and multi-person editorial tasks have their own assignment, state and revision; they do not replace the workflow assignee. A stale revision returns 409. The same write records an assignment event. A move clears it unless it names the next assignee. The UI identifies inactive assignees and assignees without access. Use assigned=me on the review queue. An assignment notifies the new assignee. Required tasks must be completed for the affected snapshot before publication. Unrelated-language edits preserve locale approval; changed shared/reviewed values need review again. |
backend/src/lib/lifecycle/assignment.ts. backend/test/workflow-assignment.test.ts |
| Review notifications | Field discussions retain version, language and canonical field path, with replies, user/team mentions and thread-wide resolve/reopen. Current field, language and recipient access are rechecked. In-admin notices for assignment, review requests and returns for changes, written in the same batch as the workflow move; the unique key (kind, entry, language, workflow revision, recipient) means a retried or racing move writes one notice per recipient, and a refused move writes none. Editor, management API and plugin moves use one notice path (reviewNoticesFor). Every recipient, an explicit assignee included, must be an active member who may read the model (own-row scope counted against every affected row) and the affected languages; update or review authority alone is not enough. A single-language move notifies only people who may also review every moved language; a returned author or an assignee needs read access to one of them. Reads recheck entry and language access, link to that language’s entry and show its current stage. Imports are quiet by default; an import job stores review_notify (quiet or notify, changed with data:import until the job ends), and a notifying import writes in-admin notices only. Carried history never notifies. At most 50 recipients per notice; mail is optional. |
backend/src/lib/review/context.ts. backend/src/lib/review/notifications.ts. backend/src/lib/review/notification-read.ts. backend/test/review-feedback-notifications.test.ts. backend/test/review-locale-notifications.test.ts. backend/test/import-notify-policy.test.ts |
| Content-list field filters and sorting | One model, latest saved values including drafts: field_filters supports up to four conditions in AND/OR groups, three levels deep. Scalar comparisons, relation membership/empty checks and configured same-site relation display fields use current target access. field_sort accepts one scalar field or the display field of a single-valued, single-target relation; missing/unreadable values sort last with stable ID ties. Points support exact radius, bounding-box and nearest queries. Rich text, media, component and JSON fields remain unsupported. Rows, counts, matching IDs, saved views and bulk freezing share the same SQL plan without a capped candidate scan. Cursors bind the full query; oversized parameters are refused. |
backend/src/lib/content-fields/. backend/src/lib/scoped-crud.ts. backend/test/content-field-query.test.ts. backend/test/content-views.test.ts. backend/test/content-bulk.test.ts |
| Crop and focal point | Per media reference, fractions of the stored image, on top-level media fields. Framing is per occurrence: each occurrence keeps its own framing when the same image appears twice in one field. Bytes never change. Kept by unrelated saves, captured in versions, carried by restore and bundle moves, kept on file replacement (with a shape warning). Served by the CMS public API and carried into generation input. A default focal point per image starts new uses only, and a site copy carries it. Component media is framed per use inside its value ({ media_id, crop?, focal? }, the bare id when unframed); site copy and standalone backend delivery keep it. A generated site delivers resized, cropped copies through Cloudflare Image Transformations on hosts its owner declares, and falls back to the original URL with CSS framing elsewhere. A page section’s image is framed per slot (page contract version 4). The managed renderer draws images through the same rule from built-in template version 4, with transform hosts set at installation (MANAGED_IMAGE_TRANSFORM_ORIGINS). |
backend/src/lib/lifecycle/media-framing.ts. backend/src/lib/graduate/site-media-presentation.ts. backend/test/media-framing.test.ts. backend/test/media-default-focal.test.ts. backend/test/component-media-framing.test.ts. backend/test/media-default-focal-bundle.test.ts. backend/test/image-delivery.test.ts. backend/test/media-mime.test.ts. backend/test/import-bundle-spine.test.ts |
| Media tags | Flat, site-owned tags for the media library. Tags are independent of folders. A name is unique in its site regardless of letter case, up to 64 characters. The shared media dialog filters by every chosen tag, or by Untagged, together with the search. It tags the selected images, renames tags and deletes them. Deleting a tag never deletes an image. A tag in use is deleted only after a confirmation that names how many images carry it. Reading requires media read permission. Changes need media:update. A site copy carries each image’s tag names. A tag on no image is not carried. Strapi import creates no tags. No public API serves tags. |
backend/src/api/media-tags.ts. backend/src/lib/media/tags.ts. backend/test/media-tags-route.test.ts. backend/test/media-tags-bundle.test.ts |
| Media folders | Logical, site-owned folders for the media library. A folder never moves or renames a stored file or its address. Each file is in one folder or in Unfiled. Folders nest at most 5 levels, a site holds at most 500, and a name is 1–80 characters, unique under its parent whatever its case. The shared media dialog, in the library and in every picker, shows All media, Unfiled and the folder tree with counts. A folder view shows that folder’s own files, not its subfolders’. New uploads and saved generated images go into the folder shown. Selected files (at most 200) move together. Rename and move send the revision read; a stale one is refused with media_folder_changed. A move into the folder itself or below it is refused (media_folder_cycle), and so is a move beyond 5 levels (media_folder_too_deep). Deleting a folder deletes its subtree after one confirmation that names its folder and file counts; no file is deleted, each becomes Unfiled. The delete is refused if either count changed. Reading requires media read permission. Changes need media:update; the management API uses the same service under media:write. A site copy and a history transfer carry the whole tree, empty folders included, in media-folders.json (bundle 1.10), and each file names its folder. Import rebuilds the tree with new ids, parents first, one receipted item per folder, reusing a folder already at the same path whatever its case. Undo removes a folder the import made only while it is unchanged and holds no file or folder; otherwise it is kept, as tags are. A Strapi 4 or 5 import recreates the folder tree from the archive’s folder entities and their explicit parent and file-to-folder links, only for folders the selected files sit in, and never from file paths; a folder that cannot be created under these limits blocks the dry run and is never renamed (see Strapi media folders). No public API, generated site or generated standalone backend carries folders. |
backend/src/lib/media/folders.ts. backend/src/api/media-folders.ts. backend/test/media-folders.test.ts. backend/test/media-folders-transfer.test.ts. backend/src/lib/import/media-folder-apply.ts. backend/src/lib/import/undo-media-folder.ts. backend/test/management-media-folders.test.ts. frontend/src/lib/components/custom-ui/image-select/MediaFolders.svelte.test.ts |
| Media files | Site-configured image, document, audio, video and download formats. SVG is an image after its supported static structure is validated; scripts, external resources and unsupported markup are refused. Raster images and whole-file operations keep their documented bounds. Large accepted files travel through chunked native transfers. See Media and size limits. | backend/src/lib/page-contract/media-kinds.ts. backend/src/lib/media/media-type.ts. backend/src/lib/media/duplicate.ts. backend/src/lib/media/media-settings.ts. backend/test/media-type.test.ts. backend/test/media-files.test.ts. backend/test/media-asset-renderers.test.ts. backend/test/management-api.test.ts. backend/test/plugin-operations.test.ts. backend/test/media-formats.test.ts. backend/test/media-stream-upload.test.ts |
| Image editing | Media center → Edit → Crop and rotate: quarter turns and a free crop, by pointer (44px corner handles), touch or keyboard (arrows move, Alt+arrows resize, R rotates, Enter saves). Save as copy uploads the edited bytes through the ordinary upload as a new file with its own digest, size, dimensions and uses; it records edited_from, takes the original’s tags, alt text and folder, and is selected at once. The original is kept. Replace original (behind ⋯) first lists where the image is used, then replaces it through the existing replacement after one confirmation; a stored format conversion is a replacement too and shows the same review. The management API and a streamed upload take edited_from; export, import and site copy carry it, relinked to the copied original or dropped when the original is not in the bundle. |
frontend/src/lib/components/custom-ui/image-select/ImageEditor.svelte. frontend/src/lib/components/custom-ui/image-select/image-edit.ts. backend/src/lib/media/upload.ts. frontend/src/lib/components/custom-ui/image-select/ImageEditor.svelte.test.ts. backend/test/media-files.test.ts. backend/test/management-media.test.ts. frontend/cypress/e2e/journey-media-edit.cy.ts |
| Private media and media permissions | Private files remain in private storage. Protected rich text can contain private images and attachments; delivery checks current reader access. Named protected image variants use the same authorization. Public contexts refuse private assets. Native transfers carry private bytes and variants privately. See Private files. | backend/src/lib/media/private-storage.ts. backend/src/lib/media/visibility.ts. backend/src/lib/media/capability.ts. backend/src/lib/media/delivery.ts. backend/src/api/private-media.ts. backend/test/media-private.test.ts. backend/test/media-private-content.test.ts. backend/test/media-private-stream.test.ts. backend/test/media-private-transfer.test.ts. backend/test/media-private-protected.test.ts. backend/test/graduate-media-private.test.ts. backend/test/graduate-private-files-worker.test.ts. backend/test/graduate-private-files-node.test.ts. backend/test/media-private-placement-race.test.ts. backend/test/media-permissions.test.ts. frontend/cypress/e2e/journey-media-private.cy.ts |
| External media sources | A remote plugin granted media:source declares up to four media_sources. The shared media dialog then shows Sources beside the library (arrow keys switch, 44px controls on phones): pick a source, search, page with Show more, and Add a file. Previews load from the plugin’s origin with no referrer. Each source declares modes (copy, reference or both) and the add request names one, never inferred (media_source_mode_unsupported, 422, for a mode the source does not declare). A source with both shows Add and Link without copying; a single-mode source shows one action. Adding a copy copies the file into the site: core reads the item again through the invocation exchange, fetches it from the plugin’s one origin with no credential and no redirect, and runs the ordinary upload rules (the site’s kinds, formats, size limits and duplicate check). The new file keeps the source’s credit and origin (media.attribution: source name, author, license, links, plugin, source and item ids), shown in its details; published content never loads from the plugin for a copy. Linking (reference) fetches nothing: the row has storage provider external, its address is the item’s delivery_url on one of the source’s declared delivery_origins, its object ledger entry has no checksum, and the same plugin, source and item follows on_duplicate. A linked file’s metadata, tags, folder and framing edit like any file; its bytes cannot be replaced or edited (media_external_bytes_unavailable); deleting it removes only the library row and the in-use rule still holds. Published pages, the public read, the managed renderer and the preview frame (whose image policy lists the delivery origin) use the delivery URL; a generated standalone backend and generated sites do not copy it, the media manifest records it as external with the URL as it was; site copy and history carry the identity, URL and metadata without bytes and import recreates the link. Existing content keeps rendering the stored URL when the plugin is uninstalled or disabled; the details say the source is not available and new links from it are refused. Loading, empty, offline, refused, unavailable and permission states are shown in the dialog. Browsing needs media:upload and is not audited. The management API, CLI and MCP offer listMediaSources, browseMediaSource and importMediaSourceItem (media:write). Export, import and site copy carry attribution on each media line. A save records the file’s read-only credit ({ author, source, license, url }, never the plugin, source or item ids) in the version; the public read (response schema r8) and generated sites serve it, and the built-in managed and generated renderers show a small credit line under the image. |
backend/src/lib/plugins/media-sources.ts. backend/src/lib/media/sources.ts. backend/src/lib/media/external.ts. backend/src/api/media-sources.ts. frontend/src/lib/components/custom-ui/image-select/MediaSources.svelte. plugins/media-source-reference. backend/test/media-sources.test.ts. backend/test/media-sources-reference.test.ts. backend/test/media-sources-reference-roundtrip.test.ts. backend/test/media-sources-bundle.test.ts. backend/test/media-credit.test.ts. frontend/src/lib/components/custom-ui/image-select/MediaSources.svelte.test.ts. frontend/cypress/e2e/journey-media-sources.cy.ts |
| Comments | Any public model can take comments (content_model.settings.comments); Blog posts are one such model. A thread belongs to one language of an entry and keeps one reply level. Visitor comments arrive through the intake Worker as JSON or a plain form post, keyed, consent required, email optional and kept privately; they wait for moderation. While a model takes no comments, its entry pages show a closed form, nothing is taken, existing comments are hidden from public reads and still listed for moderation. Moderation (/api/comments) lists, approves, refuses, marks spam and deletes, needs the publishing permission (delete on the comment’s model to delete), reports a comment another moderator moved as a conflict and never reaches another site. Reactions: one kind, helpful, one per actor and comment, only on an approved comment; a visitor is identified by a keyed digest that no other comment shares, a signed-in person by account and needs read on the model. Public order is oldest (default), newest or best (helpful reactions, then age, then id), each paged with its own cursor. The management API, CLI and MCP list, read, moderate, publish imported comments, delete and change which models take comments. A site copy carries comments with their state and email and imports a published comment as pending; undo removes them. The managed renderer and a generated site draw the comments component on every entry page of a model that takes comments, read at request time. |
backend/src/lib/comments/service.ts. backend/src/lib/comments/reactions.ts. backend/src/api/comments.ts. backend/test/comments-models.test.ts. backend/test/comments-route.test.ts. backend/test/comments-contract.test.ts. backend/test/comments-site-copy.test.ts. backend/test/blog-comments.test.ts. backend/test/graduate-site-comments.test.ts |
| Coordinated releases (Scheduling plugin) | Up to 25 non-overlapping entry/language commands in one same-site CMS batch, within the existing atomic statement budget. Validation uses the intended final publication state. A refused or rolled-back batch changes no publication pointer. Different languages of one entry may share a release. Scheduling sends one command with stable pinned versions, workflow revisions and retry identity; an uncertain answer is retried unchanged. Webhook delivery, cache purges and automatic redirects remain separate outcomes. Deployment and final acceptance are pending. | backend/src/lib/lifecycle/service.ts; backend/test/coordinated-release-events.test.ts; plugins/scheduling/src/release-runner.ts |
| Plugin policies that change content | A save or restore policy may answer transform, naming fields only. The transformed candidate passes the same validation as an editor’s save (field rules, canonical rich text, links, media, search settings) and is stored with a record of which policy changed what; an answer equal to the candidate is a plain allow. Two policies changing one field differently are refused (plugin_policy_transform_conflict); a field the model lacks, a link or media part on the wrong field, another entry, or a transform answered to a kind that writes no candidate is invalid (plugin_policy_transform_invalid). A refusal from another policy still blocks the write. A repeated key replays the stored transformed result without asking again. A restore transform may change values and search settings only. Delete policies are asked before Trash and permanent deletion, restore policies when leaving Trash, transition policies on every move, a plugin’s publish command and an import that publishes, unpublish policies when content goes offline. The management API save answers with the transformation and lists the new policy kinds. |
backend/test/plugin-policy-transforms.test.ts. backend/test/plugin-policy-fixture.test.ts |
| Field-linked preview | The editor opens the site render beside its fields. Current managed and maintained generated Blog renderers open the normal text or rich-text control over a rendered value; changes stay in the draft until Save. Other fields and older renderers focus the matching form field. The bridge checks origin, source, nonce, binding and write access; published pages carry no editor bridge. See Edit from the preview. | backend/src/lib/page-contract/field-links.ts. backend/src/lib/field-link-runtime.ts. backend/test/preview-field-links.test.ts. frontend/src/routes/content/[id]/EntryPreviewPane.svelte.test.ts |
| Management API integrity | Keyed commands claim their key first: a duplicate request sent while the first runs is answered 409 idempotency_in_progress with Retry-After, then replays the exact first answer (a media metadata edit included); another body under the same key is a reused key; a refused command releases its key; a claim whose request stopped is never taken over because time has passed: each command settles it by its own proof (one batch with its answer, its own record, or the job’s recorded position), and otherwise it stays pending. Settings changes and proposals are written and audited once for duplicate requests. An import part is bound to the SHA-256 of its first bytes before any is stored; other bytes for that part are refused (import_part_conflict). A content export is kept as immutable pages, finished into one file with its digest and size, and only then are the pages removed; two racing advances never lose or double a page. Reads are byte-ranged and bounded, and a stored file that is not the recorded one is refused. |
backend/src/lib/management/keyed.ts. backend/src/lib/management/idempotency.ts. backend/test/management-integrity.test.ts. backend/test/management-idempotency.test.ts. backend/test/management-data-transfer-export.test.ts. backend/test/management-data-transfer.test.ts |
| WordPress import | WordPress pages retain headings, code, tables and longer structured bodies. Source-provided custom metadata and supported SEO fields use explicit mappings; omitted or unsupported source data is reported. WXR and connected acquisition use the same reviewed import and receipts. See WordPress import. | backend/src/lib/import/wordpress-models.ts. backend/src/lib/import/wordpress-acf.ts. backend/src/lib/import/wordpress-seo.ts. backend/test/import-wordpress-fidelity.test.ts. backend/test/import-wordpress-real-plugins.test.ts. backend/test/import-wordpress.test.ts. backend/test/import-wordpress-source.test.ts |
| Strapi components and single types | A Strapi component becomes a site-owned component definition (slug category-name); an identical site-owned definition is reused and a different definition already using that slug is refused by name. A dynamic zone becomes a mixed component list in source order, with Strapi row ids dropped and media and relations inside remapped. A single type becomes a single-entry model, each language its own localized component. Definitions are planned and checked before any is written. A re-import is create-or-skip. A site copy carries the definitions, remaps relations inside them and refuses one naming an entry it does not carry. Undo removes definitions only once nothing uses them. Proven on the recorded Strapi 4.25.9 and 5.51.1 archives. |
backend/src/lib/import/strapi-components.ts. backend/test/import-strapi-components.test.ts |
| Strapi media folders | A Strapi 4 or 5 archive’s plugin::upload.folder entities, their parent links and each file’s folder link are the only authority. Inspection lists the tree with depth and file counts. A file whose folderPath names a folder but carries no link imports Unfiled and is reported (media_folder_link_missing); a link to a folder the archive lacks blocks the dry run (media_folder_missing). The dry run blocks a folder with an unusable name (media_folder_name_invalid, 1–80 characters), a missing parent (media_folder_parent_missing), a cycle (media_folder_cycle), more than 5 levels (media_folder_too_deep), a sibling that differs only by case (media_folder_name_collision) or the 500-folder limit (media_folders_limit), and the children of a blocked folder (media_folder_ancestor_blocked). Nothing is renamed or re-parented. Only folders that selected files need, with their ancestors, are created, through the library’s folder service with one receipt each; a folder already at the same path is reused and not receipted, and a rerun creates none. Each new file is filed in its folder when it is created. Undo follows the site-copy rules above. Roles and permissions are unchanged. Proven on the recorded Strapi 4.25.9 and 5.51.1 folder archives. |
backend/src/lib/import/strapi-folders.ts. backend/test/import-strapi-media-folders.test.ts. backend/test/fixtures/strapi/v5-folders |
| Site history transfer | Chunked .deewan native transfer (dee-wan-transfer, version 1) carries content and optionally retained history, workflow events, Trash, assignments, actor labels, folders and referenced media. Export passes process at most 100 records, preparation passes at most 80 records, and sealing passes at most 32 MiB; media chunks are 5 MiB, records at most 8 MiB, and the archive ceiling is 50,000 MiB (52,428,800,000 bytes). Checksums, source mapping, private storage and receipts remain enforced. The old 64 MiB/20,000-row cap applies to legacy buffered formats, not this transfer. See Import and migration. |
backend/src/lib/export/site-history.ts. backend/src/lib/import/bundle-history-handlers.ts. backend/src/lib/lifecycle/imported-history.ts. backend/test/site-history-transfer.test.ts. backend/test/principal-mapping.test.ts |
| Public field filters | Supports equality on text, select, boolean, number and date fields. Supports gt and lt (exclusive) and gte and lte (inclusive) on number and date fields. Accepts one sort and sparse fields. Accepts at most 4 filtered fields with values up to 255 characters. Repeating filter[field]= gives up to 10 alternatives for one field: OR within a field, AND across fields. expand= names up to 3 relation fields, one level deep: each item carries up to 25 published, readable targets per field, and GET /v1/_related/:model pages the rest with a cursor bound to the source, field, language and token. The API refuses excess input and never narrows it. Cursors carry their query and are refused under any other query. No row-count ceiling is claimed or measured on D1. Point fields support exact Haversine radius/nearest queries and inclusive bounding boxes, including antimeridian crossing, with the same access, count and cursor rules. |
backend/src/public/index.ts. backend/src/lib/public-read/relation-expansion.ts. backend/test/public-field-filters.test.ts. backend/test/public-filter-alternatives.test.ts. backend/test/public-relation-expand.test.ts |
Shapes accepted before generation refuses them
Section titled “Shapes accepted before generation refuses them”Generation reads every model and refuses these by name. Settings → Source & delivery → Advanced — build webhook and deployment → Standalone backend now lists them before a build (GET /api/sites/:siteId/generation-readiness). Content-level refusals surface before a build too: GET /api/sites/:siteId/generation-readiness/content walks published content in bounded pages and names what a build would refuse. The build itself stays the authority on site-wide conflicts.
| Refusal | Why the CMS accepted it |
|---|---|
relation_type_unsupported |
A relation kind outside the four. Reachable through the API or an import. |
relation_bounds_unsatisfiable |
Selection limits no list can meet, such as at least 3 and at most 1. |
relation_required_cycle |
Required to-one fields that lead back to their own model. The first item could never be created. |
ambiguous_relation, ambiguous_component |
A relation or component with no target can be saved. |
unsupported_field_type |
Save accepts any type name. Reachable through the API or an import. |
invalid_field_config, empty_model |
Save does not check field config JSON or require a field. |
column_name_collision, model_name_collision |
The editor reserves only id, created_at and updated_at. Slugs such as blog-post and blog_post collide. |
relation_target_absent |
A relation target can name a model outside the site. |
relation_arity_unsupported, relation_target_model_mismatch, duplicate_row_identity |
Content-level: rows written before the CMS relation checks, or by an import. |
required_value_missing, unsupported_value_type, unpublished_reference |
Content-level: imports and content saved before field rules were enforced on the server. |
Evidence: backend/src/lib/graduate/preflight.ts. backend/test/graduate-preflight.test.ts. backend/test/generation-readiness-route.test.ts. backend/test/graduate-plan.test.ts.
Rich text
Section titled “Rich text”A rich-text field stores one rich document as canonical HTML, up to 256 KiB: paragraphs, headings, lists, quotes, code blocks, rules, bold, italic, underline, strike, inline code, sub- and superscript, links, images, tables (up to 12 columns and 200 rows, with an optional header row, row headers and caption), image galleries (2–24 library images), audio, video and file blocks naming library media, and embeds stored as provider id, canonical https address and title: YouTube and Vimeo built in, plus up to 20 exact https origins an administrator allows under Settings → Integrations (embed origins). Every write (editor save, restore, management API, plugins, spreadsheet import, site copy) reads the value strictly and refuses anything else by name — field, language, element or attribute — and never echoes the content. A gallery, audio, video or file block must name a media item of this site of that kind. A Strapi import converts Markdown (GFM tables included) to the same HTML, keeps raw HTML only as far as the document holds it, and reports what it left out as a rich_markup_dropped finding.
An active plugin may declare rich_blocks: a closed set of controls (text, textarea, rich, number, boolean, select, url, media, relation and repeatable groups; groups nest up to 3 levels and hold at most 50 items) with rules, a declarative render tree and migration steps; no HTML, CSS or script. A block is stored as <figure data-block="plugin" data-type data-version data-values>. A save refuses an added block no active plugin declares (rich_block_unregistered), a newer version (rich_block_version_unknown) and values its declaration refuses (rich_block_values_invalid); an older version is accepted when its migrated values pass, and an unchanged block is kept after its plugin is removed. Its media references count toward version pins, and its words reach search. GET /v1/_rich-blocks serves the active definitions, and stops when the plugin is disabled. The host render adapter escapes every bound value, refuses unsafe addresses, migrates older blocks, draws nothing public for a block no adapter draws and a named placeholder in preview. A site copy keeps stored block versions and remaps media and relation IDs through the counted, checksummed rich-relations.json inventory. Copy and history transfer require the matching source declaration; export_rich_relation_schema_unavailable means restore it and export again. The destination does not receive plugin installations or grants. A declaration with inline: true instead stores a structured inline element as <span data-block="plugin"> inside a paragraph; it uses the same declared fields, version, reference handling and renderer rules.
| Where rich text is shown | What protects it |
|---|---|
| The admin rich-text editor | The editor emits the canonical document. It authors paragraphs, headings 1–6, bold, italic, underline, strike, inline code, sub- and superscript, code blocks, quotes, lists, rules, line breaks, links, inline images, tables (up to 12 columns and 200 body rows, header row, row headers, caption), galleries of 2–24 library images, file, audio and video blocks from the media library, and embeds from a pasted YouTube, Vimeo or allowed https link, shown as a placeholder whose Preview opens a new tab. It keeps left, center, right and justified paragraph/heading alignment as canonical data. Vendor snippets have their own isolated frame; arbitrary parent-document frames, styles and classes are refused. Any editor content the document format cannot represent is dropped before saving, and the server refuses anything else. |
| The generated Astro website and the managed renderer | Page sections hold no markup: the page contract refuses it. A paragraph may carry structured bold, italic, link and list runs (inline formatting), and Astro escapes their text. Blog post bodies and Latest entries summaries are rendered by the page contract’s renderRichDocument (backend/src/lib/page-contract/rich-document.ts), the same function in both renderers. Tables scroll inside their own box, galleries are a grid, audio and video play with a download link, and an embed is one CMS-owned frame (sandbox, allow list, referrer policy, lazy loading, 16:9) from youtube-nocookie.com, player.vimeo.com or an origin the site allows today (GET /v1/_embeds); an embed whose origin is not allowed draws as a plain link. A managed page’s frame-src names the two players and the allowed origins only. |
| The page editor’s Structure preview | Latest entries summaries are shown as text derived from the same document. |
| The CMS public API, the generated backend API and the TypeScript client | The checked canonical HTML. Media and embed blocks are data (<figure data-block=…>), not players: render the value with renderRichDocument, or your own renderer of the same document. Pass the origins from GET /v1/_embeds to frame a configured embed. |
Evidence: backend/test/rich-document-contract.test.ts. backend/test/rich-plugin-blocks.test.ts. backend/test/rich-plugin-blocks-bundle.test.ts. backend/test/rich-document-render.test.ts. backend/test/rich-renderer-parity.test.ts. backend/test/rich-field-contract.test.ts. backend/test/rich-blocks-bundle.test.ts. backend/test/version-media-pins.test.ts. backend/test/rich-text-boundaries.test.ts. backend/test/import-strapi-markdown.test.ts. frontend/src/lib/components/custom-ui/rich-editor/rich-editor-adversarial.svelte.test.ts. backend/test/graduate-site-build.test.ts (escaping page).
Not in 1.0
Section titled “Not in 1.0”Public filters across arbitrary linked fields, public $or and text-search filters, and multiple workflow assignees for one whole item are not available. Native lists can filter the configured scalar display field of ordinary same-site relations; see the delivery additions below. The 1.0 product boundary is the authority.
October 2026 source additions
Section titled “October 2026 source additions”These changes exist in the current source and have focused local checks. Final browser integration and live visual review are pending; this is not production acceptance. Entry-folder source is now present with focused service/transfer checks; final integration acceptance remains pending. Model navigation folders above are a separate feature.
| Capability | Current source behavior | Evidence |
|---|---|---|
| Plugin content and media operations | Delegated detail/version reads, normal create/update/Trash, typed relations and media metadata/bytes reuse lifecycle services and live actor grants. | backend/test/plugin-operations.test.ts; backend/test/management-plugin-operations.test.ts |
| Grouped field filters and contextual choices | Four leaf conditions, AND/OR, three group levels; shared list/count/view/bulk semantics. Relation choices and select options can depend on sibling fields, current user and entry context; changed dependencies are rechecked on save. | backend/src/lib/content-fields/contract.ts; backend/test/content-field-query.test.ts; backend/test/contextual-choices.test.ts |
| Editorial rich text | Paragraph/heading alignment; plugin relation controls and repeatable groups; typed vendor HTML/CSS/JavaScript snippets run in isolated frames in admin and both site renderers. | backend/test/rich-editorial-completion.test.ts; backend/test/rich-plugin-blocks.test.ts; backend/test/rich-snippets-route.test.ts |
| Account maintenance | Verify a new mailbox before changing the existing account’s email; preserve its identity, memberships, credentials and history, and end sessions. Self-service reset requests use existing short-lived single-use mail claims and equal known/unknown-address responses. | backend/test/account-maintenance.test.ts |
| Plugin updates and custom UI | Compare manifest/version/permissions before confirming upgrade; new permissions remain ungranted. Reviewed credentialless plugin-origin frames use declared operations and a bound channel without admin credentials. | backend/test/plugin-schema.test.ts; backend/test/plugin-ui-extensions.test.ts |
| Document scopes | Bounded per-model role rules for read/create/update/delete, including current user context. Lists, counts, related reads, revisions, exports, matching bulk selections and atomic writes apply current rules alongside model, ownership, field and locale grants. | backend/src/lib/document-rules.ts; backend/test/document-scopes.test.ts |
| Structured spreadsheet editing | CSV/XLSX offer Create, Update and Upsert through the reviewed Collection JSON core. Update is the default; match by stable entry ID or an eligible unique field. Review additions, changes and refused rows before Apply. Structured relation/media references and nested components use normal permissions, validation, definition checks and version conflicts. Blank structured cells preserve values; JSON null clears an optional field. | backend/test/spreadsheet-scope.test.ts; backend/test/spreadsheet-collection.test.ts; backend/test/spreadsheet-xlsx-import.test.ts |
| Connected WordPress and configuration | Bounded same-origin HTTPS REST acquisition produces ordinary WXR for the existing review/import path. Explicit reviewed replacement covers title, tagline, logo, icon, SEO description and supplied indexability, with destination revision, import receipt and undo ownership. | backend/test/wordpress-connect.test.ts; backend/src/lib/import/wordpress-site-configuration.ts; backend/src/lib/import/site-configuration-carry.ts |
| AI authoring assistance | Vision models receive authorized image bytes; authors review/edit alt and caption before saving. After source save, opt-in updates target only selected translations with explicit replacement consent, including manually written values. | backend/test/ai-media-descriptions.test.ts; frontend/src/lib/ai/translate-saved.test.ts |
| Entry folders | Nested site folders, one placement per translation group, scoped list/counts and separate move history. Placement changes do not create content versions. Copy/history transfer remaps placements with receipts and undo. | backend/src/lib/entry-folders.ts; backend/src/lib/entry-folder-transfer.ts; backend/test/entry-folders.test.ts |
17 October integration and editorial additions
Section titled “17 October integration and editorial additions”The following paths are present in source. Their integration checks have not yet been completed on this candidate; test files are coverage references, not a claim that this candidate passed.
| Capability | Scope | Source |
|---|---|---|
| Outbound lifecycle events | Successful content, workflow, release, media and model mutations enqueue notifications through the existing delivery pump. Refusals and idempotent replays create no new event. | backend/src/lib/outbound-events.ts; backend/src/lib/lifecycle/events.ts |
| Component links to several models | Nested, repeated and mixed components keep each selected model and entry identity through save, restore, transfer and rendering. | backend/src/lib/component-relations.ts |
| Plugin workspaces | Declared routes, navigation and dashboard placements host the existing isolated plugin frame and authorized operations. | backend/src/lib/plugins/ui-surfaces.ts |
| Form handling | Authorized retained-value review, per-form destination overrides, text templates and consent-based acknowledgements use the existing retention, erase and retry paths. | backend/src/api/forms.ts; backend/src/lib/forms/delivery.ts |
| Media workspace and asset fields | Direct library access; typed private-by-default asset fields, separate revision history and explicit public projection. | backend/src/lib/media/asset-metadata.ts |
| Collection order | Optional order shared by every language; visible anchors preserve hidden entries’ relative order. Public clients explicitly request order=manual. |
backend/src/lib/manual-order.ts |
| Plugin CMS services | Existing comments/moderation, redirects, Trash restore, eligible media deletion and admin-approved mail templates retain ordinary service checks. | backend/src/api/plugin-v1-services.ts; backend/src/api/plugin-v1-mail.ts |
| Renderer contributions | Declared metadata, links and JSON-LD are stored with content versions. Core SEO takes precedence; renderers make no live call to a plugin. | backend/src/lib/plugins/renderer-metadata.ts; backend/src/lib/seo/head-runtime.ts |
| Structured inline content | A plugin declaration can opt into inline elements. Structured values remain inside paragraphs and follow normal version, reference and renderer handling. | backend/src/lib/page-contract/rich-document.ts |
| Published hierarchy | Ancestors and breadcrumbs use published labels and accepted routes, with the site’s locale policy. Draft changes do not replace live labels. | backend/src/lib/public-read/published-list.ts |
| Maintained Astro starter | The starter command emits ownable source connected to the live public API, with the current page and renderer contracts. | backend/src/lib/managed-renderer/reference-starter.ts |
| Supported release | A tagged release, scheduler deployment and real Cloudflare publication, preview, media, mail and recovery evidence are still required. Local source changes do not satisfy these gates. | Product boundary |
Native form file fields and contextual plugin mail recipients extend these source additions. Form files stay in private storage and follow submission retention, authorized download and erasure. An approved mail template can select the entry author or assigned reviewer from an authorized entry. Runtime and provider acceptance remain pending.
Current integration additions
Section titled “Current integration additions”These source features are part of the unreleased integration candidate. The final browser gate, live mobile/desktop review and external-provider acceptance remain separate requirements.
- Chunked native site/history transfers carry accepted large media with checksums and continuation.
- WordPress page conversion preserves headings, code, tables, longer bodies and explicitly mapped source metadata.
- Protected rich text supports private images, attachments and authorized image variants.
- Native-script addresses retain Unicode letters, numbers and combining marks and can be generated from titles.
- Named compound uniqueness joins two to four scalar fields under atomic write guards.
- Blog metadata can resolve live contributor, category and tag information; snapshot behavior remains an explicit option.
- Ordinary and mixed components can be retired, retain older definitions and upgrade instances deliberately.
- Plugin rich-text blocks can contain bounded host rich-text child fields.
- Native relationship columns show scoped labels, counts and related values.
- Typed visibility conditions combine up to four terms in AND/OR groups, at most three levels deep.
- Optional translated fields can borrow absent values from published fallback languages; deliberate empty values stay empty. See Field fallback.
- Validated static SVG works through the image workflow.
- Generated applications support up to 32 named application roles with model-operation grants, a non-admin signup default and existing admin/user roles.
- DeepInfra remains the default inference provider; a deployment can select an OpenAI-compatible endpoint and explicit text, vision and image model lists.
- Related draft previews resolve authorized saved related drafts; ordinary public reads still require publication.
- Content search covers saved rich-text bodies, including drafts, with matching fields and short excerpts inside the reader’s access scope.
- Live API browser origins configure CORS, preflight and permitted public-read headers per site.
- Management media replacement keeps the media ID and references while replacing bytes through the existing replacement service.
- Generated account administration creates named-role accounts and deletes unlinked accounts, with session revocation and last-administrator protection.
- Rendered entry editing opens text and rich-text controls over supported renders; older renderers keep field-focus fallback.
- WordPress imports retain reviewed ACF groups/repeaters as editable JSON and explicitly keep future publication schedules or draft intent.
- Selected-content refresh reviews additions, updates and deletions in populated models, preserves excluded data and old media pins, and refuses conflicting edits. Resume and cancel remain available; replacement has no automatic Undo.
Delivery additions
Section titled “Delivery additions”| Capability | Source contract |
|---|---|
| Native relation display queries | Configured scalar display field; current saved values, target ACL, shared list/count/view/bulk SQL. Cross-site display queries return field_shared_display_unsupported; membership and empty checks remain available. |
| Localized originals | One asset ID, per-language current files, locale fallback and immutable retained pins. Native and management operations; export/import retain languages. |
| Explicit shared references | Source-owned published public projection, current grant revision and per-delivery custody checks across dedicated databases. Revoke or unpublish resolves to null. |
| Point queries | Strict latitude/longitude; exact Haversine radius/nearest SQL, inclusive antimeridian boxes and bound cursors. |
| Incremental sync | Published authorized entries and public media; bounded snapshot, ordered replay/tombstones, signed scope-bound checkpoint and explicit reset. |
backend/test/delivery-query.test.ts; delivery-originals.test.ts; delivery-shared-content.test.ts; delivery-sync.test.ts; delivery-sync-triggers.test.ts.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes