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, encrypts migrated owner credentials at rest with AES-256-GCM derived
from JWT_SECRET, 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.
Theme selection and the two editable locale values have separate exact-path
migrations. For an existing rendering tenant with an allowed theme wrapper,
first stop any long-lived workers, pause backoffice theme/module/locale writes,
and retain a database snapshot and a copy of the canonical JSON. Run the
following commands once per canonical tenant (TENANT_ENV names its canonical
host; aliases share its DB):
php bin/console k0smos:modules:sync-theme-selection
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
php bin/console tenant:theme:diagnose
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 synchronizes 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; do not run theme selection backfill
for them or create a DB aggregate whose wrapper check would fail. 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
per-tenant 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 modules or run content fixtures.
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.