System architecture

The platform rests on a single principle: the schema definition is the sole source of truth. From it we derive the Postgres tables, validation, admin panel forms, REST, JSON:API, GraphQL, OpenAPI 3.2, permissions, events, and SDK metadata. Nothing is described twice.

Data flow from definition to client

SchemaDefinition (JSON)
      │
      ├── Schema Compiler ──► DDL (CREATE/ALTER) ──► cms.<key> tables
      ├── Validation Engine ─► write validation (REST/GraphQL/admin panel)
      ├── Form/View API ─────► admin panel forms + external renderer
      ├── API Surface ───────► REST, JSON:API, GraphQL, OpenAPI, SDK
      └── Governance ────────► RBAC + permission sets + FLS + RLS
                                  │
                              audit log, revisions, events, webhooks

Layers

1. Schema registry

The platform_schemas and platform_schema_versions tables store definition versions (draft → published → deprecated). Publishing computes a diff, flags breaking changes, and compiles a migration.

{
  "key": "article",
  "kind": "collection",
  "name": "Article",
  "category": "Marketing / Web",
  "tags": ["seo", "content"],
  "fields": [
    { "key": "title", "kind": "text", "required": true, "validation": { "maxLength": 120 } },
    { "key": "slug", "kind": "slug", "unique": true, "from": "title" },
    { "key": "body", "kind": "richText", "localized": true },
    { "key": "hero", "kind": "media", "accept": ["image"] },
    { "key": "author", "kind": "relation", "target": "author", "cardinality": "manyToOne" }
  ]
}

2. Compiler and persistence

The compiler (src/platform/schema/compiler.ts) generates deterministic DDL: system columns (id, status, locale, created_at, updated_at, account_id), indexes, join tables for manyToMany relations, and a cms.entry_translations table for localized fields. Every translation row has its own status (draft → in_review → published). The read path asks for a visibility level (src/platform/i18n/coverage.ts): delivery passes published, so only approved translations are overlaid, while the workspace and preview tokens pass any. loadLocaleCoverage in src/platform/server/translations.server.ts returns per-entry, per-locale coverage for the list indicator.

Translations are a first-class API resource, not a panel-only feature. REST exposes GET /api/public/v1/{model}/{id}/translations, GET|PUT|DELETE /api/public/v1/{model}/{id}/translations/{locale} and GET /api/public/v1/{model}/{id}/translations/revisions; GraphQL exposes {model}Translations, {model}TranslationRevisions, save{Model}Translation and delete{Model}Translation — only for models that really declare localized fields. The shared contract lives in src/platform/i18n/translation-api.ts: the locale must be enabled and must not be the default one (default values belong on the entry row), the status must be one of draft, in_review, published, and a payload containing a field that is not localized is rejected with 422 instead of being silently dropped. Every translation write and removal appends a row to platform_entry_revisions with action = "translation" and the locale set, so translation history is auditable and queryable per locale next to the base-row history.

create table cms.article (
  id uuid primary key default gen_random_uuid(),
  account_id uuid not null,
  status text not null default 'draft',
  title text not null,
  slug text not null,
  hero jsonb,
  author_id uuid references cms.author(id),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
create unique index article_slug_key on cms.article (account_id, slug);

#### Delivery settings and authored indexes

Two parts of the definition are authored in the panel (Model detail → *Delivery & indexes*, src/components/admin/delivery-editor.tsx) and normalised by src/platform/schema/delivery.ts:

  • api.exposeRest / api.exposeGraphql — whether the model is served by REST

(and therefore OpenAPI, SDK, search) and by GraphQL. Withdrawing an interface is reported as a breaking API change in Publish impact.

  • api.defaultPageSize / api.maxPageSize — per-model pagination, read by the

query engine (fallbacks: 25 / 100, ceiling 1000).

  • indexes[] — composite and per-tenant unique indexes over stored columns.

Index identity is its name: a changed column list or uniqueness recompiles as drop index + create index, removal emits drop index only. Unique indexes always lead with account_id, so two tenants may reuse the same combination.

3. Query engine

src/platform/query/query.ts translates a single query tree into SQL for all interfaces — REST, JSON:API, and GraphQL all share the same filtering, sorting, pagination, and relation expansion.

GET /api/public/v1/article?filter[status]=published&filter[title][contains]=schema
    &sort=-updated_at&page[size]=20&expand=author&locale=pl

4. Lifecycle and environments

src/platform/publishing/lifecycle.ts defines the single allowed set of transitions: draft → review → scheduled → published → archived. Promotion between environments (Development 10 → Preview 20 → Staging 30 → Production 40) uses the same transitions, so revisions, audit, and webhooks behave identically to a regular publish.

4b. Account administration (tenancy lifecycle)

Accounts form a tree (organization → company → brand → project). Writes go through saveAccountRecord / setAccountStatus in src/platform/server/accounts.server.ts, which enforce the pure rules from src/platform/tenancy.ts:

  • keys are URL-safe and unique across the platform (they appear in API scopes and the x-account header),
  • canReparentAccount refuses a parent that sits below the account (no cycles in inheritance),
  • canArchiveAccount archives only from the leaves upwards, so a live child is never hidden,
  • defaultLocale must belong to the account's locale list; an empty list inherits from the nearest ancestor.

Branding is inherited key by key (effectiveBrand) and themes the panel accent through src/components/admin/account-theme.tsx. Seeded demonstration tenants carry is_demo and can be removed in one audited operation (src/platform/server/demo-data.server.ts). Screen: /admin/accounts.

4c. Schema lifecycle (field rename and model removal)

Evolving a model does not have to cost data:

  • Technical rename — the field editor stores a renamedFrom marker on the field

(beginFieldEdit / resolveRenames in src/platform/schema/draft.ts). The compiler turns it into alter table … rename column plus a join-table rename instead of a drop + add pair, so publishing stays non-destructive. A rename that collides with another field is rejected during validation.

  • Model removal — previewDelete in src/platform/server/registry.server.ts returns an impact report

(who references the model, how many entries exist, which DDL statements would run). deleteSchema refuses to remove a model that other models point at, and drops the runtime table only on explicit confirmation. By default the definition leaves the registry and the data stays. UI: the "Danger zone" on /admin/models/:key.

  • Versions and migrations — every save produces a version row (platform_schema_versions) holding the

full definition snapshot; publishing compiles the DDL from the diff and records the transition in the append-only ledger platform_schema_migrations (from/to version, direction, status, statements, change report, destructive and breaking-API flags, duration, actor). A failed publish is recorded too. Any two versions can be diffed with compareVersions, and previewRollback / rollbackSchema (src/platform/server/schema-migrations.server.ts) restore an older snapshot by replaying it as a new version: history stays linear, the DDL is derived from the definitions, destructive rollbacks need explicit confirmation and orphaned layouts block the operation. Because persistence, API, validation and UI are all derived from the stored definition, moving a model between versions never requires a code change. UI: the "Migrations" tab on /admin/models/:key.

  • Typed list items — a list field declares the kind of its members in itemKind (legacy

blueprints may use options.itemKind) and optional per-item rules in options.itemValidation. src/platform/schema/list-items.ts is the single source for the allowed item kinds; the validator runs every element through the same coercion the equivalent single field would use (a list of emails really validates emails, error path field[2]), and the OpenAPI generator emits the matching items schema. autoIncrement is identity-only: a client-supplied value fails with code readOnly instead of being silently dropped.

5. Authorization

The resolution order is fixed: role baseline → `grant` sets → `restrict` sets → schema policies, and deny always wins. Field-Level Security passes exclusively through readableFieldsFor / writableFieldsFor. Tenant isolation is account_id + RLS plus the x-account header (src/platform/tenancy.ts).

Record scopes (the ABAC/ReBAC condition trees a permission set carries per model) are enforced twice. src/platform/access/condition-sql.ts compiles them into SQL predicates that buildListQuery appends to WHERE through recordScopes, so Postgres never ships rows the caller may not see and total / offset / hasMore describe the visible set. The compiler is deliberately conservative: wildcard paths, list-valued operators, length operators and quantifiers yield null, and canActOnRecord — always the final authority — keeps filtering after the read. A translated predicate can therefore only narrow a result, never widen it. Authors see which case applies in the "Record scope" editor on /admin/permissions; an actor holding viewAll for the model skips scopes entirely.

Explain access. src/platform/access/explain.ts replays the same layers in order and records *which one decided*: token scope, role baseline, grant set, restrict set, object power, record scope, field condition, layout section, schema policy. Each action and each field gets an ordered trail of steps (allow / deny / neutral, plus the permission-set key or policy rule key that contributed) and one step flagged decided. The tracer calls the real enforcement helpers (canActAs, canActOnRecord, readableFieldsFor, writableFieldsFor), so it can never drift from enforcement. It is exposed by simulateAccess (src/lib/permissions.functions.ts) and rendered in the access simulator on /admin/permissions, where an optional sample record (JSON) unlocks the record-aware layers; without a record those steps report "needs a record" instead of guessing.

Principal directory. Nobody types a UUID to reference a person or a team. src/platform/access/directory.ts holds the pure ranking (exact id or email first, then prefix, then substring), src/platform/server/directory.server.ts resolves the candidate pool from memberships — an actor only ever sees principals from the accounts they can act in, and only id, name and email leave the server — and searchPrincipals (src/lib/directory.functions.ts) exposes it to the admin UI. The PrincipalPicker component (src/components/admin/principal-picker.tsx) is a keyboard accessible combobox used by group membership, permission-set assignments, the access simulator and the userRef field widget; a pasted UUID is still accepted so automation and support flows keep working.

Because the server layer reads through the service-role key (RLS bypassed), the last line of defense is src/platform/tenant-scope.ts (pure rules) + src/platform/server/tenant-filter.server.ts (query filters and row guards). Rules:

ActorReadsWrites
platform (unscoped)everythingeverything
account (accountId)own account + shared rows (account_id is null)own account only
subtree (accountScope)entire subtree + shared rowsany account in the subtree
empty scopeshared rows onlynothing

Shared rows are read-only for tenants, writes require an exact account match, and a single read of someone else's row returns NOT_FOUND (not FORBIDDEN), so that an ID can't act as an existence oracle. Event fan-out (webhooks, SSE, GraphQL subscriptions) is strictly single-account: an account's subscription receives only that account's events. Relation expansion (include=) and revision history pass through the same guard, and revision snapshots additionally pass through readableFieldsFor.

#### Webhook delivery safety

Webhook targets are operator input that the platform itself fetches, so every URL is screened by inspectTargetUrl (src/platform/webhooks/target-url.ts) — a pure function used twice: when a subscription is saved and again immediately before each attempt in deliverOnce (src/platform/server/webhooks.server.ts), because a hostname can be re-pointed after saving. Blocked: non-https schemes (except loopback outside production), credentials in the URL, private/loopback/link-local/CGNAT/multicast IPv4, IPv6 ULA and link-local, cloud metadata hosts, .internal / .local / .home.arpa suffixes and bare hostnames. A blocked target fails permanently instead of retrying.

The SSE feed (/api/public/v1/events/stream) is resumable by contract: frames carry an id: <createdAt>|<eventId> cursor, reconnects resume from Last-Event-ID (or ?since=), ?types= patterns are validated against a grammar, and each principal may hold at most four concurrent streams (429 beyond that). Planning logic lives in src/platform/events/stream-plan.ts.

Event outbox statuses are pending → processing → delivered | failed | skipped | discarded. skipped closes events nobody subscribes to; failed events form the dead-letter queue shown on /admin/webhooks, where an operator replays them (fresh retry budget, worker run in the same request) or discards them — both paths write an audit entry.

6. Taxonomy

A model has exactly one category (hierarchical path Parent / Child) and many tags. Filtering by a parent category includes the entire subtree.

Marketing            (2)
  └── Web            (1)
Operations           (1)
  └── Billing        (1)
        └── Invoices (1)

Renaming or moving a category rewrites the entire subtree through saveDraft, so the operation is versioned and audited (src/platform/server/taxonomy.server.ts).

The category/tags saved on a model definition are derived — they exist for as long as some model uses them. Alongside that stands a term registry (platform_taxonomy_terms, src/platform/server/taxonomy-terms.server.ts): an administrator can pre-define a dictionary of categories and tags (label, description, color), per account and with inheritance down the account tree. The admin panel merges both sources: derived facets + declared terms (empty categories get an empty badge). Rename/delete operates on both layers at once, and bulk assignment (assignSchemaTaxonomy) rewrites the selected definitions as new draft versions.

Delivery channels (experiences)

The same model reaches very different consumers: a website wants a long description and imagery, a watch wants three short strings, an AI agent wants machine facts, an IoT gateway wants raw numbers. Instead of duplicating models per channel, a field can be tagged with channels, and the delivery layer projects the payload onto the channel given in the request.

Rules (one place: src/platform/channels/channels.ts):

  • a field with no tags is universal — it goes to every channel,
  • a field with tags goes only to the listed channels,
  • tagging does not change persistence, validation, or the admin panel form — it is a

projection performed after permissions and Field-Level Security.

GET /api/public/v1/article?channel=smart-watch
  → title, short_title            (universal + tagged 'smart-watch')
GET /api/public/v1/article?channel=web
  → title, body
GET /api/public/v1/article
  → all fields (no projection)

The channel catalog is per account (with inheritance down the platform_accounts tree) and lives in platform_channels; the platform ships examples (Web, Mobile, Smart Watch, Voice, AI Agent, IoT, Print, POS, Partner API). Management: /admin/channels (src/platform/server/channels.server.ts), field tagging: the *Rules* tab in the field editor. REST responses add x-cms-channel and Vary: x-cms-channel, so the cache separates variants per channel. channelCoverage / channelWarnings compute a model's coverage and warn when a required field never reaches some channel.

Locale cache separation

Every cached artefact whose text depends on a language carries the locale in its identity, so switching language can never serve the previous language's copy:

  • Layout/View API — viewCacheKey includes the locale (src/platform/view/cache.ts);

resolveLayoutCached / layoutIndexCached take the request locale (?locale=) and REST adds accept-language to Vary.

  • Public delivery — listPublicEntries / getPublicEntryBySlug accept a locale and project

localized fields through localizeRecords, so page results are per-locale by construction.

  • Router loader cache — public page loaders read context.ui.locale, and the language switcher

calls router.invalidate() so cached matches are refetched in the new language.

  • Client queries — localeQueryKey (src/platform/i18n/cache-scope.ts) prefixes query keys with

the locale bucket; normalizeCacheLocale collapses EN, en, en_GB spellings, and an absent locale is its own bucket (*).

Geocoding

The geolocation field has two assist modes: forward (address → coordinates) and reverse (coordinates → address). Layering follows the platform pattern: I/O-free core → server adapter → server function → widget.

  • src/platform/geo/geocode.ts — pure core: query guards (min. 3 characters, max 200), limit clamp (1–10),

normalization of the provider's response into GeocodeResult, pointFromResult (preserves the editor's zoom).

  • src/platform/server/geocode.server.ts — the OpenStreetMap Nominatim adapter. Calls are server-side,

because the provider requires a User-Agent header (the browser won't set one) and so the editor's IP doesn't end up in external logs. Network/HTTP error → PlatformError, the widget degrades to manual lat/lng.

  • searchPlaces / reverseGeocode in src/lib/platform.functions.ts — authenticated (requireSupabaseAuth),

so lookups only come from the admin panel, not from an open endpoint.

  • src/components/admin/geo-point-input.tsx — 350 ms debounce, 5-minute react-query cache, discarding

late responses for an abandoned point.

Schema import (JSON/YAML from another CMS)

src/platform/ai/import.ts turns an export from another headless CMS into canonical definitions, next to the AI architect on /admin/models/ai. The pipeline is strictly layered:

  1. Parse — parseSchemaSource reads JSON, and falls back to YAML (the yaml package) for

snapshots such as the Directus one.

  1. Detect — detectDialect recognises Strapi content-types (attributes), a Directus snapshot

(collections + fields + relations), Contentful content types (contentTypes / items) and a canonical export of this platform (models).

  1. Map — one explicit table per dialect (STRAPI_TYPE_MAP, DIRECTUS_TYPE_MAP refined by the

interface hint, CONTENTFUL_TYPE_MAP) resolves every source type to a canonical FieldKind with a confidence: exact, approximate, fallback (widened to json/text) or unsupported (never guessed — the field is skipped with a note). Validation travels with the field: min/max/length, regex, enum values, required, unique, localized, defaults.

  1. Canonicalise — the mapped models go through sanitizeArchitectResponse, the same sanitiser

the AI answers pass, so relation, component, dynamic-zone and many-to-any targets must resolve against the export plus the existing registry; anything unresolvable is dropped as a repair note instead of becoming a broken column.

The wizard (src/components/admin/schema-import-wizard.tsx, lazy-loaded so the YAML parser stays out of the main bundle) is pure review: every mapping row can be re-typed with the shared FieldKindPicker or excluded, and re-running the import is a pure recomputation. Accepting the import feeds the normal architect review flow — derived contract (DDL, GraphQL SDL, OpenAPI 3.2), sample records, then save as drafts or publish. Nothing is persisted by the importer itself.

Notable mappings: Strapi uid → slug, media (multiple) → assetGallery, dynamiczone → dynamicZone; Directus input-rich-text-md → markdown, geometry.Point → geolocation, alias → skipped; Contentful Link<Asset> → image, an entry link accepting several content types → polymorphicRelation (many-to-any), Array<Symbol> → tags or multiSelect when the validation lists allowed values.

Solution template dry run

src/platform/templates/dry-run.ts is pure logic: buildTemplateDryRun(resolved, published) answers three questions without touching the database — what blocks publication, how to fix it and what DDL the install would emit.

  • Blueprints — a template model key that is absent from the blueprint library is a blocker

(blueprint.missing); an already published key is an informational note (blueprint.reused, never overwritten).

  • Relations and modules — every relation.target, component.target and dynamic-zone block is

resolved against the template itself plus the live registry. An unresolvable target becomes a relation.unresolved / component.unresolved / dynamicZone.unresolved blocker whose remedy names the exact key to add or the field to drop. A mutual dependency is only a warning (relation.cycle): the installer closes the deferred foreign keys in its reconciliation pass.

  • Definition validity — validateDefinition runs against the union registry (published models

minus the ones this template replaces, plus the template's own definitions in install order), so intra-template relations validate exactly as they will at publish time.

  • DDL — compileCreate runs per pending model; the statements are returned per model so the UI

can fold them. Nothing is executed, no draft or schema version is written.

dryRunTemplate (server) wraps it behind the same requireSchemaAuthor gate as installation, and installTemplate reuses the report: any blocker aborts the install with the per-item message and remedy instead of a generic error. The UI lives on /admin/templates behind “Dry run import”: blockers with fixes, install order with resolved/unresolved dependencies, and foldable DDL.

Configurator data lives in the Content Manager

The commerce configurator is split in two: a code engine and a content catalogue.

restaurant-menu models (schemas)      → published by the solution template
  food_ingredient / food_allergen     → cost per unit, allergens, removable
  food_option / food_option_group      → price deltas, selection rules, limits
  food_menu_item                       → sizes, recipe lines, modifier groups
  food_pricing_policy                  → currency, target/floor margin, labour,
                                         packaging, overhead, waste, rounding
        │  entries edited through ordinary layouts (versioned, i18n, RLS, REST/GraphQL)
        ▼
menu-configurator.server.ts            → publish template, seed, list, load snapshot
        ▼
commerce/food-menu.ts buildConfigurableItem() → ConfigurableItem (engine input)
        ▼
commerce engine (configureItem, costConfiguration, explainConfiguration)
        ▼
/admin/configurator (workbench) · REST/GraphQL order pricing

Rules of the split: no menu constant lives in a route or component; the seed (src/platform/templates/seeds/restaurant-menu.ts) is data only and references sibling rows through @schema_key:code tokens resolved to UUIDs at insert time; the engine never reads the database itself — it receives a fully materialised ConfigurableItem, which keeps pricing pure and testable. Adding a topping, changing a size price or writing a new recipe is content work, not a deployment.

Commercial rules are content too (food_pricing_policy)

Nothing commercial is hard-coded any more. A policy entry supplies the currency, target and floor margin, labour (fixed per item plus per preparation minute), packaging, overhead percentage of the ingredient cost, the default production waste and the price rounding rule. selectPricingPolicy picks it: a policy pinned on the dish wins, otherwise the highest-priority active policy whose scope (item types, categories, channels) matches. EUR survives only as a fallback for a workspace that has published no policy yet.

costConfiguration then emits waste, labour, packaging and overhead as ordinary explain lines, so the workbench shows *why* a cost exists, and returns a verdict (ok, below_target, below_floor, no_policy) plus the price that would hit the target margin after rounding. Per-line waste_percent on the recipe overrides the policy default, and cost_factor on a size scales the ingredient cost independently of the topping price factor — a family pizza can cost 1.9× while toppings scale 1.8×. explainConfiguration tags each rule with its origin (item, group, option) and group, so an editor knows which entry to open to change a rule.

Pricing policies are versioned and approved (food_pricing_policy_version)

A policy entry is only the working copy. Money is priced from an approved snapshot stored as a food_pricing_policy_version entry: the frozen numbers, a deterministic checksum, the change note, who submitted it, who decided and the effective window. Lifecycle: draft → in_review → approved | rejected → retired; only an approved version whose window contains "now" may price an order, and approving a version retires the one it supersedes, so exactly one version governs pricing at any point in time.

food_pricing_policy (working copy, editable)
   │ submitPolicyVersion()  → freezes numbers as v(N), status in_review
   ▼
food_pricing_policy_version (immutable snapshot + checksum + approval trail)
   │ decidePolicyVersion()  → approve (retires previous) | reject
   ▼
effectivePolicyVersion() → applyPolicyVersion() → PricingPolicy carrying
   versionId, version, approvalStatus, approvedBy/At, effectiveFrom/To, checksum
   ▼
costConfiguration() → PolicyOutcome names the exact version behind every price

policyChecksum (FNV-1a over the canonical snapshot) is what makes drift visible: if a controller edits the policy but nobody signs it off, policyGovernance reports drifted and pricing keeps using the older approved version instead of silently repricing the menu. States are approved, drifted, pending_approval and never_approved. Pure logic lives in src/platform/commerce/pricing-policy-versions.ts; src/platform/server/pricing-policy.server.ts is the only writer of version entries and routes every write through createEntry/updateEntry, so entry revisions, the audit log (pricing_policy.version_submitted / _approved / _rejected) and webhook events come for free. Submitting an unchanged working copy is refused — a version exists only when the numbers actually changed. Approvals are driven from the panel on /admin/configurator.

Model behaviours (governance switches)

def.behaviours holds the switches that change how a model is *governed* rather than what it stores — the equivalent of Dataverse "table behaviours". Every consumer reads the same helpers in src/platform/schema/behaviours.ts, so there is one answer per question:

  • audit — auditEntry in entries.server.ts skips entry.created/updated/deleted when off.

Security-relevant writes (secret hashing) stay audited unconditionally.

  • trackChanges — snapshotRevision returns early, so no revision rows are written.
  • attachments — treated as plain content: switching it on appends a managed attachments

(assetGallery) field, so storage, REST, GraphQL and the form need no special case.

  • quickCreate — relation pickers may create an entry inline.
  • helpUrl / helpText — rendered above every entry form by BehaviourHelp.
  • duplicateRules — advanced multi-column duplicate detection (src/platform/schema/dedupe.ts).

A rule is a list of criteria ({ field, match, weight, optional, … }) plus an action (block → CONFLICT, warn → entry.duplicate_warning in the audit log), a threshold, an optional when condition and a scope (locale: same|any, lifecycle statuses). Matching modes: exact, caseInsensitive, normalized (case + accents + punctuation + spacing), prefix (first N normalized characters), number/date (± tolerance) and fuzzy (Sørensen–Dice bigram similarity with a per-criterion minScore).

  • planDuplicateRule splits a rule into a deterministic pushdown (exact / caseInsensitive /

number / date criteria become SQL predicates) and an approximate remainder scored in JS over at most candidateLimit candidate rows; a rule with no deterministic criterion is marked scans: true. scoreCandidate requires every non-optional criterion to match and the weighted average score to reach threshold, so weights express which columns dominate identity.

  • A criterion with a missing value skips the rule unless it is optional; single-column identity

stays a field-level unique switch. validateDefinition (via duplicateRuleIssues) rejects unknown or non-column fields, duplicate rule names, matching modes the storage type cannot support, unknown statuses, locale scoping on non-localized models, rules whose every criterion is optional, relative help links and attachments on non-collections.

Contextual help

The src/platform/docs/related.ts engine maps a path to a set of related reading: documentation sections (resolved against DOC_PAGES, so an anchor can't rot) and published articles and use cases ranked by tags. Presentation is handled by src/components/site/related-reading.tsx (view) and contextual-reading.tsx (context resolution); in the admin panel it is no longer a block at the bottom of the page: AdminShell exposes a Help & related trigger in the utility bar which opens the drawer src/components/admin/help-rail.tsx for the current route. On the public site the block renders as a numbered "Read next" index (not a card grid), so it never competes with the page's own content cards.

Admin page kit (one grammar for every screen)

Every workspace screen is composed from src/components/admin/page-kit.tsx, so search, filtering, tables, empty states and create/edit flows behave identically everywhere:

  • DataToolbar — search + narrowing filters + result count + active-filter chips + reset; display

preferences (grouping, sort, view mode) sit next to it via ToolbarSegmented, never mixed into chips.

  • DataTable / DataColumn — one column rhythm: identity first, status via StatusPill, numbers

right-aligned, actions right-aligned last; optional row selection feeding BulkBar.

  • EmptyState — the only way to render "nothing here", with the primary action inline.
  • RecordSheet + FormGrid + FormField — the single create/edit surface (right-hand drawer);

long inline forms were retired. Page-level creation lives in PageHeader actions.

  • StatStrip — headline numbers, instead of ad-hoc metric cards.

Rule for new screens: reach for the kit first; a bespoke layout is only justified when the data is not tabular (for example the media thumbnail grid or the live-preview translation editor), and even then the toolbar and empty state still come from the kit.

Media library as an API resource

Asset writes go through one service (src/platform/server/assets.server.ts) no matter who calls it: the panel, REST, GraphQL or MCP. Every surface first normalises its input with the shared contract in `src/platform/assets/asset-api.ts`, so the rules — required file name, base64 ceiling (15 MB, larger files must use multipart), tag parsing, patch keys — cannot drift between interfaces and stay unit testable without a request object.

Two decisions worth remembering:

reads dimensions, duration and the real MIME type from the file header (PNG, JPEG, GIF, WebP, BMP, AVIF, HEIC, SVG, MP4) in pure TypeScript — no sharp/canvas, which the Worker runtime cannot run. A client-declared width/mimeType is corrected, so a .png that is actually something else is caught at upload time.

  • Metadata and binary are separate endpoints. PATCH /assets/{id} only touches the keys it

receives and rejects unknown ones, while POST /assets/{id}/file replaces the binary and keeps the id, alt text, references and a restorable version. This split is what makes bulk metadata clean-up (for example filling missing alt text via MCP) safe to automate.

GraphQL exposes the library as MediaAsset, deliberately distinct from the Asset type embedded in content values: one is a library row with rights and versions, the other a reference inside an entry.

Google Drive ingestion (media)

Drive is a source of files, not a source of truth about taxonomy: it has a folder tree and essentially no descriptive metadata. That's why ingestion is split into layers like the rest of the platform.

  • src/platform/media/drive.ts — pure core: classification of Drive types (native Google → export to

PDF/XLSX/PNG, non-exportable files rejected), building files.list queries, categoryFromDrivePath and tagsFromDrivePath (slugs consistent with our taxonomy), matchMapping (the deepest folder rule wins, recursive=false catches only direct children), planDriveImport (request > rule > path heuristic, tags always merged), and the AI enrichment prompts and sanitization.

  • src/platform/server/drive.server.ts — the connector gateway adapter (/google_drive/drive/v3), never

the Google API directly, so token refresh happens on the gateway side. Without a connected account, browseDrive returns connected: false and the UI shows a "not connected" state instead of an error.

  • src/platform/ai/gateway.server.ts — one-shot JSON responses from the Lovable AI Gateway (streaming,

because the reasoning model exceeds the buffered call limit). AI is only invoked when needsEnrichment detects a gap, and never overwrites values from rules (mergeEnrichment).

  • Import modes: copy (bytes land in cms-assets via uploadAsset, full control, variants,

checksum deduplication) and reference (metadata only; bucket = google-drive, link_only = true, url points to Drive). Re-importing the same external_id in reference mode updates the existing asset instead of creating a duplicate.

  • Folder rules live in platform_drive_mappings (per account, RLS + roles as in DAM) and make mapping

repeatable: the next import from the same Drive folder gets the same category, tags, destination folder, and mode.

Layout registry lifecycle

A layout is presentation metadata with its own lifecycle, mirroring the schema registry: draft → publish → versioned. src/platform/server/views.server.ts owns the writes; /admin/views is the registry and /admin/layout-editor the authoring surface (deep-linked with ?view=&schema=&kind=).

  • saveViewDraft writes the working tree only; publishView copies it into published_definition

and bumps the version — the Layout API serves published trees exclusively.

Admin consumers do not re-implement presentation: the entry list at /admin/content/<model> asks getLayout for the model's kind: "table" view and turns it into a render plan with src/platform/view/table-plan.ts (columns, labels, alignment, value formatting, responsive hiding, sort options, row density, page size, empty state). When no layout is stored the derived view is used, so the plan always exists. getLayout also returns the model's field metadata, so a layout consumer needs a single round trip.

  • discardViewDraft reverts the working tree to published_definition (audit view.draft_discarded),

so an unfinished layout iteration is never a dead end. Layouts that were never published have nothing to revert to and the action is rejected.

  • setDefaultView promotes one layout per (model, kind). Because resolveLayout reads isDefault

from the stored JSON, the flag is written to the column *and* to both definitions, and every sibling is cleared in the same operation (audit view.default_changed).

  • deleteView removes definition, published tree and history; clients then fall back to the layout

derived from the schema, which is why the UI states that consequence and asks for the view key.

  • src/platform/view/manage.ts derives which actions are legal for a row (viewRowActions) and how a

duplicate is keyed (duplicateViewDefinition), so the menu and the server never disagree.

Code map

  • src/platform/schema/ — field types, validation, conditions, taxonomy, compiler
  • src/platform/query/ — query engine
  • src/platform/channels/ — delivery channels and payload projection
  • src/platform/publishing/ — lifecycle, scheduler, content translations
  • src/platform/permissions.ts, src/platform/access/ — RBAC, permission sets, FLS
  • src/platform/api/ — OpenAPI, GraphQL SDL, interface catalog
  • src/platform/view/ — Layout/View API and declarative logic rules
  • src/platform/server/ — server operations (registry, content, promotions, taxonomy, people and access)
  • src/routes/api/public/ — public HTTP interfaces
  • src/routes/_authenticated/admin.* — admin panel

Permission reflection in the admin panel

src/hooks/use-capabilities.ts exposes can(capability) based on the roles from getSession. Navigation (NAV_GROUPS.requires) and Delivery pages use it solely to choose affordances: a preview + a ReadOnlyBadge instead of write controls. The security boundary remains requireCapability in src/platform/server/*; sdkOverview is deliberately read-only for every account member.

People, roles, and access

Identity breaks down into three independent layers, all read via actorFromUser → withAccess:

  1. user_roles — the platform baseline role (one per user; setPlatformRole

replaces previous rows, so capabilities remain unambiguous).

  1. platform_account_members — the role within a specific tenant; user_account_ids

expands it down the account tree (inheritance).

  1. platform_group_members / platform_groups — hierarchical groups used as

principals in permission sets (user_group_ids adds parent groups).

src/platform/server/people.server.ts reads and writes these tables with a privileged client (RLS blocks mutations from the client by design), and src/lib/people.functions.ts enforces manage_users and logs an audit entry before every change. The /admin/people panel is purely a presentation of these operations.

Narrowing access for API calls

A machine ActorContext (API token) carries scopes. Resolution order: role baseline → withAccess (grant/restrict sets + FLS) → sectionBlockedFields (layout section roles and conditions) → scopeAllows/scopeFieldAllowList (src/platform/api/scopes.ts). A scope never expands permissions, and a token narrower than *:* never receives management capabilities.

Public API contract surface (tenancy)

/api/public/openapi.json, /api/public/graphql (GET = SDL), /api/public/v1, /api/public/v1/schemas, and the JSON:API catalog are projections of the schema registry, so they must respect the account boundary:

  • loadRegistry(accountId) (src/platform/server/registry.server.ts) filters

platform_schemas.account_id. Passing an account id returns that account's models plus shared models (account_id is null, this site's reference registry). null = shared only. Omitting the argument = the platform view (admin/internal).

  • src/platform/server/public-registry.server.ts resolves the account for an HTTP request:

API token → actor.accountId, explicit x-account / ?account= → the indicated account (with access validation), neither present → null, i.e. the reference registry.

  • Contract responses carry vary: authorization, x-account.

Implications for the UI: public marketing links (homepage, footer) lead to the developer portal /docs, and the raw documents are described as a *reference contract*. A specific account's interfaces are shown at /admin/interfaces.

API-led categorisation of the interface surface

src/platform/api/surface.ts enumerates every generated interface; src/platform/api/taxonomy.ts classifies it. Classification is pure derivation from an endpoint (protocol, tags, path, audience), so the explorer, docs and tests never disagree:

  • Tier (API-led connectivity): system — direct access to systems of record (generated CRUD,

JSON:API, registry contracts, asset bytes, environments); process — orchestration across records (publication pipeline, revisions, translations, import/export, webhooks, event streams); experience — consumer-shaped surfaces (form and layout descriptors, search, SDK artifacts, MCP agent channels, rendered site routes).

  • Domain: content, publication lifecycle, localization, presentation & forms, media,

import/export, events, contracts, automation, web.

  • Stability: stable (contract-stable), beta (MCP/SSE, shape may still change), internal

(cron and worker hooks; not part of the public contract).

regroupSurface(groups, mode) re-buckets the catalog along any axis (model/area, tier, domain, protocol, access level, stability) and filterByTaxonomy narrows it; /admin/interfaces defaults to tier grouping. Adding a new endpoint therefore only requires the right tags in surface.ts — the categorisation, filters and counters follow.

Anonymous public read (api.publicRead)

Whether an unauthenticated visitor may read a model is a schema decision, not a code list. api.publicRead (src/platform/schema/types.ts) is off by default; it is edited in *Delivery & indexes* (src/components/admin/delivery-editor.tsx), normalised by deliveryDraftFrom / applyDelivery and validated by deliveryIssues (src/platform/schema/delivery.ts): collections only, and the model must own a slug field so the content is addressable. allowsPublicRead(def) is the single predicate every anonymous surface asks.

Turning the flag on or off is a metadata.changed entry with impact: "warning" in the compiler's change report, so widening or withdrawing anonymous access is always visible in *Publish impact* and lands in the audit trail.

The marketing site (src/lib/site-content.functions.ts) is a consumer of this contract: it reads through the registry and the shared query layer, forces publicationState: "published", overlays only translations in status published, and refuses models without the flag. listPublicModels enumerates the publicly readable models for sitemaps, feeds and navigation.

Interface copy overrides

src/platform/i18n/overrides.ts holds the pure layer that turns the bundled catalogs into an editable inventory: fold database rows into a lookup (toOverrideMap), resolve a key (resolveMessage: override → bundled → English), build the review table (messageEntries, filterMessages, messageStats) and overlay an unsaved draft (withDraft). overrides-provider.tsx injects a layer into the locale context — the admin shell wraps itself with the account's stored overrides, and the translations screen wraps its preview with the draft. Persistence and history live in src/platform/server/messages.server.ts (platform_message_overrides, platform_message_revisions), exposed to the UI through src/lib/messages.functions.ts and gated by the manage_schema capability. Reads walk the account ancestry, so a project inherits its organisation's wording.

Virtualised tables and offset pagination

DataTable (src/components/admin/page-kit.tsx) renders only the visible rows once a set passes 40 records: useVirtualizer sizes the window from the row height implied by the density (compact 36 px, comfortable 53 px), and spacer rows fill the space above and below it, so <table> semantics, column widths, hideOnMobile, row selection and the sticky header all stay intact. Virtualisation can be forced or disabled with the virtualize prop, and the viewport height with viewportHeight.

Pagination is offset-based and deterministic: Pagination derives the window from (page - 1) * pageSize, and shows the from–to of total range, the page number and First/Previous/Next/Last jumps. Page size and default sort come from the Layout API (planTable), so changing a filter or sort resets the page to 1 but never changes the table's grammar.

Table layout editor (the Layout API in reverse)

src/platform/view/table-plan.ts reads a kind: "table" view and produces table descriptors; src/platform/view/table-editor.ts does the opposite. draftFromSpec lifts a stored (or derived) ViewTableSpec into an editable draft that carries every field of the model — included or not — and toTableSpec lowers the draft back into the spec. The module, not the UI, owns the invariants: exactly one linking column (patchColumn), only sorts the query engine accepts (sortChoices + sortFieldToken), at least one column visible on small screens, and a page size clamped to 5–100 (draftIssues, clampPageSize).

TableLayoutEditor (src/components/admin/table-layout-editor.tsx) merely renders that state and is wired into the /admin/content/$key list. For a derived layout the first save creates a real account-owned view (saveViewDraft), and publishView publishes the version the list and every Layout API consumer reads; after saving we invalidate the layout, view and entries queries, so the table switches to the new columns, sort and density immediately.

Layout-driven filters

Filters are part of the layout, not the screen: ViewTableSpec.filters declares which fields a list can be narrowed by, with what control (text, select, multiSelect, numberRange, dateRange, boolean), label, placeholder, default value and whether the control lives in the collapsed "More filters" area.

src/platform/view/filter-plan.ts is the pure layer around that spec:

  • filterCandidates/defaultControlFor decide what is filterable at all (respecting

field.filterable, skipping structural and vector kinds) and pick a control per field kind, so a derived layout already ships a usable filter bar (deriveTableView).

  • planFilters resolves the authored spec against the live schema: it maps the system

columns status, createdAt and updatedAt to their physical names, hydrates select options from enumValues/enumLabels (or the model's status list), applies authored defaults and reports stale entries via unknownFilters.

  • filtersToClauses lowers UI state into canonical FilterClause objects the Query Engine

understands — ranges become gte/lte pairs, a one-value multi-select collapses to eq so an index can still be used — while filterChips/activeFilterCount describe the active narrowing, and encodeFilterState/decodeFilterState carry state through the URL.

sanitizeFilterClauses runs again inside listContent (src/lib/platform.functions.ts): the server re-checks every clause against the schema and the Query Engine operator list and caps their number, so a tampered layout payload cannot widen data access. Access conditions and tenant scope still apply on top, in SQL.

src/components/admin/table-filters.tsx renders the plan and nothing more; the Filters section of TableLayoutEditor authors it.

Table preferences (the personal layer above the layout)

The layout is the authored contract shared by everyone; preferences are a thin personal layer that does not require editing that contract. Resolution runs widest to narrowest:

layout view  →  role default  →  user preference

platform_table_preferences stores both scopes (scope = 'user' | 'role'), always stamped with account_id, under RLS: a person reads and writes only their own row, while role defaults are published by an administrator (manage_permissions, audited).

src/platform/view/table-preferences.ts is pure and runs on both sides: normalizeTablePreferences sanitises the payload (unknown columns, 24-column ceiling, allowed densities, clampPageSize, filters through decodeFilterState), mergePreferences merges the role default with the personal override per key, capturePreferences stores only the diff against the layout (so a saved preference keeps following the author's changes), and applyPreferences re-applies it to a TablePlan — hiding and reordering columns, promoting a new linking column, overriding density, page size and sort — reporting anything the layout no longer supports through ignored, which the list surfaces in its layout notice.

src/components/admin/table-preferences-menu.tsx is the "My view" popover in the list toolbar: column visibility and order, density, page size, save (together with the current sort, status facet and filters) and reset back to the layout.

Form rendered from the Layout API

The editor is now symmetric with the list: /admin/content/<model>/<id> fetches getLayout({ kind: "form" }) and renders that resolved tree through ViewRenderer (src/components/admin/view-renderer.tsx), instead of laying fields out from schema sections. Provenance is visible in the form header (layout: stored | derived).

src/platform/view/form-plan.ts is the pure bridge and answers three questions:

  • what the layout places — collectFieldNames walks section/tabs/accordion/steps/container

nodes in document order;

  • what the layout forgot — every editable field (all except inverse oneToMany relations)

missing from the layout is listed in missing and rendered in a dashed "Not placed in the layout" section, so an incomplete authored layout can never make data unreachable;

  • why a field is not on screen — hidden carries one note per field with a reason

(schema-hidden, field-condition, section-condition) and a sentence built from describeCondition. The form renders it as a collapsible "N hidden fields — why?" trace.

Conditions stay evaluated by the same engine as server-side validation (isFieldApplicable + hiddenSectionFields), so the layout only decides placement, never visibility. Field nodes referencing fields that no longer exist are reported as unknown and shown as "stale fields in layout".

Incremental search_vector maintenance

src/platform/search/incremental.ts owns the stored vector's lifecycle. The column is a plain tsvector (not GENERATED ALWAYS) plus a search_fingerprint stamp, kept in step by a per-model before insert or update trigger.

write path (INSERT/UPDATE)
  └─ cms.<model>_search_sync()
        ├─ UPDATE + fingerprint current + no searchable column changed → reuse old vector (no work)
        └─ otherwise → recompute weighted tsvector, stamp fingerprint
schema publish (dictionary / weights / field set changed)
  └─ compileMigration → create or replace trigger function   (no table rewrite, no lock)
        └─ backfillSearchVectors(def)   ← src/platform/server/search-sync.server.ts
              └─ batch: select stale ids ... for update skip locked → update → {updated, remaining}
  • The trigger runs inside the writer's transaction under the row lock, so concurrent writes to the

same row cannot interleave or lose an update; unrelated column updates cost nothing.

  • Backfill batches are idempotent and resumable: a row that already carries the current fingerprint

is invisible to them, and for update skip locked means a worker never blocks an editor saving an entry (that save stamps the new fingerprint itself).

  • Transient failures (40001, 40P01, 55P03, 57014, deadlock/timeout) are retried with

exponential backoff and full jitter (isTransientWriteError / retryDelayMs); anything else is raised. A publish is never failed by the backfill (backfillSearchVectorsSafely).

  • Installs created before this model carried a generated column; a one-off DO block detects

attgenerated and converts the column in place.

Faceted search (drill-down counts)

src/platform/search/facets.ts is the only place facet semantics live; REST, GraphQL, MCP and the /admin/search workspace all call it through src/platform/server/search.server.ts.

searchEntries
  ├─ per model: listEntries(search: q)      → RLS + policies + FLS applied
  ├─ facetSubject(def, record)              → one hit reduced to dimension values
  └─ computeFacets(subjects, specs, filters)
        ├─ matchesFilters(subject, filters, except: dimension)   ← drill-down
        └─ countDimension → FacetValue[] { value, label, count, selected }
  • Dimensions: model, status, locale, category, tag, plus <model>.<field> for every

facetable field (facetableFields): enumeration and dictionary kinds, tags/multi-select, manyToOne/oneToOne relations, boolean, and dates collapsed into today/7d/30d/older.

  • Values inside a dimension are OR-ed, dimensions are AND-ed. Counting a dimension ignores its own

filter (except), so a facet list never collapses to zero when the user adds a second value.

  • A missing value becomes __none__, so "no value" is a first-class filter.
  • Counts are derived from rows the actor may actually read; nothing is aggregated with the admin

client, so a facet cannot leak a hidden record or a hidden field.

  • perModelLimit caps the scan; hitting it sets facets.approximate and the UI says the counts are

a lower bound instead of pretending they are exact.

  • Legacy flat params (models=, status=, category=, tags=, locale=) are folded into facet

filters by mergeLegacyFilters, so both grammars produce one code path.