Skip to content
24/48h shipping across Italy
Skip to content
Documentation navigation

k0smos - Complete Documentation

Modular PHP framework for web applications and AI-ready platforms, based on PSR standards and selected Symfony components.

Version status: 0.30.0 is the current completed release, opened on 2026-09-15 and closed on 2026-09-26. Deployment limits and verification are in the root changelog.


Table Of Contents


1. Vision

k0smos is a modular PHP web application intended as a solid base for:

  • multi-tenant SaaS
  • web interfaces and APIs
  • LLM / AI agent integration
  • extensible and testable architecture

Goal: avoid full-stack Symfony while keeping its best components.


2. Technology Stack

Component Technology
Language PHP >= 8.4
DI Container PHP-DI (autowiring + compiled container)
Routing, HTTP, Console, Process, Cache Symfony components
PSR Standards 3, 4, 6, 7, 11, 14, 15, 16
Cache Redis
Queues, Async Jobs SQL (DBAL, default) or Redis (opt-in with SQL fallback)
Deploy & Runtime Docker + systemd
Testing PHPUnit 13.3.3, class-based tests and disposable tenant fixtures; Codeception removed
Search (optional) Elasticsearch
Logging Monolog
Templating PHP Plates
AI / LLM Native HTTP adapters (Ollama, OpenAI Responses API, Anthropic Messages API, OpenRouter, NVIDIA NIM, AtlasCloud, Kimi/Moonshot, SenseNova, Google AI) via Guzzle
Debug PHP DebugBar + K0smos\Debug\DebugLogger

3. Core Architecture

3.1 Request Flow

public/index.php -> resolves the tenant from the host -> builds the DI container -> Kernel::handle() -> middleware pipeline -> router/controller -> ViewModel/ResponseInterface/scalar -> emitter.

Controllers return a ViewModel (rendered through PlatesEngine), a ResponseInterface (passed through directly), or a scalar value (wrapped in a 200 response). The Kernel always owns final emission.

After Symfony routing, K0smos\Router\Router resolves the controller from the container and invokes it. During this step it automatically records route, controller, method, execution time, and result metadata in DebugBar through K0smos\Debug\DebugLogger, without requiring base controllers to depend on DebugBar.

3.2 Dependency Injection

The DI system is based on PHP-DI with Reflection autowiring, explicit definitions, interface bindings, custom factories, production container compilation, and PSR-16 container cache.

K0smos\Container\ContainerFactory is the canonical bootstrap entry point. It loads named providers from src/Container/Definition and checks duplicate bindings before registration. Each binding has one owner; cross-module extensions use explicit registry contracts. ContainerCore remains as a compatibility facade for older tests and integration code during the refactor.

DI key ownership. Every key has exactly one owner. Module service definitions are merged into a single array and then merged over the core groups, so a duplicated key resolves by silent last-writer-wins in module order rather than by an error. A module therefore never rebinds a core key — it extends core capabilities through an opt-in contract collected from ModuleRegistry — and two modules never declare the same key. Wiring for a class that lives under src/ belongs to the core definition group that owns its bounded context, so classes shared by several modules are core-owned: CartDefinitions (cart aggregate and DBAL persistence, used by cart and ecommerce), ProjectDefinitions (project recap read model and tracking, used by project and calendar), TicketDefinitions (assistance-contract overview, used by ticket and project), and the shared hero contract in RenderingDefinitions (used by every theme). A module's services.php keeps only what the module itself owns. tests/Unit/Container/DefinitionGroupCollisionTest.php enforces both directions.

Because PHP-DI's reflection autowiring skips optional (default-valued) constructor parameters, such dependencies are wired explicitly with ->constructorParameter(...); an object literal default additionally makes the entry uncompilable under enableCompilation().

Tenant-safe compilation. Compilation is disabled unless the tenant enables compilation.enabled and leaves compilation.container enabled. The generated class is tenant- and active-module-scoped, but it intentionally contains no resolved tenant configuration and no module objects: Tenant, ModuleRegistry, and app.modules are compile-safe placeholders that ContainerFactory replaces with request-runtime values immediately after build(). This keeps database, integration, and other environment-resolved credentials out of generated PHP and prevents a warm class from retaining an earlier configuration snapshot.

The container cache directory carries a format marker and lock. When the format version changes, ContainerFactory removes legacy CompiledContainer_*.php files before producing the new class; concurrent cold boots use atomic PHP-DI writes. Deployments should additionally run cache:clear --type=container and then cache:warmup after old application processes have stopped, so a rolling deployment cannot recreate an artifact using previous code.

3.3 Middleware Pipeline

The PSR-15 middleware pipeline processes every request in order. Each middleware can modify the request/response or stop the pipeline.

Order Middleware Purpose
1 RequestIdMiddleware Incident tracking with a unique request ID
2 LoggingMiddleware Request/response logging
3 DebugBarMiddleware HTML toolbar and AJAX DebugBar headers, dev only
4 SideDetectionMiddleware Detects front/admin side from the URL path
5 TenantResolverMiddleware Tenant context + RenderProfileContext, using _side
6 SessionMiddleware Starts the PHP session
7 SettingsMiddleware Loads application settings
8 TrailingSlashMiddleware Normalizes trailing slash before routing
9 SymfonyRoutingMiddleware Matches the route; sets _route, _locale for intl routes, and _route_params
10 LocaleMiddleware Resolves active locale: URL prefix -> session -> tenant default; persists it in session
11 JwtAuthMiddleware Validates k_token cookie or Authorization: Bearer; _route_type=api also accepts personal API tokens; sets User::class on the request
12 AuthMiddleware Redirects protected routes to /login when no authenticated user exists
13 BreadcrumbMiddleware Resolves breadcrumb trail
14 AuthorizationMiddleware Role-based access control (ACL + PolicyVoter)

4. Multi-Tenancy

4.1 Tenant Resolution

flowchart TD
    A[HTTP Request] --> B{TENANT_ENV env?}
    B -->|Set| C[Use TENANT_ENV as host]
    B -->|Not set| D[Extract host from HTTP request]
    C --> E[Load config/tenants/host.json]
    D --> E
    E --> E2{alias_of field?}
    E2 -->|Yes| E3[Load config/tenants/canonical.json]
    E2 -->|No| F
    E3 --> E4{redirect_to_canonical true and HTTP host?}
    E4 -->|Yes| R[301 Location to canonical host]
    E4 -->|No| F
    F[TenantResolver.resolveFromHost] --> G[Tenant object — host = canonical]
    G --> B1[Read theme and default locale from tenant DB]
    B1 --> B2[Resolve active module snapshot]
    B2 --> H[ContainerFactory.init with effective tenant and modules]
    H --> I[Kernel.handle with TenantContext]

TenantContext affects routing, services, configuration, and the search engine per tenant.

4.2 Tenant Resolution Inputs

In local development or single-tenant deployments, TENANT_ENV can pin the active tenant for both web and CLI runtime.

  • Web runtime: if TENANT_ENV is set, it overrides the HTTP Host header.
  • CLI runtime: if TENANT_ENV is empty, bin/console uses localhost.
  • Normal multi-tenant HTTP deployments should leave TENANT_ENV unset so the tenant is resolved from the real host header.
// public/index.php
$tenantEnv = getenv('TENANT_ENV');
$host = ($tenantEnv !== false && $tenantEnv !== '') ? $tenantEnv : $psrRequest->getUri()->getHost();
$tenant = $resolver->resolveFromHost($host);

4.3 Tenant Configuration

{
  "id": "0",
  "database": {
    "driver": "${K0SMOS_DB_DRIVER:-sqlite}",
    "provider": "${K0SMOS_DB_PROVIDER:-}",
    "url": "${K0SMOS_DB_URL:-}",
    "path": "${K0SMOS_DB_PATH:-var/db/0.sqlite}",
    "charset": "${K0SMOS_DB_CHARSET:-utf8mb4}",
    "sslmode": "${K0SMOS_DB_SSLMODE:-}"
  },
  "i18n": { "supported_locales": ["it", "en"] },
  "search": {
    "engine": "sql",
    "provider": "elasticsearch",
    "sql": { "table": "products" }
  },
  "modules": ["front", "media", "ai", "documentation", "theme_default"],
  "module_config": {
    "queue": {
      "redis": { "host": "127.0.0.1", "port": 6379, "db": 0, "prefix": "k0smos:queue" },
      "max_attempts": 3,
      "worker": {
        "slot": 1,
        "reserve_timeout": 30,
        "sleep": 3,
        "max_idle": 300,
        "max_jobs": 100,
        "reclaim_after": 600,
        "reclaim_interval": 60
      }
    },
    "ai": {
      "advanced": {
        "queue": "default",
        "worker_heartbeat_max_age_seconds": 180,
        "python": {
          "binary": "python/.venv/bin/python",
          "script": "python/ai_worker.py",
          "working_directory": "python",
          "timeout_seconds": 120
        }
      },
      "redis": { "host": "127.0.0.1", "port": 6379 }
    }
  },
  "features": {
    "require_change_verification": true
  }
}

The top-level modules array defines the module inventory visible to the software for the tenant. module_config is limited to classified technical, deployment, and immutable operator configuration such as queue/AI worker topology and safety limits. Backoffice-editable Psapi, Wpapi, Esapi, and Google integration settings are DB-backed and tenant JSON is not a runtime fallback. Run tenant:config:audit to reject unclassified keys and see tenant configuration for migration and precedence. An installed tenant may still carry theme.front, theme.admin, i18n.default_locale and i18n.documentation_locale as read-only migration fallbacks. A DB row wins; remove those JSON fields after per-tenant backfill and readback. All seven configured tenant JSON files now omit these editable fields after readback or proven default equivalence. New installs can omit them. The optional compiled JSON cache remains raw and invalidates on a source-content digest, including same-second edits; it never stores the DB overlay. The bundled lite integration tenants have no theme wrappers or Documentation module; their default theme pair and English Documentation fallback can be omitted without a DB theme aggregate. The top-level features block is reserved for bootstrap policies that must be resolved before DB-backed settings and feature flags load. Runtime feature flags belong in enabled_features and are managed from /admin/settings/feature-flags, not in tenant JSON. features.require_change_verification defaults to true when omitted and requires a second verification step for self-service password and email changes. Development tenants can set it to false to keep those changes immediate when mail/TOTP verification is intentionally not part of the local workflow. Maps provider/default viewport settings and Google Maps keys are DB-backed AppSettings values managed from the Maps/Client settings panels; tenant JSON must not include module_config.maps.

Configuration values are classified before storage: bootstrap/environment, deployable application configuration, tenant-editable settings, credentials, and runtime state are separate lifecycles. New tenant settings use the typed schema under src/Application/Settings; runtime cursors, jobs, and health timestamps use migration-backed repositories and are never another settings layer. The accepted decision, layer precedence, OneUptime pilot, and rejected core-taxonomy review are recorded in ADR 0001. Any new core abstraction must pass the two-consumer core promotion review.

Tenant databases permanently support:

  • SQLite
  • PostgreSQL / Supabase
  • MySQL
  • MariaDB

SQLite connections created by ConnectionFactory enable foreign keys, set a 5-second busy_timeout, and open DBAL transactions with BEGIN IMMEDIATE through Database\Sqlite\ImmediateTransactionMiddleware. Queue workers write while imports and requests commit. A deferred transaction that has already read fails immediately with "database is locked" when it later tries to write, because SQLite does not run the busy handler in that case. Taking the write lock at BEGIN lets concurrent writers wait their turn instead.

Values in the database block can use ${ENV} and ${ENV:-default} placeholders. This keeps the tenant file authoritative while cloud credentials and DSNs can live in .env.local or app.env.

Some subsystems have built-in defaults, and a few services also support env fallbacks such as MAILER_DSN; queue transports and tenant databases are primarily driven by tenant JSON, not env variables.

Current convention:

  • modules: complete module inventory visible to the software for this tenant
  • module_config: technical module settings such as queues, Redis, AI, and API clients
  • runtime activation is stored in the tenant database through enabled_modules; all non-theme modules default to enabled, while switchable theme modules default to disabled

For compatibility, legacy tenants are still supported when modules is a technical configuration object or when the module inventory is declared in modules.enabled; internally the repository keeps the inventory in modules and copies legacy technical settings to module_config.

4.4 Domain Aliases

A tenant can be served under multiple hostnames. The canonical hostname is the one whose JSON file contains the full tenant configuration. Every additional hostname is an alias pointer file — a minimal JSON with an alias_of field — placed at config/tenants/{alias-domain}.json:

// config/tenants/k0smos.example.com.json  ← alias pointer
{ "alias_of": "www.k0smos.example.com" }

Aliases can stay transparent, or they can permanently redirect HTTP traffic to the canonical hostname for SEO canonicalization:

// config/tenants/k0smos.example.com.json  ← alias pointer with canonical redirect
{
  "alias_of": "www.k0smos.example.com",
  "redirect_to_canonical": true
}
// config/tenants/www.k0smos.example.com.json  ← canonical, full config
{ "id": "11", "database": { ... }, "modules": [...], ... }

Resolution behaviour

TenantConfigRepository::resolveCanonicalHost(string $host): string follows the alias_of chain (up to 3 hops, loop-protected) and returns the canonical hostname. findByHost(canonicalHost) then loads the full config; the compilation cache (var/cache/tenants/) is keyed on the canonical hostname and is shared across all its aliases. Tenant::$host is always the canonical hostname.

When an alias pointer sets redirect_to_canonical to true, the HTTP entry point returns a 301 Moved Permanently response before container/module bootstrap. The redirect replaces only the host with the canonical hostname and preserves the original scheme, port, path, and query string. TENANT_ENV resolutions never emit redirects, so CLI and container bootstrap flows remain transparent.

Invariants

Property Value
Filename = domain Preserved — every served domain has its own config/tenants/{domain}.json
Lookup complexity O(1) — direct file lookup per hop, no directory scan
Max alias chain depth 3 hops
Cross-platform Yes — no symlinks

Error cases

Scenario Outcome
Alias target file missing RuntimeException — tenant config not found
Loop a → b → a RuntimeException — alias loop detected
Chain exceeds max depth RuntimeException — alias chain too deep
Any file contains invalid JSON RuntimeException

5. Module System

5.1 Modular Architecture

k0smos uses a bounded-context modular architecture where every module owns a specific domain.

src/
├── Module/                                     # Legacy-layout modules
│   ├── Ai/                                     # AI context
│   ├── Blog/                                   # Blog context
│   ├── Info/                                   # Informational/legal pages with admin CRUD and localized front routes
│   ├── Ecommerce/                              # Commerce context
│   ├── Front/                                  # Frontend context
│   ├── Psapi/                                  # PrestaShop integration
│   ├── Translation/                            # Backoffice translation override manager
│   ├── ModuleInterface.php                     # Contract: registerRoutes, getServiceDefinitions, getMenuDefinitions
│   ├── MigratableModuleInterface.php           # Optional: modules with their own DB migrations
│   ├── SearchableModuleInterface.php           # Optional: exposes searchable source class names
│   ├── TemplateAwareModuleInterface.php        # Optional: expose module template paths (admin/front)
│   └── TemplateIntegrationModuleInterface.php  # Optional: contribute to named template slots
├── Application/                                # Cross-cutting service layer
├── Domain/                                     # Cross-cutting entities and domain logic
├── Infrastructure/                             # Cross-cutting repositories and adapters
└── Controller/                                 # HTTP controllers

modules/                                        # Co-located-layout modules
└── {Name}/
    ├── AI.{Name}.md                            # Local AI doc (authoritative)
    ├── README.{Name}.md                        # Human README (optional)
    ├── src/
    │   ├── {Name}Module.php                    # Implements ModuleInterface + optional contracts
    │   ├── Config/
    │   │   ├── module.php                      # ModuleDefinition array (auto-discovered by ModuleCatalog)
    │   │   ├── services.php                    # getServiceDefinitions() payload
    │   │   └── menu.php                        # getMenuDefinitions() payload
    │   ├── Controller/
    │   ├── Service/
    │   └── Routes/routes.php
    ├── templates/
    │   ├── admin/                              # Registered via TemplateAwareModuleInterface (side 'admin')
    │   └── front/                              # Registered via TemplateAwareModuleInterface (side 'front')
    └── migrations/                             # Optional, namespace App\Migrations\Module\{Name}

Modules communicate only through public interfaces (Application Services), PSR-14 events, and HTTP APIs. Both layouts register through the same ModuleInterface and are instantiated by ModuleCatalog::instantiate(): legacy modules are hardcoded in ModuleCatalog::__construct(), co-located modules are auto-discovered from modules/{Name}/src/Config/module.php. PSR-4 autoload for new co-located modules is declared in composer.json (for example K0smos\\Module\\ModbusUi\\ -> modules/ModbusUi/src/).

Modules with their own database schema implement MigratableModuleInterface and expose one or more absolute Doctrine migration paths through getMigrationPaths(). Module migrations live under migrations/{Name}/ with namespaces such as App\Migrations\Info or App\Migrations\Translation; they are executed together with core migrations.

Modules that expose searchable content implement SearchableModuleInterface::getSearchSourceClasses(). The SearchSourceDefinitions calls ModuleRegistry::collectSearchSourceClasses() and resolves every source from the DI container automatically. Adding a searchable module must not require manual wiring in ContainerFactory.

InfoModule is the current reference for a content module with dedicated backoffice. It implements MigratableModuleInterface, uses the module_info table with a unique (slug, locale) key, exposes localized front routes under /info/{slug} and the public aliases /privacy-policy and /cookie-policy, and seeds generic it/en content for the policy pages through migrations.

TranslationModule manages tenant-scoped translation overrides from the backoffice. It keeps translation/{domain}.{locale}.php files as the fallback catalogue and stores database overrides in module_translation_overrides. ContainerFactory decorates the file-backed Symfony translator only when the module service definitions register TranslationOverrideRepositoryInterface. The admin surface lives at /admin/translations, and writes invalidate the decorator cache after save, delete, import, and domain reset operations.

AnonymizerModule is a standalone runtime module separate from Ai, with a dependency on media. It exposes:

  • GET /admin/tools/anonymizer
  • POST /api/admin/anonymizer/settings
  • POST /api/admin/anonymizer/upload
  • GET /api/admin/anonymizer/status
  • GET /api/admin/anonymizer/history
  • GET /api/admin/anonymizer/entity-presets
  • GET /api/admin/anonymizer/debug-text
  • GET /api/admin/anonymizer/download
  • GET /api/admin/anonymizer/mapping
  • POST /api/admin/anonymizer/normalize
  • POST /api/admin/anonymizer/delete

POST /api/admin/anonymizer/upload accepts a PDF (anonymizer_file or file), an optional anonymization_engine (opf, presidio, hybrid, ollama) and an optional workflow_mode. The standalone frontend supports sync, which uses the configured remote HTTP v1 endpoint as an async job API: submit PDF, persist the returned request_id, poll status, and archive the final PDF only after the backend reports result_available = true.

Remote authentication uses only Authorization: Bearer <token> when a token is configured. The client must never send tenant_id to the remote FastAPI backend; tenant ownership remains enforced by k0smos request history and the server-side token.

On startup and after settings save, the admin UI asks the backend for runtime capabilities through GET /v1/anonymizer/llm/status and GET /v1/anonymizer/ocr/status, and it loads backend-managed document/entity presets through GET /v1/anonymizer/entity-presets. Engine selectors are built only from engine_modes_available; OCR selectors are built only from ocr_strategies_available. The backend default engine is hybrid, but clients initialize from recommended_engine because the backend may recommend presidio when optional LLM providers are unavailable. opf is a local text engine and is selectable only when engine_modes_available contains it; it is never an OCR strategy. Upload requests send the resolved available engine explicitly instead of relying on an implicit backend default.

Additional upload fields are forwarded as multipart form fields:

  • language: it, en, fr, es (default it in the UI)
  • ocr_strategy: one of the runtime-exposed OCR strategies; omitted when the OCR selector is not shown
  • retain_mapping: sent as "true" only when enabled
  • entity_preset: explicit backend preset id or auto
  • document_type: backend category/alias or auto
  • entities, custom_redact_list, custom_allow_list: JSON string arrays
  • placeholder_policy: deterministic_by_type or legacy_type_only

Standalone responses keep a normalized JSON payload with:

  • request_id
  • requested_engine
  • anonymization_engine
  • workflow_mode
  • status
  • current_step
  • next_step
  • warnings
  • warnings_count
  • result_pdf_available
  • result_pdf_download_url
  • requested_entity_preset
  • requested_document_type
  • resolved_entity_preset
  • entity_preset_source
  • effective_entities

When the remote request is queued, the route returns 202 with queued = true and poll_after_ms; the record is later resumed through the standalone status/history/download/mapping/normalize/delete routes until the backend exposes the final PDF, which is archived in Media.

Standalone JSON errors always return at least error, code, and details. The payload also keeps useful top-level fields when available, such as request_id, current_step, next_step, failure_reason, or retry_after_seconds. Remote 400 validation failures, 401/403 auth failures, 404 missing requests or non-retained mappings, 409 download-before-completion, 410 expired mappings, and 429 rate limits are preserved as structured client errors. Remote/Python 5xx responses are mapped to local 503. The admin UI can distinguish invalid PDF, invalid remote token, rate limits, missing media.view, backend workflow failures, unavailable OPF runtime/checkpoint, and early download attempts without fragile parsing.

The standalone admin UI can resume an existing request by manually entering a request_id or selecting it from local history. Both paths use the same GET /api/admin/anonymizer/status route, show a readable remote-step timeline, and restart automatic polling when the request is not terminal.

The UI also exposes operational diagnostics: effective endpoint, auth mode (Bearer token active or no application header), client timeout, SSL policy, last observed request_id, and the last sanitized raw error snapshot for the session. Closing the visual error banner does not clear this snapshot; it is cleared only by a successful operation.

Configuration explicitly separates application auth from demo/admin fallback. Tenant-scoped settings store remote endpoint, auth_mode (none or bearer), token, timeout, and SSL policy. The UI also shows effective_endpoint_url, but this value can fall back to the local /v1/anonymizer/submit suggestion only for diagnostics or manual admin requests; it does not make the tenant "configured" by itself. The authenticated public surface never uses this implicit fallback: in sync mode, it requires an actually saved endpoint.

For deeper support, admins can enable an optional debug panel that keeps the last sanitized request payload and metadata for the last standalone API response. The setting is browser-scoped per host through localStorage (k0smos.anonymizer.debugPanel.<host>); when the runtime is in debug, the UI also unlocks a separate verbose toggle (k0smos.anonymizer.debugVerbose.<host>). Even in verbose mode, displayed content remains sanitized and never exposes raw bearer tokens, file binary content, extracted text, anonymized text, custom redact/allow list values, or mapping originals.

Operator-only text diagnostics are available for locally visible completed or failed requests through GET /api/admin/anonymizer/debug-text?request_id=... with stage=pre|post, proxying the backend debug/pre-text and debug/post-text endpoints. The UI renders pre/post text in the debug panel with page navigation, treats remote 404 as not-yet-available or not-found depending on local visibility, and keeps returned text volatile in browser memory only. Debug text, PDF content, extracted/anonymized text, mapping originals, and bearer tokens must not be logged, persisted to localStorage, or sent to third-party monitoring.

The default template is hardened for narrow viewports: upload, workflow resume, history, final result, configuration, and debug panels use stacked or full-width action groups on mobile, while diagnostic/debug snapshots remain horizontally scrollable to avoid breaking layout.

To simplify resume and support, the "Resume request" card stores the last observed request_id values in localStorage under the host-scoped key k0smos.anonymizer.requestIds.<host>. This local list is separate from server-side history: it survives refreshes and new browser sessions, can quickly resume a known request, and is cleared when the user explicitly deletes that request from the standalone UI.

When final archiving succeeds, result_pdf_download_url points to GET /api/admin/anonymizer/download?request_id=..., which checks media.view and redirects to the protected content registered in Media (/admin/media/{id}/content). If media.view is missing, the standalone route returns 403 with code = ANONYMIZER_MEDIA_VIEW_REQUIRED. The sync response also mirrors diagnostic headers: X-Anonymizer-Request-Id, X-Anonymization-Engine, X-Anonymizer-Status, and X-Anonymizer-Warnings-Count.

The standalone status, history, download, mapping, normalize, and delete routes operate only on local history for remote v1 requests, updating it on demand through the remote backend and Media metadata when the final PDF has already been archived. Mapping export is available only for requests submitted with retain_mapping = true; normalize streams a restored PDF from an uploaded anonymized PDF plus either the local request_id or an exported mapping JSON. The module still does not depend on src/Module/Ai/*.

Alongside the admin surface, the standalone module exposes an authenticated public surface under /api/ai/anonymizer/*: POST /requests, GET /status, GET /history, GET /requests/{request_id}, GET /requests/{request_id}/mapping, GET /requests/{request_id}/result, GET /download, POST /normalize, POST /delete, and DELETE /requests/{request_id}. Public JSON payloads remap result_pdf_download_url, request_detail_url, result_metadata_url, mapping_download_url, normalize_url, and media.* to the public namespace, without leaking /admin/media/*. GET /api/ai/anonymizer/requests/{request_id}/result is the dedicated JSON endpoint for warnings and final metadata; GET /api/ai/anonymizer/history supports page, per_page, status, workflow_mode, request_id, and q; GET /api/ai/anonymizer/download streams the PDF as a direct attachment and requires only anonymizer.view, not media.view.

5.2 Creating A Module

All runtime modules use the co-located layout under modules/{Name}/. src/Module/ contains shared contracts, registration infrastructure and shared bounded-context support; it is not an alternate runtime-module layout. ModuleCatalog::all() derives its catalog from module declarations.

Co-located layout:

modules/YourModule/
├── src/
│   ├── AI.YourModule.md
│   ├── README.YourModule.md
│   ├── CHANGELOG.md
│   ├── YourModule.php
│   ├── Config/
│   │   ├── module.php               # ModuleDefinition array (auto-discovered)
│   │   ├── services.php             # PHP-DI definitions
│   │   └── menu.php                 # Optional menu definitions
│   ├── Routes/
│   │   └── routes.php
│   ├── Controller/
│   │   └── YourController.php
│   ├── Service/
│   │   └── YourService.php
│   └── Infrastructure/
│       └── DbalEntityRepository.php
├── templates/
│   ├── admin/                       # Resolved via TemplateAwareModuleInterface
│   └── front/
└── migrations/                      # Optional, namespace App\Migrations\Module\YourModule

modules/YourModule/src/Config/module.php must return the definition array (code, name, description, class, order; mandatory, default_enabled, depends_on optional) and is auto-discovered by ModuleCatalog::discoverCoLocatedDefinitions(). PSR-4 for new co-located modules is declared in composer.json, for example "K0smos\\Module\\YourModule\\": "modules/YourModule/src/".

Keep the module PSR-4 mapping in the root Composer package and use the shared lock file and vendor/. Do not register module classes manually in the catalog or add nested Composer packages.

ModuleInterface:

// src/Module/ModuleInterface.php
interface ModuleInterface
{
    public function registerRoutes(RouteCollection $collection): void;
    public function getServiceDefinitions(): array;   // PHP-DI definitions
    public function getMenuDefinitions(): array;       // Menu items by channel
}

Optional extension interfaces:

// Exposes Doctrine migration paths for auto-discovery
interface MigratableModuleInterface
{
    /** @return array<string, string> ['Namespace' => '/absolute/path'] */
    public function getMigrationPaths(): array;
}

// Exposes searchable content sources
interface SearchableModuleInterface
{
    /** @return array<class-string<SearchSourceInterface>> */
    public function getSearchSourceClasses(): array;
}

// Exposes module-owned template directories merged into the Plates hierarchy
interface TemplateAwareModuleInterface
{
    /** @return array{admin?: string, front?: string} */
    public function getTemplatePaths(): array;
}

// Contributes partials to named template slots without patching owner templates
interface TemplateIntegrationModuleInterface
{
    /** @return list<\K0smos\Theme\TemplateIntegration> */
    public function getTemplateIntegrations(): array;
}

Steps (co-located layout):

  1. Read AI.md, agents/rules.md, and any local module/shared-service AI docs that apply.
  2. Create modules/YourModule/ with the src/, templates/, and (optional) migrations/ subtrees.
  3. Add the PSR-4 autoload entry to composer.json and run composer dump-autoload.
  4. Implement ModuleInterface and optional interfaces (TemplateAwareModuleInterface to expose templates, TemplateIntegrationModuleInterface for slot partials, MigratableModuleInterface, SearchableModuleInterface).
  5. Declare the module in modules/YourModule/src/Config/module.php (auto-discovered).
  6. Define routes in modules/YourModule/src/Routes/routes.php.
  7. Implement the Domain layer (Entity, ValueObject, Repository interface).
  8. Implement Application Services as orchestration.
  9. Implement Infrastructure Repositories through DBAL.
  10. Implement Controllers that delegate to services and return ViewModel or ResponseInterface.
  11. If the module has admin UI, define src/Config/menu.php with permissions and translation keys.
  12. If the module has admin UI, seed or migrate module permissions and admin grants.
  13. If the module owns persistent schema, add Doctrine migrations under modules/YourModule/migrations/ (namespace App\Migrations\Module\YourModule).
  14. If the module needs a menu icon, add a semantic icon entry to the active theme manifest, such as template/default/asset/icons/icons.php.
  15. Write tests (Unit + Integration where the blast radius warrants it).

Legacy modules follow the same steps but declare the module in ModuleCatalog::__construct() instead of Config/module.php, and keep templates under the active theme tree.

Controller Pattern - orchestrate, do not implement domain logic:

// The Controller delegates to the service.
public function create(ServerRequestInterface $request): array
{
    $data = $request->getParsedBody();
    return $this->service->create($data['name']);
}

5.3 DDD Patterns

Pattern Description
Entity Objects with identity, mutable, domain core
Value Object Immutable objects without identity
Aggregate Cluster of entities/values as an atomic unit
Domain Event Communication between bounded contexts
Repository Interface in Domain, implementation in Infrastructure

Best Practices:

  • use final readonly class for Value Objects
  • always use Dependency Injection; avoid new inside constructors for dependencies
  • return Value Objects, not primitives, where the domain benefits
  • use declare(strict_types=1) in every PHP file
  • follow PSR-12 coding style

The migration-backed ToDo module is an example of separating aggregate writes from reporting reads: its ACL-protected /admin/todos/reporting dashboard uses a dedicated query repository for operational totals, 30-day throughput and effort, 90-day cycle time, and sprint progress. Cycle time remains unavailable until at least three completed tasks form a meaningful sample. Kanban moves serialize on the target status, rebuild collision-safe sparse ranks in one transaction, and return the authoritative status, rank, and optimistic version to JSON clients. Backlog reordering follows the same sparse transactional model. Inline move clients can provide Idempotency-Key; actor-scoped responses are retained in module_todo_mutation_requests and replayed without applying the mutation twice.


6. Theme System

6.1 Theme Resolution

At request and command startup, the narrow tenant DB reader loads one theme.selection row before module resolution and container construction. It contains front, admin and an opaque revision. A valid row is authoritative; an absent row uses the legacy JSON pair or bundled defaults. A malformed row fails closed and can be repaired through the core recovery command. The effective tenant and resolved active modules travel together into the container, including email and queue consumers.

SideDetectionMiddleware selects the front/admin side for actual routes, including localized admin and kiosk paths. The dormant RouteThemeResolver is not wired into production; its legacy override contract needs a separate decision before nonempty theme.overrides can become an editor.

The core tenant-theme panel changes both sides together with a revision check. Only public themes in the explicit compatibility table are front choices; default and sober are admin choices. A target wrapper and dependencies must already be active, and its Vite manifest must be deployed. Switching themes preserves the other active wrappers, their routes, and their content. The isolated Default → Sober browser check is reproducible with tools/benchmarks/tenant-theme-smoke.mjs after both assets are built.

Canonical bundled full theme: default (Tailwind CSS 4 + Alpine.js 3). sober is the bundled admin-first theme for dense backoffice work. Other repository themes include frontend themes such as pc1 and cc1; override is used for local overrides.

Route attributes:

$defs[] = [
    'name' => 'my_route',
    'path' => '/my-path',
    'defaults' => [
        '_controller' => MyController::class,
        '_theme' => 'front|admin|utility',
        '_template_group' => 'group_name',
        '_route_type' => 'html|api',
    ],
];

6.2 Template Organization

template/
├── default/tpl/         # Tailwind CSS 4 + Alpine.js 3 - canonical full theme
│   ├── front/           # Public pages
│   ├── admin/           # Admin dashboard (+ admin/ai/, admin/modbus.tpl.php)
│   ├── auth/            # Login/auth
│   ├── spa/             # Main SPA template
│   ├── invoices/        # Invoice management
│   ├── phpdoc/          # Documentation viewer
│   └── debug/           # Debug utilities
├── sober/tpl/           # Admin-first theme: admin layout, dashboard, partials
└── ...

Templates use PHP Plates with layout/view/partial separation. Icon handling uses naming conventions and automatic logging of missing icons through Monolog/DebugBar.

The canonical default public navigation keeps dropdowns visually compact: desktop submenu entries use a single-line label, content-aware width, and a 36px minimum row height. Mobile entries may be slightly roomier for touch, but must not inherit the dense admin navigation contract. The dedicated .front-submenu* rules live in template/default/asset/app.css; avoid generic menu selectors that also alter backoffice navigation.

6.3 Public Module Template Compatibility

Co-located templates under modules/{Name}/templates/front/ are the canonical public module contract. A public theme can render them through the resolver or provide an explicit fork under template/{theme}/tpl/front/, but it must keep a front layout, the shared --k-* token bridge, Alpine 3 when required, and a CSS pipeline that sees the module markup.

Use php bin/console theme:audit-modules {theme} to inspect active module templates. missing means the theme has no explicit fork, while present-identical and present-forked distinguish a canonical mirror from a native adaptation. The maintained full-coverage themes are verified with:

php bin/console theme:audit-modules cc1 --fail-on-missing
php bin/console theme:audit-modules dm1 --fail-on-missing
php bin/console theme:audit-modules pc2 --fail-on-missing

All three audits report missing=0 (dm1: 21 forks; cc1: 19 forks and 2 identical copies; pc2: 21 forks). The Tax calculator is an identical Tailwind-compatible fork in cc1 (whose source scan already includes module templates), a Bootstrap-native fork in dm1 and a plain-CSS fork in pc2. pc3 is audited separately and reports missing=14: twelve are the Ecommerce surfaces the compatibility policy deliberately suppresses for that theme, and the remaining two use the resolver fallback.

dm2 reports missing=20, present-forked=1. Its missing entries are an intentional canonical-template policy, not unsupported pages: render-through- ThemeEngine coverage verifies the real Front contact/social, Info, Page, Documentation and Blog payloads inside the DM2 shell, including one-heading and top-level scripts-section contracts. Canonical Contact, Info, Documentation and Blog index declare their page-owned main landmark through local Plates layout metadata. DM2 delegates that landmark while retaining its styled, focusable skip target; home, scene routes, Page and Blog articles keep the layout-owned main. Default and DM1 retain their existing canonical markup. IT/EN keyboard source previews confirm the layout and Documentation skip links at desktop/mobile widths. The inline mobile menu also preserves focus across responsive breakpoints, and the compact booking action retains its accessible name. Gallery arrows continue from the image reached with Tab. The loopback-only tools/benchmarks/dm2-keyboard.mjs probe covers these behaviors, room controls and dialog focus return. Nine source-preview routes pass mobile landscape and real Chromium 200% page zoom in IT/EN under reduced motion, without horizontal overflow. Without JavaScript, the same routes retain their navigation and static content; the canonical contact page explains the form requirement and offers an email alternative only when a valid public company email is configured. The footer explains OS-enforced reduced motion, and manual pause persists across reloads. Media initially deferred by reduced motion now initialize without a reload when motion becomes eligible, exactly once and without bypassing manual pause or provider consent. Synthetic lifecycle and stubbed-provider checks are not real BFCache/provider approval. Ecommerce is the exception and core suppresses it while theme.front=dm2. Enabling ThemeDm2 explicitly publishes its high-priority Studio, Brands and Inspirations routes; equal Page slugs are therefore shadowed until that module is disabled. The DM2 theme supplies its own scoped rich-text and documentation styles in addition to scanning canonical module templates for Tailwind utilities. This keeps Page/legal prose, code and tables readable and switches Documentation from a desktop navigation grid to its mobile disclosure without forking module templates. A synthetic IT/EN source preview covered contact, privacy, Page and Documentation at six widths with no document overflow or runtime errors. The document shell also wraps long titles and public paths, while preformatted Blog/Page content scrolls inside its own bounded block. A further 72 synthetic IT/EN source-preview cases cover populated/empty Blog lists and HTML/plain articles across six widths, landscape and no-JavaScript desktop/mobile views. Localized links, plain-text escaping and loaded images remain intact; the header liquid effect and media sources are outside these document-only rules.

The dcm fixture code also supplies DM2 home content: three independent localized sections for showroom facts, four illustrated environments and four service steps, after the video experience. ContentFixture registers the legacy DM1 pack and the DM2 companion only when their owning runtime modules are active; selecting the fixture remains independent of theme.front. New slots can populate an existing home without overwriting its edited content or hero. Inspirations displays category totals and the selected image position/title; mobile arrows and swipes share the same selection, keyboard Home/End work, and closing the image viewer returns focus to the last viewed image. The supplied photos are classified by their inspected subject despite reversed filenames.

The DM2 liquid gesture is provider-independent: it reveals local client photography through hardware WebGL or a Canvas 2D fallback. Its stock cover uses the locally hosted Cucine LUBE Brera photograph and a light Deco wordmark reconstructed from the supplied logo. The same SVG bounds feed the static heading and liquid texture. Previous bundled cover URLs resolve to Brera only within this presentation; custom widget media/titles and insert-missing data ownership remain intact. See template/dm2/MEDIA-SOURCES.md for attribution. Its bounded input model forms one broad connected fluid body with attached organic lobes and a tapered directional throw; both renderers retain the same morphology instead of falling back to thin strokes or isolated dots. The supplied local film is a progressive layer used by the liquid hero. The scrolling projection uses its own editable exact-host YouTube link. Its screen now has a neutral local fallback independent of the liquid hero's photo, with a direct YouTube link and a deliberate Klaro consent action when needed; no provider thumbnail is fetched before consent. Once visible with consent granted or unmanaged, the YouTube iframe loads paused, then the controller mutes and starts it without a visitor click. It accepts YouTube's infoDelivery playing state, handles player errors and keeps the iframe visually active beneath an opaque local fallback until playback is confirmed. The mobile player remains at least 200 px high. Autoplay rejection retains the fallback. Inspirations uses an accessible circular gallery; its mobile scroll-snap rail remains independently scrollable without widening the page, and long localized headlines fit narrow viewports. The closed native image dialog reserves dimensions but has no image source until opened. Once opened it covers the whole viewport with the photograph centred above its caption bar, and the empty area around the photograph closes it like the backdrop it replaces. The current local film path is a temporary theme asset; the approved production master still has to be imported through Media. When a page supplies no structured-data graph, DM2 emits conservative localized schema.org JSON-LD for WebSite, the current WebPage and its five visible navigation destinations. Organization is included only when the tenant supplies company.name, with optional configured logo, email, legal address and VAT ID. The graph uses request/canonical URLs and never infers products, offers, prices or availability; page-owned structured data takes precedence. The Brands page now ends with a localized price-list consultation block linking to Front's canonical contact form through the exact-whitelist price-list context. It offers guidance rather than displaying prices, availability or invented downloads. DM2's header and Studio consultation actions use the related consultation context; both lead to an editable four-field POST whose validation, CAPTCHA and worker-aware delivery behavior remain unchanged. The large invitation appears only on the localized contact page; other DM2 routes retain a compact legal/consent footer. Management/site destinations are not implemented yet.

Delivery status (2026-09-13): the owner reports the latest DM2 compilation complete. This report is distinct from aggregate theme verification, measured bundle budgets, target-tenant activation/imports and real hardware/provider approval. The recorded source baseline is 40 Node tests and 55 template tests (331 assertions). Final Media import, populated Blog real-zoom/admin-preview checks, intrinsic-image/layout-shift and cross-browser verification remain open. The durable behavior and owner acceptance contract live in template/dm2/AI.theme.md and modules/ThemeDm2/src/AI.ThemeDm2.md.

module-{name}::front/... is a supported delegation target, and a theme may use it for an intentional thin adapter. The alias is derived from the directory holding templates/, so ThemeEngine::moduleTemplateAlias() canonicalizes the declared path before splitting it: every getTemplatePaths() returns __DIR__ . '/../templates/{side}' from a class under src/, and the retained src/.. segment previously made the alias resolve to the empty string — registration was skipped for every module, and any delegating template raised The template folder "module-x" was not found at request time. cc1 renders its twelve Ecommerce surfaces exclusively through those adapters, so its whole shop answered 500 until the path was canonicalized; ThemeEngineTest::testNamedModuleFolderIsRegisteredForAnUncanonicalizedTemplatePath pins it.

A thin adapter also needs the theme's own CSS to stay out of the utilities' way. Tailwind v4 emits utilities into @layer utilities, and unlayered CSS always beats layered CSS whatever the specificity. A theme that keeps an element reset such as *{margin:0;padding:0} unlayered therefore zeroes every px-*/py-*/m*-* utility on the canonical module templates it renders — the page still returns 200 and merely loses all spacing, which is why an audit cannot catch it. Keep resets inside @layer base; keep [x-cloak] and .sr-only unlayered so they still win.

Two independent reasons a fork must own its markup outright rather than delegate, both of which a missing=0 audit cannot detect on its own:

  • Styling. The Documentation reader's canonical template uses docs-* classes that exist only in the default bundle, so neither a source scan nor an identical copy is enough. A Tailwind @source scan compiles utilities, never hand-written components, so a theme relying on the fallback must also declare any component class the canonical markup uses (.erp-input, .sr-only).
  • View data. A fork that guesses key names renders an empty page while the route still returns 200. Verified contracts: Documentation supplies $rendered (RenderedDocument->html) and a DocumentationNavigationItem tree, never $body/$content; PageFrontController supplies page.rendered_html and page.rendered_css, never page.body; BlogPostViewDataFactory::forCard() supplies slug, never url/path. Check the controller, not a plausible name.

pc1 is not extended because it is scheduled for replacement, and fhg1 remains outside edits during its transition.

Enabling a new runtime module changes this audit. A module that ships a front template makes every full-coverage theme report missing=1 until its fork lands, so re-run every audit whenever the module inventory changes — the check is part of "done", not a periodic chore.

flowchart TD
    Controller --> PlatesEngine
    PlatesEngine --> Layout
    PlatesEngine --> View
    PlatesEngine --> Partial
    PlatesEngine --> IconResolver[Icon Resolver + Logger]

6.4 Shared Theme Contracts

Capabilities that more than one theme needs live in src/Theme/, never duplicated per theme and never as a theme-to-theme dependency. A theme module keeps only what is genuinely its own: the widget type code, the admin editor partial, and its markup. Their DI wiring is core-owned too — the hero normalizers in RenderingDefinitions, alongside HookPositionRegistry — so no theme declares them in its own services.php, where two themes would otherwise overwrite each other in module order.

Contract Location Owns
Hook positions src/Theme/HookPosition, HookPositionRegistry Positions each active module/theme declares for the widget editor
Hero media src/Theme/Hero/* Hero and hero-slider payload, clamping, provider video, validation
Widget locale src/Theme/Widget/WidgetRenderLocale The locale a structured widget renders live content in
Storefront width src/Theme/Layout/FrontContentWidth, FrontContentMode The tenant-wide width of the public content shell

Admin sidebar submenu memory. Which submenus an operator left open is an operator preference, shared by both admin shells through template/shared/asset/js/admin/sidebar-menu-state.js and the single key k0smos.admin.menu.expanded.v1 in localStorage, alongside density, sidebar collapse, tooltips and the accordion blocks. One key across themes is deliberate: both shells render the same ACL-filtered menu with the same stable node ids, so switching theme keeps the operator's menu. An explicit choice wins over navigation — a group the operator closed stays closed when they open a page inside it, and the trail highlight marks the path instead. Every read and write is individually guarded, because reaching localStorage can throw and so can getItem: losing a preference is acceptable, losing the sidebar is not.

Admin token bridge. Admin templates are written once and rendered by both admin themes, so they name every colour through the shared --k-* bridge and both themes must declare every token any of them references. This is not a graceful-degradation question: an undefined custom property invalidates the whole declaration at computed-value time, so a missing token paints a transparent button rather than a differently-coloured one. Alongside the core tokens the bridge carries the semantic aliases admin pages use directly — --k-primary, --k-on-primary, --k-input-bg, --k-surface-card, --k-surface-sidebar, --k-surface-alt, --k-surface-strong, --k-success-soft, and the --k-{success,warning,danger,info}-border family. Fixed Tailwind palette utilities (text-red-600, bg-slate-100) and literal semantic hexes are contract violations because they ignore both the tenant palette and the light/dark mode; var(--k-token, #fallback) stays correct, and palette swatches, whose job is to preview a colour, stay literal.

Three surfaces the browser paints itself need an explicit instruction, because none follows the theme on its own: accent-color on checkboxes and radios, without which they render in the browser's blue whatever palette the tenant chose; an explicit background and colour on select option, because styling a <select> never reaches its OS-level popup; ::file-selector-button, which otherwise keeps the platform's own "Choose file" button; and ::placeholder, whose browser default is a fixed grey that ignores both palette and mode. Both, and the bridge itself, ship inline through admin/partial/_admin_token_fallback_css and admin/partial/_admin_controls_fallback_css so they hold before the next maintainer-run bundle build. tests/Unit/Theme/AdminThemeTokenContractTest.php guards the source and node tools/benchmarks/admin-tokens-smoke.mjs resolves every referenced token in Chromium for both themes, failing on a transparent button or a vanished border.

Both bundles were rebuilt and verified against the shipped CSS on 2026-09-16: every referenced token resolves, a primary-backed control paints in the tenant accent (default) and in the sober primary (sober), semantic borders survive, and the checkbox accent, select popup, file-input button and placeholder all read from the theme. The added rules stay well inside the theme asset budgets — default 41.9 KB gzip against a 60 KB ceiling, sober 14.1 KB against 20 KB.

Admin heading contract. The backoffice has one heading grammar shared by default and sober. An admin template declares a role — .k-page-title (h1), .k-panel-title (h2), .k-section-title (h3), .k-page-eyebrow/.k-eyebrow for uppercase micro-labels, plus .k-page-head/.k-page-head-main/.k-page-actions for the header row — and each admin theme renders that role in its own idiom: default in Inter at 1.875/1.125/1rem, sober in Sora 26px for the title and dense 16/14px below it. Tailwind typography utilities (text-*, font-*, tracking-*, leading-*, uppercase) on an admin heading are a contract violation: they override the theme rule and reintroduce per-page fonts. Layout utilities stay on the element, and panels keep the inline var(--k-surface)/var(--k-border) styles that the shared accordion detects.

Shared admin controls and partials. Module admin pages and the default admin templates are written once and rendered by both admin themes, so they use only classes and partials both themes provide:

  • Controls: k-input, k-select and k-textarea (k-input--sm), and k-btn with k-btn--primary, --ghost, --danger and --sm. They are declared in each theme's foundation CSS and shipped inline through admin/partial/_admin_controls_fallback_css, inside @layer components, so a per-instance utility such as w-28 still applies. The retired erp-* classes existed only in default (sober forms lost their borders) and erp-btn in neither; erp-input remains only as a storefront bridge.
  • Partials (default versions, which sober overrides in its own idiom or inherits):
    • _table: columns field => label or ['field', 'label', 'html', 'align']; html cells are pre-escaped by the caller;
    • _filters, _empty_state and _badge (ok, warning, critical, info, neutral);
    • _breadcrumb;
    • _pagination (page, pageCount, total, first, last, pageUrl);
    • _confirm_dialog: a native modal <dialog> around a CSRF-protected POST, replacing window.confirm.
  • Forms: _field (a labelled input, textarea or select with hint and server/async errors, .k-field-error), _id_picker (records picked by search as chips, posting name[]; .k-picker, .k-chip), _html_field (HTML with a formatting toolbar and a server-sanitized preview, .k-prose). _form_runtime, inserted once per page, provides the page toast and the Alpine behaviours kAdminForm (async submit, field errors, k-saved event), kIdPicker, kHtmlField and kCrudDrawer (table rows edited in a k-drawer).
  • Plates helpers money(amount, currency), number(value, decimals) and datetime(value, 'datetime'|'date'|'time') format for the current locale.
  • _page_header accepts a trail (breadcrumb), a back link and a meta slot (string or list) rendered as .k-page-meta. The slot is not called breadcrumbs: the kernel shares a route-derived breadcrumbs array with every template, and a partial parameter with that name would render it on every page.
  • Data workspace classes for dense operational lists, roomy in default and dense in sober: k-stats/k-stat (KPI tiles, data-tone danger, warning, info or success, is-active), k-table-shell, k-table-scroll and k-table (th[aria-sort], is-num, is-muted, tr.is-selected, k-table-sub), k-bulkbar, k-drawer (a right-side modal <dialog> with k-drawer-header, -body and -footer), k-field and k-field-hint, k-segmented (buttons with aria-pressed), k-toast (data-tone), k-timeline and k-delta (is-up, is-down). The Ecommerce inventory workspace is the reference page.

tests/Unit/Theme/AdminComponentContractTest.php guards the contract:

  • no erp-* class in admin templates;
  • every k-* class in a shared template is declared by both themes, unless the template defines it in its own <style>;
  • no sober-* class outside sober's own templates;
  • colours in style attributes only as var(--k-*, fallback), with a shrinking baseline of existing offenders;
  • no counts in .k-page-desc.

AdminHeadingContractTest also rejects font-family utilities on headings; wrap a code in <code> instead.

admin/partial/_page_header.tpl.php is the canonical page opening in both themes and AdminPanelExtension::panelHeader() emits the panel roles. The two subtitle roles are deliberately distinct: .k-page-desc and .k-panel-desc are micro-help, which sober hides and which therefore require a sibling tooltip() carrying equivalent content, while .k-page-meta carries record identity, counts and operational values — data, not help — and stays visible in every theme with tabular figures. Each theme mirrors the rules into tpl/admin/partial/_admin_typography_fallback_css.tpl.php, inserted by admin.tpl.php, so the contract holds before the next maintainer-run bundle build, exactly like the settings-action fallback. /kiosk is outside the contract because it owns a self-contained shell and never renders inside admin.tpl.php. tests/Unit/Theme/AdminHeadingContractTest.php enforces role coverage in both themes, rejects heading typography utilities, keeps each inline fallback aligned with its compiled stylesheet, and fails a .k-page-desc without a tooltip carrier. node tools/benchmarks/admin-heading-smoke.mjs is the browser contract: it extracts the real fallback partials, renders the canonical header shape and a module page shape, and measures in Chromium that both resolve to identical typography inside a theme — default at 30/18/16/12px in Inter, sober at 26/16/14/11px with the title on Sora — while the description and meta visibility policies hold.

Panel geometry follows the same principle from the theme side. Module admin pages are authored against default and spell their corner radius with Tailwind utilities, so sober inherited 12px, 16px and 24px panels side by side, against its own dense single-scale contract. sober therefore maps rounded-xl/rounded-2xl/rounded-3xl onto --sober-radius-lg for elements matching the documented admin surface convention — an inline style carrying both var(--k-surface…) and var(--k-border), the same signal _panel_accordion uses — scoped to body[data-theme-side="admin"] main, excluding pills and panels that deliberately flatten a corner. default keeps the radii its pages were authored with; normalising those is a design decision, not a defect, and must not be done by editing module templates.

Hero media. HeroMediaNormalizer turns raw widget data into HeroSlide and HeroSliderOptions value objects and back into the persisted array payload, so every theme hero stores the same keys. VideoSourceNormalizer resolves local, YouTube, and Vimeo sources into a VideoSource carrying a sanitized provider id, a privacy-friendly embed URL, and an explicit requiresConsent flag — front partials must keep provider embeds behind the tenant consent manager. Slide types are text, image, video_background, and video; validation messages are shared, and each theme only supplies the "no slide configured" message. dm1 renders it as a Bootstrap carousel (dm1_media_slider), lts1 as a single editorial hero (lts1_hero). A theme that auto-advances its hero must also expose a pause control: rotation that starts on its own and runs longer than five seconds needs a mechanism to stop it, and a framework's pause-on-hover/focus is not one — it never reaches a touch visitor and unwinds as soon as focus leaves. dm1 renders that toggle only when autoplay is actually configured and treats the visitor's pause as authoritative over its viewport and video handlers.

Widget locale. A widget row carries two locale notions: locale is the row's targeting locale (empty means "every language"), while request_locale is the locale actually being served, propagated to every row by WidgetRenderer. Live content — catalog names, category labels — must follow the request, so WidgetRenderLocale::resolve() applies request_locale → locale → tenant default. For read-side rows that carry no catalog entity, CatalogLocalizationService::localizeProductNames() and localizeCategoryNames() translate a name map in one query per entity type and keep the base name whenever a translation is missing.

Storefront content width. Public pages no longer hardcode a page-level max-width. FrontContentWidth reads two tenant AppSettings values — theme.front_content.mode (fluid | fixed) and theme.front_content.max_width (clamped to 960..2400 CSS px, default 1440) — and normalizes anything out of range or unparseable back to the documented default, so a corrupted setting degrades instead of collapsing the layout.

Unlike the admin content width, which is a per-browser localStorage preference, this value governs what anonymous visitors see. It is therefore resolved server-side: PublicSettingsViewDataProvider exposes it as the frontContent view-data key, and every front layout stamps it onto <html> as data-k0smos-front-content plus the --k-front-content-max-width custom property. No client script participates, so there is no width flash on first paint.

Templates consume it through .front-content-shell in template/shared/asset/css/front-content-shell.css, imported by every theme entrypoint. The primitive carries two custom properties per tier: --k-front-content-scale preserves each page's width proportion relative to the configured base (the previous Tailwind steps expressed as fractions of 6xl), while --k-front-content-ceiling is an absolute stop so a single-column form widens with the theme but never exceeds a readable measure. Tiers, widest to narrowest: base (catalog, product page, account overview), --roomy (orders), --medium (cart, invoice), --narrow (checkout), --tight (order confirmation), --compact (registration), --form (login). Below 768 px every tier returns to full width.

Themes that already own a single site-wide container wire that container to the token instead of tagging each template: dm1 overrides Bootstrap .container at the xxl breakpoint, pc2 rewrites .pc2-container, cc1 its .cc1-container/.cc1-nav-inner/.cc1-footer-inner/.cc1-hero-inner, and pc1 its two .art-shop-* shells, and pc3 its centred .pc3-shell. Because the shared stylesheet is imported outside Tailwind's utilities layer, .front-content-shell beats any max-w-* utility on the same element — do not add it to an element that relies on a responsive max-w-* override.

Operators edit the value in the front_content panel of /admin/theme/{code}/settings. That route carries no _permission, so the panel receives an explicit frontContentWritable state derived from system.write or the bounded delegated settings.front_content permission and renders read-only without either. Saving goes through GET/POST /api/settings/front-content, which accepts the matching broad or delegated permission, clamps the payload server-side and returns the value actually stored, so the panel always reflects persistence rather than the local draft. The shared panel also accepts optional theme-owned translated description, tooltip and note keys when a theme has a deliberate full-width exception. DM2 uses that seam to state that the setting controls editorial sections, footer and document pages while its header and liquid hero remain viewport-wide; its admin overview repeats the same scope.

cc1 hero equalizer and vinyl. The cc1 homepage restores its original decorative 72-band Canvas equalizer inside the hero, preserving the established geometry, pink-to-violet gradient, glow, amplitude and tempo. It is absent from non-home routes, pauses in hidden tabs, avoids duplicate frame chains, and renders one static frame under reduced motion. Separately, every cc1 public route owns one pointer-inert lateral CSS vinyl: its outer stage keeps stable 3D perspective while its inner face follows absolute page scroll through one passive, RAF-coalesced write and performs no idle work. Reduced motion fixes the record at its base angle. The shared vinyl layer intentionally contains no Canvas/SVG edge waveform or full-viewport grid/frame. Both controllers are idempotent and independently clean up listeners and frames; neither uses audio or microphone access, and the Contact page's scoped WebGL lattice remains independent.

External-theme chrome identity. cc1 groups its locale and color-scheme switchers as adjacent controls at the end of both desktop and mobile public menus. The pc1, pc2 and pc3 homepage document titles are sourced only from the DB-backed company.name; an empty value produces an empty title instead of substituting the theme code, framework name, or theme-owned copy. Explicit titles on content routes remain unchanged.

Shared pc portfolio content. ContentFixture owns the pc1/pc2/pc3 family's editable IT+EN portfolio source through the pc package. It contributes three generic complexity tiers — pc_full, pc_simple, and pc_title — through the active-module widget-extension contract, exposes their localized admin editors through the canonical default/sober template hierarchy, and validates links before rendering. Version-neutral pc_* hooks share CV facts across compatible themes while keeping pc2 hero/capabilities/skills/stack/CTA and pc3 projects/method in distinct slots. Each public theme maps the same normalized payloads to native markup under front/_widget/{type}.tpl.php; no fixture stores theme HTML.

PC3 uses the same tenant content-width shell for its homepage and public chrome. Its desktop navigation wraps on a separate row; mobile navigation collapses after hydration and restores focus on Escape. Native locale and nested module disclosures remain usable without JavaScript. Light/dark mode is available at 320px, tolerates blocked browser storage, and maintains readable contrast in the inverted capability and contact sections. Since 0.30.0+1 the theme keeps every label at 0.72rem or above, restores the editorial prose rhythm for Page/Info/Blog bodies, composes {page} · {company} titles, emits a default canonical, complete Open Graph/Twitter metadata, noindex on auth and error pages, and a theme-built schema.org graph (Organization with social sameAs and capability knowsAbout, WebSite, typed WebPage, plus BlogPosting, TechArticle, BreadcrumbList and ItemList nodes) built only from tenant settings and rendered content. See template/pc3/AI.theme.md for the head contract, source-preview verification and the maintainer-owned production build gate.

6.5 Template Data Contract

Controller and active-module data cross the Plates/JSON boundary through the immutable contracts in src/Rendering/TemplateData. The request-scoped TemplateDataComposer owns framework root variables, rejects core-reserved or duplicate module/page keys with both owners in the diagnostic, and normalizes declared presentation values without exposing domain objects or services.

LegacyTemplateDataAdapter temporarily preserves existing raw ViewModel arrays as direct Plates variables during the documented deprecation window. Declared payloads have identical HTML/JSON shapes, while unsupported top-level HTML objects fail instead of receiving an accidental data wrapper. Front providers are registered only from active modules, and flash consumption goes through SessionStore rather than the rendering layer reading $_SESSION.

The complete root inventory, module migration example, pilot list, and release window are in the Template data contract.


7. Menu System

7.1 Menu Architecture

flowchart TD
    A[Active runtime modules] -->|menu definitions| B[SystemMenuSyncService]
    L[FrontMenuEntryProviderModuleInterface] -->|typed storefront entries| M[FrontMenuEntryCatalog]
    M -->|validated entries| B
    J[Backoffice menu library] -->|custom CRUD| C[(menu_collections/menu_items/menu_placements)]
    B -->|upsert system menus| C
    C --> D[MenuPlacementResolver]
    D --> E[MenuRenderer]
    E -->|active-module block resolution| K[MenuBlockResolverRegistry]
    K -->|ACL + feature filter via MenuBuilder| F[Filtered MenuItem array]
    F --> G[PHP Plates Template / menu_hook()]
    F --> H[Menu API - JSON]
    F --> I[MenuSerializer]

Core Components:

Component Namespace Purpose
MenuItem K0smos\Menu\MenuItem Immutable DTO (id, label, route/path, optional parentKey, children, permission, feature, icon, order, resolved block metadata/data)
MenuHookRegistry K0smos\Menu\MenuHookRegistry Registry of available hooks for menu placements
SystemMenuSyncService K0smos\Application\Menu\SystemMenuSyncService Syncs system menus from runtime modules into persistent storage
FrontMenuEntry K0smos\Domain\Menu\FrontMenuEntry Typed storefront entry a module offers: route, menu block or group, default visibility, supersedes
FrontMenuEntryCatalog K0smos\Application\Menu\FrontMenuEntryCatalog Validates offered storefront entries against the live route table and block schemas; logs rejections
SystemMenuSettingsService K0smos\Application\Menu\SystemMenuSettingsService Operator payload and saves for the system menu panel (visibility, order, owning module, availability)
DbalMenuRepository K0smos\Infrastructure\Menu\DbalMenuRepository Persistent repository for collections, items, and placements
MenuPlacementResolver K0smos\Application\Menu\MenuPlacementResolver Resolves active menus assigned to a specific hook
MenuRenderer K0smos\Application\Menu\MenuRenderer Rebuilds the menu tree for a hook/channel, applying order and placements
MenuBlockResolverRegistry K0smos\Application\Menu\MenuBlockResolverRegistry Resolves persisted typed blocks through active-module providers with unique type ownership
MenuBuilder K0smos\Menu\MenuBuilder Filters by permissions/features and recursively sorts
MenuSerializer K0smos\Menu\MenuSerializer Versioned JSON serialization for APIs; v2 includes item_type, block_type, and resolved block_data
MenuApiController K0smos\Controller\MenuApiController Read-only API endpoints for system channels (/api/menu/front, /api/menu/footer, /api/menu/back)

System channels: front, footer, back, user.

Menu hooks: front.primary, front.footer, front.sidebar, admin.sidebar.primary, admin.sidebar.secondary, admin.topbar, and admin.user.

System menus start from module declarations: typed storefront entries for front/footer (§7.2.1) and Config/menu.php for back/user. They are materialized in the database and then rendered through placements. Custom menus are created only from the backoffice and can be assigned to one or more hooks.

Current PHPUnit coverage for the menu subsystem includes MenuHookRegistry, FrontMenuEntryCatalog, SystemMenuSyncService (defaults, operator overrides, module deactivation, supersedes), SystemMenuSettingsService, MenuPlacementResolver, MenuRenderer, DbalMenuRepository, MenuApiController, and the route/theme contract for /api/menu/*.

7.2 Menu Definition

Modules declare backoffice menus (back, user) in Config/menu.php, returning an array keyed by system channel. Storefront menus (front, footer) use typed entries (§7.2.1); the front/footer keys of Config/menu.php are still read as a compatibility adapter, and a typed entry with the same id wins. Persistent sync keeps every channel updated while leaving current order/visibility/placements to storage.

Menu definitions can declare an explicit parent key. This is the preferred way for runtime modules to attach under stable shared roots owned by Front (for example api, tools, settings, or user.theme_settings) instead of re-declaring those roots in multiple modules.

For the admin sidebar, Front owns the static-first top-level anchors: dashboard, users, workspace, content, business, api, tools, and settings. User-scoped theme settings use the separate system.user channel rendered by the admin.user hook.

// modules/Wpapi/src/Config/menu.php
return [
    'back' => [
        [
            'id'         => 'wpapi',
            'parent'     => 'api',
            'label'      => 'menu.back.wpapi',
            'route'      => 'admin_wpapi_settings',
            'icon'       => 'wpapi',
            'order'      => 10,
            'permission' => 'wpapi.view',
            'children'   => [
                [
                    'id'         => 'wpapi.settings',
                    'label'      => 'menu.back.wpapi.settings',
                    'route'      => 'admin_wpapi_settings',
                    'permission' => 'wpapi.manage',
                    'order'      => 10,
                ],
            ],
        ],
    ],
];

Persistence rules applied by SystemMenuSyncService on every sync:

  • Untouched items follow the declaration. When an item's stored current visibility (or order) equals its stored default, a changed declaration updates both. Changing order or defaultEnabled therefore reaches existing tenants.
  • Operator choices win. When an operator changed visibility or order in /admin/settings/menus, the stored value is kept whatever the declaration says.
  • Inactive modules keep their rows. A system item whose module is no longer active stays stored with menu_items.is_available = 0: rendering skips it (findByHook loads only available rows) and the panel lists it as unavailable and read-only. When the module returns, the item becomes available again with the operator's choices intact.

7.2.1 Storefront entries (typed contract)

A module offers storefront entries by implementing FrontMenuEntryProviderModuleInterface and returning FrontMenuEntry value objects:

public function getFrontMenuEntries(): array
{
    return [
        FrontMenuEntry::route('shop.front', 'menu.front.shop', 'shop_front', order: 100, defaultEnabled: true, icon: 'shop'),
        FrontMenuEntry::route('shop.account', 'menu.front.account', 'shop_account', order: 850, defaultEnabled: true, supersedes: ['front.login']),
        FrontMenuEntry::route('shop.cart', 'menu.front.cart', 'cart_front', order: 860),
        FrontMenuEntry::route('footer.shop', 'menu.front.shop', 'shop_front', FrontMenuEntry::CHANNEL_FOOTER, order: 120, defaultEnabled: true),
    ];
}
  • Targets. route() names a route rather than a URL. block() renders a typed menu block with a default payload. group() is a label that other entries nest under via parentId, and a group without visible children never renders.
  • defaultEnabled is the visibility an operator starts from. New entries should start off (false) unless they are essential, so activating a module never changes a published storefront by itself. Entries migrated from Config/menu.php kept their former default.
  • supersedes lists entries of other owners that start hidden while this entry exists. For example, the customer account replaces the backoffice front.login / footer.login on shops. Like any default, it never overrides an operator's explicit choice.

FrontMenuEntryCatalog accepts a declaration only when:

  1. its route is a public page: registered, answering GET, outside /admin, /kiosk, /api and /preview, without _auth_required, _permission* or _route_type: api defaults, and without required path parameters other than _locale;
  2. its block type is registered for the menu context and the default payload validates;
  3. its parent is an accepted group of the same channel (declared by any module);
  4. its id is unused and its module offers at most FrontMenuEntryCatalog::MAX_ENTRIES_PER_MODULE (12) entries.

A rejected declaration is dropped, not repaired, and logged once per request as Front menu entry rejected.

Shipped entries:

Module front footer
Front front.home (on), front.tools group (on), front.contact (on), front.login (on) footer.home, footer.contact, footer.login (on)
Tax front.tools.tax_calculator under front.tools (on) —
Documentation front.documentation (on) —
Blog blog.index (on) footer.blog (on)
Ecommerce shop.front (on), shop.catalog catalog tree block (off), shop.new_products new products block (off), shop.account (on, supersedes front.login), shop.cart (off), shop.wishlist (off) footer.shop (on), footer.account (on, supersedes footer.login)

The system menu panel in /admin/settings/menus shows one tab per channel. Storefront channels list three sections: entries in the menu (tree order), entries available but switched off (grouped by the offering module), and entries of inactive modules (unavailable, read-only).

7.3 Filtering And Building

MenuBuilder algorithm:

  1. Recursion: filter submenus first.
  2. Permission / feature on current node: evaluate parent access.
  3. Parent without access and without visible children: discard it.
  4. Parent without access but with visible children: keep it as a non-clickable group and remove only the route.
  5. Cleanup: remove items without route and without children.
  6. Sorting: by ascending order, then alphabetically by id.

Both permission AND feature checks must pass for visibility. Menu visibility does NOT guarantee access: controllers must always verify permissions.

This behavior ensures every installed and active backoffice module remains reachable in the sidebar when the user owns at least one allowed child action.

URL generation by channel / hook:

  • front.* channels/hooks use the intl_ route variant when the locale is active, for example /it/blog. Internal custom paths (item_type: path) take the tenant locale prefix through LocalizedUrlGenerator (§12.3); external and already prefixed paths are unchanged.
  • Admin hooks always use canonical routes and are never locale-prefixed, for example /admin/dashboard, not /it/admin/dashboard. Admin language is session-driven.

Theme rendering of the primary menu (default and lts1):

  • An entry with a panel (a menu block, or a parent with children) renders through front/_partials/nav-disclosure. When the entry has a page (its path, or the block's root url), the label is a real link and a separate chevron opens the panel. Parents therefore stay clickable and crawlable. Without a page the label itself toggles.
  • Desktop panels are native <details> elements: they work without JavaScript, close with Escape (focus returns to the toggle), on an outside click or when focus leaves, and open after a short hover intent on devices with a hover pointer. Children that have children render as columns.
  • On mobile each panel is a button with aria-expanded / aria-controls, and grandchildren become nested disclosures.
  • Blocks render through front/_partials/menu-block. ecommerce.* blocks use their dedicated partial; any other module's resolved block renders through the generic contract (heading, links: [{label, url}]), so a new block type needs no theme change. Resolvers may add a top-level url: the block's root page, linked from the menu label.
  • lts1's nav wraps onto a second line instead of scrolling, because a scroll container clipped the panels. Default keeps its single-row bar, which hides entries behind the search box when a menu is very long.
// Application usage
$permChecker = new UserPermissionChecker($authChecker, $user);
$featChecker = new UserFeatureFlagChecker($featureFlagResolver, $user);
$items = $menuRenderer->renderSystemChannel('back', $permChecker, $featChecker);
return view('dashboard', ['menu' => $items]);

UserFeatureFlagChecker delegates to FeatureFlagResolver, which resolves global (per-tenant) flags — not per-user ones. The bound $user is forwarded for future per-user resolution but is ignored today. See § 18.2 "Scope and evaluation paths".


8. Error Handling

flowchart TD
    EB[ErrorBootstrap.register] -->|global handler| MW[ErrorHandler Middleware - Stratigility]
    RID[RequestIdMiddleware] -->|generate incident_id| MW
    MW -->|catch exception| ERG[ErrorResponseGenerator]
    ERG -->|Accept: json| JSON["RFC7807 Problem+JSON"]
    ERG -->|Accept: html| HTML["HTML Template + Whoops"]
    JSON --> R[Response]
    HTML --> R
    EHL[ErrorHandlerListener] -->|log with context| MON[Monolog Logger]
    EHL -->|optional 5xx/4xx capture| GT[GlitchTip / Sentry SDK]
    MON --> LOG[app.log]

Components:

Component Namespace Purpose
ErrorBootstrap K0smos\Bootstrap\ErrorBootstrap Registers the global error handler (Symfony ErrorHandler)
HttpException K0smos\Http\Error\HttpException Application exception with status codes and headers
ErrorResponseGenerator K0smos\Http\Error\ErrorResponseGenerator Generates HTTP responses (RFC7807 / HTML)
ErrorHandlerFactory K0smos\Http\Error\ErrorHandlerFactory Creates Stratigility ErrorHandler with incident_id tracking
ErrorHandlerListener K0smos\Http\Error\ErrorHandlerListener Logs errors with rich context and reports configured GlitchTip events
RequestIdMiddleware K0smos\Middleware\RequestIdMiddleware Generates a unique incident_id (16 bytes hex)
NotFoundHandler K0smos\Http\Error\NotFoundHandler Handles unmatched routes

Usage:

throw new HttpException(403, 'User not found');
throw new HttpException(429, 'Rate limited', ['Retry-After' => '3600']);

Production JSON response (APP_DEBUG=0):

{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "incident_id": "a1b2c3d4e5f6g7h8",
  "timestamp": "2026-02-06T12:34:56+00:00"
}

Development (APP_DEBUG=1): adds detail, exception, and trace fields. HTML responses use a Whoops page.

Logging context includes: incident_id, method, uri, status, exception_class, tenant_id, route, user_id.

Incident tracking: every error response includes X-Incident-Id as a support reference.

Feature Development Production
Stack trace Shown Hidden
Exception detail Shown Generic message
Whoops page Yes, if installed No
Debug bar Yes No
Incident ID Shown Shown
Logging Full context Full context

9. Queues And Jobs

The canonical operator guide is doc/public/en/operations/queues.md: k0smos has one queue subsystem, two alternative transports, and two shipped logical queue names (default and notifications), normally requiring one worker per name and tenant. The runtime QueueTopologyRegistry is the authoritative producer inventory; queue:topology --host=<tenant> renders its effective names, expected process count, and heartbeat diagnostics as text or JSON. --host selects the tenant; --format=json changes standard output for automation and does not select or contact a server. Detailed Redis-to-SQL failover, worker-aware fallback coverage, systemd examples, and the reproducible on-demand source inventory are tracked in the canonical guide instead of a second occurrence document. Redis/live-worker smoke checks that require a dedicated environment are listed in doc/public/en/operations/queues.md § "Environment-dependent Smoke Checks".

9.1 Queue Architecture

flowchart TD
    Controller -->|dispatch typed message| MB[MessageBusInterface]
    MB -->|serialize| QMS[QueueMessageSerializer]
    QMS -->|enqueue JobInterface| RQ[("Queue transport: SQL default / Redis opt-in")]
    RQ -->|reserve| QW[QueueWorker]
    QW -->|lookup handler| JHR[JobHandlerRegistry]
    JHR -->|bridge typed jobs| QMJH[QueuedMessageJobHandler]
    QMJH -->|resolve| MHR[MessageHandlerRegistry]
    MHR -->|dispatch| MH[MessageHandlerInterface]
    MH -->|execute| DS[DomainService]
    JHR -->|legacy jobs| JH[JobHandlerInterface]
    JH -->|execute| DS
    DS -->|result| Result
    QW -->|ack/fail| RQ
    OS[systemd/signals] -->|SIGTERM/SIGINT| SSH[SignalShutdownHandler]
    SSH -->|graceful stop| QW

Components:

Component Namespace Purpose
QueueManagerInterface K0smos\Domain\Queue Queue manager contract (push, reserve, ack, retry)
MessageBusInterface K0smos\Domain\Queue Application-level typed async API
QueueMessageInterface K0smos\Domain\Queue Contract for serializable messages
DbalQueueManager / DbalQueue K0smos\Infrastructure\Queue\Dbal SQL transport (default) — stores jobs in queue_jobs
RedisQueueManager / RedisQueue K0smos\Infrastructure\Queue Redis transport (opt-in primary)
FailoverQueueManager / FailoverQueue K0smos\Infrastructure\Queue Redis-driver SQL fallback wrapper
QueueWorker K0smos\Application\Queue Main worker loop (transport-agnostic)
JobHandlerInterface K0smos\Application\Queue Low-level job handler contract (JobInterface)
MessageHandlerInterface K0smos\Application\Queue Typed handler contract (QueueMessageInterface)
JobHandlerRegistry K0smos\Application\Queue Registry for low-level handlers and generic bridges
MessageHandlerRegistry K0smos\Application\Queue Registry for typed message handlers
QueueMessageSerializer K0smos\Application\Queue Serializes/deserializes typed messages into JobInterface
QueuedMessageJobHandler K0smos\Application\Queue Generic bridge from worker to typed message handler
SignalShutdownHandler K0smos\Application\Queue Controlled shutdown on SIGTERM/SIGINT
QueueTopologyRegistry K0smos\Application\Queue Active producer metadata, effective logical queues, expected worker tuples, and read-only diagnostics
QueueTopologyCommand K0smos\Command Text/JSON operator view exposed as queue:topology

Transport selection (SQL default, Redis opt-in)

The async transport is pluggable. QueueWorker, AsyncMessageBus, and queue:work depend only on QueueManagerInterface/QueueWorkerHeartbeatInterface, so the transport is chosen per tenant from module_config.queue.driver:

queue.driver Manager Heartbeat Storage
database (default, also used when the key is absent) DbalQueueManager DbalQueueWorkerHeartbeat tenant DB tables queue_jobs, queue_worker_heartbeats
redis FailoverQueueManager (RedisQueueManager primary, DbalQueueManager fallback) FailoverQueueWorkerHeartbeat Redis structures under the queue prefix, with SQL fallback in queue_jobs / queue_worker_heartbeats

Rationale: the tenant database is always present, so async always has a working backend without Redis. This removes the previous hard Redis dependency for notification emails, AI tasks, and deadline jobs (which had no synchronous fallback and were lost when Redis was down). Redis stays available as an opt-in transport for high-volume tenants; when selected, dispatch/reserve/reclaim and heartbeat operations fall back to SQL if Redis cannot be resolved or an operation fails. Jobs already stored only in Redis are not migrated into SQL. AI advanced-mode reply-task and attachment-processing registries follow the same driver selection: DBAL is used for the default database driver; Redis driver tenants use lazy Redis registries with DBAL fallback and mirrored SQL state so Redis outages do not make advanced chat require Redis.

SQL transport notes:

  • Delayed jobs are pending rows with a future available_at; no separate structure is needed.
  • reserve() claims a job atomically with a conditional UPDATE ... WHERE status='pending', portable across SQLite/MySQL/PostgreSQL. It is non-blocking: the worker's idle-sleep governs the poll cadence (Redis still uses blocking pops).
  • Retries (fail while attempts remain) requeue the row; exhausted attempts move it to status='failed' (dead-letter). reclaimStale() requeues rows stuck in reserved past the timeout.
  • Repositories fail fast via SchemaGuard with a php bin/console migrate instruction when the tables are missing.

9.2 Mini Typed Async Message Bus

The project supports two levels:

  • preferred level: MessageBusInterface + QueueMessageInterface
  • low-level transport: QueueInterface + JobInterface

The worker still consumes only JobInterface; the typed mini-bus uses a generic bridge.

$messageBus->dispatch(
    new SendContactMailMessage(
        fromAddress: 'noreply@k0smos.example.com',
        fromName: 'k0smos',
        to: 'hello@k0smos.example.com',
        subject: 'Contact',
        textBody: 'Hello',
    ),
    new MessageDispatchOptions(queue: 'default'),
);

Standard envelope metadata in the typed bridge:

  • tenant_id
  • message_class
  • message_payload
  • correlation_id
  • max_attempts
  • delay_seconds
  • dispatched_at
  • available_at

9.3 Creating Handlers And Messages

Recommended path:

final readonly class SendEmailMessage implements QueueMessageInterface
{
    public function __construct(
        public string $to,
        public string $subject,
    ) {}

    public function toPayload(): array
    {
        return ['to' => $this->to, 'subject' => $this->subject];
    }

    public static function fromPayload(array $payload): static
    {
        return new self($payload['to'], $payload['subject']);
    }
}
final readonly class SendEmailHandler implements MessageHandlerInterface
{
    public function __construct(private EmailService $emailService) {}

    public function messageClass(): string
    {
        return SendEmailMessage::class;
    }

    public function handleMessage(QueueMessageInterface $message): void
    {
        assert($message instanceof SendEmailMessage);

        $this->emailService->send($message->to, $message->subject, 'Hello');
    }
}

Fallback / legacy compatibility:

$queue = $queueManager->queue('default');
$job = new Job('send-email', ['to' => $email, 'subject' => 'Welcome', 'body' => 'Hello!']);
$queue->dispatch($job);

Use JobInterface directly only for:

  • existing legacy integrations
  • purely infrastructural jobs
  • temporary compatibility during incremental refactors

9.4 Notification Pipeline

The Notification bounded context uses the queue in a hybrid way:

  • InApp is synchronous and immediately persists into module_notifications
  • Email is asynchronous and dispatches SendNotificationEmailMessage on the notifications queue
  • /api/settings/notifications exposes email_queue_worker_active, derived from the heartbeat table/transport for the configured email queue
  • user preferences are stored in module_notification_preferences

Current event sources:

  • TicketAssigned
  • TicketStatusChanged
  • TicketCommentAdded
  • ProjectOwnerAssigned

Authenticated REST API:

  • GET /api/notifications
  • GET /api/notifications/unread
  • GET /api/notifications/preferences
  • POST /api/notifications/preferences
  • POST /api/notifications/read-all
  • POST /api/notifications/{id}/read

Important constraints:

  • tenant scope is mandatory on storage and queries
  • email templates are rendered only with PHP Plates
  • email translations use the notification domain
  • template locale aligns with the user's preferredLocale when available
  • notification email workers must consume the same queue stored in notifications.channels.email.queue, for example TENANT_ENV=k0smos.example.com php bin/console queue:work --queue=notifications
  • producer-side queue sync fallbacks create throttled in-app admin notifications of type queue.sync_fallback, stored without using the email queue

9.5 Worker Lifecycle

flowchart TD
    A[Worker starts] --> B[Poll configured queue transport]
    B -->|no job| C[Sleep N seconds]
    C --> D{Shutdown signal?}
    D -->|No| B
    D -->|Yes| E[Log + Exit gracefully]
    B -->|job found| F[Reserve job]
    F --> G[Lookup handler in Registry]
    G --> H[Execute bridge or low-level handler]
    H -->|success| I[ACK - remove from queue]
    H -->|failure| J{attempts < max?}
    J -->|Yes| K[Retry with delay]
    J -->|No| L[Retain as dead letter + log/notify]
    I --> D
    K --> D
    L --> D

Best Practices:

  • Prefer typed messages because they are more stable than anonymous arrays.
  • Keep handlers idempotent so retries are safe.
  • Use atomic payloads with self-contained data, not fragile references to DB records that may change unexpectedly.
  • Use meaningful job names, for example send-welcome-email, not task1.
  • Protect long work with timeouts, for example set_time_limit(300).

9.6 systemd Integration

Copy-ready host-native examples live under doc/example/systemd/. One instance maps to exactly one tenant/logical-queue/slot tuple:

k0smos-queue-worker@k0smos.example.com-default-1.service
/etc/k0smos/queue-worker-k0smos.example.com-default-1.env

The environment file sets TENANT_ENV, QUEUE_NAME, and QUEUE_WORKER_SLOT. When QUEUE_WORKER_ID is unset, the generated identifier is k0smos.example.com.default.1. Encoding the queue fixes the former tenant.slot ambiguity across multiple lanes.

Run queue:topology --format=json before enabling units. The exact rule is one instance for every distinct effective logical queue with an asynchronous producer. Multiple job types do not add units; a second slot for capacity does:

sudo systemctl enable --now \
  k0smos-queue-worker@k0smos.example.com-default-1.service \
  k0smos-queue-worker@k0smos.example.com-notifications-1.service

The systemd guide documents the full migration/permissions checklist, slot 2 scaling, optional per-tenant target, deployment restart order, status/journal commands, and graceful SIGTERM behavior. The bundled Docker Compose worker profile covers both shipped lanes.


10. AI And LLM Integration

10.1 AI Architecture

flowchart TD
    Controller --> CS[ChatService]
    CS --> CMR[DbalChatMemoryRepository]
    CMR --> DB[(Tenant DB)]
    CS --> CCB[ChatContextBuilder]
    CS --> AI[AiClientInterface]
    AI --> CD[CachingDecorator]
    CD --> LD[LoggingDecorator]
    LD --> HP[HTTP AI Provider]
    HP --> OLL[Ollama]
    HP --> OAI[OpenAI]
    HP --> ANT[Anthropic]
    HP --> ORT[OpenRouter]
    HP --> NIM[NVIDIA NIM]
    HP --> ATL[AtlasCloud]
    HP --> KMI[Kimi/Moonshot]
    HP --> SNV[SenseNova]
    HP --> GGL[Google AI]
    OLL --> Resp[Response]
    OAI --> Resp
    ANT --> Resp
    ORT --> Resp
    NIM --> Resp
    ATL --> Resp
    KMI --> Resp
    SNV --> Resp
    GGL --> Resp
    Resp --> Controller

Components:

Component Namespace Purpose
AiClientInterface App\AI Generic contract for AI text generation and chat
AiProviderFactory App\AI\Provider Selects the active HTTP provider from tenant-scoped configuration
AiProviderSettingsService App\AI\Provider Resolves serialized provider profiles, global/scope/offload routing, endpoint, model, API key, and system prompt from AppSettings
AiScopeSettingsService App\AI\Provider Stores AI scopes that route only to enabled serialized integration clients
ChatService App\AI\Chat Stateful chat orchestrator
ChatMemoryRepository App\AI\Chat Message persistence interface
DbalChatMemoryRepository App\AI\Chat Default driver-agnostic DBAL implementation
ChatReplyTaskRegistry App\AI\Chat Advanced reply task state; DBAL by default, Redis+DBAL failover when queue.driver=redis
ChatAttachmentProcessingRegistry App\AI\Document Advanced attachment pending state; DBAL by default, Redis+DBAL failover when queue.driver=redis
ChatContextBuilder App\AI\Chat Builds the conversation for AiClientInterface (system prompt + last 20 messages + summary)
CachingDecorator App\AI\Decorators Caches LLM responses (24h TTL)
LoggingDecorator App\AI\Decorators Logs requests/responses with timing

AI providers are configured from /admin/settings/ai through serialized integration clients. Registry version 2 stores auth mode, connection status, optional account metadata, and masked credentials. OpenAI and Anthropic support API-key clients and externally obtained workload-identity access tokens. ChatGPT Plus/Claude consumer account sessions are listed as unsupported metadata and are rejected for runtime API use because provider docs do not expose them as API credentials. Local connect/disconnect/test API actions manage stored credential state; they do not perform OAuth or token exchange.

SenseNova uses its OpenAI-compatible Chat Completions endpoint with bearer API-key authentication and non-streaming requests. The catalog defaults to https://token.sensenova.ai/v1 and sensenova-6.8-flash-lite; operators may override either value per integration client. The wire contract is documented by the official SenseNova API reference.

10.2 Chat Service

ChatService manages stateful multi-turn conversations: it loads history, builds context, calls the LLM, and saves messages.

// Send a message and get a reply
$result = $this->chatService->reply($chatId, $userInput);
// Returns: ['content' => string, 'created_at' => string]

// Retrieve conversation history
$history = $this->chatService->history($chatId, limit: 20);

Memory backend:

  • Default: DbalChatMemoryRepository, persisted via Doctrine DBAL Connection and compatible with SQLite/PostgreSQL/MySQL/MariaDB
  • Multi-tenant: chat memory is tenant-isolated by the tenant database

Tenant technical configuration, preferably under module_config.ai:

{
  "module_config": {
    "ai": {
      "history_limit": 20,
      "chat": { "max_prompt_length": 32000 },
      "advanced": {
        "queue": "default",
        "worker_heartbeat_max_age_seconds": 180,
        "python": {
          "binary": "python/.venv/bin/python",
          "script": "python/ai_worker.py",
          "working_directory": "python",
          "timeout_seconds": 120
        }
      },
      "upload": { "max_file_size_mb": 20, "max_extracted_characters": 12000 },
      "rate_limit": { "reply_per_minute": 10, "document_upload_per_minute": 5 }
    }
  }
}

10.3 Decorator Pattern

flowchart LR
    Client -->|generateText/chat| CD[CachingDecorator]
    CD --> LD[LoggingDecorator]
    LD --> HP[HTTP AI Provider]
    CD -->|cache hit generateText| CachedResult
// Stack: CachingDecorator wraps LoggingDecorator, which wraps the active HTTP provider.
$llm = new CachingDecorator(
    new LoggingDecorator($providerClient, $logger),
    $cache
);

Additional patterns:

  • Rate Limiting through Redis (llm:ratelimit:{userId}, configurable hourly)
  • Prompt Sanitization (removes sensitive patterns and limits length)
  • Cost Tracking (tracks input/output tokens and per-model cost)
  • Graceful Degradation when the LLM is unavailable

10.4 Future Integrations To Evaluate

The current runtime uses native k0smos HTTP adapters behind AiClientInterface. The following items are candidate integrations only: they are not active dependencies and must be evaluated through a focused spike before being added to composer.json, DI wiring, tenant settings, or production documentation.

Candidate Evaluation Scope
LLPhant Assess whether it should return as an optional application-layer adapter for chat, embeddings, retrieval, or tool orchestration. Compare its provider coverage, maintenance status, streaming/tool-call support, error handling, and fit with the existing AiClientInterface contract before any runtime adoption.

Any future AI integration must preserve tenant-scoped provider settings, explicit auth-mode validation, prompt-context boundaries, and the current direct-provider fallback path so one library cannot become a hidden single point of failure.


11. Search Engine

Optional vector retrieval is a disposable projection over canonical module/SQL content. Qdrant is the reference adapter; the active Documentation module is the only current public allowlist and lexical search remains the unconditional fallback. Tenant filters, embedding identity, deletion state, privacy rules, rebuild command, and the offline evaluation gate are documented in Vector search reference and ADR 0002.

A runtime graph database was evaluated separately and is not adopted. The Client/Project/Ticket traversal remains a bounded indexed SQL query; the synthetic proof measured 0.571 ms p95 and did not justify a duplicate Neo4j projection. The reproducible evidence, operational comparison, and reopening gates are in Runtime graph database evaluation and ADR 0003. This runtime decision is unrelated to the repository-analysis tool documented in Graphify.md.

The search system has two independent levels:

  1. Low-level backend (SearchEngineInterface) - SQL / Elasticsearch, tenant-configurable.
  2. High-level content sources (SearchSourceInterface) - pluggable and auto-discovered from modules.

11.1 Backend: SearchEngineInterface

flowchart TD
    TC[TenantContext] --> SC[SearchConfig]
    SC -->|engine=sql| SQL[SqlSearchEngine]
    SC -->|engine=elasticsearch| ES[ElasticsearchEngine]
    SQL --> QR[SearchResult]
    ES --> QR

The backend is configured in tenant JSON (search.engine). SqlSearchEngine queries the products table by default; the table is configurable through search.sql.table.

Key types:

Type Purpose
SearchEngineInterface Executes searches on the backend
SearchQuery Term, page, perPage, filters, sort
SearchContext Tenant ID, sources, locale, metadata
SearchResult Status, engine, hits, total, meta
SearchHit id, source, title, snippet, score, attributes

Built-in providers implement the additive SearchCapabilityProviderInterface and declare a SearchCapabilities value for optional behavior such as typo tolerance, prefix matching, highlights, facets, suggestions, multi-source reads, and sorting. Modern and SQL engines attach the effective declaration to SearchResult::meta; consumers must use that metadata instead of inferring behavior from the provider name. When modern search falls back to SQL, the fallback result keeps SQL's conservative capability declaration. Custom providers that have not yet opted into the additive contract remain compatible and receive a conservative all-optional-features-disabled declaration.

Built-in product search engines normalize requests through SearchFieldContract. It allow-lists category/currency and inclusive price filters plus relevance/name/price/updated sorting; unrecognized fields never reach provider query syntax. Requested category_id and currency facets are capped at 100 values per field and normalized in SearchResult::meta.facets as ordered value/count rows. SQL grouped counts, Elasticsearch/OpenSearch aggregations, Meilisearch facet distributions and Typesense facet counts therefore expose one response shape. If a provider does not declare requested facet or sort support, ModernSearchEngine selects SQL before calling it. With no fallback it removes only unsupported options and reports degraded_capabilities.

11.2 Content Sources: SearchSourceInterface

Modules expose searchable content by implementing SearchSourceInterface. A source knows which context it supports (front or admin) and owns its query logic.

// src/Domain/Search/SearchSourceInterface.php
interface SearchSourceInterface
{
    public function getName(): string;            // 'blog', 'products'
    public function getLabel(): string;           // 'Blog', 'Products'
    public function supports(string $context): bool;  // 'front' | 'admin'
    public function getListingPath(): ?string;    // '/blog', '/admin/catalog'
    /** @return SearchHit[] */
    public function search(string $term, int $limit): array;
}

A source that answers in the admin context reaches records the public site never shows, so it also implements PermissionAwareSearchSourceInterface and names the permission its results require:

// src/Domain/Search/PermissionAwareSearchSourceInterface.php
interface PermissionAwareSearchSourceInterface extends SearchSourceInterface
{
    public function requiredPermission(): ?string;   // 'page.view', or null when public
}

AdminSearchController skips every admin source the current user is not granted, and every admin source that declares no permission at all. A backoffice search therefore never returns more than the listing page its hits link to would; a new admin source without a declaration returns nothing until it makes its authority explicit.

Built-in implementations:

Class Context Table Notes
BlogSearchSource front module_blog Queries published posts via DBAL
PageSearchSource front, admin module_pages The admin context also matches drafts and requires page.view
ProductSearchSource admin products Delegates to SearchEngineInterface; requires ecommerce.view

11.3 Registry And Auto-Discovery

SearchSourceRegistry aggregates sources. Its factory resolves them dynamically from ModuleRegistry:

// src/Application/Search/SearchSourceRegistry.php
final class SearchSourceRegistry
{
    public function __construct(SearchSourceInterface ...$sources) {}

    /** @return SearchSourceInterface[] */
    public function forContext(string $context): array { /* ... */ }
}

In SearchSourceDefinitions, the factory calls ModuleRegistry::collectSearchSourceClasses(), which iterates all modules implementing SearchableModuleInterface:

SearchSourceRegistry::class => static function (ContainerInterface $c): SearchSourceRegistry {
    $classes = $c->get(ModuleRegistry::class)->collectSearchSourceClasses();
    $sources = array_map(fn(string $cls) => $c->get($cls), $classes);
    return new SearchSourceRegistry(...$sources);
},

Adding a new searchable module requires implementing SearchableModuleInterface, with no ContainerFactory changes:

final class TodoModule implements ModuleInterface, SearchableModuleInterface
{
    public function getSearchSourceClasses(): array
    {
        return [TodoSearchSource::class];  // resolved by autowiring
    }
}

11.4 Search Endpoints

Route Auth Context Description
GET /search?q=term Public front Returns hits from all front sources such as blog
GET /admin/search?q=term Required admin Returns ACL-filtered navigation plus the admin sources the user is granted

Response format:

{
  "status": "complete",
  "sources": ["blog"],
  "hits": [
    {
      "id": "42",
      "source": "blog",
      "source_label": "Blog",
      "title": "Doctrine and DBAL",
      "snippet": "Doctrine DBAL is a powerful database abstraction layer...",
      "path": "/blog/doctrine-and-dbal",
      "score": null
    }
  ],
  "total": 1
}

An empty term returns {"status": "empty", "total": 0, "hits": []}.


12. Authentication And Locale

12.1 JWT Authentication

Authentication is stateless and based on a JWT stored in an HttpOnly cookie.

Aspect Value
Cookie name k_token
Cookie flags HttpOnly; SameSite=Lax; Path=/
Cookie duration Max-Age=28800 (8 hours)
JWT TTL 28800 s, configured in ContainerFactory
JWT signature HMAC-SHA256 (JWT_SECRET env variable)
Expiration validation StrictValidAt, always applied (exp, nbf, iat)

Login flow: POST /login -> LoginController::submit() -> AuthService::authenticate() -> emits JWT -> sets k_token cookie -> client redirects.

Request flow: JwtAuthMiddleware reads the cookie or Authorization: Bearer -> JwtService::validateToken() -> loads the user from DB -> sets User::class on the request attribute.

Logout: GET /logout -> LogoutController -> expires the cookie (Max-Age=0) -> redirects to /login.

Route protection (route definition):

'_auth_required'  => true,                             // redirect to /login if unauthenticated
'_permission'     => 'x.action',                       // also implies authentication
'_permission_all' => ['gate.use', 'x.action'],         // optional: every code required
'_permission_any' => ['system.write', 'x.delegated'],  // optional: at least one required

_permission_all is conjunctive and adds to _permission rather than replacing it, so a route declaring both is checked against the union. Use it when a surface gates its own entry and must still defer to the domain permission behind each action — kiosk mode is the reference case (§ 30). A surface gate must never replace a domain permission: that widens the ACL through the back door.

_permission_any is disjunctive: at least one listed code must be granted. Use it when a broad administrative code and a narrow delegated code should both suffice — /api/settings/company accepts system.write or the delegated settings.company, so an operator can maintain the public company identity without the one code that opens every settings page. The two defaults compose: the route is granted when every required code holds and at least one alternative does, so an alternative can never buy entry past a gate.

12.2 Self-Service Change Verification

Self-service password and email changes in /admin/user-settings are two-step operations unless the tenant explicitly disables features.require_change_verification.

Step 1 validates the current password and the requested value. When verification is required, the server creates a user_pending_changes row with an encrypted payload and returns:

  • verification_required: true
  • verification_method: "totp" when the user has TOTP enabled
  • verification_method: "email" when the user has no TOTP secret
  • pending_id for the confirmation request

Step 2 calls POST /api/user-settings/verify-change with pending_id and a 6-digit otp_code. TOTP codes are verified with the user's persisted authenticator secret. Email OTP codes are generated server-side, stored only as SHA-256 hashes, sent to the current email address, and expire after 10 minutes. TOTP pending changes expire after 5 minutes. Both methods allow up to 5 failed attempts before the pending change is invalidated.

The pending payload is encrypted with AES-256-GCM using a key derived from JWT_SECRET plus a per-record salt. Password changes store the already hashed password; email changes store the validated target email. The final update uses the stored payload, avoiding a time-of-check/time-of-use gap between step 1 and step 2.

This flow affects only authenticated self-service routes. Admin user-management routes continue to rely on users.write ACL checks and do not require this second verification step.

12.3 Locale Resolution

Handled by LocaleMiddleware, which runs after SymfonyRoutingMiddleware and before JwtAuthMiddleware.

Priority, high to low:

  1. URL prefix /{_locale} for localized root or /{_locale}/path for normal intl routes, intercepted by SymfonyRoutingMiddleware through intl: true; updates the session.
  2. Query param ?lang=xx, validated, saved in session, then redirected without the param for GET requests.
  3. Session value (_locale key).
  4. Tenant default (i18n.default_locale from the early DB snapshot, or legacy JSON while the key is absent).

Locale and menus:

  • front channel links use the intl_ variant, producing locale-prefixed URLs such as /it/blog.
  • back admin channel links always use canonical routes with no locale prefix.

URL prefix policy (i18n.url_prefix): a tenant setting next to the default language (/admin/settings/default-locale), stored in app_settings and overlaid on the tenant config by the bootstrap snapshot. It has two modes:

  • non_default (used when the key is absent): only non-default locales carry a prefix (/shop, /en/shop).
  • always: every locale does (/it/shop, /en/shop). LocaleMiddleware answers 301 from an unprefixed public GET/HEAD path to /{default}/… when its route has an intl_ variant. API routes, /admin, /api, /kiosk, /preview and asset paths, and file-like paths such as /manifest.webmanifest are never redirected, and the query string is kept.

Public links take their prefix from one service, K0smos\Application\Locale\LocalizedUrlGenerator. path($path, $locale) localizes an internal path and route($name, $params, $locale) generates a named route (the intl_ variant when the locale is prefixed). Templates use the Plates helpers localized_path('/shop') and localized_route(...) for the current locale.

The default and lts1 layouts, the Ecommerce storefront templates and menu block data (every category and product carries a generated url) use the generator. So do the Ecommerce canonical, JSON-LD, redirects, account/login redirects, search and predictive paths, the backoffice "view in shop" links and the native sitemap entries. Other themes adopt localized_path() when they are next touched. Search documents store their path: after changing the mode, run ecommerce:search:rebuild for external indexes.

Deployment-owned locale policy (config/tenants/{host}.json):

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

default_locale is a core DB setting validated against supported locales and loaded before HTTP, email, CLI/import and search consumers. The Documentation module owns documentation_locale, validates it against installed catalogs and reads it after container construction. A present invalid DB value is an error; only an absent key uses legacy JSON or a code fallback. Both values have separate Settings pages and insert-if-missing migration commands.

12.4 Translation Overrides

Static PHP translation files remain the authoritative catalogue:

translation/{domain}.{locale}.php

TranslationDefinitions builds the file-backed Symfony translator with PhpFileLoader. When the Translation runtime module is active, its service definitions expose TranslationOverrideRepositoryInterface; TranslationDefinitions then wraps the file translator with DatabaseOverrideTranslator.

Resolution order:

  1. Per-request memory cache for the current domain + locale pair.
  2. PSR-16 cache key for the override map.
  3. module_translation_overrides rows for the current domain and locale.
  4. File-backed Symfony translator fallback.

The decorator falls back silently to file translations when the override table does not exist yet, so enabling the code before running migrations does not break rendering.

Backoffice management lives at /admin/translations. Administrators can edit, delete, reset, search, import, and export overrides. Deleting an override never rewrites PHP files; it restores the file-backed value by removing the database row. Orphaned overrides remain visible so removed file keys can be reviewed and cleaned up.

Translation module UI strings live in the translation domain. Admin menu labels still use the messages domain because the shared menu renderer translates menu labels there.


13. Environment Configuration

k0smos uses phpdotenv with a Symfony-style hierarchy.

File Purpose Git Loaded When
.env Production defaults, no secrets Committed Always
.env.local Machine-specific overrides and secrets Gitignored If it exists
.env.test Optional local PHPUnit overrides; bootstrap still forces isolated temp SQLite DB Gitignored Tests only
app.env Docker container defaults Committed Docker

Runtime load order: .env -> .env.local (override).

PHPUnit load order: .env -> .env.local -> .env.test, then tests/Support/isolated-tenant-bootstrap.php creates a disposable tenant based on localhost and overrides the tenant/database settings with temporary SQLite storage. Environment files cannot redirect the configured suite to a runtime tenant database; the owning process removes its fixture at shutdown.

Key variables:

Variable Purpose Dev Prod
APP_ENV Environment development production
APP_DEBUG Debug mode true false
TENANT_ENV Tenant hostname / CLI target localhost unset in HTTP multi-tenant
JWT_SECRET HMAC key for JWT signature test-key in .env.local
K0SMOS_SECRET_CURRENT_KEY_ID Current tenant credential-encryption key ID empty secret manager / service environment
K0SMOS_SECRET_KEYS JSON map of current and previous base64 32-byte keys empty secret manager / service environment
MAILER_DSN Mail transport DSN null://null smtp/sendmail/etc.
LOG_TO_STDOUT Application log transport false true in containers
GLITCHTIP_DSN GlitchTip/Sentry-compatible project DSN empty in .env.local or service env
GLITCHTIP_REPORT_4XX Report handled 4xx HTTP exceptions false false unless explicitly needed
K0SMOS_HTTP_PORT Docker host HTTP port for FrankenPHP 8080 deployment-specific
SERVER_NAME Native FrankenPHP Caddy site address (scheme decides TLS) http://localhost:8080 k0smos.example.com (enables auto HTTPS)
K0SMOS_PUBLIC_ROOT Native FrankenPHP document root passed to Caddy root public absolute public/ path if started elsewhere
K0SMOS_REDIS_HOST Redis host override used by tenant placeholders 127.0.0.1 service hostname or external Redis
K0SMOS_REDIS_PORT Redis port override used by tenant placeholders 6379 service port
K0SMOS_REDIS_DB Redis DB for app-level Redis integrations tenant JSON tenant/runtime-specific
K0SMOS_QUEUE_REDIS_DB Redis DB for queue transport tenant JSON tenant/runtime-specific
QUEUE_NAME Logical queue consumed by one worker process tenant queue.name, then default worker-specific override
QUEUE_WORKER_SLOT Queue worker generated ID slot tenant JSON worker-specific override
QUEUE_WORKER_ID Worker identifier override; generated as tenant.queue.slot when unset unset worker-specific override

Best Practices:

  • never commit .env.local
  • never put secrets in .env or .env.test
  • set .env.local permissions to 600
  • follow doc/public/en/security/secret-management.md before protecting or rotating DB credentials; database backups are not recoverable without a matching escrowed keyring
  • never run production with APP_DEBUG=true

13.1 FrankenPHP Runtime Profiles

Native FrankenPHP support is alpha (experimental). Classic request mode only; native Windows is web-only. Validate in staging before production. A step-by-step start guide lives in FrankenPHP quickstart.

k0smos supports FrankenPHP in classic request mode. Worker mode is not enabled by default because it keeps application state in memory across requests and requires a separate reset strategy for tenant, session, auth, locale, debug, and database state.

Docker web runtime

The Compose web service is a host-repository development runtime:

  • docker-compose.yml builds the runtime image from docker/frankenphp/Dockerfile
  • the host Git checkout is mounted into /app
  • Composer dependencies must exist on the host before starting the web container
  • app.env sets TENANT_ENV=app
  • Redis is managed by Compose as the redis service
  • Caddy listens on port 80 inside the container
  • the host port defaults to 8080 and can be changed with K0SMOS_HTTP_PORT
docker compose up -d
K0SMOS_HTTP_PORT=8090 docker compose up -d

Start the separate queue worker to process async jobs. Async works out of the box on the SQL transport (the default), so a worker is needed whenever jobs are dispatched — regardless of whether Redis is used:

docker compose --profile worker up -d

Native FrankenPHP

Native FrankenPHP uses deploy/frankenphp/Caddyfile, serves public/, and is parameterized by two Caddy environment placeholders resolved at config load: {$SERVER_NAME:http://localhost:8080} (the site address) and {$K0SMOS_PUBLIC_ROOT:public} (the document root). See deploy/frankenphp/README.md for the full reference.

The SERVER_NAME scheme decides TLS. The default http://localhost:8080 serves plain HTTP for local development (no certificate, no prompt). A bare hostname such as k0smos.example.com turns on Caddy's automatic HTTPS (serves :443, adds an HTTP→HTTPS redirect). A bare host without a scheme — even localhost:8080 — also enables automatic TLS, which is why the default keeps the explicit http:// prefix. Behind another reverse proxy, terminate TLS there and review forwarded headers.

# local plain HTTP (default site address http://localhost:8080)
TENANT_ENV=k0smos.example.com APP_ENV=production APP_DEBUG=false frankenphp run --config deploy/frankenphp/Caddyfile
# production HTTPS for a real hostname
SERVER_NAME=k0smos.example.com TENANT_ENV=k0smos.example.com APP_ENV=production APP_DEBUG=false frankenphp run --config deploy/frankenphp/Caddyfile

Before starting native FrankenPHP, verify that Composer dependencies are installed, JWT_SECRET is configured, the target tenant JSON exists, the tenant database has been installed/migrated intentionally, runtime directories are writable (var/db, var/cache, log, tmp/sessions, storage/upload paths), and Redis is reachable when queue/AI features are enabled.

Trusted proxy handling is not enabled by the bundled native profile. When deploying behind a reverse proxy, review forwarded headers and trusted proxy policy before relying on forwarded scheme, host, or client IP values. Broad security headers such as CSP are intentionally not added here; introduce them only after testing existing routes, assets, admin flows, and embedded providers.

Native Windows

Native Windows supports the web runtime, but persistent queue-worker supervision is not supported. queue:work can execute without pcntl, but Windows cannot provide the SIGTERM/SIGINT graceful shutdown behavior used by supervised Unix/Linux workers.

For low- or medium-volume DBAL-backed queues, Windows Task Scheduler may invoke a bounded worker periodically:

php bin/console queue:work `
  --host=k0smos.example.com `
  --queue=default `
  --max-idle=10 `
  --max-jobs=100

Run the task every minute, use the project root as its working directory, and select the Task Scheduler policy that does not start a second instance while the previous one is running. Configure one task per queue. Always include --max-idle, because --max-jobs counts processed jobs and cannot terminate a worker while its queue remains empty.

This workaround can add up to one scheduling interval of delivery latency and is not intended for sustained workloads. Use Docker/WSL2 or a Linux host for persistent queue workers. Redis-backed queues additionally require a compatible Redis extension; the FrankenPHP Windows distribution does not bundle php_redis.dll.

FrankenPHP embeds a thread-safe (TS) PHP. When the host also has a non-thread-safe (NTS) PHP (for example Scoop's php-nts), that install exports PHPRC/PHP_INI_SCAN_DIR; FrankenPHP inherits them and its TS engine then fails to load every NTS extension DLL (Module compiled with ... NTS ... PHP compiled with ... TS). Point FrankenPHP at its own php.ini whose extension_dir is the FrankenPHP ext folder, and clear the NTS scan dir for its process: copy deploy/frankenphp/php.windows.ini.example to php.windows.ini, then set PHPRC to it and PHP_INI_SCAN_DIR to empty. The FrankenPHP Windows distribution does not bundle php_redis.dll, so Redis-backed features must run under Docker or Linux until a TS-compatible build is added. Details in deploy/frankenphp/README.md.

PHP extension baseline

Required extensions: redis, json, mbstring, openssl, sodium, dom, xml, simplexml, xmlwriter, zip, filter, fileinfo, pdo, intl, ctype, iconv, tokenizer, session, and at least one of pdo_sqlite, pdo_mysql, or pdo_pgsql.

Conditional/recommended extensions: pcntl for Unix/Linux queue workers, curl for payment/storage integrations, ftp for FTP storage, gd/exif for image features, opcache for production performance, zlib for compression, and apcu only if an APCu-backed cache adapter is configured.

13.2 Docker, DDEV, And Lando

The committed local environments share PHP 8.5, the FrankenPHP public/ docroot, Composer 2, Redis 7, Node 22 tooling, queue/scheduler commands, optional Xdebug, and an isolated default SQLite database. DDEV uses the maintained FrankenPHP ZTS add-on; Lando's general API 3 service builds the repository's docker/frankenphp/Dockerfile and mounts the same Caddyfile used by Compose.

All three invoke the guarded scripts under tools/local-env/. Bootstrap refuses production/non-local tenants, remote URL DSNs, DB hosts outside the local allowlist, and SQLite files outside var/db before applying migrations. Smoke checks CLI, HTTP, queue topology, and an empty local queue; its optional PHPUnit run still uses the isolated temporary database enforced by tests/bootstrap.php. No DDEV/Lando file contains credentials. As of 2026-09-14, their platform lifecycles have not been tested; the shared Linux-host bootstrap/HTTP/queue checks do not establish DDEV/Lando parity.

Setup, parity matrix, pinned add-on revisions, local TLS/hostnames, database variants, Xdebug toggles, platform/permission notes, lifecycle commands, and the manual nine-theme build procedure are maintained in Local development environments.


14. Developer Tools

14.1 Testing

PHPUnit covers unit and integration tests, including existing HTTP/kernel and container scenarios. The test structure mirrors src/. Codeception and its empty API/acceptance scaffolding have been removed following the owner's decision; no executable scenarios were removed.

PHPUnit 13.3.3 is adopted after an isolated comparison against 12.5.35: 3,094 cases and 20,702 assertions match, including four pre-existing failures. The coverage sample and configured PHPStan findings are unchanged in substance; sequential timing shows no material regression. Windows and single-process full-suite ordering were not revalidated in this comparison. The framework decision and historical Pest comparison are recorded in Test framework direction and ADR 0010.

The mock expectation compatibility cleanup is delivered: argument-constrained mocks now declare their invocation counts explicitly. The focused 49-test suite passes with 235 assertions and zero PHPUnit deprecations on both 12.5.35 and an isolated 13.0.6 runner. Existing mock notices remain unchanged; this maintenance established the compatibility baseline before the subsequent Codeception removal and updated PHPUnit evaluation.

tests/
├── bootstrap.php           # Environment setup and env files
├── Support/                # Shared DTOs and helpers for PHPUnit
│   ├── isolated-tenant-bootstrap.php # Canonical disposable tenant/SQLite bootstrap
│   └── ConsoleRunResult.php # Value object: exit code + stdout + stderr
├── Unit/
│   ├── Http/Error/         # HTTP error tests
│   ├── Middleware/         # Middleware tests
│   └── ...
└── Integration/
    ├── Command/
    │   └── InstallFlowCommandTest.php   # migrate -> fixtures -> admin role flow
    ├── Http/Error/
    ├── Theme/
    └── ...

Conventions:

  • Test class: {ClassName}Test, method: test_{action_described}.
  • Mock external dependencies with createMock().
  • Use declare(strict_types=1) in every test file.
  • Heavy integration tests (real DB, DI container) must be annotated with #[Group('integration')] to allow selective execution with phpunit --group integration.
  • Tests must never run against production or runtime tenant databases. phpunit.xml.dist selects tests/Support/isolated-tenant-bootstrap.php, which derives a temporary tenant from the complete localhost configuration, overrides tenant and database environment settings, blocks runtime Redis, supplies a deterministic test keyring, and verifies the resolved database before any container runs. Cleanup belongs to the creating process; forked tests cannot delete their parent's tenant descriptor or SQLite files.
  • Seed remains a test suite for fixture behavior. Its data is disposable and must not be retained in runtime tenant storage for browser inspection.
  • Integration tests that remove temporary SQLite databases or generated media must release file handles before cleanup. Close cached DBAL test connections, PSR-7 response body streams, and GD image resources before unlinking files so the same suite stays portable on Windows.
  • composer test runs the configured PHPUnit suites. composer qa:test runs them with fresh JUnit XML and compact JSON reports and fails on an empty selection. composer ci:test:integration selects tests/Integration directly.

14.2 Developer Observability

The current observability stack separates responsibilities:

  • Monolog for persistent application logging.
  • GlitchTip / Sentry-compatible error tracking for optional production exception capture when GLITCHTIP_DSN is configured.
  • PHP DebugBar as a dev-only surface for request-local diagnostics.
  • Clockwork as a dev-only browser profiler at /clockwork, backed by /__clockwork metadata.
  • K0smos\Debug\DebugLogger as a safe application adapter over DebugBar collectors.
  • K0smos\Debug\HttpDebugTracer as a structured tracer for sanitized HTTP snapshots.
  • Whoops as the debug-mode HTML exception page.

StandardDebugBar is registered in the container and exposed as DebugBar::class; application code should not access collectors directly through array access. The correct integration points are DebugLogger for messages/measures/exceptions and HttpDebugTracer for structured snapshots. Both degrade to no-op when the required collector is unavailable. ClockworkBridge receives the same DebugLogger messages/measures and HttpDebugTracer snapshots, and subscribes Clockwork to the existing Monolog instance and DBAL connection in debug mode. DBAL datasource registration is applied both to fresh DBAL connections and to cached PHPUnit DBAL connections, so query collection remains available when the test container reuses an existing connection.

Current behavior:

  • DebugBarMiddleware injects the toolbar when APP_DEBUG=true and the response is text/html.
  • ClockworkMiddleware delegates to the optional Clockwork PSR-15 middleware only when APP_DEBUG=true; otherwise it is a no-op and /clockwork is not exposed.
  • The same middleware can attach AJAX DebugBar headers to traceable JSON responses.
  • Router automatically instruments every controller invocation with route, controller, method, timing, and result metadata.
  • HttpDebugTracer feeds a dedicated DebugBar panel with sanitized request, dispatch, result, response, and exception snapshots.
  • Clockwork receives the same sanitized lifecycle snapshots under a k0smos user-data section and shows Monolog entries plus DBAL queries when available.
  • Kernel completes tracing by recording the final rendered response, not just the raw controller result.
  • Whoops pages include incident, method, URI, route, environment, and debug state to make local exception pages correlate with logs and profiler entries.
  • Base controllers no longer receive DebugBar in their constructors.

Correct application usage:

final readonly class ExampleService
{
    public function __construct(private DebugLogger $debugLogger) {}

    public function run(): void
    {
        $measure = $this->debugLogger->startMeasure('example.run', 'Example run');
        $this->debugLogger->info('ExampleService', 'Started');

        try {
            // ...
        } catch (\Throwable $exception) {
            $this->debugLogger->error('ExampleService', 'Failed', [], $exception);
            throw $exception;
        } finally {
            $this->debugLogger->stopMeasure($measure);
        }
    }
}

For k0smos code:

  • do not use $this->debugBar['messages'] directly in application code
  • do not inject DebugBar or Clockwork into controllers only for generic tracing
  • use Monolog for persistent logs, DebugLogger for dev request-local diagnostics, and HttpDebugTracer for shared HTTP snapshots

GlitchTip integration is native core infrastructure, not a runtime module. It lives under src/Observability/GlitchTip, is registered from the HTTP/CLI bootstrap and logging definitions, and is controlled only by process environment variables. It is not discovered by ModuleCatalog, has no ACL, does not use tenant JSON settings, and does not add migrations or templates.

GlitchTip is disabled by default. Configure GLITCHTIP_DSN with a GlitchTip project DSN to initialize the Sentry-compatible SDK during HTTP and CLI bootstrap. GlitchTip's PHP setup documentation uses the Sentry PHP SDK with a project DSN and recommends setting release and environment for deployment tracking: https://glitchtip.com/sdkdocs/php/.

Setup:

  1. Provision a GlitchTip project and copy its DSN.
  2. Set GLITCHTIP_DSN through .env.local, the process environment, or the service manager environment; never store it in a versioned file. Set GLITCHTIP_REPORT_4XX only when handled 4xx responses must be reported.
  3. Start k0smos in an isolated environment with APP_ENV=production and APP_DEBUG=false, then follow the production verification checklist below.

The current k0smos integration reports handled HTTP exceptions through GlitchTipReporter and can report Monolog error records that include an exception context through Sentry's Monolog handler. Fatal/unhandled errors outside the application middleware are initialized through the SDK as early as possible in public/index.php and bin/console.

CLI caveat: Symfony Console catches and renders command exceptions by default. The SDK is initialized for CLI/worker processes, but command exceptions handled inside Symfony\Component\Console\Application need a dedicated console hook or explicit command-level capture before the CLI side can be considered fully production-verified.

Runtime flags:

Variable Default Purpose
GLITCHTIP_DSN empty Enables GlitchTip/Sentry-compatible reporting when set.
GLITCHTIP_REPORT_4XX false Reports handled 4xx HTTP exceptions only when explicitly enabled. 5xx errors are reported by default when the DSN is present.

Privacy defaults:

  • send_default_pii=false
  • request bodies are disabled with max_request_body_size=never
  • known sensitive keys such as passwords, tokens, API keys, cookies, authorization headers, and heartbeat secrets are redacted before events leave the process

Production verification checklist:

  • configure GLITCHTIP_DSN in .env.local, app.env, or the service manager environment, never in committed .env
  • run with APP_ENV=production and APP_DEBUG=false
  • trigger a controlled non-public 500 and confirm the event arrives with environment, release, stack trace, and k0smos.incident_id
  • confirm the same incident id appears in the HTTP response/header and Monolog context
  • confirm handled 404/403 responses do not report while GLITCHTIP_REPORT_4XX=false
  • confirm authorization headers, cookies, passwords, API keys, tokens, and secrets are redacted in GlitchTip
  • confirm a Monolog error record carrying an exception context creates an issue, while one without it does not add a useless duplicate
  • confirm CLI/worker behavior with a controlled error in an isolated environment, keeping the Symfony Console caveat above in mind

OTLP / distributed tracing (APM) is deliberately deferred: it is revisited only once OneUptime and GlitchTip are stable and a concrete need for distributed tracing appears. Do not add a tracing stack before that.

Clockwork is installed as a development dependency. Default local metadata storage is var/clockwork; override it with CLOCKWORK_STORAGE_FILES_PATH. Keep CLOCKWORK_ENABLE=true only in non-production debug environments.

Never enable in production: keep APP_DEBUG=false.

Production uptime monitoring is handled through the OneUptimeApi module. The module exposes bin/console oneuptimeapi:heartbeat [heartbeat-secret-or-url] for OneUptime Incoming Request monitors. For a periodic heartbeat, use system cron or the platform scheduler rather than the Redis queue worker:

*/5 * * * * cd /path/to/k0smos && php bin/console oneuptimeapi:heartbeat >> /dev/null 2>&1

The command returns exit code 0 on success and a non-zero exit code on configuration or HTTP failures. Keeping this outside the queue worker prevents heartbeat delivery from depending on the same worker infrastructure it may be used to monitor.

The module also contributes an opt-in public status widget to the default front footer (template slot front.footer.widgets). Enable it per tenant with oneuptimeapi.front_widget_enabled; the browser reads GET /api/oneuptimeapi/status-summary, a server-side proxy that returns only derived, non-sensitive signals and never exposes the API key or instance URL. See modules/OneUptimeApi/src/AI.OneUptimeApi.md § "Front Status Widget".

14.3 QA Toolchain

k0smos standardizes machine-readable QA reports under reports/ to support AI-assisted review workflows. The directory is gitignored except for reports/.gitkeep.

Root configuration files:

  • phpstan.neon for PHPStan static analysis with strict rules and cache in var/cache/phpstan
  • phpstan-tests.neon for a separate level-5 gate over 40 selected unit-test files, with cache in var/cache/phpstan-tests
  • .php-cs-fixer.dist.php for @PER-CS2.0 style with strict_types and explicit safe/risky fixes
  • rector.php for advisory dry-run refactors on src/
  • phpunit.xml.dist for PHPUnit suites, diagnostics and the isolated tenant bootstrap
  • agents/qa-review.md for the agent workflow that reads reports and proposes fixes

Generated reports:

  • reports/phpstan.json
  • reports/phpstan-tests.json
  • reports/cs-fixer.json
  • reports/rector.json
  • reports/metrics.json
  • reports/metrics/ (HTML)
  • reports/phpunit.xml (native PHPUnit JUnit output)
  • reports/phpunit.json (summary and test-case details)
  • reports/admin-sidebar-latency-*.json and reports/admin-sidebar-screenshots/ are benchmark artifacts, not QA reports; see 14.3's measurement-harness note

Bounded cleanup baselines, per-tranche gates, and measured before/after results are recorded in the Progressive quality program. A stale report is not evidence for selecting a refactor.

Local temp/cache files:

  • .php-cs-fixer.cache
  • var/cache/phpstan/
  • var/cache/phpstan-tests/
  • var/.phpunit.cache/
  • reports/ remains gitignored except for reports/.gitkeep
Tool Purpose Composer Script CI gate
PHPStan Type errors, null safety, contract mismatch composer qa:phpstan Yes
PHPStan tests Selected architecture, ACL, database, HTTP error, kiosk and OPC UA tests composer qa:phpstan:tests Yes
PHP-CS-Fixer Style check and automatic fixes composer qa:cs-check, composer qa:cs-fix Yes (qa:cs-check)
Rector Advisory refactoring and PHP modernization composer qa:rector, composer qa:rector-fix No
PHPMetrics Architecture and complexity metrics composer qa:metrics No
PHPUnit Existing unit, HTTP/kernel and integration tests with XML/JSON reports composer qa:test Yes

qa:test runs PHPUnit once through bin/qa/run-phpunit-report.php. It removes previous PHPUnit reports, requests native JUnit XML, and converts fresh output even if tests fail. The runner's non-zero status is preserved; missing or invalid reports also fail an otherwise successful run. An empty test selection fails through --fail-on-empty-test-suite. The converter counts top-level suite totals once and includes all nested class/data-provider cases. Runner warnings and notices must also be reviewed in console output.

qa:all and qa:ci each include this test step once. qa:test:unit remains a compatibility alias for the complete direct PHPUnit suite without report generation. test, qa:test and ci:test:integration disable Composer's process timeout so complete suites can finish; their aliases and aggregate callers inherit that behavior. Arguments after -- are forwarded to PHPUnit. The retired Codeception gate executed no tests; the removal and framework comparison are documented in Test framework direction.

Blocking thresholds:

  • PHPStan: 0 errors at configured level — not currently met: the 2026-08-24 baseline reports 485 findings on src
  • PHPStan test scope: 0 errors across 40 files — met on 2026-09-14; the full tests/ tree has 288 level-5 findings and is not in this gate
  • PHP-CS-Fixer dry-run: 0 violations — not currently met: 730 files would be rewritten
  • PHPUnit: a non-empty selection, 0 failing tests, and the configured warning and risky-test checks
  • Rector and PHPMetrics remain advisory

The latest advisory Rector inventory (2026-08-31) completed with zero errors and reported 872 suggested rule applications across 463 src files, covering 65 distinct rules. The raw reports/rector.json remains ignored and no Rector transform was applied; the result does not reopen the deferred P4 cleanup lane.

Operational workflow:

composer qa:all
# generates reports/*.json, reports/phpunit.xml and reports/metrics/

# the agent reads:
# - agents/qa-review.md
# - reports/*.json

composer qa:ci
# blocking gate before commit

Playwright note: for future pure JavaScript frontends (React, Vue, Svelte SPAs), use Playwright as a separate E2E suite with a dedicated Node.js toolchain and CI pipeline. Do not mix it into the PHP toolchain. Existing PHP backend and server-rendered page tests use PHPUnit HTTP/kernel fixtures; broader browser acceptance coverage requires its own explicitly scoped scenarios.

Frontend measurement harnesses live under tools/benchmarks/ and are deliberately outside the QA toolchain: they are diagnostic, hardware-dependent, and never a gate. They add no dependency to any lock file — a locally installed Chromium is driven over the DevTools protocol from plain Node 22 through the shared client in tools/benchmarks/lib/cdp.mjs.

  • admin-sidebar-latency.sh measures how long the authorized admin navigation takes to become visible and stable across themes, devices, cache states, JavaScript availability, menu size, and CPU/network profiles. admin-sidebar-fixture.php creates the throwaway tenants and SQLite file it needs — it refuses any database path outside var/db/benchmark-* or the system temp directory — and php-server-router.php is the php -S shim that serves built assets, never a deployment target.
  • admin-sidebar-report.mjs renders two runs into the evidence tables of doc/public/en/quality/admin-sidebar-performance.md; admin-sidebar-screenshots.mjs captures the reviewable admin shell states.
  • Output lands in reports/ alongside QA output but is not QA output. Compare only runs taken back to back on the same machine.

14.4 Composer Scripts

The following list is the complete application-facing script set from composer.json. Run composer run --list to inspect the installed version of the list.

Script Purpose
composer test Runs the complete PHPUnit suite, including the final seed suite, with disposable tenant storage.
composer seed Runs only the Seed PHPUnit suite (MockDataPopulationTest) with disposable data.
composer test:errors Runs the focused HTTP error and request-ID middleware tests.
composer qa:prepare Creates reports/ and reports/metrics/; reporting scripts invoke it automatically.
composer qa:phpstan Runs static analysis and writes reports/phpstan.json.
composer qa:phpstan:tests Analyses the bounded test scope and writes reports/phpstan-tests.json.
composer qa:cs-check Runs a PHP-CS-Fixer dry-run and writes reports/cs-fixer.json.
composer qa:cs-fix Applies PHP-CS-Fixer changes.
composer qa:rector Runs advisory Rector analysis and writes reports/rector.json.
composer qa:rector-fix Applies Rector refactors. Review the resulting diff before committing.
composer qa:metrics Produces PHPMetrics HTML and JSON reports under reports/.
composer qa:test:unit Alias for the complete PHPUnit suite.
composer qa:test Runs PHPUnit once, rejects empty selections, and writes fresh reports/phpunit.xml and reports/phpunit.json, including on test failures.
composer qa:all Runs the complete QA toolchain, including advisory checks and one PHPUnit report run.
composer qa:ci Runs the blocking CI/pre-commit QA gate with one PHPUnit report run.
composer ci:test:integration Runs PHPUnit directly against tests/Integration with the canonical isolated bootstrap.
composer ci:docs:phpdoc Generates API documentation in public/phpdoc.
php bin/console documentation:validate Validates the allowlisted public Markdown manifests, sources, links, headings, safety rules, and locale coverage without a tenant database.
php bin/console documentation:preview Starts a loopback-only local server through the normal tenant bootstrap, documentation renderer, and active front theme.

| composer app:install | Runs PHPUnit, migrations, tenant-theme synchronization, base/demo fixtures, admin-role synchronization, and the final readiness check for the resolved tenant. Add -- --skip-tests to skip its PHPUnit step. | | composer app:install-demo | Migrates, synchronizes selected wrappers, backfills theme/locales without overwriting DB edits, and loads demo fixtures. With TENANT_ENV it targets that tenant; otherwise it deduplicates aliases and iterates canonical tenant configurations. | | composer app:migrate | Runs migrations for the selected tenant, or for all canonical tenants when TENANT_ENV is unset. See Module Migrations for option forwarding. | | composer app:migrate-all | Always runs migrations for all canonical tenants, ignoring an inherited TENANT_ENV. | | composer app:backup / app:backup-all | Write a verified SQLite copy of each selected tenant database to var/backups (db:backup); run it before migrations or bulk imports. | | composer app:reinstall | Runs app:install, then an idempotent migration and a forced demo-fixture load for the resolved CLI tenant. This is a local reset/convenience workflow; fixtures can overwrite existing data. | | composer app:queue | Starts the queue worker; set TENANT_ENV or pass -- --host=<hostname>. | | Composer lifecycle: post-install-cmd, post-update-cmd | Runs automatically after Composer install/update to patch Rector's bootstrap and copy PHP DebugBar assets. |

Public Markdown documentation

The default-enabled documentation runtime module serves reviewed Markdown at localized /docs URLs. It is intentionally separate from Page (tenant-authored database pages) and from the existing PHPDoc iframe (generated developer API reference). Version-1 JSON manifests own stable cross-locale IDs, localized paths and SEO copy, parent hierarchy, ordering, draft visibility, and permanent redirects.

Every renderer input lives physically below doc/public/{locale}. A manifest source is a lowercase locale-relative .md path and must equal its public path plus .md; the repository file is the source of truth and the active frontend theme renders its HTML. The complete relocation is English-only for now: the Italian catalog is absent, Italian navigation uses i18n.documentation_locale, and retired Italian slugs redirect to their matching English stable documents.

The locale filesystem boundary is strict: paths reject traversal, are normalized with realpath(), and must remain below the selected locale root. CommonMark runs with raw HTML stripped and unsafe links disabled. Only visibility: public entries can resolve or enter front search/vector projection. Request-local navigation/render caches use manifest and source fingerprints. The canonical default-theme template renders the manifest parent hierarchy as semantic nested lists and provides breadcrumbs, generated TOC, previous/next links, locale switching with a visible tenant-configured documentation-locale fallback, keyboard landmarks, responsive layout, code/table overflow, a restrained H1–H6 scale, print rules, dark-mode tokens, and progressively enhanced copy-code controls. CommonMark puts conventional unprefixed fragment IDs directly on the heading elements, so the TOC, heading permalinks, same-page links, and cross-document fragments share a target that clears the fixed header.

Authoring, classification, source, rename/redirect, and retirement rules are documented in modules/Documentation/src/README.Documentation.md. A focused coverage test requires every English .md below doc/public/en/ to appear exactly once in its manifest, no Markdown to remain below doc/ outside doc/public/, and the retired Italian catalog to remain absent while its former paths stay covered by English redirects. AI*, HU*, agents/*, reports, plans, changelogs, tenant configuration, root/module READMEs, and files outside the approved roots remain unreachable. The authoring loop is database-free for validation; when visual verification is needed, documentation:preview binds only to 127.0.0.1, ::1, or localhost and serves the real application/theme path. The final production theme bundle is still built explicitly by the project maintainer. The complete repository-root workflow is:

php bin/console documentation:validate
cd template/default && npm run build && cd ../..
php bin/console documentation:preview

Open http://127.0.0.1:8088/docs, perform the responsive/accessibility, dark mode, locale, print, code, and table review, then stop the preview with Ctrl+C. Documentation has no database schema or module-specific migration.

See the next section for the Rector patch details.

OPC UA module

The default-enabled opcua module provides an admin-only industrial device surface backed by php-opcua/opcua-client 4.4. It owns named server profiles, a registered node list, retained readings, one-level browse, batch read, typed scalar write, on-demand recording, and the tenant-scoped opcua:record command. The dedicated config/tenants/opcua.json lite tenant requests only this module.

Persistence is created exclusively by the module Doctrine migration: module_opcua_servers, module_opcua_nodes, and module_opcua_readings, plus insert-if-missing opcua.view, opcua.read, opcua.write, and opcua.manage permissions and admin grants. Passwords are write-only; empty edits preserve the stored secret and browser payloads expose only password_configured. All APIs are authenticated, permission-gated, and CSRF-protected; endpoints, NodeIds, quantities, and scalar types are validated before device I/O.

V1 deliberately supports only opc.tcp:// with SecurityPolicy/Mode None and anonymous or username/password session authentication. Deploy it only on a trusted or segmented industrial network. X.509, encrypted policies, persistent session management, subscriptions, queue scheduling, charts, and MCP tools are future work. The canonical operational and security contract is modules/Opcua/src/AI.Opcua.md; setup and cron examples are in modules/Opcua/src/README.Opcua.md.

The repository already excludes QA caches, temp files, and runtime reports through .gitignore; before committing, verify that generated artifacts were not force-added.

Post-install vendor patches (ComposerScripts)

post-install-cmd and post-update-cmd invoke K0smos\Command\ComposerScripts::patchRectorBootstrap, which applies two idempotent patches to vendor/rector/rector after every Composer install or update:

  1. bootstrap.php typo fix — corrects use PHPParser\Node → use PhpParser\Node so rector's "already loaded?" guard evaluates correctly.
  2. preload.php guard — inserts an interface_exists(\PhpParser\NodeVisitor::class, false) early-return before rector's bundled php-parser require_once calls. Without this guard, phpmetrics/phpmetrics (autoloaded via autoload_files) loads the project's nikic/php-parser first, causing a fatal "Cannot redeclare interface PhpParser\NodeVisitor" when rector's preload later tries to require its own bundled copy.

14.5 CLI Commands

The command surface is tenant-scoped. bin/console does not hold a command list: K0smos\Command\ConsoleCommandRegistry merges the core commands declared as console.core_command_classes in CommandDefinitions with the commands that the tenant's active modules contribute through ConsoleCommandProviderModuleInterface, then hands Symfony Console a lazy ContainerCommandLoader built from the #[AsCommand] names.

Two consequences worth knowing:

  • php bin/console list shows different commands per tenant. A lite tenant without Ecommerce has no ecommerce:* command at all, and its services are never built. Adding a command to a module therefore only requires declaring it on the module and defining it in the module's services.php.
  • Commands are instantiated only when executed, so a command whose dependencies cannot be resolved can never break an unrelated command. This is what keeps composer app:migrate-all running across every tenant.

The complete all-modules command inventory and its direct or related Composer counterparts are maintained in the Console and Composer command catalog.

php bin/console list                                          # List all commands available to this tenant
php bin/console app:install                                   # PHPUnit + migrate + demo fixtures + admin role sync
php bin/console migrate                                       # Run all migrations (core + modules)
php bin/console migrate --module=psapi                        # Psapi module migrations only
php bin/console k0smos:admin:grant-role                       # Sync admin role permissions and assign it to target email
php bin/console queue:work                                    # Start the queue worker
php bin/console k0smos:psapi:import --dry-run                 # Simulate import without DB writes
php bin/console k0smos:psapi:import --entity=customers        # Import customers only
php bin/console k0smos:psapi:import --entity=categories --entity=products  # Import multiple entities
php bin/console k0smos:psapi:import --since=2024-01-01        # Incremental import since date
php bin/console k0smos:psapi:import                           # Run the default core profile
php bin/console k0smos:psapi:diagnostics --audit-legacy-customers --json
php bin/console k0smos:psapi:diagnostics --smoke-version=9 --json

Fixtures (k0smos:fixtures:load)

Fixtures populate the database with system data or content packs, separate from migrations. They are tenant-aware and code-gated: each content fixture declares a fixtureCode() and runs only when that code is listed under fixtures.enabled in the tenant JSON config — independent of the active theme, so the same pack can be reused across themes. Base fixtures (roles, permissions, users, info pages) are universal. Set TENANT_ENV for the right tenant.

Tenant config:

"fixtures": {
    "enabled": ["default", "pc", "cc", "dcm"]
}

Fixture code migration (0.17.x). The editable homepage pack code was renamed default_homepage → default. Deployed tenants must replace it in fixtures.enabled; a stale code is a silent no-op (the pack simply does not seed), never a crash.

All content fixtures live in the content_fixture module (modules/ContentFixture/src/DataFixtures/); theme modules no longer own fixtures (ThemeModuleInterface has no getFixtures()). ContentFixtureCollector returns the registered content fixtures and the code gate in each fixture's supports() decides which actually run. The shared gate is ConfigEnabledFixtureTrait (it replaced the theme-bound ThemeAwareFixtureTrait), reading TenantContext::isFixtureEnabled().

Fixture commands are data-mutating install operations. They are not tests and must not be run casually against production/shared tenant databases. Fixture and migration seed data must be conservative: tenant-editable content (legal pages, widgets, social links, demo content, theme settings, and similar records) is inserted only when missing. Do not use upsert/update semantics for content an administrator may have edited unless the migration is an explicit, documented data correction approved for that tenant.

php bin/console k0smos:fixtures:load                          # Load base fixtures for current tenant
php bin/console k0smos:fixtures:load --group=demo --force     # Base + demo fixtures
TENANT_ENV=k0smos.example.com php bin/console k0smos:fixtures:load --group=demo --force
TENANT_ENV=k0smos.example.com composer app:install-demo        # Migrate + base/demo fixtures
Fixture Group Order Code Content
RolesFixture base 10 — (universal) System roles: admin, editor, viewer
PermissionsFixture base 20 — (universal) Modules and permissions for all runtime modules
UsersFixture base 30 — (universal) Ensures canonical admin (admin@localhost) exists and has admin role
InfoPagesFixture base 40 — (universal) Privacy Policy, Cookie Policy, and T&C in IT+EN for all tenants
PackageContentFixture demo 90 cc cc-content.k0sdata.json: service cards, gallery, image widgets, public social links, and the public contact email
PackageContentFixture demo 91 dcm dcm-content.k0sdata.json for active ThemeDm1; dcm-dm2-content.k0sdata.json for active ThemeDm2: localized home showroom, environments and services. Both packs can coexist.
PackageContentFixture demo 108 default default-homepage-widgets.k0sdata.json: editable IT+EN homepage copy
PackageContentFixture demo 111 pc pc-content.k0sdata.json: structured pc1/pc2/pc3 IT+EN portfolio content on version-neutral hooks plus public social links

(The Code column is the value to list under fixtures.enabled.) All fixtures are idempotent and run in isolated transactions. Selection is code-gated via ConfigEnabledFixtureTrait::supports(); the in-load() schema/empty-slot guards remain.

The default homepage template owns section layout only. Its primary public copy is fixture-owned under default_home_*; front.home.default.* provides localized fallback and SEO metadata. The fixture treats a matching hook/locale title or sort slot as already occupied, preserving tenant-edited and renamed widgets. The former stack/library section is not rendered or seeded.


16. Ecommerce Catalog

The runtime ecommerce module depends on cart, page, and media. Its catalog persistence lives in products, categories, product_images, product_variants, and product_variant_images; schema changes are delivered only through the module's Doctrine migrations. The authoritative field/publication/identity/ownership specification is modules/Ecommerce/src/CATALOG-CONTRACT.md.

16.1 Backoffice Catalog

The focused catalog surfaces replace the retired all-in-one Alpine workspace: /admin/catalog answers a permanent redirect to the product list (/admin/catalog?product={id} to that product's editor).

  • /admin/catalog/products performs DB-level filtering, stable sorting, and pagination through ProductCatalogQueryInterface.
  • /admin/catalog/products/create and /admin/catalog/products/{id}/edit use a server-rendered PRG editor; /admin/catalog/products/{id}/variants owns variant management.
  • /admin/catalog/categories plus its create/edit routes manage the category tree and reject missing/self/cyclic parent relationships. Categories whose parent no longer exists appear in a "missing parent" group; deleting a category moves its children to its parent.
  • Lists and forms link each product and category to a staff preview (/preview/products/{id}, /preview/categories/{id}; auth + ecommerce.view) and, when the item is public, to its storefront page. The preview renders the storefront templates in the storefront theme, inactive items included, with a staff banner, X-Robots-Tag: noindex, nofollow, Cache-Control: no-store and purchasing locked for hidden products. All staff previews (catalog, Blog /preview/blog/{id}, Info /preview/info/{id}) share the dedicated public /preview/ prefix because the theme side follows the path (§6.1); /admin routes must not declare _theme => 'front'.

Product writes through either the editor or /api/admin/ecommerce/products reuse ProductValidator. It checks required/unique identity, non-negative money and weight, integer stock, currency shape, category and active-tax references, catalog content/SEO limits, and HTTP(S)/internal image URLs. Invalid API writes return HTTP 422 with translated per-field errors; invalid PRG writes re-render the submitted values. The editor lists active tax rates only, uses Media for image selection/upload/reordering, keeps static save/cancel labels, and protects dirty forms on internal navigation and browser/tab exit. Related and complementary products are picked by search (chips, up to 24 each), the currency comes from the active currencies, specifications are edited as label/value rows (the stable code derives from the label), and the description is HTML with a formatting toolbar and a preview; it is sanitized with CatalogHtmlSanitizer on save and in the preview (POST /admin/catalog/products/description-preview).

Imported products display their external reference and field-level ownership state. Source-owned fields refresh on every unlocked import; tenant-owned fields remain local; tenant-overridable fields follow the source until a user changes or explicitly protects them. Later source differences remain pending without overwriting the live tenant value. Enabling import_locked is still the hard freeze and causes Psapi/Wpapi to skip the product/category, its variants, stock, and images entirely. Editing an unlocked imported product requires explicit confirmation but never changes that lock state. The whole-product lock has its own ACL/CSRF-protected action. A successful non-no-op edit records actor, source identities and changed field names (never connector payloads) through the ImportedProductEdited domain event. Separate idempotent listeners write one entry to the shared audit_log and send one preference-aware in-app notification to the actor; only a hash of the request identity is retained.

Catalog reads require ecommerce.view; writes require ecommerce.edit plus the ecommerce.catalog browser-CSRF intention. The canonical JSON namespace is /api/admin/ecommerce/...; secured legacy aliases remain for one compatibility window.

Backoffice sidebar and permissions

The E-commerce sidebar groups its pages by task: Catalog (products, categories, inventory, reviews, import conflicts), Sales (orders, customers, abandoned carts, and a shortcut to Invoices when that module is active), Marketing (coupons, promotions) and Configuration (shipping, payments, VAT rates, currencies, shop settings). A menu link whose route no active module registers is dropped, and so is a group left empty. Permissions: ecommerce.orders (orders), ecommerce.customers, ecommerce.shipping, ecommerce.marketing (coupons, promotions), ecommerce.reviews (moderation), ecommerce.settings (VAT rates, currencies, shop settings). Migration Version20260926120000 creates the new ones and grants them, with ecommerce.settings, to administrators and to every role that held ecommerce.orders, which gated those pages before.

Backoffice sales, marketing and configuration pages

Every page follows the shared admin contract ("Shared admin controls and partials"): page header with tooltip and meta, KPI tiles that filter the list, URL filters, sortable paginated k-table, empty states, translated labels (ecommerce domain) and localized money and dates. Lists run in SQL through read models; none loads a whole table to filter it in PHP.

  • Dashboard (/admin/ecommerce): new and processing orders, revenue of the last 30 days (cancelled and refunded excluded), stock outages, reviews to moderate, carts idle for 24 hours, new customers, latest orders, and the last run of each catalog import with the open import conflicts.
  • Orders (/admin/orders, OrderAdminQuery): search by reference, name words or e-mail, status, date range; payment and shipment state per row. The order page shows items, totals, customer, addresses, payment and invoice; the status and the shipment (status, carrier, tracking) save without a reload and update the badges and progress timeline. Cancelling or refunding asks for confirmation.
  • Customers (/admin/customers, CustomerAdminQuery): order count, spend and last order from one grouped join; registered/guest and active filters. The create/edit form validates e-mail (unique), names, phone, password (8+ unless guest) and the optional first address.
  • Coupons and Promotions: lifecycle status (active, scheduled, expired, used up, turned off), shared rule fields (DiscountRuleInput): discount type and value (a percentage cannot exceed 100), scope with typed targets picked by search (products, categories) or checkboxes (shipping methods), minimum order, validity window. Coupons add a unique code and use limits; promotions a priority.
  • Shipping, VAT rates, Currencies: tables edited in a drawer (the server renders it open for ?new=1 and /{id} without JavaScript). A VAT rate assigned to products, a shipping method used by orders (deleting it would cascade to shipments) and the base currency cannot be deleted. One default VAT rate and one base currency at a time; the base currency keeps rate 1.
  • Reviews: pending first, status and rating filters; approve and reject save without a reload.
  • Abandoned carts (Cart module): active carts idle for at least 1–72 hours (newest 200), with an e-mail-only filter for recoverable carts.
  • Invoices (Invoice module): register with status tiles and search, invoice page with sync state and errors, manual sync, and Fatture in Cloud settings that report invalid fields on the form instead of failing.

Form endpoints answer JSON when asked (Accept: application/json, used by the async form runtime) and redirect otherwise (post/redirect/get). Without JavaScript an invalid full form is re-rendered with its errors, while a single action such as an order status change redirects back without the message. Controllers share this through K0smos\Controller\Concerns\AdminFormResponses. The order timeline is cumulative: a parcel picked up, in transit or delivered marks processing done, also for an order refunded afterwards.

16.2 Public Catalog

/shop renders an active-only, server-paginated catalog with crawlable search, category, and previous/next links. Canonical details use /shop/products/{slug} (the numeric id URL redirects while id remains the cart, wishlist, review, and order identity); categories use /shop/categories/{slug}. Base slugs are unique per entity type (uniq_products_slug, uniq_categories_slug); every writer derives them through CatalogSlugger, which appends -2, -3, … on collision. A replaced base or translated slug is kept in catalog_slug_history, so its old URL answers 301 to the current page while the item stays active. Product/category pages emit canonical links, breadcrumbs, and schema.org JSON-LD. Media-backed list images use batched responsive variants and accessible alt text with the original URL as fallback. Active zero-stock products remain visible but are not purchasable; inactive products return 404 publicly while remaining editable in the backoffice.

Product detail is assembled by a typed, locale-aware application read model, not by template queries. The complete gallery is batch-resolved through Media and exposes responsive variants, accessible alt text, intrinsic dimensions and an original-image zoom target. Structured specifications use stable provider-neutral codes plus locale rows and round-trip through Data Exchange. Explicit related and complementary products are active-only, de-duplicated, positioned and bounded. Shipping/returns links come from DB-backed AppSettings. Variant selection progressively updates URL state, SKU, price, availability, quantity bounds, cart identity, gallery and JSON-LD Offer/image data. Variant galleries are tenant-curated separately from imported product images, resolve in the same Media batch, and fall back to the product gallery. Server-rendered GET selection and POST purchase forms keep the flow operable without JavaScript. Native canonical and structured breadcrumb URLs retain the active locale prefix.

A product belongs to its primary category and to any additional categories (for example an offers collection) stored in product_categories. A category landing lists both kinds and, when its rule includes them, the products of its active subcategories: each category chooses include, exclude or inherit, and inherit follows the shop-wide default in the Ecommerce settings panel (/admin/ecommerce/settings, default off). The same panel sets the product page shipping and returns links. A landing page shows the category name, description and image, a breadcrumb through its ancestors (also in the JSON-LD BreadcrumbList) and links to its active subcategories, and uses the category's meta title and description in the page head.

Every storefront surface links a category only when its landing page exists: the sidebar tree drops inactive categories with their subtree (CategoryService::createStorefrontTree()), slug-less categories render as text, and product cards, the product badge, the detail panel and the lts1 products widget link only active categories. Menu, megamenu and predictive queries resolve slugs like the storefront: base slug on the default locale, translated slug elsewhere, blank translations fall back.

The product page gallery keeps an Alpine preview (main image, arrows, counter, thumbnails, arrow keys, variant switches) and exposes its images through [data-product-gallery] to the shared full-screen viewer template/shared/asset/js/front/product-gallery.js (PhotoSwipe 5, MIT): swipe, pinch/zoom, keyboard, optional Fullscreen API and focus return. Without JavaScript every image stays a link to its full-size file.

Theme widgets and navigation surfaces obtain bounded category groups through CategoryService::listActive(parentId, limit). The shared read returns active categories with a non-empty slug from one exact tree level, ordered by position/id; dm1 and lts1 use this contract instead of duplicating category filtering in theme code.

The shop filter remains a canonical server-rendered GET form. Its progressive enhancement calls /shop/search/predictive?q=... after two characters and renders at most eight grouped product/category suggestions through an ARIA combobox/listbox. A bounded DBAL query contributes at most three active, locale-resolved category matches and preserves locale-aware category paths. The client debounces, cancels superseded requests, ignores stale responses, and keeps keyboard selection plus a localized “view all” link. The endpoint applies a Redis fixed-window limit of 30 requests per minute per tenant and hashed user/IP identity; it returns HTTP 429 with Retry-After and fails open if Redis is unavailable. Search logs retain only a term hash/length, provider, duration, result count, zero-result flag and random correlation id. On selection, the theme sends only that opaque id, position 1–8 and the product/category group to the separately rate-limited /shop/search/predictive/selection endpoint; query text, titles, entity ids and result URLs are not sent.

Ecommerce defines one provider-neutral ProductSearchDocument per product and tenant-supported locale, with stable {productId}:{locale} identity. Its bounded DBAL source batches localized product and variant names, SKUs, localized category ancestry, scalar variant attributes, structured specifications, decimal-string price range, normalized default-location availability, tenant identity, and explicit public status. Only active rows with a resolved name and slug receive a canonical public path. Storefront queries force tenant, locale, published, and active filters even if a caller supplies conflicting values; tenant-specific modern index names remain available through search.modern.index_per_tenant. Authoritative catalog, localization, Data Exchange and inventory writes enqueue typed projection messages. A persistent provider/index-scoped fingerprint and category-ancestry ledger makes Elasticsearch/OpenSearch, Meilisearch and Typesense updates and deletes retry-safe. Operators can reconcile the complete derived index with php bin/console ecommerce:search:rebuild --batch-size=100; repeated runs skip unchanged documents, remove stale rows, and --force rewrites current rows. SQL search is an explicit successful no-op because it has no remote index.

Storefront filtering, sorting and facets use the core allow-listed field contract. SQL and all four modern providers return the same ordered facet shape for category and currency; provider-specific relevance/price field names are mapped internally. Unknown fields are discarded before remote filter strings are assembled. Optional facet/sort capability gaps select SQL first, or are reported as explicit deterministic degradation when no fallback is configured.

16.3 Localized Catalog Content

Default-locale product and category text remains in products / categories. Optional overrides live in product_translations, category_translations, and the name-only product_variant_translations, keyed by entity plus locale with locale-scoped product/category unique slugs. Every nullable translated field falls back independently to its base value; a missing or partial translation therefore never removes a valid active entity from the storefront. CatalogLocalizationService batches list/category resolution and also resolves translated product slugs before base slugs.

Persisted product/category editors expose all tenant-supported locales with a default/missing/partial/complete status. Non-default locale forms edit only translatable content, show source-language provenance, allow fields to remain empty for fallback, and offer an explicit copy-from-default action. Stable numeric ids continue to own cart, wishlist, review, variant, and order links. LocalizedProductContent and LocalizedCategoryContent expose deterministic catalog.{entity}.{id}.{locale} keys for a future DataExchange provider without introducing a module dependency.

16.4 Import Ownership And Conflict Review

ImportMergePolicy is the provider-neutral pure merge contract. Ecommerce's CatalogImportOwnershipService applies the product/category/variant/image field matrix in CATALOG-CONTRACT.md; both Psapi and Wpapi consume it. Esapi currently imports clients and invoices rather than catalog entities and must use the same contract if catalog synchronization is introduced.

Explicit owners are normalized in catalog_import_field_ownership by entity, id, locale, and field. catalog_import_conflicts stores the source name and the two compared field values only — never a raw connector payload. Repeated open differences coalesce. Choosing Keep my edit acknowledges the current source value until it changes; choosing Use source value applies it and returns the field to source ownership. A later local edit supersedes stale pending conflict metadata.

The product, category, and variant editors render the compact ownership panel; /admin/catalog/conflicts lists every unresolved conflict (filter by source, item type, field) and resolves a selection in bulk: keep the catalog value, or accept the source value after a confirmation (up to 200 per request). The field-protection and conflict-resolution POST routes require ecommerce.edit and the ecommerce.catalog CSRF intention, then redirect back to the editor. Translated product/category content has independent locale-scoped ownership. Existing rows follow the source by default; historical non-source product/category images are adopted automatically as tenant overrides.

16.5 Normalized Inventory

Ecommerce inventory is normalized into sellable items, locations, optimistic levels, append-only movements, and expiring reservations. A variant is the sellable identity; products without variants use an explicit product fallback. InventoryService is the sole normalized write boundary: adjustments and reservation transitions update level state and record their movement in one transaction, reject conflicting reuse of an idempotency key, and prevent oversell or commit after expiry. Ecommerce catalog repositories route admin and Wpapi saves through its stock projector, Data Exchange reconciles it directly, and Psapi consumes the core CatalogInventoryWriterInterface. The service also owns the temporary stock_quantity projection, which mirrors default-location on-hand transactionally until remaining catalog readers move to normalized levels.

The ACL/CSRF-protected /admin/ecommerce/inventory workspace is where stock quantities change; the product and variant editors show stock read-only and link to it filtered on the SKU (their create forms keep an initial stock, which becomes the first ledger movement). The workspace offers:

  • KPI tiles for the location (out of stock, low stock, tracked SKUs, incoming units), each linking to the matching filter;
  • filters kept in the URL: search on SKU, product or variant name, status (out at available ≤ 0, low up to the level threshold, ok), category (its whole subtree, primary or additional assignment), location when more than one is active, and 25/50/100 rows per page;
  • a sortable, paginated table (available = on hand − committed) with row selection and a bulk low-stock threshold, which reports rows that changed meanwhile and leaves them selected;
  • an adjustment drawer: add or remove a quantity, or set a counted quantity (recorded as the difference), with a reason (goods received, stock count, damaged or lost, returned to stock, correction, manual), an optional note and the threshold. Saving needs no reload; a stale row version returns the fresh row, and on hand can never drop below committed;
  • a movement history drawer (reason, date, source, author, note);
  • a CSV export of the filtered view (; separator, UTF-8 BOM, 10,000 rows).

Ledger-only reasons (reconciliation, import, reservations, order transitions) are refused from the backoffice. Routes: GET /admin/ecommerce/inventory, GET …/movements and GET …/export need ecommerce.view; POST …/adjust and POST …/thresholds need ecommerce.edit and the ecommerce.inventory CSRF scope (form field or X-CSRF-Token header). POST …/adjust answers JSON when asked (Accept: application/json) and otherwise redirects back to the filtered list. Checkout reserves aggregated sellable identities; the core payment service resolves Ecommerce's optional shared coordinator and commits captured purchases or releases failed/cancelled ones without redefining the payment service in the module.

Operators must schedule the tenant-aware command below. It processes a stable expiry/id batch of 1–500 active reservations and is safe to retry:

TENANT_ENV=k0smos.example.com php bin/console ecommerce:inventory:expire-reservations --limit=100

17. Psapi / PrestaShop Integration

The Psapi module syncs data from a PrestaShop Legacy Webservice /api/ into the current Ecommerce and Media bounded contexts. Payload handling targets PrestaShop 1.7, 8, and 9, while real-shop certification remains a separate environment-dependent check. The runtime module depends on both bounded contexts and exposes an ACL-protected admin settings/status surface, a queue-backed back-office import action, plus import and redacted diagnostics CLI commands.

The 0.19.0 baseline includes canonical profiles, durable reload-safe queued runs, atomic checkpoints, dependency quarantine, stable external identity, authoritative stock, Media lifecycle ownership, Ecommerce admin/shop acceptance, shared field-level catalog ownership/conflict review, entity-level hard locks, legacy-table audit, and redacted compatibility probes. Imported products and categories also preserve their indexed source URLs as canonical through the opt-in Page URL rewrite subsystem (see the Page module), so PrestaShop link_rewrite (and WooCommerce slug) paths keep serving after an import instead of losing their search ranking. The Page back office validates tenant-specific PrestaShop/WooCommerce path patterns while retaining safe defaults. Native catalog and Blog detail URLs redirect directly to imported canonicals; deleting an authoritative Page, product, category, or post converts its non-manual URLs to HTTP 410, including replace-mode Data Exchange cleanup. Manual redirect rules remain untouched.

17.1 Architecture

modules/Psapi/
├── migrations/Version20260218000000.php
├── migrations/Version20260711113000.php
├── migrations/Version20260711170000.php
├── migrations/Version20260711210000.php
├── migrations/Version20260712133000.php
├── src/{Config,Controller,Routes}/
├── src/PsapiModule.php
└── templates/admin/psapi-*.tpl.php
src/
├── Application/Import/Ownership/   # Provider-neutral catalog merge policy
├── Application/Psapi/              # Settings, shared runner, typed job launch/status
├── Command/Psapi{Import,Diagnostics}Command.php
├── Domain/Psapi/                    # Sync, quarantine, and external-identity contracts
└── Infrastructure/Psapi/
    ├── Client/                       # PSR-18 Legacy Webservice client
    ├── Diagnostics/                  # Redacted compatibility and legacy audit probes
    ├── Import/                       # Nine entity importers + ImportResult
    ├── Queue/                        # Typed back-office import handler
    └── Sync/                        # DBAL sync, quarantine, and identity repositories

17.2 Client And Pagination

PrestaShopWebserviceClient talks to the PS Legacy Webservice through HTTP Basic authentication (API_KEY:) and requests JSON output:

GET /api/{resource}?output_format=JSON&display=full&limit={offset},{pageSize}

Configuration is read only from tenant-scoped AppSettings (psapi.*). Import legacy module_config.psapi values with tenant:config:migrate-editable; the runtime never falls back to tenant JSON. New deployments save credentials through /admin/psapi/settings; do not add the API key to tenant JSON.

Key Default Purpose
base_url - Root URL of the PrestaShop shop
api_key - PS Webservice key; masked in the admin response
lang_id 1 Language ID for multilingual fields
source_locale tenant default Enabled tenant locale represented by lang_id
page_size 100 Records per API page
default_currency EUR Currency for imported products/orders
allow_private_network false Explicit local-network destination opt-in
allow_insecure_http false Explicit unencrypted HTTP opt-in
order_state_mapping {} Source order-state ID to k0smos status mapping

Product/category imports persist the selected text in Ecommerce's locale rows with source=prestashop and source_language_id=lang_id. When source_locale differs from the tenant default, the importer refreshes the translation while preserving existing base-locale text; a new entity seeds the base row so default fallback is always renderable. Preserved source URL rewrites use the same mapped locale. Product/category importers discover the PrestaShop language table once per run and register every localized link_rewrite; the configured lang_id/source_locale mapping remains the fallback when language discovery is unavailable.

Outbound requests stay under the configured same-origin /api path. The client resolves all destination addresses before construction and every retry, rejecting private/reserved networks and HTTP unless their independent AppSettings flags are enabled. Transport failures, 429, and 5xx responses retry at most three times with bounded backoff; authentication and redirect responses are not retried or followed. The dedicated module transport uses a 5-second connect timeout and a 30-second total request timeout.

Core dependency failures are persisted in metadata-only psapi_quarantine records. Products require their non-root default category; orders require a real imported customer, every referenced product, valid source currency data, and a known/configured positive state ID. Blocked rows are not saved with null catalog links, synthetic @import.local email addresses, product id 0, or unknown-state fallbacks. Quarantine stores no remote payload or duplicated PII. Once the parent is available, rerun the affected entity without --since; a successful save removes its quarantine record. Dry-run reports candidates without persisting them.

The settings page and both status endpoints require psapi.view; all mutations require psapi.manage. POST /admin/psapi/settings, POST /api/admin/psapi/test-connection, and POST /api/admin/psapi/import opt in to the shared psapi.settings CSRF intention. Cookie-authenticated browser calls submit a session synchronizer token in X-CSRF-Token (with a parsed form-field fallback), while explicit Bearer authentication remains available for non-ambient API clients. Full-kernel tests cover anonymous 401/redirect behavior, read/manage permission separation, and valid/invalid CSRF paths.

The settings page includes a translated import control for the core and extended profiles plus dry-run. It saves the current form before launching the job, so a reload renders the persisted AppSettings values instead of defaults. PsapiImportRunner is shared by CLI and back-office execution and preserves the canonical dependency order. The back office dispatches PsapiImportMessage through the worker-aware typed queue bridge and falls back to the same handler synchronously when no worker is active. Queue payloads contain only job id, profile, and dry-run; credentials remain in tenant AppSettings.

GET /api/admin/psapi/import-status returns a requested or latest credential-free run. psapi_import_runs and psapi_import_run_entities are the authoritative status, counter, fixed-watermark-window, and per-entity checkpoint store. PsapiImportJobStore coordinates that repository and writes only the latest run id plus a lightweight credential-free projection to AppSettings. Only one pending/running run per profile is accepted; a duplicate launch returns HTTP 409 with the existing run so the browser can adopt it.

The settings controller resumes polling automatically after a reload, labels worker-queue versus synchronous fallback execution, retries transient status failures with bounded backoff, stops on a bounded timeout or stale run, and keeps a manual refresh action. Worker-queued execution is independent from the browser; synchronous fallback must finish the launch request before the page is closed.

The client exposes generators for ordinary offset iteration and durable source-ID cursor iteration, keeping memory constant regardless of dataset size:

// PsapiClientInterface
public function fetchAll(
    string $resource,
    array $filters = [],
    int $pageSize = 100,
    int $startOffset = 0,
): \Generator;

public function fetchAllAfterId(
    string $resource,
    array $filters = [],
    int $pageSize = 100,
    int $afterId = 0,
): \Generator;

// Usage in an importer
foreach ($this->client->fetchAll('products', $filters) as $batch) {
    foreach ($batch as $data) { /* process record */ }
}

Date-capable resources currently use PS date-range syntax:

?filter[date_upd]=[2024-01-01 00:00:00,2026-12-31 23:59:59]

PrestaShop requires date=1 when date filters are used. The client adds it automatically and resolves envelope/watermark behavior through PsapiResourceCapabilityMap. Customers, categories, products, and orders use a closed date_upd interval whose upper bound is captured when the run starts. Inside that interval, durable runs sort by [id_ASC] and repeatedly request offset zero with filter[id] advanced beyond the last committed source ID. This avoids offset shifts when already-seen source rows change between pages. The destination page and its source-ID checkpoint commit atomically; the stored offset remains only a progress counter. Because combinations/carriers do not expose a portable date_upd contract, those extended importers deliberately perform a full idempotent refresh when --since is supplied. Selective imports run PsapiSelectiveDependencyPreflight first, using capability dependencies and metadata-only quarantine records to report missing parents without exposing source payloads.

17.3 Importers

All eight importers share the first three arguments. The four resumable core importers add the optional durable run context shown here:

public function import(
    ?SymfonyStyle $io = null,
    ?\DateTimeImmutable $since = null,
    bool $dryRun = false,
    ?PsapiImportRunContext $runContext = null,
): ImportResult
Importer PS resource Authoritative destination Notes
CustomerImporter customers ecommerce_customers Upsert by source id
AddressImporter addresses ecommerce_customer_addresses Resolves customer/country/state; durable identity survives alias and postal changes
CategoryImporter categories categories Skips PrestaShop root ids <= 2
ProductImporter products products Resolves the default category
VariantImporter combinations product_variants Resolves option labels; durable identity survives SKU changes
StockImporter stock_availables inventory_items, inventory_levels, inventory_movements Uses the provider-neutral inventory writer with psapi provenance; base stock updates product inventory, combinations resolve durable variant identity, and legacy columns are refreshed only as a service-owned projection
ImageImporter images/products/*, images/categories/* product_images, categories.image_url, module_media Republishes validated binary assets through Media and records source media identity
CarrierImporter carriers ecommerce_shipping_methods Durable identity survives labels; tenant shipping prices/rules remain manual and are preserved
OrderImporter orders ecommerce_orders, ecommerce_order_items Normalizes one or many order rows

Order rows preserve historical commercial snapshots but deliberately contain no image URL or binary snapshot. OrderItemMediaResolver looks up the current primary catalog image once per distinct product for admin order details, customer order history, and payment confirmation. The presentation falls back cleanly when the product was deleted or has no image, while imported Media binaries remain owned by the product catalog.

The implemented required/defaulted fields and rerun ownership rules are maintained in modules/Psapi/src/CORE-FIELD-MATRIX.md. For catalog entities it distinguishes source-owned, tenant-owned, tenant-overridable, and never-imported fields plus the entity hard lock; for customer/order data it retains derived configuration, mapped dependencies, historical snapshots, and preserved enrichment. The shared conflict policy is Ecommerce-owned and Wpapi consumes the same core merge service rather than maintaining a second matrix.

ImportResult is a final readonly Value Object:

final readonly class ImportResult
{
    public function __construct(
        public string $entity,
        public int $imported,
        public int $updated,
        public int $skipped,
        public int $failed,
        public array $errors = [],
        public int $quarantined = 0,
    ) {}

    public function merge(self $other): self { /* ... */ }
    public function total(): int { /* includes quarantined */ }
}

17.4 Durable Runs And Sync Log

Each importer still records cumulative compatibility/status state in psapi_sync_log after a run (skipped with --dry-run). Per-run counters and resumable progress do not use this table: they live in psapi_import_runs and psapi_import_run_entities. Migration Version20260711210000 removes the old last_page column.

CREATE TABLE psapi_sync_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entity_type VARCHAR(50) NOT NULL UNIQUE,
    last_sync_at DATETIME NULL,
    records_imported INTEGER NOT NULL DEFAULT 0,
    records_failed INTEGER NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

SyncLogRepositoryInterface / DbalSyncLogRepository follows the standard repository pattern: interface in Domain/, implementation in Infrastructure/.

DbalImportRunRepository owns durable run/entity records. A run captures mode, profile, dry-run flag, execution mode, fixed watermark window, status, timestamps, aggregate counters, and a bounded redacted error summary. Each core resumable entity stores its last committed source ID, committed-row offset, and counters. The source ID is the authoritative resume cursor; the offset is not sent back to the source for durable runs. PsapiImportRunContext::commitPage() wraps destination writes and checkpoint advancement in one DB transaction. Duplicate queue deliveries cannot claim a fresh/completed run twice; failed or stale deliveries resume from the committed checkpoint.

Rows whose destination model has no native ps_id use psapi_entity_map. The table stores only source type/id and local type/id pairs. Addresses, combinations, carriers, and product/category media adopt previously imported records through their legacy fallback on the first post-migration run, then use the durable source link when mutable business fields change.

17.5 Module Migrations

The Psapi module owns its Doctrine migration class in:

modules/Psapi/migrations/Version20260218000000.php
modules/Psapi/migrations/Version20260711113000.php
modules/Psapi/migrations/Version20260711170000.php
modules/Psapi/migrations/Version20260711210000.php
modules/Psapi/migrations/Version20260712133000.php
namespace App\Migrations\Module\Psapi

config/migrations.php auto-discovers co-located directories under modules/{Name}/migrations and maps them to App\Migrations\Module\{Name}. The initial Psapi migration creates the legacy sync structures; the later migrations add psapi_quarantine with a unique entity/source key and dependency lookup index, then psapi_entity_map with unique source identity and local-record lookup indexes. Version20260711210000 adds durable import runs/entities with the one-active-run-per-profile key and removes cumulative last_page checkpoint semantics. The initial migration also creates the legacy ps_customers table for backward compatibility; current importers do not write to it. After a static runtime-usage audit and a zero-row audit on the locally resolved tenant, Version20260712133000 reversibly archives that table as ps_customers_legacy instead of deleting potentially deployed data.

The read-only audit and redacted compatibility probe are available through:

TENANT_ENV=k0smos.example.com php bin/console k0smos:psapi:diagnostics --audit-legacy-customers --json
TENANT_ENV=prestashop9.example.com php bin/console k0smos:psapi:diagnostics --smoke-version=9 --json

The smoke probe reads at most one row from each required JSON resource plus one product image listing and emits only version/resource status metadata. PrestaShop 1.7, 8, and 9 compatibility must be recorded from real shops; mock-client coverage is not staging evidence. The live certification procedure is documented in modules/Psapi/src/README.Psapi.md.

$moduleGlob = ROOT_DIR . DIRECTORY_SEPARATOR . 'modules'
    . DIRECTORY_SEPARATOR . '*'
    . DIRECTORY_SEPARATOR . 'migrations';
foreach (glob($moduleGlob, GLOB_ONLYDIR) ?: [] as $dir) {
    $moduleName = basename(dirname($dir));
    $migrationPaths['App\\Migrations\\Module\\' . $moduleName] = $dir;
}

MigrateCommand supports --module to run migrations for one module only:

php bin/console migrate --module=psapi

The deployment-level migration entry point is composer app:migrate. Without TENANT_ENV, its wrapper discovers all canonical configurations under config/tenants/, de-duplicates aliases, and runs MigrateCommand once per tenant. When TENANT_ENV is set, only that tenant is migrated. Additional options are forwarded after Composer's argument separator, for example:

composer app:migrate -- --allow-no-migration
TENANT_ENV=k0smos.example.com composer app:migrate -- --module=psapi

composer app:migrate-all explicitly forces the canonical all-tenant scope, even when the calling process or a loaded .env file defines TENANT_ENV. It uses the same wrapper and forwards options in the same way:

composer app:migrate-all -- --allow-no-migration

Direct php bin/console migrate execution remains single-tenant and falls back to localhost when TENANT_ENV is absent.

17.6 Tests With MockPsapiClient

Importer tests use isolated in-memory or temporary SQLite databases and a MockPsapiClient. Four representative resources are loaded from JSON fixtures; the remaining resource payloads are supplied by focused tests.

tests/
├── Fixtures/Psapi/
│   ├── customers.json    # 10 customers in PS format
│   ├── categories.json   # 5 categories (ids 1,2 skipped as PS roots)
│   ├── products.json     # 8 products with multilingual name array
│   ├── orders.json       # 5 orders, some with single-object order_row
│   └── core-commerce.json # dependency-complete core dataset imported twice
├── Unit/Psapi/
│   ├── MockPsapiClient.php          # Implements PsapiClientInterface from fixtures
│   └── ImportResultTest.php         # Unit tests for ImportResult VO
└── Integration/Psapi/
    ├── {Customer,Address,Category,Product}ImporterTest.php
    ├── {Variant,Stock,Image,Carrier,Order}ImporterTest.php
    ├── PsapiImportCommandTest.php   # canonical order and CLI summary
    └── CoreCommerceImportAcceptanceTest.php # stable IDs and no degradation across reruns

The current focused Psapi suite passes 164 tests and 773 assertions. It covers the importer layer, canonical core/extended command profiles, real HTTP-client URL/envelope/capability contracts, outbound destination policy/retries, product/category media lifecycle, source order currency/rate/state mappings, failed-row exit behavior, settings, controller 409/422/502 response contracts, shared CSRF middleware, full-kernel authentication/ACL/CSRF behavior, dependency quarantine/preflight paths, inclusive timestamp boundaries, full-page failure and resume, source changes between pages, cross-resource cursor wiring, a twice-imported dependency-complete core fixture, Ecommerce catalog visibility/editing, official address/carrier semantics, authoritative stock, redacted diagnostics, settings persistence, and the typed import job lifecycle. Four executable jsdom regressions inside the 16-test default-theme frontend suite cover reload polling, duplicate-run adoption, transient polling recovery, and bounded repeated-failure stop.

DBAL 4-compatible setup pattern:

protected function setUp(): void
{
    $tenant = new Tenant('psapi-test', 'k0smos.example.com', [
        'database' => ['driver' => 'pdo_sqlite', 'url' => 'sqlite:///:memory:'],
    ]);
    $this->conn = (new ConnectionFactory())->create(new TenantContext($tenant));
    // Apply Psapi migration inline
    $migration = new Version20260218000000(/* ... */);
    $migration->up(new Schema());
}

17.7 Wpapi WordPress/WooCommerce Import

Wpapi consumes the shared import HTTP transport and outbound URL policy, then executes entity importers through WpapiImportRunner. The runner is the single owner of dependency order and exposes woo-core, wp-content, and full; selective CLI --entity runs preserve that order. wp-content resolves no WooCommerce importer/client, and the CLI returns failure when any importer reports failed rows.

WooCommerce product/category/variant/image importers use the same Ecommerce field matrix and ImportMergePolicy as Psapi. They do not own parallel lock or conflict tables. Source-owned stock refreshes; tenant-overridable local edits survive and create reviewable compared-value conflicts; import_locked remains the hard product/category freeze. Historical non-Woo product images are adopted as tenant overrides.

The settings panel launches those same profiles through a credential-free typed message on the shipped default logical queue. The payload contains only job id, profile, and dry-run; the handler resolves tenant AppSettings at execution time. If no active worker heartbeat exists, the dispatcher invokes the same handler inline and reports synchronous mode. POST /api/admin/wpapi/import requires wpapi.manage plus the wpapi.settings CSRF intention; GET /api/admin/wpapi/import-status requires wpapi.view. Lightweight AppSettings snapshots claim one active job per profile, support HTTP 409 adoption, and let the shared Psapi/Wpapi admin controller resume bounded polling after reload. They contain no credentials/source payloads and are an interim lifecycle mechanism; durable run/entity/checkpoint tables stay in the next reliability phase.

Source URL continuity uses Page's existing subsystem, exactly like Psapi. The WooCommerce product/category and WordPress post importers receive UrlRewriteWriterInterface explicitly and upsert canonical rows in module_page_url_rewrites under the configured wpapi.default_lang. Ecommerce renders product/category; Blog contributes the published-only blog_post renderer. Page resolves flat and nested/dotted source paths through its existing dispatcher/catchall. Wpapi owns neither a rewrite table nor a parallel router, and a rewrite row never makes a draft Blog post public. Localized WooCommerce translation values add per-language canonical rows while the base payload retains default_lang. Source URL patterns are managed by Page, not duplicated in Wpapi.

GET /sitemap.xml combines canonical HTTP 200 rewrites with active-module providers for published native Pages, catalog products/categories, and Blog posts. Redirect and gone rows are excluded, and a native entity URL is omitted when that entity already owns an imported source canonical.

17.8 Esapi EasyStore Import

Esapi imports EasyStore contacts into Client and issued invoices into Invoice; those modules remain the authoritative domain owners. Esapi owns tenant-scoped connection settings, external-ID mappings, provenance, and synchronization state. Its canonical execution order remains anagrafica before fattura, and dry-run execution must not mutate destination or synchronization records.

The client is created only through EasyStoreApiClientFactory over the shared bounded/no-redirect PSR-18 import transport. Public HTTPS is the default; private/reserved networks and insecure HTTP require separate warning opt-ins. Destination policy is checked before every attempt, JSON responses are capped at 10 MiB, and only transport errors, HTTP 429, and 5xx responses receive at most three attempts with capped exponential jitter/backoff. Settings save and connection probe require esapi.manage plus the esapi.settings CSRF intention. Operator responses are translated and never contain upstream bodies, API keys, target identifiers, or transport exception details.

The upstream contract is not yet certified. The evidence matrix and precise unblocking requirements live in modules/Esapi/src/VENDOR-CONTRACT.md. Current paths, envelopes, and status handling are legacy implementation observations; pagination, timezone, incremental-window, quota, schema, idempotency, webhook, and sandbox rules must not be invented until an approved versioned vendor export, sandbox, and redacted fixtures are archived.


18. Admin Settings And Permission APIs

18.0 Admin Dashboard

The /admin route is the administrative home, not an ACL inventory.

  • it shows contextual shortcuts to profile, settings, system info, settings editing, users, and roles based on effective permissions
  • it exposes system/operational summaries; the full personal ACL summary moved to /admin/user-settings and is visible only to users with the admin role
  • it keeps catalog/sales operational blocks lower on the page as a quick work area
  • it no longer duplicates extended ACL tables; the read-only inventory remains in /admin/settings/info

18.0.1 User Self-Service Settings

/admin/user-settings and /admin/theme/{code}/settings are personal pages for the authenticated account. They do not replace operational ACL management and do not require users.*, roles.*, or system.read; they require only authentication. Active theme modules publish their settings link below “Theme settings” in the topbar user menu through the system.user / admin.user channel. Theme preferences no longer create a primary-sidebar root.

Route Path Permission Purpose
admin_user_settings /admin/user-settings authentication Personal profile, email, password, language, UI tooltip toggle, and administrative access summary
admin_theme_{code}_settings /admin/theme/{code}/settings authentication Capability-filtered preferences implemented by that theme module; default owns palette, mode, background, admin width, and visual-editor controls; sober owns mode/density/sidebar; pc1/pc2/pc3/cc1/dm1/lts1 own mode
admin_theme_user_settings /admin/theme/user-settings authentication Compatibility redirect to the former default aggregate panel
api_user_settings_profile /api/user-settings/profile authentication Updates the current account profile
api_user_settings_email /api/user-settings/email authentication Starts an email change after current-password verification; returns pending verification metadata when enabled
api_user_settings_password /api/user-settings/password authentication Starts a password change after current-password verification; returns pending verification metadata when enabled
api_user_settings_verify_change /api/user-settings/verify-change authentication Verifies TOTP/email OTP and applies the pending email or password change
api_user_settings_locale /api/user-settings/locale authentication Updates preferred language and current session
api_user_settings_theme /api/user-settings/theme authentication Saves CSS palette and/or light/dark mode for the current user; an omitted preference is preserved

Operational rules:

  • it remains a self-service page and does not edit other users, roles, or permissions
  • the "Your administrative access" block appears only when the authenticated user has the admin role
  • password and email changes require the current password first, then TOTP or email OTP when tenant bootstrap policy features.require_change_verification is enabled or omitted
  • pending password/email changes live in user_pending_changes; repositories must fail fast when the table is missing and schema changes must go through Doctrine migrations
  • in the same block, the "Open user sheet" link to /admin/users/{id} appears only when the user also has effective users.write
  • the page summarizes roles, effective permissions, enabled features, and last login, but ACL editing remains centralized in /admin/users/{id} and /admin/roles/*
  • the tooltip toggle is client-side only; it stores preference in the browser with k0smos.tooltips, does not use dedicated APIs, and applies to frontend and admin layouts
  • palette and mode are persisted server-side on the authenticated user (theme_palette, theme_mode) and applied to layouts through html[data-palette][data-mode]; capability-specific controls may submit either field without resetting the omitted preference
  • admin content width is a browser-local default-theme preference: k0smos.adminContentMode (fluid / fixed) and k0smos.adminContentMaxWidth, applied by the admin layout through html[data-k0smos-admin-content] and --k-admin-content-max-width; it is available in /admin/theme/default/settings and in the topbar quick toggle
  • background pattern is exposed only by implementing modules (default) and remains browser-only with k0smos.bgPattern
  • sober density and initial sidebar state remain browser-only with k0smos.sober.density and k0smos.sober.sidebarCollapsed
  • the shared visual editor engine (quill or tiptap) is a theme technical preference saved only in the browser with k0smos.visualEditorEngine; it does not go through tenant config and must not be resolved by module controllers
  • the admin topbar fullscreen command uses the browser Fullscreen API and introduces no server-side or tenant-side persisted state

18.1 Settings Pages

The admin settings area is split across overview, operational sub-pages, and a full write cockpit:

Route Path Permission Purpose
admin_settings /admin/settings system.read Read-only overview of application settings (Klaro, CAPTCHA, social, company data)
admin_settings_app /admin/settings/app system.read Runtime module inventory and application-structure entry point exposed in the sidebar
admin_settings_menus /admin/settings/menus system.write Persistent system menu ordering/visibility plus custom menu library

Custom menus support backward-compatible group, route, and path items plus typed block payloads contributed only by active modules. Draft/published state, localized labels, a monotonic optimistic version, validated canonical JSON, and keyboard/numeric reordering are persisted by core Menu. A stale save returns HTTP 409; validation errors are translated at the HTTP boundary. Widget, Page, and Ecommerce contribute metadata through ConfigurableBlockProviderModuleInterface, so core Menu has no dependency on their bounded contexts.

The dedicated configurator derives its locale fields and default from the tenant locale middleware rather than assuming EN/IT. It exposes the active module schemas as source-specific controls, uses the published Media library for media fields, previews the localized hierarchy, and shows metadata-only resolver diagnostics for stale references. The page requires system.write; its library read API requires system.read, while save/delete require system.write and the menu.settings CSRF intention. Contextual tooltips and paired k-panel-desc copy are rendered by default and remain available when sober uses fallback. The page template passes the CSRF token to both isolated Plates panels and the tenant locales/default locale to the custom-library panel explicitly; inserted partials do not inherit the parent's local data.

The reusable Composition surface is deliberately metadata-only. Widget, Page, and Menu share stable type codes, labels/descriptions, typed field schemas, defaults, required/range/choice/URL validation, normalized payloads, optional Media identifiers, consumer contexts, and optional editor-partial metadata. Their persistence, editor lifecycle, and rendering remain separate: Widget owns module_widgets.type/data plus extension/theme rendering; Page owns GrapesJS json_data plus published HTML/CSS; Menu owns its tree, localization, draft/publication state, optimistic version, and menu renderer. URL fields accept only internal absolute paths, fragments, and HTTP(S) URLs; protocol-relative and non-web schemes are rejected. Registry serialization is stable by block and field code, collisions fail fast, and only active opt-in modules contribute. Ecommerce dynamic-category blocks use CategoryMenuQueryInterface: its DBAL read model resolves localized active nodes breadth-first, preserves sibling position/id order, issues at most one bulk query per level, and enforces a maximum of three descendant levels and 60 nodes including the root. Persisted ecommerce.category_branch items are revalidated by the Ecommerce resolver before invoking that query. Its read service consumes the tenant-scoped Menu repository for one requested hook and projects only active, published collections. Missing/inactive references produce no public block or broken link; their metadata-only diagnostic is retained separately and appears only when an admin/debug consumer explicitly asks for diagnostics. The menu editor picks curated ids instead of typing them: a ConfigurableFieldSchema may declare a pickerSource, an internal /api/ path the editor queries with ?q=term, ?ids=1,2 (labels for saved selections) or no parameter (browse). The answer is {"items": [{"id", "label", "detail", "active", "depth"?}], "truncated"}; integer fields pick one id, entity_ids fields several (chips). Ecommerce serves /api/admin/ecommerce/pickers/categories (tree order with depth and breadcrumbs; search by name or slug; 300 browse / 30 search rows) and /api/admin/ecommerce/pickers/products (search by name or SKU, 30 rows; browse lists the latest active products), both ecommerce.view. Fields with choices render as a select.

Automatic blocks read through CatalogMenuSourceQueryInterface instead of curated ids:

  • ecommerce.catalog_tree lists the active root categories with their subcategories (depth 1–3, 1–60 nodes filled breadth-first, position order).
  • ecommerce.category_children lists one category's subcategories as megamenu columns under a link to the category (depth 1–2).
  • Both can hide categories whose subtree has no active product (on by default) and show product counts: distinct active products of the subtree, counting primary and additional assignments.
  • ecommerce.new_products shows the newest active products, optionally within one category and its active descendants (category_id 0 means the whole catalog).
  • ecommerce.on_sale shows products covered right now by an active promotion without a minimum order amount: a percentage or fixed discount scoped to the product or to its primary category, as checkout matches it. With no such promotion the block resolves to nothing and the menu drops it.

The same resolver is contributed through MenuBlockResolverProviderModuleInterface, so normal core Menu rendering and the Menu API receive bounded public block data without importing Ecommerce. Its collaborators (product query, Media, cache, content revision, tenant and catalog source) are optional constructor parameters, which PHP-DI autowiring skips; modules/Ecommerce/src/Config/services.php wires each one explicitly. Unknown, invalid, missing, or inactive references are removed before template or API serialization; diagnostics are not part of the public payload.

EcommerceMegamenuReadService caches one requested-hook projection for five minutes. Its identity includes tenant, normalized locale, collection code/ version/publication/activation and a DBAL content revision, so publication and relevant catalog changes select a new key. The catalog part of the revision comes from the committed content-change outbox (content_changes rows of the ecommerce owner: identity count and revision sum, deletions included), plus category/product updated_at, Media updated_at, additional category assignments and promotions. The core PSR-16 cache is per request today; the key already invalidates correctly for a persistent binding. On the lts1 staging copy, the catalog tree plus new-products blocks cost about 8 ms p50 and 13 ms p95 per page (home page 113/131 ms with the blocks, 102/123 ms without, PHP built-in server without opcache). default renders all ten Ecommerce block types through ecommerce-menu-block-content.tpl.php; desktop and mobile use the shared native details/summary disclosure partial. Links remain in initial server HTML, Escape closes and restores summary focus, outside click closes, and explicit focus/reduced-motion/viewport-overflow styles support keyboard, touch and zoom. Resolver budgets cap category branches at three levels/60 nodes, curated categories at 24, featured product output at eight and editorial links at 16.

| admin_settings_info | /admin/settings/info | system.read | Dynamic system information: users, roles, permissions, runtime modules, features | | admin_settings_edit | /admin/settings/edit | system.write | Full application setting management, permission CRUD, and runtime module switches (Alpine.js) | | admin_settings_feature_flags | /admin/settings/feature-flags | system.write | Dedicated subpage for global feature flags (read/write through /api/settings/features) | | admin_settings_agents | /admin/settings/agents | system.write | Local agent CLI connectors overview (host-gated; read/write through /api/settings/agent-connectors) | | admin_theme_sober | /admin/theme/sober | system.read | Sober admin theme overview contributed by theme_sober | | admin_theme_sober_settings | /admin/theme/sober/settings | authentication | Sober user preferences: mode, density, and initial sidebar state |

UI notes:

  • admin pages settings, settings/info, and settings/edit use shared contextual tooltips for micro-help and ACL/route metadata
  • the full settings write cockpit, including notification, social, consent, CAPTCHA, assistance, runtime-module, ACL, AI/client, and menu panels, resolves visible server and Alpine copy from the EN/IT settings catalogue; translated Alpine labels are safely JSON-encoded and do not use language-specific JS fallbacks
  • company favicon settings use a visual Media thumbnail picker, never raw URL inputs or a native select. One JPEG/PNG/WebP source generates padded browser, Apple, and separate PWA regular/maskable variants; legacy URL values remain read-only until an operator selects Media or explicitly restores defaults
  • integration and device-operation pages use contextual help selectively on high-risk surfaces: OneUptime explains its tenant-scoped live overview and Modbus highlights connection parameters and immediate, non-reversible writes
  • an enabled theme module is independent from the currently selected admin theme: ThemeSober overview renders through the canonical page-header fallback, while its settings route inherits the capability-filtered default panel
  • technical details in the debug block appear only when the app is in debug mode and the current user is admin/dev

Controller: AdminSettingsController (src/Controller/AdminSettingsController.php)

  • view() loads read-only application settings state plus a dynamic system summary
  • info() loads users with roles, roles with permissions, runtime modules, features, and permissions in a read-only table
  • app() exposes the runtime module application page rendered at /admin/settings/app
  • edit() loads permissions and modules; editable settings are then managed through Alpine.js APIs
  • featureFlags() renders the dedicated /admin/settings/feature-flags subpage; flags are read/written through FeatureFlagsSettingsApiController (GET|POST /api/settings/features)

The dedicated menu page is handled by AdminMenuSettingsController (src/Controller/AdminMenuSettingsController.php) and renders template/default/tpl/admin/settings/menus.tpl.php.

Admin sidebar:

  • Settings is a static non-clickable sidebar group owned by Front
  • current children are Overview (/admin/settings), Modules (/admin/settings/app), AI and clients (/admin/settings/ai), Agent connectors (/admin/settings/agents), Payments (/admin/settings/payment), Feature flags (/admin/settings/feature-flags), Menus (/admin/settings/menus), Roles (/admin/roles), the Translation module entry (/admin/translations), and System info (/admin/settings/info)
  • Translation is contributed by the Translation runtime module under the shared settings root
  • appearance-specific personal pages live below “Theme settings” in the topbar user menu; they do not add a sibling Theme root to the admin sidebar

Templates: template/default/tpl/admin/settings/

  • view.tpl.php - settings overview with quick access to system info and edit page
  • info.tpl.php - dynamic ACL/runtime inventory with live DB data
  • edit.tpl.php - translated Alpine components for company, mail, notification, social, Klaro, CAPTCHA, assistance, permission CRUD, and runtime module state; AI/client and menu configuration is composed from translated settings partials

18.2 Settings APIs

JSON APIs for admin settings management:

Method Path Permission Action
GET /api/settings/captcha system.read Reads CAPTCHA state
POST /api/settings/captcha system.write Saves CAPTCHA
GET /api/settings/company system.read Reads public company metadata (company.*)
POST /api/settings/company system.write Saves public company metadata (company.*)
GET /api/media/favicons system.read Lists bounded tenant favicon source candidates and prepared/active state
POST /api/media/favicons/active system.write Generates the complete family and assigns one tenant-scoped Media ID
POST /api/media/favicons/reset system.write Clears the managed assignment/projection and restores shared defaults
POST /api/media/favicons/clean-rebuild system.write Removes every generated favicon family and recreates only the active one
GET /api/settings/klaro system.read Reads Klaro configuration
POST /api/settings/klaro system.write Saves Klaro
GET /api/settings/social system.read Reads social links
POST /api/settings/social system.write Saves social links
GET /api/settings/modules system.read Reads requested/effective runtime modules
POST /api/settings/modules system.write Saves runtime module state
GET /api/settings/features system.read Reads global feature flags
POST /api/settings/features system.write Saves existing global feature flags
GET /api/settings/agent-connectors system.read Reads the local agent CLI connector registry + enablement-gate status
POST /api/settings/agent-connectors system.write Saves the connector registry (enable_secret, connectors)
POST /api/settings/agent-connectors/test system.write CLI version health probe (does not require gate unlock)
POST /api/settings/agent-connectors/run agent.run Executes a non-interactive prompt (gate-enforced)

Operational note:

  • feature flags are logical/global toggles read from enabled_features
  • they do not replace runtime modules and do not stop bootstrap, routes, or services
  • plain module-name feature flags such as blog, bookmark, and widget are deprecated/removed; module activation is authoritative in tenant JSON plus /api/settings/modules
  • runtime toggles can act only on modules listed in tenant JSON; if a module is missing from the modules array, UI and API show it as tenant-blocked
  • show_legal_footer controls visibility of the company legal metadata block in public footers; policy links and the Klaro trigger remain route-driven and tenant-aware

Scope and evaluation paths

Feature flags are global per tenant, not per-user. The enabled_features table holds one row per flag for the whole tenant database (code is unique); there is no user dimension — configured_by is audit only ("who toggled it"), not "for whom".

All runtime gating resolves through a single service, K0smos\Application\System\FeatureFlagResolver, which applies the canonical defaults in K0smos\Domain\System\FeatureFlagDefaults over the global state read from the repository. Centralising here means defaults and state are computed in one place and cannot drift between call sites, and the store being unavailable degrades to defaults instead of failing the render.

Path Entry point Consumers
Template globals FeatureFlagsViewDataProvider → FeatureFlagResolver::resolve() → featureFlags view key Theme templates (e.g. show_legal_footer in the front footer)
Menu / slot gating UserFeatureFlagChecker → FeatureFlagResolver::isEnabled() MenuItem::$feature gating, TemplateIntegration::$featureFlag slot gating (TemplateSlotRenderer)
Admin CRUD DbalFeatureFlagRepository (findAll/findStates/findKnownCodes/updateStates) GET|POST /api/settings/features, admin UI; findStates() also backs the resolver

User::$enabledFeatures / User::hasFeature() are not a gating path: they carry a read-time snapshot of the global set (computed with the same FeatureFlagDefaults) used only by display surfaces (user settings, debug tracer) and are the carrier reserved for future per-user overrides.

Default semantics: a code listed in FeatureFlagDefaults defaults to its declared value; an explicit row in enabled_features always overrides the default; codes not listed there default to disabled when absent. The admin API only writes known codes (findKnownCodes()), i.e. codes already present in the table — it cannot mint arbitrary flags.

Adding a new feature flag

  1. Seed the row in a migration (INSERT OR IGNORE INTO enabled_features (code, enabled) VALUES ('my_flag', 1)), so it exists, is toggleable from the admin UI, and is accepted by the save API.
  2. If the flag must stay enabled even when its row is missing (fresh installs, DB errors), add it to FeatureFlagDefaults::DEFAULTS.
  3. Consume it: read featureFlags['my_flag'] in a template, set 'feature' => 'my_flag' on a menu item, or set featureFlag: 'my_flag' on a TemplateIntegration.

Per-user feature flags (future)

Per-user flags are a legitimate need — gradual rollout, beta opt-in, A/B tests, plan-based capabilities. They are not implemented today: every user resolves to the same global set. The architecture keeps the door open without committing to a half-built design — FeatureFlagResolver::resolve(?User) and isEnabled(string, ?User) already take the bound user, and the menu/template paths already forward it, so only the per-user layer is missing, not the wiring.

Before reaching for per-user flags, decide which mechanism actually fits — the two are not interchangeable:

  • Entitlement / "who is allowed to do what" → model it as ACL (roles/permissions, User::permissionCodes()), which is genuinely per-user already. Do not duplicate it as a flag.
  • Rollout / experimental toggle / beta opt-in → this is where a per-user feature flag is the right tool.

Implementing it would require:

  1. Storage — a per-user override table (e.g. user_features(user_id, code, enabled)), ideally tri-state so a user can be force-enabled or force-disabled independently of the global value. Load a user's overrides in DbalUserRepository into User::$enabledFeatures (its reserved carrier).
  2. Resolution — layer the overrides on top of the global state inside FeatureFlagResolver::resolve(?User), at the marked seam (global default → global state → per-user override).
  3. Admin UI / API — surface to assign overrides per user (and optionally segment/percentage rollout), distinct from the global toggles under /api/settings/features.

Local agent CLI connectors (App\Integration\AgentConnector) are a separate connector category that executes installed, already-authenticated agent CLIs (codex, claude) as subprocesses — not an AI provider and not credential storage (authentication is delegated to the CLI). Two auth modes only (cli_native, official_token); in-app OAuth capture is excluded. The module is disabled by default and host-gated: it activates only when the system env K0SMOS_AGENT_CONNECTORS_KEY is set and the tenant's stored enable_secret equals HMAC-SHA256(tenant_id, system_key). Execution is guarded by the dedicated agent.run permission; run modes (read_only/patch_only/workspace_write) are enforced via verified CLI sandbox flags. Full reference: src/App/Integration/AgentConnector/AI.AgentConnectors.md.

18.3 Permission APIs

RESTful API for permission management (PermissionApiController):

Method Path Permission Action
GET /api/permissions system.read Lists all permissions with module info
POST /api/permissions system.write Creates permission (module_id, action, name, description)
PUT /api/permissions/{id} system.write Updates permission (action, name, description)
DELETE /api/permissions/{id} system.write Deletes permission

Permission code is automatically generated as {module_code}.{action} at creation. All responses are JSON.

18.4 Direct User Permissions And Runtime Modules

Admin user management now supports both direct per-user permissions and per-user runtime module access, separate from permissions inherited from roles:

  • Page: /admin/users/{id}
  • API: POST /api/admin/users/{id}/permissions
  • API: POST /api/admin/users/{id}/modules
  • Storage: user_permissions table
  • Storage: user_enabled_modules table
  • UI context: the same user sheet hosts role sync, direct permissions, and module access; direct permissions remain the exceptional ACL override layer, while module access is a per-user runtime gate

Operational rules:

  • Direct permission and module access panels are visible only to users with the admin role.
  • users.write alone is not enough: a non-admin user can edit the user profile, but cannot assign/revoke direct permissions or change available modules for another user.
  • A user's effective permissions are the union of:
    • permissions inherited from roles
    • permissions assigned directly
  • Requested user-module state starts as ON for all runtime modules until an override is explicitly saved in user_enabled_modules.
  • If a user module is disabled, dependent modules are disabled recursively; if it is re-enabled, required dependencies are realigned only when the tenant and global runtime allow them.
  • The user form shows distinct badges for direct permissions, inherited permissions, modules missing from the tenant inventory, and globally disabled runtime modules.

Interaction with runtime modules:

  • Effective access to module permissions and routes is filtered in this order:
    • tenant JSON module inventory (modules)
    • globally persisted runtime state in enabled_modules
    • per-user module state in user_enabled_modules
    • final ACL (roles + direct permissions)
  • If a runtime module is excluded by tenant JSON or disabled from /admin/settings/edit, associated permissions are no longer shown in user editing.
  • The same rule applies on the API side: saving permissions for unavailable runtime modules is rejected as an invalid selection.
  • Even if an ACL permission exists, UserModuleAccessPolicy still denies permissions for a module disabled for that user.
  • Non-runtime modules such as system and users remain always manageable in the ACL UI.

18.5 Role Management

Roles are a base k0smos capability set, separate from system.* and distinct from simple user management:

Route Path Permission Purpose
admin_roles /admin/roles roles.read Lists roles, user/permission counters, and access to the dedicated role sheet
admin_roles_new /admin/roles/new roles.write Creates a new role
admin_roles_edit /admin/roles/{id} roles.write Edits role metadata and assigned permissions

Related APIs:

Method Path Permission Action
POST /api/admin/roles roles.write Creates role (code, name, description, optional template)
POST /api/admin/roles/{id}/profile roles.write Updates role name and description
POST /api/admin/roles/{id}/permissions roles.write Syncs role permissions
DELETE /api/admin/roles/{id} roles.write Deletes an unassigned custom role
POST /api/admin/users/{id}/roles roles.write Syncs roles assigned to a user

Operational rules:

  • roles.read and roles.write are dedicated permissions in the base roles module.
  • roles.read enables menu, dashboard, and role list; roles.write enables role creation, detail sheet, and sync APIs.
  • role definition (name, description, permissions) lives in /admin/roles/*
  • assigning roles to users remains on /admin/users/{id}, because it concerns a single account
  • direct permissions remain an exceptional override and do not replace roles
  • the viewer, contributor, and manager system presets are creation-time templates, not authorization objects: they copy grants from currently active modules into a normal tenant-local role, which remains fully editable
  • preset grants are additive only; there is no explicit deny or role inheritance
  • the permission matrix supports text search, changed-only review, and module bulk selection; saves include a permission-set fingerprint and return 409 when another request changed the role after the page was loaded
  • the admin role is special: it is protected from destructive operations, does not expose UI permission editing, and its canonical grants can be realigned by system commands/migrations
  • a user with roles.write but without the admin role cannot assign or remove the admin role from other accounts
  • an account cannot remove the last admin role from itself; the system must keep at least one administrator
  • permissions for disabled runtime modules are not proposed in the role UI and are rejected by the sync API

Architectural note: valid alternatives for k0smos:

  • System roles + custom roles: simple baseline. Seeded roles (admin, editor, viewer) remain stable references; tenants add their own real-world roles.
  • Multiple roles per user: recommended model. Avoids monolithic roles and allows composing access across different responsibilities.
  • Direct permissions as exception: recommended model. Useful for punctual waivers, temporary support, or transitions, but not as the primary model.
  • Scope by tenant, area, or module: plausible future extension. Same role, but limited to a specific operational context.
  • Base role + add-on permission bundles: valid alternative if custom-role count grows too much; keeps a few main roles and adds targeted packages.

18.6 API Documentation

Core k0smos exposes the active tenant API surface through:

Route Path Permission Purpose
admin_api_docs /admin/api/docs system.read Swagger UI page for administrators
api_docs_openapi_json /api/docs/openapi.json system.read Generated OpenAPI 3.1 JSON

The OpenAPI document is generated from the tenant-aware Symfony RouteCollection, so disabled modules are omitted. Routes are included when they are marked with _route_type=api or when their path starts with /api/.

Route definitions can add an optional _openapi default with summary, description, tags, tagDescriptions, requestBody, responses, security, internal, and visible. When metadata is absent, the generator still emits path, methods, auth mode, permission, module tag, module description, and controller target.

Initial metadata coverage is intentionally metadata-light but accurate. It currently covers CRM intake, the high-value integration APIs in Anonymizer, Psapi, Wpapi, Esapi, OneUptimeApi, and FireflyiiiApi, plus the high-value editor API routes in Page and Media. Internal admin/AJAX routes are marked with internal so the generated document can distinguish operator-facing APIs from externally reusable integration endpoints.

Authenticated admin responses publish request-local Server-Timing samples for route/controller dispatch (k0smos-route), view/bootstrap data (k0smos-view-bootstrap), menu collection (k0smos-menu-collect), menu authorization/feature filtering (k0smos-menu-acl), menu-to-template mapping (k0smos-menu-map), Plates rendering (k0smos-template), and the terminal kernel lifecycle (k0smos-terminal). MenuRenderProfile carries only the current render's ACL-filtered items and timings; no user-specific menu output is cached or shared. These metrics pair with the Default/Sober browser marks for cold/warm median and p95 sidebar measurements. When Clockwork is active it publishes its own Server-Timing samples; ClockworkMiddleware merges the kernel samples back so a debug-enabled profiling session still sees both.

Import connectors that consume remote HTTP APIs use shared infrastructure under src/Infrastructure/Import/Http: ImportHttpClientFactory provides the bounded 5-second-connect / 30-second-total, no-redirect PSR-18 transport, while OutboundUrlPolicy requires public HTTPS destinations by default and checks every resolved address before each attempt. Psapi, Wpapi, and Esapi expose separate tenant-scoped opt-ins for trusted private networks and insecure HTTP. Esapi and Wpapi cap JSON responses at 10 MiB and retry only bounded transient, 429, and 5xx failures. Wpapi also keeps raw media downloads credential-free, limits media to 25 MiB, and protects settings save, connection probe, and import launch with the wpapi.settings CSRF intention.

Security defaults:

  • the OpenAPI JSON is admin-authenticated by default because it reveals operational routes
  • authenticated API routes are documented with JWT bearer and personal API token security schemes
  • external tenant-mutating APIs must not be marked anonymous unless they are explicitly designed as read-only public endpoints

18.7 Finance And Firefly III

Finance is the tenant-scoped personal-finance bounded context: accounts, currencies, transactions, budgets, categories, tags, recurring bills, piggy banks, JSON import/export, dashboard summaries, and the Finance MCP tool pack. Finance write paths, read-side monetary mapping, and persistent account/piggy-bank recalculations normalize monetary values with BcMath\Number at 10-decimal scale, avoiding PHP float arithmetic on stored money strings.

FireflyiiiApi is the admin-side Firefly III import adapter. It depends on finance, reuses finance.import, and stores only tenant-editable connection settings in AppSettings: fireflyiiiapi.base_url and the write-only fireflyiiiapi.token. The admin UI provides a saved-settings panel, masked token replacement/clear behavior, dry-run previews, warning rendering, explicit ambiguous-run confirmation, and status polling. The controller accepts a save_settings action and write imports dispatch FireflyImportMessage through WorkerAwareMessageDispatcher::dispatchOrRun(); the queue payload contains dates and flags only, never the Firefly token. Since completed SQL queue jobs are acked and removed, import lifecycle, progress, errors, retention, and incremental checkpoint timestamps are persisted in the module-owned fireflyiiiapi_import_jobs table through FireflyImportJobStore. The migration preserves a legacy AppSettings last-sync timestamp as a synthetic completed job and removes obsolete runtime keys.

The importer is idempotency-aware: dry-run/import preview reports matched, new, and ambiguous entities before writes; ambiguous or blank-name entities are logged at run start and skipped. Natural keys are currency code, account name+type, category/tag/budget/bill name, and piggy bank name+account. Transactions are matched by description, date, two-decimal amount, source account, and destination account. Missing Firefly withdrawal destinations are created as Finance expense accounts; missing deposit sources are created as revenue accounts.


Project Structure

See AI.md for the full directory map.

Relevant core controllers in src/Controller/:

  • AdminController.php - /admin dashboard: shortcuts + system/operational summary
  • AdminSettingsController.php - /admin/settings (view/info/edit)
  • FeatureFlagsSettingsApiController.php - REST /api/settings/features
  • PermissionApiController.php - REST /api/permissions (CRUD)
  • AdminSearchController.php / FrontSearchController.php - admin/front search

Search sources in src/Infrastructure/Search/:

  • BlogSearchSource - front context, module_blog table
  • ProductSearchSource - admin context, delegates to SearchEngineInterface

Search contracts in src/Domain/Search/:

  • SearchSourceInterface.php - contract for pluggable sources
  • SearchEngineInterface.php - low-level backend contract
template/                  # PHP Plates templates
├── default/tpl/           # Tailwind CSS 4 canonical full theme
├── sober/                 # Tailwind CSS 4 admin-first theme
tests/
├── bootstrap.php
├── Unit/
└── Integration/
docker/
├── worker/Dockerfile
└── systemd/queue-worker@.service
.env                       # Production defaults
.env.test                  # Test environment
app.env                    # Docker environment
docker-compose.yml

  • Klaro is configured per tenant through AppSettings keys klaro.enabled and klaro.config.
  • Frontend bundles for default, pc1, and cc1 themes load Klaro locally from the npm package, without external CDNs.
  • Recommended default for testing is false; set true only for preview/debug.
  • Public legal pages are rendered by the Info module with canonical routes /info/privacy-policy, /info/cookie-policy, and /info/terms-and-conditions; /privacy-policy and /cookie-policy aliases remain available.
  • Base Info seeds prepare starter content for privacy-policy, cookie-policy, and terms-and-conditions in it/en. Replace them with final legal text before publication.
  • Public footers read company metadata from AppSettings (company.name, company.vat, company.founded_year, company.legal_address, company.contact_email) and use show_legal_footer to show or hide the company legal block.
  • cc1 renders company.contact_email through its server-side obfuscation partial (@ → [at], . → [dot]) on every contact surface; it emits neither the clear address nor a mailto: URI in public HTML.
  • Canonical footer pattern for reopening Klaro: a button conditional on klaro.enabled + klaro.config that calls window.klaro.show().

20. Key Namespaces

Namespace Purpose
K0smos\* Core framework (Kernel, Middleware, Router, Menu, Http\Error)
K0smos\Tenant\* Multi-tenancy (TenantContext, TenantResolver, Tenant)
K0smos\Theme\* Theme engine (ThemeEngine, ThemeEngineFactory)
K0smos\ViewModel\* Response rendering (ViewModel)
K0smos\Breadcrumb\* Breadcrumb system (BreadcrumbManager, BreadcrumbTrail)
K0smos\Rendering\* View data aggregation (BaseViewDataAggregator, PlatesHelperRegistry)
K0smos\Menu\* Menu system (MenuItem, MenuBuilder, MenuSerializer, MenuHookRegistry)
K0smos\Http\Error\* Error handling (HttpException, ErrorResponseGenerator, ErrorHandlerFactory)
K0smos\Domain\AccessControl\* Auth domain (User, Role, Permission, AuthorizationChecker, PolicyVoter)
K0smos\Application\ModuleRuntime\* Runtime module state resolution and admin toggles
K0smos\Domain\ModuleRuntime\* Module runtime persistence contracts
K0smos\Domain\Queue\* Queue contracts (QueueManagerInterface, QueueInterface, JobInterface, MessageBusInterface, QueueMessageInterface)
K0smos\Domain\Search\* Search contracts (SearchEngineInterface, SearchSourceInterface, SearchQuery, SearchResult, SearchHit)
K0smos\Application\Search\* Search registry (SearchSourceRegistry aggregates sources by context)
K0smos\Infrastructure\Search\* Search sources (BlogSearchSource, ProductSearchSource, Sql/SqlSearchEngine)
K0smos\Application\Queue\* Queue workers + typed bus (QueueWorker, JobHandlerInterface, MessageHandlerInterface, registries, AsyncMessageBus)
K0smos\Infrastructure\Queue\* Queue implementations (DBAL, Redis, Redis-to-SQL failover)
K0smos\Module\Notification\* Notification bounded context (in-app/email channels, preferences, listeners, API contracts)
K0smos\Domain\Sales\* Sales domain (Purchase VO, PurchaseItem VO, PaymentStatus enum, PurchaseRepositoryInterface)
K0smos\Payment\* Isolated native payment kernel contracts, DTOs, status guard, token strategy
K0smos\Application\Payment\* Payment orchestration (PaymentCheckoutService)
K0smos\Infrastructure\Payment\* Native gateway adapters, DBAL payment intent/attempt/webhook repositories
K0smos\Domain\Psapi\* Psapi domain (SyncLog, SyncLogRepositoryInterface)
K0smos\DataFixtures\* Fixture system (FixtureInterface, FixtureRunner; group-aware, tenant-aware, ordered, transactional)
App\AI\* AI module core (AiClientInterface, native HTTP providers, ChatService, text-revision services, Decorators)

21. Key Patterns (Code Reference)

Discovery is an optional, disabled-by-default content navigator, depending on Media and Widget while booting without Ai or optional content owners. Its Default/Sober backoffice at /admin/discovery manages draft/published collections, selected result/evidence sources and typed visitor limits. Source writes require discovery.manage_sources; publishing or changing an already public collection also requires discovery.publish, including policy-voter approval. Settings require discovery.configure. Administrative writes use discovery.admin CSRF protection and optimistic revisions prevent lost source edits.

GET /explore displays the minimal Default-compatible navigator; native POST forms work without JavaScript and share retrieval with POST /api/discovery/search. POST requests use discovery.public CSRF protection. Search text is sent in the body, responses are no-store, and the explore slug is reserved while Discovery is active. Products and posts are distinct results; Pages are selectable as supporting evidence only. Active-owner interfaces preserve localized publication, current canonical URLs, tax-inclusive base-currency prices and normalized inventory. Product constraints apply to one variant and do not become editorial requirements. Missing values never satisfy a constraint or an exclusion.

Retrieval chooses at most 200 qualifying candidates per source, scanning bounded 201-row owner batches up to 10,000 rows before requiring more specific search text. Exact public titles/SKUs rank first, then matched criteria and reciprocal lexical order with stable identity ties. Counts explicitly describe this bounded selection. Final hydration rechecks source fingerprints and publication; a changed corpus/result revision rejects pagination with HTTP 409. Canonical records stay authoritative, including imported content; no public request contacts a connector.

Revisioned visitor preferences and idempotent conversation now use temporary SQL state owned by the browser PHP session. The Default page and structured discovery_chat widget share state per collection and locale. Public AI requires an explicitly enabled discovery.interpret scope, a supported Ollama/OpenAI model, reviewed tariffs and positive quota/budget. Until operator-preset guided questions replace it, the free-text turn is restricted to authenticated holders of discovery.configure (administrators by default). Clients receive a server-computed free_text flag, and other visitors use editable criteria and native search. Every call reserves its maximum configured cost before traffic; timeouts retain reservations. Reset invalidates pending replies without refunding daily usage. Manual navigation remains usable without AI or JavaScript. Office/PDF/text ingestion now uses Media originals and shared bounded extraction, including real OCR and structural citations. The Default/Sober document panel offers private version history, selective approval, current canonical links, replacement, retry and revocation. Only complete current extractions can publish approved chunks; Media originals retain their own access policy. The enforced document worker currently requires Linux; macOS/Windows use a Linux container. See src/App/AI/Document/AI.DocumentExtraction.md for parsers and deployment. Hybrid retrieval combines current document/graph evidence with immutable semantic candidates and canonical result checks. Public embeddings use shared durable budgets and visitor-owned reference caching. Partial retrieval is distinguished from an exhaustive empty result, and graph updates run independently of vector activation. Projection and graph administration now expose bounded recovery, parity, reviewed switching/rollback and separate relation approval. discovery:work inspects status by default; periodic --advance recovers lost wakeups, due retries and cleanup. discovery:projection shares explicit operator actions. Optional AI relations require explicit discovery.relations, separately confirmed tariffs and positive daily ceilings, and create only proposals from selected current evidence. Human publication remains independent. The reviewed 54-query IT/EN comparison passes with all four expected document citations and zero degraded queries; the persisted activation report enforces those conditions. Ordered SQL and point identity indexes bring the 10,000-content synthetic fixture to 31 ms graph / 367 ms retrieval p95, excluding embedding. Real-owner SQL capacity, model qualification and production asset checks are separate deployment concerns; methods and limits are recorded in the module's evaluation document. Qdrant stores derived vectors and identity metadata in one collection per immutable Discovery generation. The tenant SQL connection owns the graph, review decisions, projection ledgers and temporary preferences; Media owns original files. The measured Qwen3-Embedding 0.6B Q8 / 1,024-dimensional Ollama configuration is separate from the visitor interpretation model. Interpretation produces validated preference patches; relation generation produces proposals for human review. Saving limits makes no AI call. Doctrine owns schema; canonical content and original Media remain with their existing owners. See modules/Discovery/src/AI.Discovery.md and the activation gates in doc/public/en/architecture/vector-search.md.

Content edits/imports now write identity-only intent in the canonical transaction. Ecommerce projection handoff and Discovery freshness have independent committed checkpoints and retries. The default worker drains at most 100 deliveries every five seconds; worker failure leaves intent queued. Core recovery replays bounded canonical IDs and cleans up missing tracked identities with a durable cursor. content:changes exposes trusted CLI diagnostics/retry/recovery. Discovery's admin update panel operates only its subscriber; source replay also requires blog.edit, page.edit or ecommerce.edit. Migration order, recovery semantics and the remaining external-write fencing limit are documented in src/Application/ContentChange/AI.ContentChange.md.

21.1 ModuleInterface

// src/Module/ModuleInterface.php
interface ModuleInterface
{
    public function registerRoutes(RouteCollection $collection): void;
    public function getServiceDefinitions(): array;   // PHP-DI definitions
    public function getMenuDefinitions(): array;       // Menu items by channel
}

21.2 Route Definition

// config/routes.php
$defs[] = [
    'name' => 'route_name',
    'path' => '/path',
    'defaults' => [
        '_controller' => MyController::class,
        '_method' => 'handle',                    // Controller method to call
        '_theme' => 'front|admin|utility',        // Theme resolution (explicit wins)
        '_template_group' => 'group_name',        // Template subdirectory
        '_route_type' => 'html|api',              // 'api' skips theme
        '_auth_required' => true,                 // Require authentication
        '_permission' => 'module.action',         // Required permission
        '_permission_all' => ['a.use', 'b.read'], // Optional: all codes required
        '_permission_any' => ['a.write', 'b.own'],// Optional: at least one required
    ],
    'methods' => ['GET'],
    'priority' => 1000,                           // Higher = matched first
    'intl' => true,                               // Enable i18n prefix
    'requirements' => ['id' => '\d+'],            // Param constraints
];

Core and active-module routes are merged and then stably ordered by this priority across the complete collection. Ties retain registration order and the framework catch-all is appended last. This global pass is required for specific module paths such as /it/docs to win over broad routes such as /{_locale}/{slug} even when the broad route's module registered first.

When no route matches, the framework catch-all consults a single core-owned K0smos\Router\ChainUnmatchedPathResolver before rendering a 404. The chain is built from the resolvers that active modules contribute through UnmatchedPathResolverProviderModuleInterface; they run in module order and the first non-null result wins, so an empty chain simply declines and the normal 404 renders. Page's PageUnmatchedPathResolver — URL rewrites for multi-segment and dotted source URLs — is the reference implementation.

A module must never rebind UnmatchedPathResolverInterface in its own services.php. Module definitions merge into one array, so a second contributor would silently overwrite the first, and overriding a core-owned key also makes ContainerFactory::guardDuplicateDefinitionKeys() log a permanent boot warning it cannot distinguish from an accidental collision.

21.3 MigratableModuleInterface

// Implement to expose Doctrine Migrations paths for auto-discovery
interface MigratableModuleInterface
{
    /** Returns ['Namespace' => '/absolute/path'] for Doctrine Migrations */
    public function getMigrationPaths(): array;
}
// Module registration exposes module-owned migration paths, usually
// ROOT_DIR/migrations/{Name}.
// Run only module migrations: php bin/console migrate --module=psapi

21.4 SearchableModuleInterface

// Implement to auto-register searchable content in SearchSourceRegistry
interface SearchableModuleInterface
{
    /** @return array<class-string<SearchSourceInterface>> */
    public function getSearchSourceClasses(): array;
}
// SearchSourceRegistry factory calls ModuleRegistry::collectSearchSourceClasses()
// and resolves each class from the DI container automatically.
// Built-in content sources: BlogSearchSource (front context), ProductSearchSource (admin context).
// Admin navigation search is also built from MenuRenderer + MenuBuilder.
// SearchHit can carry a canonical per-hit path; controllers fall back to getListingPath() only when path is null.
// Endpoints: GET /search (public, front), GET /admin/search (auth required, admin).

Notes:

  • SearchableModuleInterface is for business/content search sources exposed by a module.
  • /admin/search also indexes navigation automatically from active module menu definitions (Config/menu.php), filtered by ACL and feature flags for the current user.
  • The modern search backend is selected from configuration through search.modern.provider; SearchEngineFactory depends on a provider registry, not concrete Elasticsearch classes.
  • Search backend failover: if search.engine=modern but the external backend is disabled or not effectively configured, SearchEngineFactory immediately falls back to SQL.

21.4.1 TemplateAwareModuleInterface

// Implement to register module-owned template directories with the Plates engine.
interface TemplateAwareModuleInterface
{
    /** @return array{admin?: string, front?: string} Absolute paths */
    public function getTemplatePaths(): array;
}

Notes:

  • Plates hierarchy (high -> low priority): override > {theme}-child > {theme} > module paths > default.
  • ModuleRegistry::collectTemplatePaths() aggregates paths from all TemplateAwareModuleInterface modules, keyed by side; non-existent directories are silently dropped.
  • ThemeEngineFactory autoloads the active ModuleRegistry and injects the side-specific list into ThemeEngine.
  • Co-located modules typically declare __DIR__ . '/../templates/admin' (relative to modules/{Name}/src/{Name}Module.php).

21.4.2 TemplateIntegrationModuleInterface

// Implement to contribute partials to named template slots.
interface TemplateIntegrationModuleInterface
{
    /** @return list<\K0smos\Theme\TemplateIntegration> */
    public function getTemplateIntegrations(): array;
}

Notes:

  • TemplateIntegration is a readonly value object with slot, template, side (admin/front/shared), priority (default 100, ascending order), optional permission, optional featureFlag, required flag, and context array.
  • Contributions are aggregated by ModuleRegistry::collectTemplateIntegrations(), indexed per slot by TemplateIntegrationRegistry (DI: RenderingDefinitions), and rendered through TemplateSlotRenderer which honors ACL/feature-flag gating via injected resolver callables.
  • Templates render a slot with the renderSlot($slot, $params = [], $side = 'front') Plates helper (registered in PlatesHelperRegistry, built per request with the themed engine and the same ACL/feature-flag resolvers as menu_hook()). It returns '' when no module contributes, so callers can wrap conditionally. Reference call site: template/default/tpl/front.tpl.php renders front.footer.widgets in the footer. Other front themes must add the same renderSlot() call to surface footer slots.
  • Missing templates are silently skipped in production unless required: true or debug mode is active, in which case TemplateSlotRenderer throws.
  • Prefer slot contributions over patching foreign templates: the owner exposes stable extension points, other modules contribute without coupling.
  • Reference implementation: the OneUptimeApi module contributes a public status widget to front.footer.widgets; its real gating lives in the public proxy route GET /api/oneuptimeapi/status-summary, not in the slot (modules are built before the container and cannot read their own settings when declaring integrations).

21.4.3 FrontViewDataProviderModuleInterface

// Implement when an active module contributes a namespaced public read model.
interface FrontViewDataProviderModuleInterface
{
    /** @return array<class-string<FrontViewDataProviderInterface>> */
    public function getFrontViewDataProviderClasses(): array;
}

interface FrontViewDataProviderInterface
{
    /** @return array<string, mixed> Unique top-level keys only */
    public function provide(ServerRequestInterface $request): array;
}

Notes:

  • ModuleRegistry::collectFrontViewDataProviderClasses() collects provider classes from active modules only. RenderingDefinitions resolves them from DI into FrontViewDataProviderRegistry.
  • SpaController merges the registry payload into the public ViewModel. The tenant and initial keys are core-reserved; duplicate provider keys fail fast instead of being silently overwritten.
  • Providers own their application reads and optional fallback behavior. Themes consume the resulting arrays and must not query feature-module repositories or make their runtime module depend on an otherwise optional feature.
  • Blog is the reference implementation: BlogFrontViewDataProvider exposes the three latest published posts as blogFeed, using the same BlogPostViewDataFactory as native Blog routes. An inactive Blog module contributes nothing; repository failure yields an empty feed.

21.5 Controller Pattern (ViewModel)

// Controllers return ViewModel, ResponseInterface, or scalar.
// The Kernel handles rendering through ThemeEngine or Serializer.
public function handle(ServerRequestInterface $request): ViewModel
{
    $data = $this->service->getData();
    return (new ViewModel($data))
        ->setTemplate('page_name', $request->getAttribute('_template_group'));
}
// Content negotiation: Accept: application/json -> JSON, otherwise HTML via Plates.

21.6 Module Menu Definition

// modules/{Name}/src/Config/menu.php
return [
    'back' => [
        [
            'id' => 'module.context',           // Convention: module.context
            'label' => 'menu.module.context',   // i18n key, never hardcoded strings
            'route' => 'route_name',
            'permission' => 'module.action',    // Optional ACL
            'feature' => 'feature_flag',        // Optional feature flag
            'order' => 300,
            'children' => [ /* ... */ ],
        ],
    ],
];
// NOTE: 'back' channel links are always canonical (/admin/...) and never locale-prefixed.
// 'front' channel links use intl_ route variant when active locale is set.
// MenuBuilder keeps a parent item as a non-clickable group when the parent permission fails
// but at least one child remains accessible.
// Runtime module toggles are stronger than menu filters: a disabled module is not bootstrapped,
// so its menu definitions are not registered at all.

Storefront entries (front, footer) are typed; see §7.2.1:

final class BlogModule implements ModuleInterface, FrontMenuEntryProviderModuleInterface
{
    public function getFrontMenuEntries(): array
    {
        return [
            FrontMenuEntry::route('blog.index', 'menu.front.blog', 'blog_index', order: 200, defaultEnabled: true, icon: 'blog'),
            FrontMenuEntry::route('footer.blog', 'menu.front.blog', 'blog_index', FrontMenuEntry::CHANNEL_FOOTER, order: 220, defaultEnabled: true),
        ];
    }
}
// Route targets must be public GET pages without required parameters besides _locale;
// FrontMenuEntryCatalog drops and logs anything else. New entries default to off.

21.7 Queue System

Transport is pluggable per tenant via queue.driver (SQL/DBAL by default, Redis opt-in with SQL fallback) — see §9.1 "Transport selection". Call-sites below are transport-agnostic.

// Preferred API: MessageBusInterface -> QueueMessageInterface
$messageBus->dispatch(
    new SendNotificationEmailMessage(
        userEmail: $user->email,
        subject: 'Subject',
        template: 'notification/ticket-assigned',
        context: ['ticket_id' => 42],
    ),
    new MessageDispatchOptions(queue: 'notifications'),
);

interface QueueMessageInterface {
    public function toPayload(): array;
    public static function fromPayload(array $payload): static;
}

interface MessageHandlerInterface {
    public function messageClass(): string;                // class-string<QueueMessageInterface>
    public function handleMessage(QueueMessageInterface $message): void;
}

// Low-level transport API remains valid for legacy/infrastructure jobs:
$queue = $queueManager->queue('default');
$job = new Job('send-email', ['to' => $email, 'subject' => 'Welcome']);
$queue->dispatch($job);

// Low-level handler:
interface JobHandlerInterface {
    public function jobType(): string;                    // NOT static
    public function handle(JobInterface $job): void;      // Receives JobInterface, NOT array
}

// QueueWorker always consumes JobInterface.
// Typed messages are bridged through QueuedMessageJobHandler + MessageHandlerRegistry.

21.8 Data Fixtures

// FixtureInterface - implement to create a fixture
interface FixtureInterface {
    public function load(Connection $connection): void;  // idempotent: INSERT OR IGNORE / DELETE+INSERT
    public function order(): int;     // lower = earlier; 10-30 system, 100+ theme widgets
    public function getGroup(): string; // 'base' | 'demo' | 'test'
    public function supports(TenantContext $tenantContext): bool; // tenant/theme-specific opt-in
}

// FixtureRunner - injected via DI with TenantContext, executes in isolated transactions
$runner->run($connection, 'base');   // all base fixtures supported by the current tenant
$runner->run($connection, 'demo');   // base + demo fixtures
$runner->run($connection, 'test');   // base + demo + test fixtures

// CLI - tenant resolved from TENANT_ENV; FixtureRunner filters fixtures through supports(TenantContext)
// php bin/console k0smos:fixtures:load                       # runs all base fixtures for current tenant
// php bin/console k0smos:fixtures:load --group=demo --force  # base + demo fixtures
// TENANT_ENV=k0smos.example.com composer app:install-demo     # migrate + base/demo fixtures for current tenant

// Registered fixtures (FixtureDefinitions):
// base:  RolesFixture(10)               - admin/editor/viewer roles
//        PermissionsFixture(20)         - system/users/ai/blog/info/modbus/media/ecommerce modules + permissions
//        UsersFixture(30)               - ensures admin@localhost exists and is linked to admin role
//        InfoPagesFixture(40)           - privacy/cookie/terms pages for every tenant
// demo (content_fixture module, code-gated via fixtures.enabled):
//        PackageContentFixture(90)  - cc-content.k0sdata.json (code `cc`)
//        PackageContentFixture(91)  - active owners' dcm/dcm-dm2 content packages (code `dcm`)
//        PackageContentFixture(108) - default-homepage-widgets.k0sdata.json (code `default`)
//        PackageContentFixture(111) - pc-content.k0sdata.json (code `pc`)

21.9 Error Throwing

throw new HttpException(404, 'Resource not found');
throw new HttpException(429, 'Rate limited', ['Retry-After' => '3600']);
// ErrorHandler middleware auto-converts to RFC7807 Problem+JSON.

21.10 AI Chat

// ChatService::reply() - NOT sendMessage()
$result = $this->chatService->reply($chatId, $userInput);
// Returns: ['content' => string, 'created_at' => string]

$history = $this->chatService->history($chatId, limit: 20);

// Memory: DbalChatMemoryRepository (driver-agnostic via Doctrine DBAL Connection)
// Decorator stack: CachingDecorator(LoggingDecorator($providerClient))

21.11 AI Text Revision

use App\AI\Text\AiTextRevisionRequest;
use App\AI\Text\AiTextRevisionService;

// Reusable core service for module-specific editorial flows.
$revised = $this->revisionService->revise(new AiTextRevisionRequest(
    content: $content,
    settingKey: 'blog_revision_system_prompt',
    defaultSystemPrompt: 'You are a professional editor for web content...',
    contentLabel: 'Content to revise',
    finalInstruction: 'Return only the final revised content.',
));

// Module controller remains a thin adapter:
return $this->json(['revised_content' => $revised]);

Template reuse in the default theme:

<?php $this->insert('admin/ai/_text_revision_editor', [
    'field_name' => 'body',
    'content' => (string) ($post['body'] ?? ''),
    'revision_url' => '/admin/blog/ai/revision',
    'section_title' => 'Content',
    'button_label' => 'Revise with AI',
    'editor_mode' => 'rich_text',
    'editor_config' => [
        'fieldName' => 'body',
        'initialContent' => (string) ($post['body'] ?? ''),
        'storageKey' => 'k0smos.blog.wysiwyg',
        'uploadUrl' => '/api/media/upload',
    ],
]) ?>

Rules:

  • put shared AI text-revision logic in src/App/AI/Text/*
  • keep module controllers responsible only for ACL, route contract, and module-specific default prompt
  • keep admin UI generic in template/default/tpl/admin/ai/_text_revision_editor.tpl.php
  • the same partial can host either a plain textarea or the shared richTextEditor bridge (editor_mode => 'rich_text')
  • when WYSIWYG mode is enabled, keep the HTML textarea as the authoritative submitted field and synchronize the visual editor into it
  • if a module wants a first-level UX choice between raw HTML and visual HTML, drive the shared editor from the host container using data-k0smos-editor-mode plus data-k0smos-editor-visual
  • persist per-feature prompts through AiFeatureSettings::modelSetting()

21.12 Plates Template Syntax

<?= $this->e($variable) ?>                        // Escape output (XSS prevention)
<?php $this->layout('front', ['pageTitle' => '']) ?> // Set layout with vars
<?= $this->section('content') ?>                  // Yield section
<?php $this->insert('partial', ['key' => $v]) ?>  // Include partial
<?= $this->t('key', [], 'domain') ?>              // i18n, NOT $translate callable
<?php $this->start('page'); ?> ... <?php $this->stop(); ?> // Named section block
<?= $this->csrfField('ecommerce.admin') ?>         // Hidden _csrf_token for a route's _csrf_protected scope

Page templates read the controller's flat view variables ($orders ?? []). LegacyTemplateDataAdapter never creates a $data wrapper, so a template that reads $data['orders'] first silently renders its fallback. $orders ?? ($data['orders'] ?? []) stays acceptable as a compatibility fallback. tests/Unit/Theme/TemplateFlatViewDataContractTest.php enforces this with a shrinking baseline.

21.13 Repository Pattern (DBAL)

// Interface in Domain:
interface UserRepositoryInterface { /* ... */ }

// Implementation in Infrastructure:
final class DbalUserRepository implements UserRepositoryInterface
{
    public function __construct(private readonly Connection $connection) {}

    public function findByEmail(string $email): ?User
    {
        $row = $this->connection->executeQuery(
            'SELECT * FROM users WHERE email = ?', [$email]
        )->fetchAssociative();
        return $row ? User::fromRow($row) : null;
    }
}

22. Payment System (Native)

Architecture

The payment system uses a native k0smos payment kernel isolated under src/Payment. Gateway adapters live under src/Infrastructure/Payment/Native/Gateway, while PaymentCheckoutService is the application boundary that maps payment results back to Sales Purchase and Order state.

HTTP flow:

GET /checkout         -> CheckoutController::handle()    - cart summary + gateway selection
POST /checkout        -> CheckoutController::initiate()  - creates Purchase + native PaymentIntent -> redirect to provider
[gateway page]        -> customer pays
GET /payment/return/{gateway} -> PaymentController::return() - verifies local payment_token, refreshes/captures provider status
GET /payment/done     -> PaymentController::done()       - reads local Purchase status
POST /payment/notify/{gateway} -> PaymentController::notify() - retains webhook, queues processing, always 200

Security and isolation:

  • Return and cancel callbacks trust only the local signed payment_token.
  • The token carries the local intent and purchase ids plus an expiry claim; the stored intent hash must match the received token before a return is accepted.
  • Provider query parameters are treated as hints and are never authoritative by themselves.
  • Webhooks are stored in payment_webhook_events before processing, then handled by the typed queue message ProcessWebhookEvent.
  • Intent transitions are monotonic and persisted with compare-and-set semantics, so duplicate returns, duplicate webhooks, and return/webhook races cannot downgrade captured/refunded payments or double-emit PaymentCompleted.
  • src/Payment has no Ecommerce, Sales, Tenant, DBAL, or concrete HTTP client imports so it can be extracted later.

Main Classes

Class Responsibility
PaymentCheckoutService Starts payments, completes returns, records webhooks, updates Purchase/Order, emits PaymentCompleted
PaymentGatewayRegistry Resolves enabled gateway adapters by native method code
ManualTestGateway Base/mock gateway used locally and extended by real gateway adapters
StripeCheckoutGateway Stripe Checkout Sessions through Stripe REST API
PayPalOrdersGateway PayPal Orders v2 through official REST endpoints
RevolutGateway Revolut Merchant Orders API, also used for Google Pay and Apple Pay methods
PaymentStatusTransitionGuard Prevents out-of-order provider events from downgrading captured/refunded payments

Domain Model Extensions

Purchase (readonly VO) has three additional fields:

public PaymentStatus $paymentStatus = PaymentStatus::Pending,
public ?string $paymentGateway   = null,   // e.g. 'stripe', 'paypal', 'revolut'
public ?string $paymentReference = null,   // native intent/provider transaction ID

PaymentStatus (PHP 8.1 backed enum):

enum PaymentStatus: string {
    case Pending    = 'pending';
    case Captured   = 'captured';    // isPaid() -> true
    case Authorized = 'authorized';  // isPaid() -> true
    case Failed     = 'failed';
    case Cancelled  = 'cancelled';
    case Refunded   = 'refunded';
}

Database Tables (Migration-First)

  • payment_intents: current intent state, purchase link, gateway code, amount in provider minor units, ISO currency code, currency decimal digits, signed token hash, provider external reference, timestamps
  • payment_attempts: append-only provider operation log for start, return, and webhook processing with sanitized payload JSON
  • payment_webhook_events: retained raw webhook payloads/headers, event id, processing status, failure reason, timestamps
  • Added purchases columns: payment_status VARCHAR(32) DEFAULT 'pending', payment_gateway VARCHAR(64) NULL, payment_reference VARCHAR(255) NULL, client_id INTEGER NULL (optional logical FK to module_clients.id in CRM)

Payment tables must be created through Doctrine migrations before runtime use. Repositories use SchemaGuard and fail explicitly if a tenant has not been migrated. The guard costs one SELECT <columns> FROM <table> WHERE 1 = 0 probe per table; schema introspection runs only after a failed probe, to name the missing table or column. On SQLite it quotes names with backticks, because an unknown double-quoted name is read as a string literal and would pass. Historical payum_payments and payum_tokens tables are left intact by the native migration and can be removed only by a later explicit cleanup. Webhook retention is controlled by payment.webhook_retention_days and enforced with php bin/console payment:webhooks:prune.

Supported Gateways

Method key Adapter Provider
manual_test ManualTestGateway local/test only
stripe StripeCheckoutGateway Stripe Checkout Sessions REST
paypal PayPalOrdersGateway PayPal Orders v2 REST
revolut RevolutGateway Revolut Merchant API
revolut_google_pay RevolutGateway Google Pay via Revolut
revolut_apple_pay RevolutGateway Apple Pay via Revolut

The Revolut wallet methods currently use the hosted checkout flow and share the same Revolut gateway credentials. Embed the Revolut Web SDK wallet buttons only if hosted checkout is not sufficient for the target checkout experience.

Legacy Tenant JSON (optional, import-only)

Payment configuration now lives entirely in the backoffice (DB-backed AppSettings, see below). The payment block has been removed from the shipped tenant files (config/tenants/{localhost,app,...}.json) and is no longer required. The shape below is documented only as the legacy/import format: a tenant may still carry a payment block, in which case PaymentSettings reads it once as a fallback and imports it into the DB on first save (see Migration model). New tenants should configure payments from /admin/settings/payment instead.

"payment": {
    "token_secret": "${K0SMOS_PAYMENT_TOKEN_SECRET:-}",
    "enabled_methods": ["manual_test"],
    "methods": {
        "stripe": {
            "gateway": "stripe",
            "secret_key": "${STRIPE_SECRET_KEY:-}",
            "webhook_secret": "${STRIPE_WEBHOOK_SECRET:-}"
        },
        "paypal": {
            "gateway": "paypal",
            "client_id": "${PAYPAL_CLIENT_ID:-}",
            "client_secret": "${PAYPAL_CLIENT_SECRET:-}",
            "mode": "${PAYPAL_MODE:-sandbox}",
            "webhook_id": "${PAYPAL_WEBHOOK_ID:-}"
        },
        "revolut": {
            "gateway": "revolut",
            "api_key": "${REVOLUT_API_KEY:-}",
            "webhook_secret": "${REVOLUT_WEBHOOK_SECRET:-}"
        },
        "manual_test": { "gateway": "manual_test" }
    }
}

Only methods listed in enabled_methods are shown at checkout, and only when a matching gateway adapter is registered for the method. Gateway credentials are never stored in code.

Admin Settings UI and DB-backed credential source

Gateway credentials and enabled methods are configured from the admin panel at /admin/settings/payment (sidebar: Settings → Payments, permission system.write). The page is reachable from the Settings overview card and exists as a real template for both the default and sober themes.

Surface Route Permission
Page GET /admin/settings/payment system.write
Read API GET /api/settings/payment system.read
Save API POST /api/settings/payment system.write

K0smos\Application\Payment\PaymentSettings is the configuration facade and the single runtime source of truth: it reads and writes DB-backed AppSettings keys (payment.enabled_methods, payment.webhook_retention_days, payment.token_secret, and payment.methods.{method}.{field}). TenantPaymentConfigProvider::runtimeConfig() now delegates to it, so the native payment runtime consumes DB values directly.

Migration model:

  • The shipped tenant files no longer carry a payment block; it has been removed in favour of the DB-backed admin configuration. If a tenant still defines one, it is read only as a legacy fallback until the first save. PaymentSettings::isConfigured() (key payment.configured) flips to 1 on first save; afterwards tenant JSON gateway credentials are ignored.
  • token_secret no longer needs a JSON entry: PaymentDefinitions falls back to the K0SMOS_PAYMENT_TOKEN_SECRET env var and, if unset, to a value derived from the tenant id/host.
  • The first save imports the effective legacy values into the DB (blank secret fields are treated as "keep current"), so no legacy credential is lost.
  • Env placeholder resolution (${ENV:-default}) survives only on the legacy fallback path inside PaymentSettings.
  • The offline/mock manual_test method is preconfigured by code defaults, so local checkout and automated tests work without real provider credentials.

Secret handling on the API:

  • Reads mask stored secrets as *** and expose has_* booleans; clear text is never returned.
  • Saves preserve the stored secret when the incoming value is blank or ***, and clear it only through an explicit clear_<field> flag.
  • Validation returns HTTP 422 with field-level errors (invalid PayPal mode, invalid base URL, out-of-range retention, or enabling a provider/wallet without the required credentials).

23. Module Runtime Control

  • K0smos\Module\ModuleCatalog is the source of truth for installable runtime modules, codes, defaults, and dependencies.
  • Tenant JSON lists the module inventory visible to the software through the top-level modules array; per-module technical settings belong in module_config.
  • Runtime state is persisted per tenant in enabled_modules, but DB toggles are always limited by the tenant module inventory and mandatory modules.
  • ModuleBootstrapper is used by HTTP, CLI, queue and cross-tenant commands to resolve the DB theme/default-locale overlay and active modules as one snapshot before ContainerFactory->init(...).
  • ActiveModuleResolver computes:
    • tenant-allowed state = tenant JSON module inventory + mandatory modules
    • requested state = catalog defaults + DB overrides, normalized against the tenant module inventory
    • effective state = requested state after mandatory-module and dependency constraints
  • ModuleManagementService exposes tenant_enabled, requested_enabled, and effective_enabled; a module blocked by tenant JSON cannot be re-enabled by DB/UI.
  • Theme/module writes share the theme.selection revision guard; the selected wrappers and dependencies cannot be disabled. GET|POST /api/settings/modules carry the revision and stale writes return HTTP 409.
  • Disabling a runtime module removes it from bootstrap on the next request, so its routes, menus, services, and search sources are not registered.
  • The dedicated App panel at /admin/settings/app ("App" item under "Settings") shows tenant-filtered modules with summary cards, filter tabs (all/active/inactive/blocked), toggle controls, and a DB-backed "activate all" action for the tenant-visible module inventory; it persists through ModuleSettingsApiController (GET|POST /api/settings/modules).
  • Runtime-module and feature-flag toggle UI also exists in /admin/settings/edit.
  • Read-only ACL/runtime inventory lives in /admin/settings/info.
  • /admin is the admin home: it shows permission-aware shortcuts and system/operational summaries; the self-service ACL summary is in /admin/user-settings.

24. Direct User Permissions

  • Direct user permissions are stored in user_permissions and managed from /admin/users/{id}.
  • Per-user runtime module access is stored in user_enabled_modules and managed from /admin/users/{id} via POST /api/admin/users/{id}/modules.
  • Only users with role code admin can grant/revoke direct permissions or user module access; users.write alone is not enough.
  • Default user-module state is ON for existing users until explicit disablement; admin-role users receive the same ON default unless explicitly disabled.
  • Effective ACL starts from role-inherited permissions + direct user permissions; then module-scoped permissions are constrained by the tenant module inventory, tenant runtime state, and user module state.
  • If a runtime module is tenant-blocked, runtime-disabled, or user-disabled, its permissions must not be shown or accepted in the user permission editor/API, and runtime policy authorization denies them.

25. JWT And API Authentication

  • Cookie: k_token, HttpOnly, SameSite=Lax, Max-Age=28800 (8 h), set by LoginController::submit().
  • JWT TTL: 28800 s, configured in SecurityDefinitions.
  • StrictValidAt is always enforced; expiry/nbf/iat are verified on every request.
  • JwtAuthMiddleware resolves auth in this order: Bearer JWT -> Bearer personal API token (only when _route_type=api) -> JWT cookie k_token.
  • Personal API tokens use the k0s_pat_ prefix, are stored server-side as SHA-256 hashes, and resolve to the owning user in the current tenant database (tenant isolation is structural — a token hash exists only in its own tenant DB). Two mechanisms coexist:
    • Legacy PAT — one full-authority token per user on users.api_token_hash, generated from /admin/user-settings. No expiry, no scope. Kept for backward compatibility.
    • Scoped tokens — user_api_tokens table: multiple named tokens per user with an optional scopes allow-list (permission codes; NULL = full authority), expires_at, and revocation. Managed via ApiTokenService and /api/user-settings/api-tokens (UserApiTokenController, own tokens only; scope validated ⊆ the issuer's current permissions). At authentication these resolve first; an expired/revoked token is rejected, and a scoped token returns User::withScopedPermissions(userPerms ∩ scope) (roles dropped) so — because every voter reads permissionCodes() — the token's authority is bounded on every api route and shrinks automatically if the user loses a permission. Prefer scoped, least-privilege tokens for third-party clients (e.g. MCP). See src/App/Auth/AI.Auth.md.
  • On failure, auth middleware continues silently; redirects are the responsibility of AuthMiddleware.
  • Locale is resolved by LocaleMiddleware, which runs after routing. Admin menus (back) always link to canonical routes without /{locale}/ prefixes.
  • ACL resolution merges role permissions with direct user permissions from user_permissions; policy checks can still deny module-scoped permissions through user_enabled_modules.

26. Theme Asset Rules

  • All nine supported Vite themes resolve their source entry through the Vite manifest with entryCss() / entryJs(). Production URLs carry content hashes and no mutable version query; the legacy direct-path fallback exists only for installations awaiting their operator-owned rebuild.
  • template/sober/tpl/admin.tpl.php loads the sober Vite bundle and is admin-only; keep theme.front on default or another public theme when theme.admin is sober.
  • Both admin layouts server-render the complete ACL-filtered primary navigation with real links, labels, icons, active/ancestor state, and the active expanded branch. Alpine hydrates the existing nodes by stable menu id; it does not own first render. Never reintroduce a primary-navigation x-for, cloak the whole sidebar, or cache authorized menu output across users/tenants. Mobile no-JavaScript fallbacks preserve access when the drawer runtime is unavailable.
  • Sidebar instrumentation exposes k0smos:admin-sidebar-server-rendered, k0smos:admin-sidebar-first-visible and k0smos:admin-sidebar-interactive marks plus the k0smos:admin-sidebar-ready measure. admin-sidebar-first-visible resolves on the first animation frame in which the primary navigation actually holds usable controls, so it stays truthful if a layout ever defers that navigation. Use these entries for cold/warm Default/Sober comparisons; they are diagnostic names, not application events.
  • The admin mobile drawer has one accessibility contract shared by both layouts through template/shared/asset/js/admin/sidebar-a11y.js: opening moves focus into the drawer, closing restores it to the trigger before the drawer becomes inert, Escape is bound on the shell so it also closes from the overlay, Tab and Shift+Tab stay contained, and the closed drawer leaves the tab order through CSS visibility before hydration and runtime inert/aria-hidden afterwards. The docked desktop rail is never marked hidden, and server markup never ships inert/aria-hidden so a failed bundle cannot strand the navigation. prefers-reduced-motion suppresses shell transitions and the active-item smooth scroll.
  • Measured scenario matrix, metric definitions, the reproducible Chromium harness under tools/benchmarks/admin-sidebar-*, and the accepted before/after evidence live in Admin sidebar startup performance.
  • Heavy optional frontend features must use two-phase bootstrap: synchronous registration of Alpine/form helpers, then async vendor chunk import. The Default entry conditionally imports Markdown/DOMPurify, Klaro, maps, masks, storefront search, the product gallery viewer, and rich-text/blog/AI-revision code from page DOM markers.
  • Shared runtimes under template/shared never import npm packages themselves: the theme injects them (for example bootstrapProductGallery({ loadViewer })), so dependencies resolve from the theme's own node_modules and stay out of pages that do not need them. Third-party frontend libraries must be permissively licensed (MIT/BSD/Apache), maintained and preferably dependency-free; record the licence and rejected alternatives in the theme's AI.theme.md (PhotoSwipe 5.4.4, MIT, in default and lts1).
  • The Storage module is the current reference: storageManager is registered before Alpine.start(), while ElFinder is loaded afterward and must never block sidebar or admin layout.
  • Every npm run build command emits a Vite manifest and deterministic build metadata, then calls scripts/check-theme-asset-budget.mjs; the root npm run build:themes command runs that same fail-fast contract for all nine supported themes. config/quality/frontend-asset-budgets.json owns per-theme raw/gzip entry, JS-chunk, and CSS-asset limits plus the Default forbidden-eager-source contract, and its keys are the canonical theme inventory. Production builds are minified and tree-shaken, emit no public source maps, and reject unhashed JS/CSS. Generated bundles remain maintainer-built artifacts; the complete commands and cache/visual checks are in Frontend delivery and verification and the security/obfuscation decision is in ADR 0004.
  • The per-theme budget check runs from inside one theme directory and cannot see the published tree, so npm run verify:themes (scripts/check-theme-build-inventory.mjs) audits public/build as a whole: a supported theme that was never built, a directory owned by a retired theme, a missing manifest or build-metadata.json, a surviving unhashed js/app.js or css/app.css, a published .map, or metadata naming another theme all fail the release gate. It is the final step of npm run deploy:themes. The root aggregates enumerate every supported theme, test:themes enumerates exactly the themes that declare a test script (lts1 declares none), and tests/Unit/Theme/FrontendBuildPolicyContractTest.php derives both rules from the canonical inventory.
  • Backoffice contextual micro-help uses the shared tooltip() Plates helper from the default theme; do not introduce third-party libraries such as Popper/Tippy for standard cases.
  • Tooltip visibility is a client-side browser preference managed from /admin/user-settings through localStorage['k0smos.tooltips'] and applied to front and admin layouts through html[data-k0smos-tooltips].
  • Non-default admin themes that render fallback default/module templates, such as sober, must provide .k-tooltip-* CSS, restore html[data-k0smos-tooltips] from localStorage['k0smos.tooltips'], and register Alpine tooltipButton() before Alpine.start().
  • Tooltip triggers use a transparent 14px visual anchor with a 24px effective target (WCAG 2.5.8), a visible focus ring, and an anchor slot retained when browser tooltips are disabled. default and sober consume template/shared/asset/js/tooltip.js: Alpine teleports panels to body, deterministic fixed positioning falls back/clamps inside the visual viewport, and bounded scroll/resize/geometry updates keep alignment current. Hover opens after ~150ms; focus/click opens immediately; pointer/focus departure, outside interaction and Escape close according to pinned state. Escape restores focus, destruction cleans every resource, long panels scroll-contain, and reduced motion disables transitions.
  • Inline descriptions use the description-class contract: page subtitles carry .k-page-desc and panel descriptions carry .k-panel-desc (emitted by AdminPanelExtension::panelHeader()). Text-lean admin themes (sober) hide both with display:none, so every element carrying either class MUST have a sibling tooltip() with equivalent content — the description is never the sole carrier of information. Keep inline descriptions to one production-tone sentence.
  • The tooltip() helper (TooltipExtension) and AdminPanelExtension are translator-aware: their own labels (trigger accessible name, debug headings Route/Permissions/Note/Notes, permission descriptions, boolean values, save-permission notice) resolve through tooltip.* / admin.panel.save_permission_required keys in translation/messages.{it,en}.php with English fallbacks. Callers still pass their own content/label/title already localized. Every core admin settings page (view/info/edit/menus/app/ai/agents/mcp/payment/feature-flags), dashboard, users, roles, notifications, apidocs, and user/theme settings now render a discreet page-header tooltip with routes/permissions debug payloads.
  • User theme preferences (theme_palette, theme_mode) are persisted server-side and serialized into front and admin layouts as html[data-palette][data-mode]; the background pattern remains client-side in localStorage['k0smos.bgPattern'].
  • template/default/tpl/admin.tpl.php exposes a shared .admin-content-shell container: in adaptive mode it follows local k0smos.adminContentMode / k0smos.adminContentMaxWidth, but individual templates can force contentContainerMode => 'fluid'|'fixed' when UX requires it.
  • template/sober/tpl/admin.tpl.php exposes .sober-content-shell with the same data-admin-content-mode contract and keeps .sober-topbar-actions pinned to the top-right edge; user and notification popovers open right-aligned.
  • Avoid unnecessary desktop transform on admin layout containers that host fixed-position tooltips or popovers; it creates a containing block and can clip or offset panels.
  • On mobile (<1024px), the theme contract ignores fixed width limits and returns to width: 100%, so dashboards, tables, CTAs, and forms remain readable.

27. MCP Server (Model Context Protocol)

Core infrastructure that exposes the tools of the active runtime modules to AI agents (Claude, ChatGPT, Hermes, IronClaw, OpenClaw, …) over the Model Context Protocol. It is not a runtime module: the endpoint lives under src/ and config/routes.php, and modules contribute tools through an opt-in contract — the same core-vs-module split as OpenAPI docs and GlitchTip. Full internal reference: src/Application/Mcp/AI.Mcp.md. Customer-facing project context: doc/public/en/integrations/mcp.md.

27.1 Transport And Protocol

  • Streamable HTTP, stateless (not stdio): a single POST /api/mcp returning application/json. No SSE, no sessions. GET/DELETE are 405 (POST-only route).
  • JSON-RPC 2.0. Methods: initialize, ping, tools/list, tools/call, and notifications/* (answered with HTTP 202, no body). Batches are rejected (-32600); malformed JSON is -32700 (HTTP 400).
  • Protocol-version negotiation with baseline 2025-11-25 and compatibility for 2025-06-18, 2025-03-26, and 2024-11-05; capabilities {"tools": {"listChanged": false}}; serverInfo.name = k0smos.

27.2 Module Opt-In

  • A module implements McpToolProviderModuleInterface::getMcpToolProviderClasses() returning provider classes (McpToolProviderInterface), each bundling McpToolInterface tools.
  • McpToolRegistry is assembled from the active ModuleRegistry only, so tools of inactive modules (tenant-JSON allowlist, enabled_modules toggle, or dependency closure) never appear. Tool names must be globally unique (fail-fast on collision).
  • Tools are thin adapters over application services; they must not embed business logic or inline DBAL.
  • Tool packs can implement McpToolMetadataInterface to enrich tools/list with MCP annotations, successful-output schemas, and namespaced k0smos metadata. The current Ticket, Project and Finance packs do this.
  • Any MCP service or tool-pack change must keep doc/public/en/integrations/mcp.md aligned, because that file is the shareable customer/project-context explanation for client setup and safe usage.

tools/list is the operator-facing and agent-facing description of the exposed surface. Each listed tool includes the standard name, title, description, and inputSchema fields. Metadata-aware tools also include:

  • annotations: MCP ToolAnnotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint) for safer client UI and planner behavior.
  • outputSchema: JSON Schema for successful structuredContent from tools/call; list/detail/report envelopes are explicit while rich module-owned objects may keep additionalProperties.
  • _meta.k0smos/module, _meta.k0smos/requiredPermission, and _meta.k0smos/oauthScope: the contributing module, exact ACL permission, and OAuth scope to request for the same authority.
  • _meta.k0smos/domain and _meta.k0smos/resultShape: module-owned grouping hints for diagnostics and client presentation.

27.3 Gating (all must pass)

  1. Kill switch — mcp.enabled AppSetting (DB-backed, default off). Off → HTTP 403. No tenant JSON key. Toggled from the admin panel GET /admin/settings/mcp (GET/POST /api/settings/mcp, system.read/system.write).
  2. Endpoint ACL — route _auth_required + _permission = mcp.use, over bearer PAT or JWT.
  3. Active-module filtering — via ModuleRegistry (§27.2).
  4. Per-tool ACL — each tool's requiredPermission(); tools/list hides ungranted tools, tools/call on one returns -32000.

27.4 Authentication

Bearer token (personal API token, JWT, or OAuth MCP access token). For MCP clients that can send a static Authorization header, use a least-privilege scoped token (§25): scope it to mcp.use + the exposed tool permissions. Because the scope is enforced by intersection with the user's live permissions, the token cannot exceed the user and is bounded on every route. OAuth-capable clients use native OAuth 2.1 metadata, dynamic registration or a pre-registered public client id, authorization-code + PKCE, short-lived opaque access tokens (k0s_oat_...), and rotating refresh tokens (k0s_ort_...) bound to /api/mcp.

OAuth MCP tokens are not general-purpose API credentials. JwtAuthMiddleware resolves k0s_oat_... tokens only when the request path is /api/mcp; the OAuth authenticator then verifies the hashed token record, expiry/revocation state, tenant resource, active user, and live permission intersection before returning User::withScopedPermissions(...). Static personal API tokens remain available for clients that can send bearer headers directly, but ChatGPT should use the OAuth flow instead of a copied PAT.

OAuth authorize, token, and dynamic-registration endpoints use independent lazy-Redis fixed windows (30/60/10 requests per minute), fail open if Redis is unavailable, and return OAuth-shaped HTTP 429 responses with Retry-After. Lifecycle and resource-token failures are logged with stable codes and one-way fingerprints only. The server currently advertises DCR, not CIMD; although MCP 2025-11-25 prefers predefined clients and CIMD before DCR, remote metadata fetching remains disabled because supported clients, including Claude, use DCR and do not justify its SSRF-sensitive operational cost.

OAuth refresh tokens are issued to clients registered for refresh_token or requesting offline_access. They expire after 30 days, are persisted only as SHA-256 hashes, and are single-use. Every refresh rotates the token and returns a new access / refresh pair. Replaying a consumed refresh token revokes the grant family and all access tokens paired with it. A refresh request can retain or narrow its scopes but can never add authority.

27.5 ChatGPT Remote MCP

To expose k0smos to ChatGPT, first enable the tenant MCP server from /admin/settings/mcp. The user or OAuth subject that ultimately calls tools must have mcp.use plus the permissions of the tools ChatGPT may call; active-module filtering and per-tool ACL still apply after connection.

Run migrations for the exact public tenant that ChatGPT will call before testing the connection. Example:

TENANT_ENV=k0smos.example.com php bin/console migrate --allow-no-migration

The OAuth flow requires the oauth_clients, oauth_authorization_codes, oauth_access_tokens, and oauth_refresh_tokens tables. If ChatGPT reports Dynamic client registration failed, check /oauth/register: a ready tenant returns HTTP 201 with a k0s_oauth_client_... id; a tenant missing OAuth storage returns temporarily_unavailable and must be migrated.

ChatGPT Developer Mode can create a developer-mode app from a reachable HTTPS MCP server URL:

  • URL: https://k0smos.example.com/api/mcp

For authenticated private data or write actions, k0smos follows the MCP authorization spec: protected-resource metadata on the MCP server, OAuth/OIDC metadata from the authorization server, dynamic client registration for public clients, authorization-code + PKCE, and a consent screen at /oauth/authorize. The token endpoint returns a short-lived opaque bearer token plus a rotating refresh token, both bound to the public client and tenant MCP resource.

OAuth discovery and runtime endpoints:

Endpoint Role
GET /.well-known/oauth-protected-resource Protected-resource metadata for /api/mcp; includes the canonical resource URI, authorization server, supported scopes, and documentation URL.
GET /.well-known/oauth-protected-resource/api/mcp RFC 9728 path-aware discovery alias for clients deriving metadata from the MCP resource path.
GET /.well-known/oauth-authorization-server Authorization-server metadata; includes issuer, authorization endpoint, token endpoint, DCR endpoint, authorization_code + refresh_token, public client auth method, and S256 PKCE support.
GET /.well-known/openid-configuration Compatibility metadata alias; no ID tokens are issued for MCP.
POST /oauth/register Public dynamic client registration. Redirect URIs must be HTTPS or loopback HTTP callbacks for desktop/CLI clients, and are exact-matched later.
GET /oauth/authorize Authenticated admin/browser consent screen. Validates client, redirect URI, resource, requested scopes, and PKCE method.
POST /oauth/authorize/decision Consent approval/denial with CSRF protection. Approval creates a one-use authorization code.
POST /oauth/token Authorization-code + PKCE or rotating refresh-token exchange. Returns access_token, refresh_token, token_type, expires_in, and scope with no-store cache headers.
GET/POST /oauth/userinfo OIDC UserInfo endpoint. Requires a valid OAuth MCP access token with openid; returns sub plus profile/email claims only when those scopes were granted.

Supported OAuth scopes intentionally reuse k0smos permission codes: mcp.use, ticket.*, project.*, and finance.* permissions currently exposed through MCP tool packs. The OIDC identity scopes openid, profile, and email are protocol scopes only; they do not grant k0smos tool authority. Unknown scopes are rejected, and requested permission scopes that the live user does not currently hold cannot be granted. If a request omits mcp.use, k0smos adds it only after confirming the user is allowed to grant it.

In the ChatGPT UI, use the OAuth-capable or mixed authentication option for this tenant endpoint; never configure private tenant tools as no-auth. If advanced OAuth fields are shown, the authoritative inputs are the MCP URL and the two metadata URLs listed in the README. k0smos supports public clients with no client secret, dynamic client registration, PKCE S256, and OIDC UserInfo at /oauth/userinfo. CIMD, private_key_jwt, and a dedicated OAuth client-management UI are not implemented; DCR and rotating refresh tokens are.

ChatGPT Plus or consumer-account sessions are not k0smos API credentials and cannot authenticate this endpoint. Static PAT bearer tokens remain available for clients that can send headers directly, such as Claude Code, Hermes, MCP Inspector, or similar HTTP MCP clients. The README contains the operator checklist.

27.6 Claude, Hermes, IronClaw, And OpenClaw Compatibility

Claude and Claude Desktop custom connectors are added from Settings → Connectors with the tenant MCP URL. Claude uses DCR, PKCE, expiring access tokens, and refresh. k0smos accepts the exact current callback https://claude.ai/api/mcp/auth_callback and the announced future-domain callback https://claude.com/api/mcp/auth_callback. Automated HTTP-kernel coverage registers both and runs the full OAuth/MCP lifecycle with the current callback, making the release gate fully automated.

Claude Code supports remote HTTP MCP servers with either explicit headers or OAuth. k0smos supports both paths:

  • Static bearer header: create a scoped MCP PAT from /admin/settings/mcp and configure Claude Code with --header "Authorization: Bearer k0s_pat_...".
  • OAuth: configure Claude Code as an HTTP MCP server with an oauth object. k0smos supports public DCR, PKCE S256, metadata discovery through /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, and loopback HTTP redirect URIs such as http://127.0.0.1:8765/callback.

Recommended Claude Code OAuth configuration:

{
  "type": "http",
  "url": "https://k0smos.example.com/api/mcp",
  "oauth": {
    "scopes": "mcp.use ticket.view project.view finance.view"
  }
}

Set oauth.scopes deliberately. openid profile email are identity scopes for UserInfo; mcp.use and tool permission scopes such as ticket.view or finance.create control the k0smos tools Claude can see and call. Without tool permission scopes, Claude may complete OAuth but receive an empty or very small tool list.

Hermes accepts either headers.Authorization with a scoped PAT or auth: oauth. Its MCP SDK path performs protected-resource discovery, DCR, PKCE, token persistence, and automatic refresh. Hermes versions without an explicit OAuth scope override should use a scoped PAT when strict scope minimization is required.

IronClaw accepts a static bearer header. Its OAuth CLI expects a public client id: register the exact IronClaw HTTPS/loopback callback at /oauth/register, then pass the returned k0s_oauth_client_... id plus comma-separated scopes to ironclaw mcp add, followed by ironclaw mcp auth.

OpenClaw should configure the server with transport: "streamable-http" and either a static bearer header or auth: "oauth". openclaw mcp login runs the browser flow and stores refreshable credentials; openclaw mcp doctor k0smos --probe proves live tool discovery. Copyable configuration for every client is kept in /admin/settings/mcp and doc/public/en/integrations/mcp.md.

27.7 Current Tool Packs

  • ticket (modules/Ticket/src/Mcp): ticket_list, ticket_get, ticket_create, ticket_add_comment, ticket_change_status, ticket_assign, ticket_close, each reusing the existing ticket ACL codes and running mutations as the authenticated token user.
  • project (modules/Project/src/Mcp): project CRUD, folder CRUD, document listing and document search over ProjectService and the project read model.
  • finance (modules/Finance/src/Mcp): finance overview, account/transaction/category/tag/budget/bill/piggy-bank listing, spending reports, conversational expense/income/transfer recording, transaction update/delete, and piggy-bank money moves. Tools reuse finance.view|create|edit|delete; record tools can auto-create named expense/revenue accounts, categories and tags within the tool contract.

Further modules add a small src/Mcp/ provider the same way.

28. Multilingual Data Exchange

The default-enabled data_exchange module moves editable tenant content through deterministic .k0sdata.json or .k0sdata packages. Source modules opt in with DataExchangeProviderModuleInterface; currently supported data is allow-listed public app settings, Widget, Page, localized Blog, and the complete Ecommerce tax/category/product/variant/image catalog. Relationships use portable keys, never tenant-local IDs.

Operators use /admin/data-exchange or the k0smos:data-exchange:export|import|translate commands. Every write can be previewed. Insert-missing with preserve is the default; update is explicit; replace requires data_exchange.manage, exact-package checksum confirmation, and performs dependency-ordered upserts plus reverse cleanup in one transaction. Reports include creates, updates, deletes, skips and conflicts and can be downloaded without full imported content.

Packages require exact SHA-256 indexes and may carry trusted Ed25519 signatures. ZIP handling is bounded against traversal and archive bombs. Descriptor-declared Media references are bundled by checksum, deduplicated and rebound on the destination; successful packages are archived privately through Media. Rich HTML/CSS is rejected before provider execution when it contains active browser content.

The content-translation pipeline is separate from Symfony UI catalogues. It supports locale records and nested Blog/catalog maps, translates rich HTML text nodes and GrapesJS component text only, preserves human targets, and stores reviewable diffs/provenance/provider/model/estimated usage. Ai remains optional for ordinary import/export. Imports use the canonical default queue with the same-handler synchronous fallback and durable run/staging tables.

The normative wire contract, security ceilings, signature environment variables and entity logical keys are documented in Data exchange package specification.

29. Documentation Publishing

The optional, default-enabled Documentation module publishes reviewed Markdown from doc/public/{locale} through the active frontend theme. The root complete.md document is the primary entry returned by /docs; every subdirectory contains secondary, topic-specific material.

navigation.json provides stable IDs, public paths, deliberate reading order, breadcrumbs, visibility, and redirects. The sidebar derives its visual groups from the physical path segments, so source directories and public navigation remain aligned without synthetic Markdown index pages. All secondary English documents return to this complete reference through their breadcrumb parent.

Source resolution is contained with realpath(). CommonMark strips raw HTML, rejects unsafe links, renders tables and heading permalinks, and places unprefixed IDs directly on headings. The page index is extracted from rendered H2/H3 elements; authored fragments and stable doc: cross-document links use the same targets. Catalog and render caches are file-fingerprint based, while HTTP responses advertise a five-minute public cache lifetime.

For the exact request flow, locale fallback, manifest contract, rendering pipeline, caching behavior, search exposure, authoring workflow, and validation commands, see the Documentation module guide.


30. Kiosk Mode

Kiosk mode is a core surface at /kiosk: a simplified backoffice for operators who need to keep a website and their own account running without the full administration interface. It is an alternative view of k0smos, not a second application, and it is unrelated to digital signage: no unattended display, no paired device, no scheduled content.

It is core rather than an optional module because the surface is itself a security boundary. It exists for every tenant and is gated by ACL, so granting kiosk.use can never leave an operator holding a permission with no page behind it.

  • Three core pages: /kiosk (the operator's available capabilities), /kiosk/account (display name, language, light/dark and palette, password with email/TOTP confirmation, and TOTP enrollment/reset) and /kiosk/site (public company details and the storefront content width).
  • Kiosk narrows the backoffice and never widens it. Every kiosk route requires kiosk.use and the domain permission its /admin counterpart requires. AuthorizationMiddleware enforces this through _permission_all, a conjunctive list that adds to _permission rather than replacing it, so a route declaring both is checked against the union. Use the same pattern for any future surface that needs its own entry gate.
  • The surface is a set of validated declarations, never the admin sidebar filtered by ACL: a filtered sidebar would look simplified while absorbing every future module menu entry the operator happens to have permission for. Core declares its own pages in KioskSurface::coreCapabilities().
  • Modules extend the surface through KioskCapabilityProviderModuleInterface. A declaration names a route, never a URL, and KioskCapabilityCatalog accepts it only when the route is registered, is a backoffice route (/admin or /kiosk), really enforces every permission the declaration names — read from _permission, _permission_all and _permission_any — and the declaring module has not already reached the per-module cap of five entries. A contribution is therefore a link to a page the operator could already open from /admin: it cannot escalate privileges, cannot surface a route that enforces nothing, cannot render inside the kiosk shell, and cannot displace a core page by reusing its id. Rejected declarations are dropped, reported by rejected() and logged once per request at warning level, so a missing card is never silent. Page, Blog, Info and Media ship the first four contributions, and a card disappears when its module is inactive because its route is then absent.
  • Kiosk owns no business rule and no schema. Every mutation is performed by the core endpoint that already owns it — /api/user-settings/*, /api/settings/company, /api/settings/front-content — which is why the kiosk routes are GET-only.
  • kiosk.use is a gate, not a grant: alone it opens the shell and the account page and nothing else. The kiosk role preset adds the minimum website codes and deliberately excludes system.write, the single code that unlocks every tenant-wide settings page. Instead the settings routes accept delegated alternatives: settings.company and settings.front_content each cover one public group, /api/settings/company and /api/settings/front-content accept either them or system.write through _permission_any, and the preset carries both. Each /kiosk/site panel resolves its own writable state from the matching code, so an operator holding one of them saves that panel and reads the other. Unlike the action-derived viewer/contributor/manager presets, kiosk is an explicit code allowlist; it grants blog.delete and media.delete but withholds page.delete and info.delete, because pages are structural and information pages carry the legal copy.
  • /kiosk is the second path prefix SideDetectionMiddleware resolves to the admin side, which is what lets the tenant resolver pick the admin theme and the theme engine register the admin module template roots. Prefix matching stops at a segment boundary, so a public page slugged kiosknot stays public. Kiosk routes outrank the Page /{slug} catch-all.
  • The kiosk layout is a separate small shell under template/default/tpl/admin/kiosk/, which sober scans for Tailwind classes, so it renders styled in both admin themes without a per-theme copy. Capability labels resolve in the contributing area's own translation domain.
  • The single backoffice entry point is one back menu item under Tools, gated by kiosk.use.
  • Confinement is opt-in. A role marked kiosk_confined keeps its holders inside the surface: any other backoffice path redirects to /kiosk, an API call answers 403. It changes no permission — KioskConfinementMiddleware runs after AuthorizationMiddleware, so a path the operator lacks permission for is still a plain 403 and confinement only removes reach. It is unanimous across roles (one unconfined role keeps full access), refused on system roles, and its allowed set is derived from the surface itself, so it can never strand an operator on a card kiosk offers. Confined logins also lose the persistent "remember me" cookie, and the shell drops its own link back to /admin. Role edits require kiosk.use to remain assigned while confined, and scoped API identities preserve confinement even though their role grants are removed. Public pages/media remain reachable.
  • Capability visibility evaluates the complete route permission expression with policy voters. Cards must reference static GET pages below a backoffice root; explicit supporting operation route names must be registered and guarded. Page and Media declare the APIs their editors need; core account endpoints are exact allowlist entries, excluding token creation.
  • Company partial saves preserve omitted fields. Both website settings endpoints use SettingsWriterInterface / DbalAuditedSettingsWriter to commit settings and a settings.update audit row together. Metadata records the actor, group and canonical field names, never values; rollback also refreshes the local settings cache. This uses the existing migration-owned audit schema.

Page, Info and Blog editors render their create/edit/delete controls from the same authorization checker used by their routes. Page publishing remains a separate permission, and JSON save responses retain the same allowed action URLs. Blog deletion requires blog.delete; its additive module migration Version20260913090000 registers the permission and grants the system admin, without extending tenant-managed editor roles. Existing roles need an explicit blog.delete grant if their operators should delete articles.

The kiosk development review is complete; tools/benchmarks/kiosk-smoke.mjs retains the browser regressions with isolated fixtures and automatic cleanup. Workstation and screen-reader deployment guidance lives in Kiosk mode.

Kiosk save buttons activate only after JavaScript starts; a visible notice also covers a failed bundle. The skip link works without scripting, successful password verification restores focus, and persistent status regions announce feedback. Website forms accept only their endpoint's successful response shape; fluid mode ignores the hidden fixed-width input and preserves its stored value.

The permission gate is seeded by migrations/Maintenance/Version20260907090000.php and the delegated settings codes by Version20260907120000.php, both alongside PermissionsFixture and all insert-if-missing. The operator-facing guide is Kiosk mode; the implementation detail is src/Application/Kiosk/AI.Kiosk.md.

Shared AI provider/Python capabilities are activated by participating modules independently of private Ai chat. The existing settings editor receives active scope suggestions, while public consumers require explicit enabled scope/client/model configuration. Module-declared sensitive HTTP roots are excluded from generic body, exception, SQL and debug telemetry before middleware construction. Ownership and verification: src/App/AI/AI.SharedAi.md, src/Http/Privacy/AI.RequestPrivacy.md.

31. Independent Static Site Publication

The optional, disabled-by-default site_publish module owns bounded independent- site profiles and their publication ledger. /admin/site-publish keeps saved revision, exported revision and verified live generation separate. The supplied dm3 profile preserves the existing Italian React design, six chapters and scroll-controlled film; Media, company settings and optional published legal Pages retain their own ownership.

The second registered profile, content, publishes complete multi-locale snapshots from public owner contracts: exact-locale Page rows, optional Blog rows whose non-default locales require translations, published anonymous menus, allowlisted settings and referenced Media originals plus existing responsive WebP derivatives. It reconstructs semantic HTML and rejects active/editor content, unsafe links and unknown Media. Page CSS/project JSON, Blog editor metadata, credentials, tenant Media IDs and private paths do not cross the boundary. Duplicate routes/documents/redirects or an inactive selected owner fail the full generation; publication never falls back to a partial snapshot.

Explicit publication records one audited generation request on default; its worker freezes schema-v1 SQLite plus checked local media, builds and validates a fresh release, then atomically selects it on Linux. A monotonic generation sequence fences stale workers even when the document revision is unchanged. Rollback and crash recovery preserve edited content. Public HTML/assets require neither PHP nor the tenant database, and an append-only asset pool keeps older browser tabs working across activations.

SQLite is the canonical and sufficient public generation database. An optional deployment-only site.database block can mirror a finalized generation to Supabase/PostgreSQL through the same normalized DBAL connection rules, with mandatory TLS for remote Supabase. The versioned PostgreSQL schema preserves every portable row under exact site/generation keys, enables deny-by-default RLS and is applied explicitly by an operator; publication performs no runtime DDL. The mirror verifies the manifest and SQLite digest and commits one complete generation transactionally. Live Supabase connectivity and cross-database parity execution remain deployment qualifications rather than repository test requirements.

The independent checkout owns its schema and build/release tools; this is not a theme and does not use the tenant's theme.front to select the public site. Its deployment-only site.root and site.public_url leave the tenant database unchanged. The same-host Caddy profile keeps static and backend document roots disjoint and preserves the original Host for backend routes; the supplied Apache profile provides the equivalent private-loopback backend topology.

Document-root selection is therefore explicit. A virtual host aimed at k0smos public/ runs the PHP front controller and renders the configured tenant theme; site.root does not override it. An activated independent site instead serves HTML from site/<site>/current, with /assets/ and /site-media/ mapped to the append-only site/<site>/published-assets/ pool. site/<site>/app/public holds source/import assets and is never the activated document root. Bun runs only for publication builds. Apache can serve the static release without a JavaScript runtime and can either execute the explicitly routed k0smos paths with PHP-FPM or reverse proxy them to a private PHP listener; reverse proxying is not required for the independent frontend itself.

The operator contract, import preview and backup, ACL/CSRF, command examples, portable schema, retention, hosting and exact deployment limits are documented in Independent static site publication. Tenant 33's existing descriptor, SQLite database and independent checkout were restored without changing its root database configuration. Revision 1 is active as generation g20260921135800-0d4666306d53d2ab with independently verified source and release digests. The Apache profile passed isolated routing, denial, range/cache/CSP and backend-outage checks against that release, then passed the static/backend checks on the actual ports 80/443 listener after the privileged switch. External DNS does not resolve from this workspace. The active Include was migrated to the generic Apache template and passed the same listener checks afterward. The controlled live-profile PHP-FPM outage kept static HTML/CSS/MP4/404 available while backend routes returned 503; Apache, storage and host remain shared failure domains. Socket restoration returned the backend routes to their expected responses without changing the static release. Old-vhost backup/restore is not an acceptance requirement. Default/Sober production theme bundles remain maintainer-built.