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.