Skip to content
Skip to content
Documentation navigation

Tenant configuration boundary

Tenant JSON is a bootstrap and deployment document, not the mutable settings database. TenantConfigInventory is the machine-readable allowlist used by tenant:config:audit; every leaf path must have an explicit classification and rationale. Unknown paths fail the command.

Backoffice-editable settings owned by Psapi, Wpapi, Esapi, and Google are tenant-scoped AppSettings. Their typed owner metadata is declared by AppSettingOwnerCatalog; runtime readers are DB-only and code defaults apply only where the owner defines one. Tenant JSON is not a fallback.

Use:

php bin/console tenant:config:audit --format=json
php bin/console tenant:config:migrate-editable --dry-run
php bin/console tenant:config:migrate-editable

The migration preflights all conflicts before writing, redacts credential values, protects migrated owner credentials with the core authenticated enc:v2 envelope and independent environment/secret-manager keyring, writes atomically, and records _migration.tenant_json.{owner}.ready. Existing plaintext credentials for these owners are upgraded idempotently by the same command. Use --overwrite only after reviewing the dry-run conflict report. Preserve a copy of the tenant JSON as the operator recovery artifact before removing the migrated owner object; secrets must subsequently be rotated through their credential-owning admin surface. Credential protection remains bound to the original tenant, owner and complete setting key; see secret management.

On existing MySQL, Base Version20261015110000 changes the settings key's physical storage to complete bytes plus a generated digest index, preserving two-column SQL callers and credential binding. Back up and quiesce all settings writers, web requests and workers before the full migration. Custom legacy schema and leftover migration artifacts require reviewed recovery; the production command never drops them automatically. Retain the upgraded schema on code rollback. See the migration reference for the precise accepted shape and recovery boundary. SQLite/PostgreSQL keep their TEXT key; focused MySQL checks do not establish a complete installation.

Theme selection and the two editable locale values use shared exact-path backfills integrated into every full migrate, including composer app:migrate, composer app:migrate-all, installation and demo installation. For an existing tenant, first stop any long-lived workers, pause backoffice theme/module/locale writes, and retain a database snapshot and a copy of the canonical JSON. The normal update is one Composer operation; TENANT_ENV selects one canonical tenant, while app:migrate-all explicitly selects every canonical tenant, deduplicating aliases:

TENANT_ENV=k0smos.example.com composer app:migrate -- --allow-no-migration
# Or, deliberately update every configured canonical tenant:
composer app:migrate-all -- --allow-no-migration

Full migration first applies Doctrine, then initializes missing configuration. Tenant migration metadata is initialized before canonical version recording. Migrate/generate share the same platform-aware name: valid existing names remain unchanged, including long SQLite names and 64-byte MySQL names; only overlong native identifiers receive a deterministic complete-tenant-ID digest suffix. Detected legacy PostgreSQL truncation requires reviewed recovery before another history can be created. No automatic metadata rename or deletion is performed. For an absent theme selection, it atomically enables only selected wrappers, their dependencies and installed route-override wrappers, then inserts the pair. It does not run the broad module synchronizer on every update. Existing DB choices, revision tokens and unrelated operator module states remain unchanged. Locale backfill validates applicable stored values even after JSON cleanup; valid DB values win over outdated legacy values. Invalid applicable legacy or stored values fail clearly without overwriting them. Active modules declare their own keys and supported catalog locales through a typed contribution; duplicate target or legacy keys fail before locale writes. Inactive Documentation contributes nothing and its stored values remain untouched, without an owner-specific status row. No theme asset manifest is required for these data operations.

The explicit commands remain available for previews or targeted maintenance after the required schema exists. Theme preview also checks the initial wrapper preparation plan without writing; a separate sync is no longer a prerequisite:

php bin/console tenant:config:migrate-theme-selection --dry-run
php bin/console tenant:config:migrate-theme-selection
php bin/console tenant:config:migrate-locales --dry-run
php bin/console tenant:config:migrate-locales

For rendering tenants, independently verify deployed theme assets before serving traffic (all theme builds remain owner-run):

TENANT_ENV=k0smos.example.com php bin/console tenant:theme:diagnose

--module and --baseline skip configuration backfills. --all-or-nothing governs Doctrine only, not the whole schema/data update. A failed data phase returns nonzero while completed schema migrations remain recorded; rerunning the full command completes the outstanding backfill without replacing DB edits. The conflict/credential-sensitive tenant:config:migrate-editable remains explicit.

Read back theme.selection, i18n.default_locale, and, for tenants with the Documentation module active, i18n.documentation_locale from app_settings:

SELECT setting_key, setting_value FROM app_settings
WHERE setting_key IN ('theme.selection', 'i18n.default_locale', 'i18n.documentation_locale');

Confirm the selected wrappers and deployed manifests with the diagnostic command. Remove each legacy JSON field only after its corresponding DB value has been validated. For an inactive Documentation module, a legacy value of en can be omitted after proving the module's English compatibility fallback; retain any non-default value until activation and DB readback. Check a new request after editing JSON, including when config compilation is enabled, then resume writes and restart workers. The compiled JSON cache uses a source-content digest as well as mtime, so a same-second edit invalidates it. Backfills insert only absent keys and preserve subsequent backoffice edits on rerun. A fresh install may omit the four fields: it prepares the default wrapper and seeds the default theme pair. The only legacy fallback is a missing selection row; a malformed present row is a configuration error. Normal HTTP returns 503 if the DB or required schema is unavailable.

The two bundled lite integration tenants have no theme wrapper in their deployment module allowlists. Their explicit default/default JSON pair was omitted as equivalent to the code fallback. Automatic and explicit theme backfills now report skipped_no_theme_modules for these wrapperless inventories; they do not create a DB aggregate or enable forbidden wrappers. Their inactive Documentation locale was also the en compatibility default and was omitted. Their i18n.default_locale was backfilled and read back before that JSON field was removed. The five configured rendering tenants likewise had their DB theme/default/Documentation values read back before JSON cleanup. A private, consistent SQLite/JSON backup was checked for integrity and restore, and no workers were active during this cutover. On other deployments, follow the update/readback/diagnosis sequence above before removing any legacy field.

The DB theme pair is one theme.selection record with a revision token. The admin page /admin/settings/themes changes front and admin together. Choices need an already active wrapper/dependencies and a deployed asset manifest; theme switching does not enable or disable modules or run content fixtures. Feature modules never restrict the choice: a front theme without its own templates for an active module only shows a warning, and those pages use the standard module templates. Module and theme forms return 409 for stale revisions. To recover a malformed selection after a failed deployment, explicitly prepare the target modules if needed, then replace the row:

php bin/console k0smos:modules:sync-theme-selection --front=default --admin=sober
php bin/console tenant:theme:recover --front=default --admin=sober

The two locale pages live under Settings and validate values against their respective deployment-supported languages and installed Documentation catalogs. i18n.supported_locales remains a deployment policy used before routing and privacy classification. The legacy JSON shape during migration is:

"i18n": {
    "default_locale": "it",
    "documentation_locale": "en",
    "supported_locales": ["it", "en"]
}

Documentation first tries the navigation locale, then the configured Documentation fallback catalog. A present invalid DB value fails validation; only an absent DB key uses the legacy JSON value or its code default.

The effective runtime snapshot is assembled before modules and the container. It combines raw JSON/database locator with the narrow DB theme and default locale settings. The resolved JSON disk cache never contains the DB overlay. Long-lived queue workers must be gracefully restarted after a theme or default locale switch. For a code-release rollback to a version that only reads JSON, restore the latest DB choices to deployment JSON before sending traffic to the older release. See field disposition.

For an isolated browser check after building Default and Sober assets, run node tools/benchmarks/tenant-theme-smoke.mjs. It creates and removes a disposable SQLite tenant, switches the admin theme through the real form, and writes screenshots under reports/tenant-theme-smoke/.

Independent site deployment locators

The optional Site Publish module classifies five deployment keys: site.root (independent checkout), site.public_url (plain HTTPS canonical origin), the optional site.profile (content contract), site.runtime (one executable basename), and site.timeout_seconds (30–3,600 seconds). It also accepts an optional nested site.database target for the public PostgreSQL/Supabase projection. Neither the site checkout nor that projection replaces the root database block. Locales, editorial revisions and publication state live in module tables.

"site": {
  "root": "site/dm3",
  "public_url": "https://k0smos.example.com",
  "runtime": "bun",
  "timeout_seconds": 600
}

The runtime defaults to bun and the timeout to 600 seconds. The runtime is a validated executable name, not a path or editable command; arguments remain fixed by Site Publish. Site tooling writes every structured result to the private digest-checked receipt path supplied by k0smos, while stdout/stderr remain bounded progress output only.

SQLite remains the canonical public generation and needs no database locator. To mirror each finalized generation to Supabase/PostgreSQL, configure the separate target with environment placeholders rather than literal credentials:

"site": {
  "root": "site/dm3",
  "public_url": "https://k0smos.example.com",
  "database": {
    "driver": "supabase",
    "url": "${K0SMOS_SITE_SUPABASE_DATABASE_URL}"
  }
}

site.database accepts a PostgreSQL URL or explicit host plus dbname/database, with optional port, user/username, password, charset, sslmode and serverVersion. Remote Supabase connections always use sslmode=require; an explicit attempt to disable it is rejected. Missing, unresolved or unknown configuration fails publication with a bounded code. Credentials stay on the server and are excluded from AppSettings, manifests, receipts, logs and browser assets. Apply the module's versioned PostgreSQL schema explicitly before selecting this target; publication never creates or alters remote tables. The SQLite-only path remains valid when the block is absent.

The declaration alone identifies the site: the checkout folder's name is the site code, and the content profile defaults to that same code. Both accept lowercase letters, digits and hyphens, 2 to 40 characters. Set site.profile only when the folder name differs from the contract it publishes, for example a showroom checkout carrying the dm3 profile. The site code and profile are frozen in the module's target row at first import: renaming the folder or changing the profile afterwards resolves to a different site or fails closed, so treat it as a migration rather than a configuration edit.

Identity is read from configuration only, so operators keep editing content while a checkout is unmounted or being replaced; publication refuses until it resolves, and the editor and site:status name the exact reason.

By default the checkout must be strictly below the repository's site/ folder. An operator can supply an absolute parent-directory allowlist through K0SMOS_SITE_ALLOWED_ROOTS (platform path separator). The target must have real app/, tooling/ and contracts/ directories; source/tenant-database/public root overlap, traversal and mutable-input symlinks fail closed. An opaque source binding prevents two tenants from publishing into the same checkout. Media originals and credentials remain owned by k0smos. See Site Publish for backup, import preview and activation.