How the Directus service is built and configured, how its schema is kept as code, and the guards around changing schema, flows and permissions.

Reference Value
Subject Directus runtime, schema as code, roles and policies, flows, guarded scripts and workflow lanes; for anyone changing the CMS
Verified source apps/cms and cms.yml on main at f40e8704; checked 2026-11-07. Dockerfile, extensions, Build Bot file grant, workflow lanes and scripts re-verified on main at 0461e045 (#646, #677, #686, #690, #693), checked 2026-11-10
Support state CMS declarations and publication contracts checked against main f40e8704. Optional source/date fields, validation and narrow Human Editor grants are declared in the snapshot; live grants/schema require their own audit

Runtime

Part Contract
Image Dockerfile: directus/directus:12.5.1 pinned by digest. A separate Node 22.23.4 stage packs [email protected], verifies its SHA-512, widens its host range to Directus 12 and fails if the manifest changes. A second Node 22.23.4 stage runs npm ci --ignore-scripts on extensions/article-editor and builds it with the pinned @directus/extensions-sdk 18.1.1
Extensions directus-extension-sync (id-map endpoint used by directus-sync), the local noindex middleware and the local directus-extension-article-editor interface. Article Block Editor (article-block-editor, host 12.5.1) wraps Directus's built-in block editor, keeps image extras by block ID and adds per-image Layout (Normal, Row, Beside next block), Light version and Show both themes controls. The snapshot sets it on projects_sections_translations.content, projects_translations.description, blog_posts_translations.body and legal_pages_translations.body; production takes that only through the confirmed owner packet
Service One Railway service, https://cms.yesid.dev, built from apps/cms only; health checks use /server/ping
Data Neon Postgres through DB_CONNECTION_STRING; Cloudflare R2 through the built-in s3 storage driver (STORAGE_S3_*)
Behavior Asset transforms locked to named presets (STORAGE_ASSET_TRANSFORM=presets). One Directus instance: cache, synchronization and rate limiter use memory stores (CACHE_STORE, SYNCHRONIZATION_STORE, RATE_LIMITER_STORE = memory). Set no REDIS or REDIS_* variable: Directus 12 moves its message bus, locks and shared stores, WebSockets and collaboration included, to Redis whenever one exists (apps/cms/.env.example:92). Directus MCP enabled for the AI Editor role; server-level MCP deletes are on (mcp_allow_deletes: true in settings.json), and the AI Editor policy has no delete permission. MCP sign-in by OAuth is on (mcp_oauth_enabled, mcp_oauth_dcr_enabled); those sessions run with the signed-in user's rights, and the Human Editor policy has 51 delete permissions
Deployment prerequisites IP_TRUST_PROXY=true and a Directus license key are set on the Railway service, per the Dockerfile header

apps/cms/package.json is scripts-only; Directus itself runs from the image. apps/cms/vercel.json sets ignoreCommand: exit 0, so Vercel never builds this package.

Schema as code

directus-sync.config.cjs dumps to apps/cms/directus/:

Path Holds
directus/snapshot/ Collections, fields and relations, including editor groups, labels and ordering
directus/collections/ roles, policies, permissions, flows, operations, presets, dashboards, panels, folders, settings, translations
Build Bot directus_files read grant in directus/collections/permissions.json id, filename_download, type, filesize, width, height, folder, title and description for files in the seven delivery folders or unfiled, used by the export to read covers and build images; pinned by build-media-policy.test.ts

Editorial rows are not in Git. Settings fields holding per-environment file UUIDs (project_logo, public_foreground, public_favicon) are committed as null; sync-push.ts merges the live values in for the push and restores the file afterward.

Command (in apps/cms) Effect
bun run sync:diff Read-only preview of what a push would change
bun run sync:pull Replaces the local dump with remote state and can create remote id mappings; review the whole diff and stage only apps/cms/directus/
bun run sync:push Always through the sync-push.ts guard; never call directus-sync push directly

The guard refuses production unless DIRECTUS_SYNC_ALLOW_PROD_SCHEMA_PUSH=1 and DIRECTUS_SYNC_PUSH_ACK=sync-push-can-delete-cms-data are set, keeps permissions out unless DIRECTUS_SYNC_INCLUDE_PERMISSIONS=1 with DIRECTUS_SYNC_PERMISSIONS_ACK=permissions-push-can-delete-parallel-work, and then refuses any production push that touches policies or permissions regardless of acknowledgements (refuseProductionPolicySync). The snapshot declares the 11 live Human Editor asset grants. Twenty deferred Ops Bot/Build Bot grants are reference-only declarations in the offline plan. Eight unfiltered create/read rows use permissions: null; other fields, validation and presets are enforced.

A committed dump, a clean diff or a green test does not prove the live CMS matches. The About keys stop_stack and stop_clients label the Education and Languages sections. The global tech_stack collection supplies technology data. Fields read by the exporter are content contracts; a field hidden from the form can be required by the export.

The CMS holds optional projects_impact_metrics.source_url, projects_impact_metrics.as_of, services.impact_metric_source_url and services.impact_metric_as_of fields. Their editor validation and export schema apply to stored evidence. Human Editor has the two narrow Service-field read/update grants. Site metric displays show values and labels; the source/date fields are not displayed or included in metric text in llms-full.txt.

Workflow lanes

All write lanes run only from main by workflow_dispatch, inside the production GitHub environment, after the offline test job.

Dispatch action Does
diff Read-only sync:diff (also runs automatically after every push to main)
push Guarded sync:push; include_permissions is off by default and production policy sync is refused
flows-push Read-only permission audit, diff, push of flows only, then a convergence diff
permission-control-audit GET-only semantic comparison of declared and production permissions
No permission write action Production permission rows, including the Build Bot file grant, cannot be pushed (production policy sync is refused) and use the confirmed owner-browser write path

Roles, policies and flows

Saving CMS content does not itself start a build. A production build uses live CMS content, whether triggered by Publish site or a merge to main. A merge after a CMS content write therefore publishes that content when the production build succeeds; the code diff does not bound the content published.

Role Purpose
Administrator Full access, including schema
Editor Day-to-day content and files (Human Editor policy)
AI Editor Content edits through the Directus MCP
Build Bot Read-only content policy used by the production export token, including delivery-folder file reads for build images
Ops Bot Declared content-operations policy; its presence in Git does not prove an active account
Public Per-collection anonymous reads; status-bearing collections require published rows; files use folder and field allowlists
Flow Trigger Does
Publish site Manual on build_queue (the Publish site item at the bottom of the sidebar), with confirmation Asynchronous version-before read, one hook POST, context, ten-minute wait, version-after read and editor notification. Distinct notices per failure branch; see Operations and recovery
Translation reminder on publish items.update on content collections Checks for published status, logs it and sends the editor an in-app notification to check French and Spanish; no requests