Skip to content
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.36.0 development is open from 2026-10-11. 0.35.0 is the latest closed release, opened on 2026-10-02 and sealed on 2026-10-11. It records template consolidation, theme/navigation improvements, imported-customer credentials/recovery, cart/locale fixes, unified tenant updates and optional search/database integrations. Theme builds remain owner-run. Native/provider qualification and runtime deployment remain tenant-specific. Brainstorming development migrations/build are confirmed and its default worker is refreshed; owner UI/Hermes acceptance remains separate from isolated native workflow/MCP verification. Evidence and deployment checks are in the root changelog and owner documentation; closure does not imply deployment.


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) OpenSearch → Typesense → Meilisearch → Elasticsearch; SQL fallback
Logging Monolog
Templating PHP Plates
AI / LLM Native HTTP adapters (Ollama, OpenAI Responses API, Anthropic Messages API, OpenRouter, OmniRoute, 9router, 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: concrete/public catchall URL prefix -> session -> Accept-Language -> tenant default; persists URL choices 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",
    "modern": { "provider": "opensearch", "enabled": false },
    "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.

Default is also the canonical owner of backoffice Storage and Blog editor runtimes. Sober explicitly reuses those files through the dependency-injected template/shared/asset/js/admin/page-capabilities.js coordinator: register Storage state before Alpine, then load ElFinder and the Blog controllers after startup. Each theme owns its dependencies, Vite resolution, shell and single Alpine bootstrap. Complete initialization jobs are cached; feature failures remain independent. Template inheritance is unchanged and does not implicitly inherit JavaScript registration. See the two theme AI.theme.md files. Sober additionally requests menu, module and Klaro settings factories only when their inherited admin components are present, completing registration before Alpine starts. A failed panel offers localized native reload recovery while the shell and independent capabilities remain usable. The 90,000-byte raw entry limit is unchanged. The fresh owner-run artifact measures 85,135 raw / 28,935 gzip bytes and passes the official 26-JS/five-CSS budget check; the agent ran no theme build. Deployed browser acceptance remains separate. Existing lazy monolithic editor warnings remain visible.

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

Default's current source implements the approved ruled-section visual system: neutral light/dark canvases, exterior hatching, coloured example surfaces, readable documentation and forms, and restrained backoffice workspaces. Public example patterns use CSS fixed attachment only on desktop with normal motion; mobile/reduced-motion and all backoffice patterns stay static. Tenant-authored widgets and Page content, the five palette preferences, navigation, canonical commerce and shared Sober contracts retain their owners. Default's illustrative home specimen describes product areas and is labelled as an example. Owner-run asset compilation and representative tenant/device qualification remain separate from source delivery; the durable specification is template/default/AI.theme.md.

For design work, tools/mockup/ supplies a reusable page/viewport/mode/palette gallery and loopback preview server. Generated material lives in ignored root mockup/<task>/ and is cleaned after implementation review. The root README documents the Windows SSH tunnel; agents/theme-mockups.md defines when agents use this workflow. Mockups never publish through the Documentation catalog.

Public reliability pass (2026-10-02): the public shells are default, cc1, dm1, dm2, lts1, pc1, pc2 and pc3; Sober is admin-only. Default remains canonical template fallback, while each public theme owns its bundle, Alpine instance and visual identity. PHP template inheritance does not load runtime capabilities. Shared recovery therefore uses explicit narrow runtimes/partials, without another theme's full bootstrap or changes to ThemeEngine/tenant overrides.

Public predictive search invalidates stale queries and cancels dismissed/destroyed work; DM1's native markup uses that same runtime. Contact forms retain drafts, refuse duplicate sends, require explicit nonredirected JSON success, localize server field feedback and expose bounded CAPTCHA recovery. Front owns the neutral contact partials, and native theme forms retain their presentation. Public path generation uses the tenant locale helpers; API endpoints and fixed-language Documentation publication URLs keep separate ownership. Native login adapters preserve flat controller destinations/provider flags; Blog readers honor HTML/plain formats. DM1 Documentation renders the populated navigation-item tree, including optional group documents and current ancestry. PC1/CC1 mobile navigation preserves visible focus on dismissal. PC1 decorative animation pauses while hidden/offscreen and respects reduced motion, without changing normal visible content. Default uses the guarded canonical public mobile model, with visible focus recovery at native breakpoints and owned teardown, and keeps its canonical recursive menu tree. LTS1 uses one named primary navigation and a single list for Home, Catalog, New products and Contact across desktop and mobile. Its compiled header controller enhances native disclosures and coordinates their opening with Catalog. LTS1 presents one directly visible Catalog entry: a wide desktop navigator with root links and selected subcategory columns, and a full-height mobile panel that opens directly to roots with Back/Close and one branch at a time. The theme partitions already published/authorized menu rows by stable entry/route/block identities; Shop and Ecommerce editorial blocks move into Catalog except New products, which follows Catalog and precedes Contact. The New products disclosure retains the published newest-product cards and their category scope; it does not substitute the ordinary shop or updated-product sort. Currency, wishlist, cart and account/login follow the same navigation row, with account last. Narrow viewports scroll the complete band horizontally. A native language disclosure pairs a globe SVG with the current locale code and retains owner-provided switch URLs. Unrelated nested entries stay in one compact native Site disclosure/tree, rendered once for all viewport classes with or without JavaScript. Overlapping supplied trees are merged; no taxonomy query, menu persistence rewrite or module activation is added. Category order, visibility, counts and localized URLs retain owner authority. Every menu entry pairs its label with a decorative SVG; category icons use explicit keys or semantic IT/EN aliases, with a generic fallback. The duplicate home category bar and category-widget selector are removed, retaining real category cards and retiring the separate home catalog query. Native details and ordinary links provide the no-JS fallback; empty site menus create no toggle. Mobile enhancement alone contains focus and restores prior background inert/scroll state on close; desktop dismissal follows ordinary navigation focus. The owner authorized the LTS1 compilation on 2026-10-11: production budgets and the nine-theme inventory pass, and Apache HTTP/SHA-256 checks confirm the current JS/CSS and payment assets are served. Twelve native IT/EN × light/dark × desktop/mobile cases / 218 checks pass against actual Kernel rendering on an isolated copy of tenant 31 data. Browser automation never uses the runtime database. Broader provider/device qualification remains separate; other theme builds retain their manual ownership. LTS1's header and footer use the supplied La Termosanitaria logo, hosted locally under /themes/lts1/brand/. Its browser favicon uses the original logo's LA lettering (ICO at 16/32/48px, PNG at 32px and Apple Touch Icon at 180px). Company logo and custom favicon settings take precedence over these theme fallbacks. The assets load directly from the public directory without a frontend rebuild; other themes and shared project icons remain unchanged. LTS1's footer payment marks are configured at /admin/widgets: select LTS1 — Payment methods, hook lts1_footer_payments. Edit the accessible section label and each mark's name, image path, alternative text and optional link; move marks up/down, remove them or disable the widget through its status. Locale targeting follows the shared Widget contract. The owner-requested initial marks are SEPA, PayPal, Visa and Mastercard, copied from the original store and hosted locally under /themes/lts1/payments/ in their original colors. Empty blocks and pages without Ecommerce omit the section. These editorial marks do not configure payment gateways; checkout retains authority over available payment choices. Marks render at 64px width with their natural proportions, align to the right and wrap when necessary; no visible payment heading is rendered. Removing the heading needs no asset rebuild. Earlier header/footer refinements require an owner-run LTS1 rebuild. Their isolated source verification passes 510 PHP cases / 6,221 assertions and 50 IT/EN/light/dark cells at 320/390/767/768/1440px plus two no-JS visits / 1,836 checks. This does not qualify the existing deployed assets.

LTS1's first commerce homepage uses the original store's department imagery: a wide heating banner, bathroom/renewable-energy stories, semantic category SVG cards, real latest arrivals, supplier marks and assistance. Configure each block at /admin/widgets using the existing LTS1 Hero/Category grid/Featured products/ Promotions/Brands/Assistance types and their lts1_* home hooks. The hero is a single localized slot; empty sections remain omitted. Development tenant 31 has six blocks per locale (IT/EN), inserted after backup while preserving existing content. Generic tenants receive no automatic editorial claims or assets.

Home assistance appears before “Explore the catalog”. On desktop it shares a row with a product search card; smaller screens stack assistance and search. Search accepts a product code, name or description and submits to the localized catalog, alongside the existing header search. Configure assistance through its existing Widget. Supplier marks use one horizontally scrollable carousel with uniform responsive blocks and proportional logos, usable by touch and keyboard. These presentation changes require a manual LTS1 stylesheet rebuild.

The catalog's left category tree uses compact rows and an accordion. Click its heading to close the whole tree; use a branch's chevron to show or hide children. Opening a sibling closes the previous branch at that level. The selected category's path opens automatically, and category names remain links preserving the current search and filters. Keyboard and native no-JS navigation are supported; mobile retains larger touch targets. Rebuild LTS1's stylesheet for this layout.

The tree has its own bounded scrolling area: wheel input over categories scrolls that area and leaves the central page in place, including at its top/bottom. LTS1's numbers show distinct active catalog products in each category's configured landing scope, including additional memberships and enabled active descendants. They no longer use the primary-only search facet buckets. Text search, price bounds and price sorting omit these unfiltered totals; empty known categories show zero. An absent count read omits numbers. The existing provider facet contract remains unchanged.

Published Home is an explicit editable entry in the single primary navigation list before Catalog, followed by New products and Contact. Links and disclosure summaries use one shared style for icons, geometry and hover/current/open states. Header CSS is organized in LTS1's asset/navigation.css, while compiled asset/header-navigation.mjs owns shared dismissal/focus behavior. Default's inline public-mobile model is no longer included by LTS1. On mobile the complete band scrolls horizontally so all entries and trailing currency/wishlist/cart/account utilities share one row; keyboard focus reveals offscreen controls and popups escape scroll clipping. Plain links and disclosure triggers share font, spacing and alignment. Language and currency share SVG/current-code native disclosures, option states and dismissal behavior. Clicking a currency option submits its value immediately through the existing POST even without JavaScript; there is no extra Apply step. Footer destinations retain one ordered list with two columns below 640px and three above, readable wrapping and 44px touch targets. Assistance copy ends with “Scrivici.” (“Contact us.” in English); development tenant 31's IT/EN Widget bodies were updated after backup without changing other fields or content. Current header/footer refinements pass 87 focused PHP cases / 499 assertions, eight client cases and 50 source-browser cells plus two no-JS visits / 2,064 checks, plus 40 isolated Kernel currency checks under both locale-prefix policies with native no-JS submission.

Account/login is the final header utility. When more than one active currency exists, the selector shares Catalog's row and submits the existing currency POST on option selection, retaining the current locale/query with or without JavaScript. Selection persists in the canonical currency session and homepage prices convert through CurrencyService. Ecommerce's declared layout context is available on all public HTML pages through the active-module registry, excluding admin/JSON and homepage-feed composition. Neutral cached menu-block prices retain their source currency. Source checks include 36 browser cells / 924 checks and 40 isolated Kernel currency checks with native no-JS fallback under both locale-prefix policies. JavaScript and CSS require the owner's LTS1 rebuild before rollout.

Default, LTS1 and PC2 delegate page-owned main markup and retain focusable skip targets; Default/LTS1 before/after-content hooks surround both section branches once. Fallback sidebars stack below desktop width, and LTS1 renders home_bottom. LTS1 separates readable action/text/focus roles from its domain colors: normal/hover buttons, cart badges, small links/domain labels, inverted bands, white buttons and promo copy have corrected light/dark pairs. Its canonical accent bridge serves both small text and filled actions. Keyboard outlines are unlayered so inherited utilities cannot suppress them; the clipped login password uses an inset outline. The same actual-source predicate measures 116 pairs: 62 below their text/focus thresholds before, zero after. This is sRGB source evidence, not native computed contrast or a complete WCAG certificate. Thermal lines, domain accents, palette, the thermal/domain signature remains; the owner-approved October source world is «Scheda tecnica». The preceding ratios describe the earlier bounded tranche. LTS1 now retires per-card entrances and the intersect dependency. Legacy reveal classes remain static/visible. PC2 retains its own guarded reveal/skill observer thresholds and reduced-motion fallback; unsupported observers preserve authored skill widths. Source/DOM contracts do not claim native timing.

PC2's native fixed particle field has one canvas-owned loop, dynamic 48 normal/22 static counts, reduced static resize redraw, visibility/BFCache pause/resume and idempotent departure cleanup. Identical actual-source scheduler/presentation predicates pass 13/27 before and 27/27 after; reduced/hidden/cached queued frames 1→0, explicit repeated-init frames 2→1. Original point/pair colors, speed, radius and pairing equations remain. These are deterministic scheduler measurements without native FPS, CPU, battery or offscreen claims. PC2's same-menu native header owns max 1040 breakpoint state/listeners and clears marked generic/catalog child state before mobile hiding. Escape/link/outside, breakpoint and teardown recovery leave focus visible; ordinary desktop child behavior and unchanged catalog handlers remain. Unbound primary/generic children are readable; native disclosure labels are disabled/expanded until binding, then restored on teardown. Identical 22 source/DOM predicates pass 0 before/22 after in IT/EN, retaining eleven fixture menu links per locale. The existing one-menu DOM, Alpine startup and identity remain; native geometry/AT is not certified. DM1 keeps fonts in its existing JavaScript import path; eight redundant Sass imports were removed. Comparable current-source Vite builds reduce font-face blocks 121→61 and CSS 531,353/110,235→472,589/86,684 raw/gzip9 bytes. All 61 unique complete declarations (URLs, styles, weights, subsets/unicode ranges),114 emitted font binaries, non-font CSS and compiled application JavaScript stay identical. Budgets pass on both sides without dependency/ceiling changes. This measures asset sizes, not elapsed font load/paint; production assets still require publication.

PC2 conditionally imports the canonical Default standalone consent manager, never its complete entry or Alpine. Configured startup begins immediately; pending footer clicks coalesce, load/setup/show failures announce and permit retry, and shared consent observers are notified only after successful setup. Unconfigured pages perform no consent import. Seven actual-library managed script/iframe scenarios preserve pending/failure/denial/grant/saved-grant/revocation semantics. Destruction prevents late owned explicit setup/actions, without aborting imported library evaluation or downloads. Managed inert authoring remains required; raw active sources cannot be blocked retroactively, nor can executed scripts be undone. Earlier comparable builds, before the shared lifecycle extraction, reduce eager JS/CSS 368,219/110,359 →138,877/39,705 raw/gzip9 bytes; configured eager + consent graph 368,808/110,965 adds 589/606 bytes and two assets. CSS 76,980 →57,846 raw meets the unchanged 75,000 limit. Toolchain, locks, canonical helper and resolved Klaro vendor hashes were identical in that measurement. All-emitted assets add the same 589/606 bytes; metadata/manifests are accounted separately. This is payload/graph measurement, not elapsed browser performance.

DM1 account adapters honor flat customer, orders, addresses, currency, wishlist count and invoice fields, with nested legacy fallback and explicit empty/zero precedence. Exact shop-auth destinations use safe JSON script values and missing destinations use the tenant locale policy. Native markup, profile/address/review forms and invoice links remain unchanged. Invoice owns the public request/data for /shop/account/invoices/{id}; Ecommerce owns its account template. Populated actual ViewModel responses are checked across all eight themes, both IT/EN tenant defaults and prefix policies; DM1 compatibility/empty/auth states are checked too. The existing flat wishlist adapter remains intact. 472 cases / 4,448 assertions and 16 independent native auth-factory/request probes pass without server auth or provider/navigation claims.

The new evidence uses actual-source DOM/Alpine tests and synthetic ThemeEngine renders without runtime tenant databases or provider requests. Native viewport, assistive-technology, zoom, elapsed performance and live provider acceptance are not established by those checks. Maintainer asset publication and representative tenant qualification remain separate. Owner contracts live in the theme AI files and modules/Front/src/AI.Front.md.

Final scoped verification passes 1,352 PHP cases / 14,153 assertions, 383 public runtime cases, 16 native auth-factory probes, 422 representative IT/EN route renders and 320 empty/populated shell-state renders. These are synthetic source and HTML contracts. Eight isolated production builds pass unchanged asset budgets, metadata/lock hashes and the affected generated desktop-width utilities. One contact lifecycle test now waits for observed readiness instead of assuming a 40ms startup; strict single mount/removal assertions remain. Published bundles and tenant/runtime data were not changed. Independent design and detector evidence preserves incumbent identities; five existing width/padding transitions remain measurement candidates without proven native jank/cost. LTS1 search destination and module-conditional fallback catalog links remain product decisions. Browser/AT/zoom/provider acceptance, elapsed performance and operator asset publication remain explicit delivery boundaries.

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. An ordinary audit succeeds while strict --fail-on-missing reports every absent physical fork; neither result alone proves rendered compatibility.

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

Current physical inventory (2026-10-10, 34 module-owned front files): CC1 has 20 forks, 2 identical copies and 12 missing forks; DM1 has 22 forks and 12 missing. Those twelve are intentional canonical reuse, reviewed in tests/Support/PublicCanonicalTemplateReuse.php: Front's _contact_config and _contact_recovery, and ten Ecommerce templates (cart, cart-page and product runtimes, checkout confirmation and retry, completed cart, catalog sort/price controls, sign-in notice and the two password reset pages). Keep the strict audit truthful; do not copy neutral recovery code merely to reduce warnings. Compatibility requires real module roots, ordinary/stable-alias resolution and rendered workflows. CC1's Tax adapter is Tailwind-compatible, DM1's is Bootstrap-native and PC2's is plain CSS. The portfolio themes present no storefront (owner decision, 2026-10-10): PC1 forks 5 surfaces and leaves 29 canonical, PC2 forks 9 and leaves 25, PC3 forks 7 and leaves 27; none of them forks an Ecommerce template, and PC2 leaves catalog menu blocks out of its navigation.

Themes and runtime modules are independent in both directions. The selected public theme never suppresses a module or refuses its activation, and an active module never prevents selecting a theme: only the selected theme wrappers must be active. When a theme has no fork for some page templates of an active module, the backoffice shows a non-blocking warning — on the matching front choice of /admin/settings/themes and on the module row of the module panels — measured by ThemeModuleTemplateCoverage (underscore partials excluded, default never reported). The pages still work through the canonical templates. The warning is informational: it says the pages use the standard (default) module templates, lists them, and states that this is not an error. The current page-only advisory excludes underscore partials: CC1 and DM1 list the three canonical Ecommerce pages every theme shares on purpose — the two password reset pages and the payment retry notice (3/15); PC1, PC2 and PC3 use all 15 canonical Ecommerce pages, alongside their other documented module fallbacks. DM2 uses canonical Front 2/3, Ecommerce 15/15, Blog 2/2 and each Info, Page, Tax, Documentation and Discovery page. LTS1 now forks the contact form presentation and three commerce pages (catalog/product/cart) and styles the remaining twelve canonical Ecommerce pages through semantic tokens; its other documented module fallbacks remain intentional. Every public theme layout renders the front.footer.widgets slot, so module footer contributions such as the OneUptime status line appear in all of them.

LTS1 source refresh (2026-10-03): the owner-approved «Scheda tecnica» world replaces demo-02 in source, using enamel surfaces, blue actions, a red/blue thermal navigator, normal-width Libre Franklin Variable (owner revision of 2026-10-04, replacing the initial expanded Archivo) and code/data-only IBM Plex Mono. Native catalog/product/cart consume canonical module data and behavior. The native contact keeps canonical recovery with a simpler border-only form. Opening category navigation reads real localized taxonomy independently from widget hooks. The lazy cart sheet contains focus; mobile tabs follow active modules. Home commercial facts, promotions, brands and assistance are structured widgets with safe optional Media, empty defaults and insert-missing IT/EN fixtures. The shared-core hero supports legacy data, ten slides, pause/manual controls and consent-safe playback. Search submits to HTML /shop, SEO/noindex and inherited prose/state styles follow the native contract. Canonical account/auth/wishlist/recovery remain module-owned with localized feedback and safe flat payloads.

Final post-review current-source Chromium confirmation passes 324 cells / 1,686 checks and 32 full-page captures with zero failures/exceptions. It includes IT/EN, both schemes, 320–1440px, actual 200% tab zoom, keyboard, motion and no-JS. Real Widget saves/rejections/retries pass twenty Default/Sober × IT/EN workflows, 207 checks and sixty JSON writes on isolated SQLite. Regression tests are owner-approved. See template/lts1/AI.theme.md for independent review, design documentation and exact evidence boundaries. The independent reviewer scored all five requested fixes resolved with ship; that verdict covers the fixes and does not certify production deployment. All builds, including isolated production compilation, remain manual. On 2026-10-04 development tenant 31 received the verified existing owner-compiled LTS1 refresh after an older served bundle caused missing cart registrations. Actual HTTP asset/hash checks, native Chromium home/catalog/cart/product snapshots, 16 component tests and current build budgets pass; no compilation was run. Other tenant rollouts, real providers/devices/assistive technology and editorial Media qualification remain deployment gates.

LTS1 is the first concrete ecommerce concept (owner direction, 2026-10-04). Its prompts, requests and verification cover the theme, Ecommerce/Cart, shared services, backoffice and enabled integrations. New ecommerce themes use the reusable agents/plans/ecommerce_theme_creation.md with the generic theme plan. The EC-01–EC-17 checklist covers catalog/search/Mega Menu, variants/inventory, prices/currency/tax/discounts, complete cart recovery, checkout/payment, customer identity/account, administration/imports, route/locale/SEO, accessibility, privacy and served assets. Checks distinguish supported, optional and proposed features and record expected results, configuration, evidence and verification status; source-only visual acceptance cannot establish system or deployment acceptance. EC-16 additionally requires an accurate public ecommerce guide covering use, configuration, customer/staff identities and recovery, integrated with this reference and the Documentation catalog. The Ecommerce guide now documents current capabilities, operator workflows and remaining limitations. EC-17 adds registration privacy and country-specific purchase/fiscal gates. The owner requires private/business customers in Italy, the EU and non-EU countries; verify all six cases across guest and registered checkout. This is required coverage, with implementation and market-specific qualification still pending. The 2026-10-04 source audit found missing inline guest/new-customer address entry, free-text countries and missing customer/invoice fiscal identifiers and recipient routing. Inline guest/registered delivery/billing, full native postal review and required current shipment delivery are now delivered (2026-10-13). Italy-first HTTP/checkout postal validation (2026-10-14) normalizes IT/Italia/Italy, preserves five-digit CAP zeroes and requires a two-letter province, without rewriting saved/imported rows or historical captures. Other country text retains existing behavior; actual postal coherence, market availability, qualified fiscal fields and provider routing remain pending. Registration needs contextual privacy information qualified against the tenant's actual purposes and legal text; layout links/cookie consent alone do not establish conformity. These system gaps remain pending, as documented in the Ecommerce guide.

Shop customers use localized /shop/login and /shop/account, their ecommerce_customers identity and CustomerSession; framework operators retain separate User/JWT permissions. "Forgot your password?" opens a reset by emailed link (/shop/password/forgot): the link is valid for one hour, works once, and the page answers the same whether or not the address has an account. Customers imported from another shop are told at sign-in, in an inline notice above the form, when a new password is needed or when changing a migrated one is suggested. Outgoing mail must be configured for the reset to arrive; without it the shop states that reset by email is unavailable and staff set the password from the customer edit form. LTS1 selects customer account links while Ecommerce is active, and typed shop entries supersede default front/footer staff-login entries. Mega Menu data comes from published core Menu configuration and Ecommerce's bounded public blocks; desktop/mobile presentation remains theme-owned.

Customer profile/password, imported sign-in, reset issuance and connector refresh now coordinate through a current locked Customer write scope. Email changes revoke links in the same operation; import/retention cannot restore an older password. Reset mail is handed off after its committed current-recipient decision. Import row savepoints isolate recoverable failures; a lost page transaction aborts without advancing its checkpoint. Disposable SQLite/process evidence and remaining remote SQL/native-session qualification live in the Ecommerce owner's credential contract.

Cart mutations use POST /cart/mutate after authorized GET /cart/bootstrap, without an IT/EN prefix; native shop/cart/checkout forms retain locale policy. All eight compiled front consumers now use verified memory-only SQL and current numeric versions, with explicit retry for unknown outcomes. Stock, product, variant, quantity and concurrent-version refusals preserve confirmed selections. Hidden/pagehide now clears private projections/reviews, fences late work and suppresses new background requests. Visibility/pageshow restores only fresh authorized SQL; queued/interrupted preflight cannot submit after restoration. Uncertain already-sent commands retain explicit exact retry until current identity is verified. All-eight owner builds and actual BFCache/HTTP/device qualification remain required. The full container also retains the registered sales group, so native account/admin and Invoice consumers resolve the optional accepted-postal reader through its existing core factory instead of interface autowiring. Legacy POST /cart/sync now returns side-effect-free private 410 cart.legacy_sync_retired with /cart, /cart/bootstrap and /cart/mutate recovery paths, without reading inputs or SQL. Its unused mutable service/DI entry is removed while canonical projections remain. Legacy coupon POST/DELETE also return private 410. Owner-built bundles and reviewed cutover remain required; token-based checkout shopping/fresh settlement is also retired. Every shopping read uses verified SQL authority and fresh acceptance requires a current grant; accepted session-bound receipt replay/resume survives closed shopping credentials, draft expiry/deletion and gateway changes. See the Cart owner.

Server-side catalog repricing is delivered in 0.34.0. The strict cart boundary accepts selection intent; checkout requires a current session-bound quote and persists one accepted order/purchase/payment attempt. Monetary tampering, real inventory, concurrent SQLite, kernel HTML/no-JS and Alpine/jsdom regressions pass. Kernel checkout preview coverage explicitly requests IT/EN pages and checks localized EUR display, canonical numeric totals despite forged prices, quote/customer fields and persisted no-JS additions using disposable SQLite. Canonical and native DM1/PC2 cart/payment recovery are aligned. Runtime migration, provider sandbox qualification and real-browser visual acceptance remain rollout checks. The LTS1 source refresh and backend correction have separate evidence; production rollout remains owner-controlled. Checkout/payment and currency fallback recovery follow locale policy. Explicit internal currency returns preserve their saved slug/query; missing or invalid targets generate the active-locale shop route. External-provider and production bundle qualification remain owner gates.

dm2 forks one module template (present-forked=1; 29 missing with every catalog module, including the thirteen Ecommerce pages it renders through the resolver). 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 canonical page markup; Default now delegates its main landmark in the same way while retaining its own skip target. 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 activation remains independent of theme.front=dm2; its pages use canonical module templates when no native fork exists. 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 explicit consent granted, 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.

DM2 source acceptance (2026-10-03) now covers thirteen public cases in IT/EN: Home, scenes, canonical contact/legal/Page/Documentation, populated/empty Blog, HTML/plain articles and preview-shaped markup. Chromium passes 286 cases / 1,668 predicates at six widths, landscape, real 200% tab zoom, no-JS and cold image decoding, including native keyboard/local input, visibility/motion, persisted BFCache and real installed Klaro denied. Linux WebKitGTK passes 48 cases / 396 checks at measured 1440/447 px; scripted gallery activation uses its native dialog. Firefox and Safari/iOS are unavailable, and emulated viewport/touch/software rendering does not qualify representative hardware.

Fifteen exact bundled sources now carry file-derived intrinsic dimensions; 76 repeated image predicates pass (54 old mismatches corrected). Unknown or owner-versioned source metadata, mobile images, alt text and authored crop ratios survive. Only the stock header logo receives a definite 52/40 px height matching its existing limits to reserve its verified ratio before decode. The closed viewer stays source-free and transfers selected-image dimensions before loading. The complete after matrix is frozen; focused final logo/lifecycle corrections are recorded separately: 30 cold-image cells / 290 checks prove initially undecoded logos reserve identical final geometry, and 26 native lifecycle checks cover rapid pending initialization, pinned visible keyboard focus and actual nonpersisted reload cleanup. Both final proofs pass with zero failures/errors; current hashes and all published bytes match. Internal observer/timer disposal counts remain isolated runtime evidence. Mixed font/style/image CLS does not prove a zero-shift or image-only performance gain.

Real isolated Kernel acceptance passes 8 tests / 1,000 assertions for Default/Sober × IT/EN: protected Blog preview, body formats, auth/locale/ permissions/noindex/no-store and independent widget source/editorial/media saves, edit reloads and rejected-draft preservation. Native preview geometry uses synthetic markup; actual tenant/editorial operation remains owner-owned. All 33 published DM2 artifacts are unchanged; existing entry budgets (95,468 raw / 32,546 gzip bytes) and the nine-theme inventory pass without a build. Current source compilation, final Media master/import, activation, actual tenant edits, rights, representative hardware/performance, real YouTube playback/consent, masked-player approval, client creative approval and management/site destinations remain separate owner gates. The behavior, evidence and limitations live in template/dm2/AI.theme.md and modules/ThemeDm2/src/AI.ThemeDm2.md, with artifacts under .harness/public-frontend/evidence/dm2-2026-10-03/.

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

LTS1 header, cart-sheet and catalog quick-add use the authorized SQL reader template/shared/asset/js/front/cart-store.js. State stays in memory; storage carries only a random invalidation signal. Strict explicit writes preserve versions, identity-generation fencing and exact retry receipts; quick-add sends a unit delta. Loading/unavailable states are visible. All eight public theme entries also register shared wishlistPage before their single Alpine startup; Cart imports lazily only on wishlist initialization. Escaped data config carries the verified page generation. Strict unit adds preserve saved wishes and exact originating-row retries; account changes clear rows and require reload. Missing products remain removable, stale variants require selection and the obsolete authenticated handoff returns 410 without side effects. No CSS or navigation changes are needed. The same entrypoints register shared shopPage; canonical catalog initialization lazily imports Cart for SQL badges and explicit unit adds. Exact uncertain retry belongs to its initiating product, with reactive state verified against actual Alpine bindings. Authenticated catalog hints require matching page generation and private/no-store HTML; catalog lists use declared scalar payloads and previews retain purchase restrictions. Dormant product cart methods and unmounted inline cart/sync factories are removed, preserving native SQL forms and galleries. Shared cartCompletion starts only at the hidden resolved-result marker; pending/refunded results also invalidate because acceptance closes shopping before provider I/O. A random storage nonce and content-free local event hide/fence stale projections and refresh current SQL. No receipt cart token or legacy selections are read/removed, and no mutation or payment retry is issued. Absent/disabled readers start no traffic; blocked storage retains local recovery. Shared cartRecovery exposes IT/EN current-quantity review and confirmation only after an uncertain replay receives an allowlisted unredirected JSON 409 refusal. Two authorized SQL GETs must agree on generation, cart token, context, version and expiry; changes, failed/superseded reads and invalidation revoke review. In-flight work prevents disposal. Success forgets only the browser command, restores focus to the status and clears originating retry controls without sending a mutation or undoing committed effects. Reviewed rows/fingerprints stay in memory. Canonical catalogs, all-theme wishlists and LTS1 quick-add/sheet share this markup; native cart review remains available. Once-only legacy import now has a strict server import intent at /cart/mutate, with normalized candidates, current authority, retail repricing, a tenant-global reference-hash claim and one migration-backed 30-day cutover clock. Historical references are eligibility only: unknown/terminal/managed/owned/settled sources refuse, and historical rows/contact/coupons are never projected or adopted. Claims survive through the cutoff; ordinary exact replay retains current SQL and lost-cookie recovery. The lock planner can prepare the verified actor's absent slot at its sorted browser/owner position before cart locks. Every native cart now offers compiled explicit legacy review: normalized unvalidated references/ quantities appear separately from freshly authorized SQL. Confirmation requires another unchanged generation/token/context/version/expiry read and byte-identical saved keys before strict server validation. Unknown/malformed/unsynced/terminal data is visibly handled and retained. Exact uncertain replay preserves its command; only verified acceptance attempts to remove unchanged originating cart/token keys, preserving unrelated/revision/newer data. Cleanup is best effort against old concurrently writing legacy bundles. Owner compilation, reviewed cutover, deployed import journeys remain open. Sync/coupon token APIs and token-only checkout shopping/fresh settlement are retired in source. Wishlist save/remove now require distinct wishlist-scoped header CSRF, same-origin checks and frozen page generation matched against the current active registered Customer and verified browser owner. Shared account/catalog and native product/LTS1 catalog writers use compiled frozen controls; identity changes revoke hints and writes, while cart revisions preserve the assertion and wishlist requests leave cart, inventory and financial state intact. Profile/address HTTP validation and recovery have their own qualified contracts at the Ecommerce owner. All eight front bundles require owner compilation and deployed qualification; full HTTP/ device/concurrency acceptance and pricing/identity retain their server owners. LTS1 visitor search submits to localized /shop, while its header suggestions reuse the shared predictive combobox. The October correctness tranche retains the approved visual direction; the broader storefront refresh remains open.

PHP presentation 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.

Backoffice JavaScript has a separate ownership rule: Default is the canonical base theme, and other themes may explicitly reuse its capability runtimes, injecting their own loaders into shared orchestration. This never permits loading Default's entire application bootstrap or starting a second Alpine instance. Storage and Blog are the current coordinated capabilities. Canonical Default search, notification and lightweight editor-recovery modules are also explicitly reused by Sober. Both shells render shared escaped redirect feedback and provide keyboard bypass/dismissal. Async forms and sanitized previews retain source on failures; destructive actions use native confirmation with a confirmed POST fallback without JavaScript. Blog archives use bounded summaries, shareable filters and pagination; custom repositories may opt into AdminBlogQueryInterface without changing the public CRUD contract. Admin asset graph measurements are reproducible with tools/benchmarks/admin-asset-payloads.mjs; use isolated before/after builds. Payload bytes do not establish browser speed or screen-reader compliance.

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/_partials/_admin_token_fallback_css and admin/_partials/_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/_partials/_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.
  • .k-workspace fills the available admin shell without an extra page-level maximum width, respects the operator's fixed/fluid preference and allows grid children to shrink. Actions wrap; wide tables scroll inside their container. Ecommerce and related Cart/Invoice pages share this contract in both themes.
  • 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 CSS style block;
  • 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/_partials/_page_header.tpl.php is the canonical page opening in both themes. Default encloses its complete title, description, metadata and actions with a 1px neutral border, 12px corners and 20px padding. The H1 itself remains unboxed with Inter medium; admin-only text/border aliases soften the palette ink and derive thin structural lines without weakening field boundaries or changing public tokens. Sober retains its native unframed opening. Media uses the canonical header and per-record shared accordions: filenames remain visible below always-visible previews; only the metadata/action body starts closed. Explicit choices persist under stable record keys, and expanded cards do not stretch neighbors. Without JavaScript all bodies remain visible. Source Chromium fixtures cover Default/Sober, light/dark and 390/1440px; owner-built production assets and deployed-tenant acceptance remain separate.

AdminPanelExtension::panelHeader() emits the panel heading 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/_partials/_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 shared-slide technical hero (lts1_hero, legacy slide plus up to ten slides). 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 content in a second form. hook() renders a position's widgets and hook_first() renders the one widget of a single-content slot. A theme that needs the same content differently — an index of what a section holds, a navigation entry that exists only when its section has content — reads hook_items(): the active widgets' structured payloads (id, type, title, data) in display order, without rendering them twice or parsing their HTML. The values are editor input, so the template escapes them. PC3's home header uses it for the project text blocks that travel its hero spiral and for the section links of its bar; that spiral is perpetual, and therefore ships a control that stops it and stands still under reduced motion.

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 and pc3 its centred .pc3-shell; pc1 renders the canonical Ecommerce surfaces, which carry .front-content-shell themselves. 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 header occupies one row and wraps extra navigation when needed; mobile navigation collapses after hydration and restores focus on Escape. Native locale and nested module disclosures remain usable without JavaScript. The language control pairs a globe with the current locale and an opening-state chevron. Its links explicitly save the chosen language before redirecting to the clean destination, including a return to the unprefixed default locale after browsing another language; alternate-language metadata keeps its clean URLs. Light/dark mode is available at 320px, tolerates blocked browser storage, and maintains readable contrast in the inverted capability and contact sections. Project-row hover changes only the background colour, keeping text and grid geometry stationary. 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. Persisted visibility/order intent flags distinguish declaration-following values from explicit operator choices. Migration Version20260927090000 adds nullable flags; legacy rows infer intent from distinguishable current/default differences until written. Historical explicit choices already equal to defaults cannot be reconstructed.
  • Operator choices win. When an operator changed visibility or order in /admin/settings/menus, the stored value is kept even if a declaration later matches it and changes again. Per-item reset restores declaration following.
  • 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), blog.latest_posts latest published posts block (off) 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).

Themes may contribute virtual presentation groups through MenuPresentationProviderModuleInterface; core selects the contribution for the actual front theme through MenuPresentationRegistry. With LTS1 and Ecommerce active, the Frontend tab separates Ecommerce catalog, Beside Catalog, Site menu and Header links. Contact and New products occupy Beside Catalog and retain editable visibility/order. Visible, hidden and unavailable states stay inside the first three groups. Header offers are theme-managed: stored visibility/order overrides are retained, but their controls are read-only and omitted from saves. The logo and fixed account/cart/wishlist controls supply these accesses; Home and the superseded backoffice login do not create a duplicate Site menu. These groups are derived views of the existing system.front collection, not additional menus, channels or tables. Stable IDs, route names and typed blocks determine placement; renamed labels and unrelated children keep their meaning. Custom menus on front.primary undergo the same public projection, while their saved hierarchy remains editable in the library. Footer and admin placements, other front themes and inactive Ecommerce keep their existing behavior. The system settings controller lives in shared compiled JavaScript imported by Default and Sober; rebuilding both admin themes is required before rollout and the owner retains this build step. Broken presentation contributions fall back to ordinary settings with a logged warning. Focused verification passes 178 PHP cases / 2,689 assertions, six controller cases and 97 real-Kernel source browser checks on disposable fixtures. This proves source behavior, not the unrebuilt Default/Sober asset delivery. The adjacent-links extension passes 105 isolated Default/Sober × IT/EN × desktop/mobile checks, including membership and protected save/reload behavior; those builds also remain owner-run.

Blog's blog.latest_posts block reads published posts in the requested locale and uses the base row when no translation exists, with an optional heading and a limit of four by default (one to eight allowed). Canonical Page URLs take precedence over native Blog routes; both use the shared locale-prefix policy. Drafts never appear and an empty block is omitted. Existing generic menu renderers handle its heading and links in Default and LTS1 without theme-specific templates.

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".

SQL stale reclaim is local to the logical queue before its batch limit. Updates match the observed reservation timestamp and attempt, skipping a competing new lease without changing it or counting a recovery. This preserves queue-specific timeouts and existing retry budgets; it does not cancel an old running handler or imply exactly-once effects. The native webhook diagnostic retains an older stale reservation in another queue to verify this isolation.

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 --> GW[OmniRoute / 9router]
    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
    GW --> 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.

Routing gateways: OmniRoute and 9router

The OmniRoute and 9router integrations use one shared core Chat Completions adapter for synchronous chat and Discovery, plus a matching transport in the existing Python runner for queued chat and attachment context. Configure each gateway independently before connecting k0smos:

  1. Open /admin/settings/ai, add an integration client of type OmniRoute (omniroute) or 9router (9router), and enable it.
  2. Enter the gateway endpoint and its API key. Both catalog defaults are http://127.0.0.1:20128/v1; use separate ports/hosts when running both, and an address reachable from the PHP and queue-worker environments. Reverse-proxy prefixes, /v1 bases and full /chat/completions URLs are supported.
  3. Copy an actual chat model ID or configured alias from the gateway dashboard or GET /v1/models. k0smos preserves it verbatim and invents no default. Assign the client/model to ai.default or the relevant private scope.
  4. For Discovery, save explicit models on discovery.interpret and/or discovery.relations, then review their separate tariffs and positive quotas/budgets in Discovery settings. discovery.explain remains reserved.

The existing tenant secret envelope encrypts the gateway key. k0smos requires that key even for a local deployment; enable key enforcement in the gateway. Upstream accounts, OAuth sessions, routing and fallback policies stay there. The settings “test” action checks saved credential state only; it does not make a provider call. All k0smos gateway chat requests use stream=false.

Discovery requests a JSON schema, no tools and max_tokens=1024, then rejects incomplete, refused, tool-bearing, malformed or oversized answers and absent, estimated or out-of-range usage. Local acceptance limits remain 16 KiB input, 64 KiB response, 32,768 reported input tokens and 1,024 reported output tokens. Domain validation still decides whether a preference patch or graph proposal is usable. There is no retry with a weaker response format.

Gateway translators may turn schemas into prompt instructions, alter token caps or retry upstream calls. Use a verified model route for Discovery, configure gateway-side quotas, and review every route reachable through a combo/fallback. The local tariff reservation is not a guarantee of upstream cost. Alias or routing changes inside the gateway are invisible to the k0smos identity hash; requalify them and update/reconfirm tariffs. These limitations are visible in the 9router translators and OmniRoute translators.

This integration does not activate Discovery or public free-text input. Its embeddings remain on the explicit, pinned Ollama search.embedding path. Gateway compatibility is covered by isolated HTTP fixtures; qualify the actual gateway version/model route before deployment.

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. ArcadeDB and Qdrant implement the core vector port; Documentation permits direct projections and Discovery retains its own Qdrant generation lifecycle. Both remain opt-in. Tenant, model, privacy and deletion invariants remain in Vector search and ADR 0002.

ArcadeDB now provides optional native SQL/document access, tenant-bound graph vertices/edges and exact cosine retrieval. Existing canonical DBAL repositories and Discovery's SQL graph retain their owners. The 2026-10-04 ARCA request explicitly reopens the external-provider boundary; the earlier Client/Project/ Ticket SQL measurement remains valid for that particular traversal. See Search and multimodel databases for setup, migrations, provider status, rollout and the future Vespa/Supabase/pgvector architecture; ADR 0003 records the scope change.

The search system has two independent levels:

  1. Low-level backend (SearchEngineInterface) - SQL / modern providers, 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=modern| ES[ModernSearchEngine]
    ES --> P[OpenSearch / Typesense / Meilisearch / Elasticsearch]
    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. Modern automatic selection uses OpenSearch, Typesense, Meilisearch, then Elasticsearch among explicitly enabled endpoint profiles. An explicit provider remains pinned. Readers and writers share selection and authentication; outages fall back to SQL. Configuration and fresh-index upgrades are described in Search and multimodel databases.

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.

The public login view never receives bootstrap administrator credentials. Inherited login templates (including LTS1) start with empty credentials and show no demo credential hint.

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.

Explicit language choices: a clean unprefixed URL follows the current session language; it does not itself select the tenant default. PC3's language selector therefore adds lang=<choice> to its server-generated destination, then lets LocaleMiddleware save the choice and remove the parameter. Metadata continues to use clean alternate URLs. The 2026-10-11 review identified the same remaining risk in other theme selectors that still use the raw locale_switcher map for interactive links: returning from a prefixed language to the unprefixed default can keep the previous language. Those selectors are outside the PC3 fix.

Ecommerce recovery uses that same boundary for checkout/cart/payment and missing or invalid currency returns. A valid explicit internal currency destination keeps its path, query and fragment. Customer payment URLs freeze the accepted locale base, while technical notification callbacks retain the origin. The isolated tools/benchmarks/ecommerce-preview-smoke.mjs HTTP matrix covers Default/LTS1, both prefix policies and IT/EN, checking the immediate Location and the rendered destination locale. It includes translated no-JS stock recovery, stock changing between checkout preview and confirmation, token/receipt failure, currency fallback, offline manual-payment confirmation/signed completion and resume preserving the accepted URL base. Under non_default, an unprefixed default-locale base resolves the destination language from the current session. Captured manual cancellation URL routing is separate from provider cancellation-state qualification; no runtime tenant or build is used.

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, shared schema/theme/locale migration, 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 | Runs the shared migration flow 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 | Alias for app:install on the resolved CLI tenant. Its existing base/demo fixture policy remains; migrations and fixtures are no longer repeated. It does not erase the database. | | 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

For customer journeys, operator steps, activation, permissions and current privacy/address/fiscal limitations, read the Ecommerce guide.

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/AI.Ecommerce.md#catalog-contract.

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'. Preview search, paging and currency returns retain the staff path and query. Admin public links use canonical default-locale identity; public locale alternatives use the saved entity's translated slug/canonical URL.

Base and translated catalog slugs share the locale-resolved fallback namespace. Migration Version20260927093000 adds locks for transactional slug claims; conflicting explicit writes fail atomically and generators suffix new values. Existing ambiguous URLs are not silently renamed. The bounded, read-only ecommerce:slugs:audit --entity=category --locale=en --limit=100 --after-id=0 command reports losing ids and deterministic winning ids; use --entity=product for products and resume with next_id. Repair reviewed losing identities through normal editors, retaining history without redirecting a contested winning URL. Data Exchange category schema 2 carries explicit product scope; product schema 3 carries ordered additional category slugs. Older packages omitting these fields preserve values, while an explicit empty list clears additional assignments subject to import locks and field ownership.

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. Each dataset and action requires its owner's permission; dashboard access alone never grants order/customer visibility.
  • Legacy payment register (/admin/sales, PurchaseAdminQuery): retained for payment notifications without an order, protected by ecommerce.orders. It paginates 25 purchases with reference/email, payment-status and unlinked filters; values cover matching purchases by currency, including unsuccessful payments unless filtered. Product lines load in one batch for the displayed purchases and remain fully inspectable in native disclosures.
  • 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 grouped SQL reads; spend stays separated by currency and spend ordering requires a selected currency. Order history is paginated. Registered/guest and active filters are supported. 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. Filtering, counts, lifecycle ordering and pagination run in SQL; native target inputs keep scoped discounts usable without JavaScript.
  • 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; changing the base divides all exchange rates by the selected persisted rate in one transaction, preserving conversion ratios. The base stays active at rate 1; historical orders are unchanged.
  • Reviews: pending first, status and rating filters; approve and reject save without a reload.
  • Carts (Cart module, /admin/carts): defaults to active, nonempty, unexpired selections past meaningful inactivity, excluding purchase/accepted checkout references. Native filters cover account/guest, inactivity, lifecycle, shopping expiry (including next 24 hours) and stored-email presence. SQL pagination shows 50 carts per page; counts/positive quantities cover all matches and monetary totals stay by currency. Each row includes inactivity age, expiry/closure and at most three product previews. Ownership comes from verified lifecycle identity independently of email. Numeric IDs replace bearer references; tokens and owner identities are omitted from the payload. Stored email requires active staff ecommerce.customers in addition to page access through ecommerce.view; other viewers receive only a presence flag. Contact eligibility is explicitly unreviewed, with no mailto or campaign action. Filters/toggles/paging/reset work without JavaScript under Default/Sober in IT/EN. Missing/invalid lifecycle metadata and unknown legacy statuses require separate reconciliation; accepted financial evidence is never idle shopping. Source qualification passes 73 isolated cases / 666 assertions plus PHPStan; deployed viewport and remote SQL window-function support remain to qualify.
  • Cart retention settings (/admin/carts/settings): read with ecommerce.view, save with that permission plus system.write or ecommerce.settings and cart.lifecycle CSRF. Native IT/EN forms work in Default/Sober. Defaults are 24 hours inactivity, 30 guest/90 customer/7 empty/30 closed days and batch 200; all values and cross-field constraints validate before an atomic field-only audit. These operational proposals need controller retention review. Saving never starts deletion or resets all clocks; bounded cleanup is available after its separate checkpoint migration, while scheduling remains operator-owned. Existing repositories/workers read the complete validated six-key SQL policy without restart. Current durations apply on meaningful changes or explicit restore; polling, unchanged intent and exact replay retain previous clocks. Invalid policy or unavailable storage abort affected writes rather than silently applying default retention. Consent, order and financial records retain their separately qualified retention policies.

With Cart active, TENANT_ENV=k0smos.example.com php bin/console cart:cleanup runs one bounded retention batch after Version20261012140000 is applied. Back up before migration and review the retention policy/preview before scheduling execution. --dry-run and --status are read-only; --batch-size=1..1000 overrides the current batch setting. A fixed pass time, closed-retention snapshot, high-water mark and durable cursor provide restart and error recovery. SQL token/generation fencing excludes overlapping workers sharing the tenant DB, including stale-worker takeover. Candidate effects and cursor advance commit together under shared parent/cart locks. The scan limit includes ineligible records, so an archive of fresh carts cannot cause an unbounded scan. Complete dependency plans and other child work have separate caps; oversized histories remain intact as dependencies_overflow, with the same limits in preview. Independent --prune now drains eligible histories through finite primary-key phases after Cart Version20261012160000. It shares the retention lease and uses separate durable typed cursor/high-water tuples; each invocation scans at most the configured batch from one phase, including kept records. Effects and checkpoint commit together, fixed pass time/ policy survive restart, and new/kept records wait for another pass. Child keys stay private in SQL; public output contains only phase/counts/time/error codes. Historical incoming merge links are locked before detachment. The service owns its transactions and refuses outer savepoint-only commits. Checkout draft issuance uses the same shared lock contract and locking current identity/version/lifecycle/settlement reads before replacing that session/cart's drafts. A closed/stale cart gets JSON 409 or native redirect recovery; issuance never renews shopping clocks or recreates drafts after cleanup. Review requests no longer prune drafts from unrelated carts; independent --prune expires drafts only strictly before its frozen pass time, preserving exact-boundary validity.

Expired unprotected active carts close without renewing activity/expiry, with guest revocation, versioned pointer detachment and draft invalidation. Old unprotected expired/merged carts delete only after closed retention and all mutation/import/merge retry horizons; empty slot epochs remain. Converted carts, purchase associations and every accepted checkout receipt are preserved, irrespective of age or ambiguous payment state. Payment/inventory facts are never deleted. The independent phases also prune expired mutation/merge/import receipts, credentials and counters. Merge proof credentials and validated fixed import-window claims survive their horizons; malformed clocks/checkpoints fail closed, and normal retention reports ambiguous retry evidence as invalid_metadata. Eligible old unprotected merged links/lines drain under both-parent locks and version advances, preserving activity/expiry/closure. Stable empty slot rows remain intact. Core Cart DI now selects only an explicit runtime-active settlement owner. Coherent converted shopping minimizes after closed retention and all retry/import/merge horizons under the same fenced transaction and evidence locks. Items/drafts, email, coupon, subtotal and owner metadata are erased; original Cart ID/token, currency, purchase association and all existing clocks remain. Stamp/version advance once, and repeated passes validate the clean stub as already_minimized without rerunning pre-erasure line proof. Newer selections and empty identity epochs are unchanged. Native receipt/result recovery and financial/inventory facts remain intact. Invalid/unresolved/oversized evidence defers with count-only reasons. Apply the Payment guard and Cart/Ecommerce lookup indexes before reviewed activation. Cart Version20261012190000 adds count-only maintenance health and up to 50 fixed-code failures per mode. Real wired execution requires that schema; existing read-only previews/status remain available without it. --health, also with --prune, checks overdue completion, stalled meaningful progress, failures and completed-pass protected/deferred trends without writes. Thresholds --overdue-after / --stalled-after default to 86,400 / 3,600 seconds and accept 1–31,536,000. Exact boundaries alert; healthy exits 0, attention/errors 1, invalid options 2. History survives recovery, and stale lease holders cannot annotate their successors. Counts are observations, not live inventory, and output contains no shopping/customer/financial identifiers or SQL exception text. Retention/pruning now guard all four owned transaction units: lease claim, candidate/checkpoint, completion and release. Uncertain BEGIN/COMMIT/engine state retires the connection without reopening SQL for release or health annotation. A commit reply may be lost after persistence: inspect through a fresh initialized runtime, retain committed cursors/leases and wait for normal 300-second lease expiry. Never reset them manually. Certainly rolled-back failures permit one fenced release; a known secondary release failure preserves the primary error, while an uncertain release retires SQL. CLI failure remains fixed and nonzero; uncertain failures cannot safely record their own health history. Actual SQLite boundary/engine failures and injected reply loss after native commit pass in 22 cases / 272 assertions; the cleanup/pruning/health selection passes 102 / 1,900, with 23 scoped PHPStan paths. Caller/non-autocommit work is refused. Scheduler/alert delivery and backup/erasure policy remain operator work. Output is limited to cursors, counts, last successful complete pass and fixed failure codes. Existing system/platform cron can invoke one batch periodically; monitor each mode's read-only health check and nonzero exits. The command installs no scheduler. This migration and scheduled cleanup have not been run on runtime tenants; native draft/active-closure overlap is qualified below, while aged retention and other pruning phases remain separate.

Cart Version20261012100000 must precede lifecycle readers. The additive one-to-one metadata table preserves cart headers, lines and settlement IDs. Legacy abandoned rows become active; accepted purchase/receipt rows become converted. Historical timezone-naive dates stay unchanged and new UTC activity starts conservatively at migration time. Snapshot saves check supplied numeric versions and reject expired/terminal writes. Reads, identical saves and repricing do not refresh activity. Conversion closes/revokes/releases owner pointers in one transaction and retains receipt recovery. The old token-based public API remains transitional for unmanaged carts; managed references cannot authorize legacy writes or coupon actions. Checkout adopts the common shopping authority; native cart/product forms now use the strict mutation protocol, while remaining browser consumers and authentication merge are still under implementation. Native cart coupon forms share the same authority, controls and replay protocol and work without optional scripts. Eligibility uses current SQL currency and verified Customer rules; selection never consumes usage, creates a stored quote or reserves inventory. Bootstrap/mutations include the saved code while amounts remain selection subtotals. Checkout calculates/revalidates discounts and final totals; coupon changes invalidate earlier confirmation versions. Exact replay preserves clocks, and invalid/expired coupons remain removable. Localized legacy POST|DELETE /shop/cart/coupon now return side-effect-free private 410 cart.legacy_coupon_retired with native /cart and strict /cart/bootstrap / /cart/mutate recovery paths. Their controller ignores request data and has no shopping/pricing/lock/credential dependencies. Native coupon and protected checkout-preview behavior remains supported. Deploy with owner-built migrated consumers at reviewed cutover. Legacy /cart/sync also returns side-effect-free private 410; token-checkout shopping/fresh-settlement is now also retired, preserving accepted session-bound receipt recovery. Exact contracts and rollback boundaries live in the Cart owner.

An internal atomic transfer engine can adopt an authorized guest cart or union its product/variant quantities with a verified Customer's current cart. Shared locks, current stock/publication/coupon validation, owner mapping, source closure, guest revocation and a source receipt commit together. An expired unprotected owned selection may be retired for adoption; accepted settlement never frees its slot. Conflicts preserve both carts. Exact receipt replay returns current owned shopping state without adding, repricing or financial effects. Numeric version changes invalidate earlier checkout confirmation. Ecommerce now owns a private native merge review/resolution route: localized GET/POST /shop/cart/merge. Successful Customer login, registration and reset sign-in capture verified guest linkage before identity changes and transfer afterward. Conflicts preserve successful authentication and offer visible recovery; logout/account-switch detaches the server grant without deleting account SQL. See the durable Cart owner for internal authority, receipt-horizon proposals and remote SQL qualification boundaries.

An internal pending-transfer service now captures verified guest linkage before sign-in and binds a scalar server-session grant to the tenant, current verified owner and post-authentication browser generation. Only source/version and hashed linkage are retained; no raw guest cookie reaches that grant or browser data. Recovery expires within 30 minutes or source expiry and is never renewed by reads, duplicate attempts or review. Locked current-source review rejects revoked, detached, expired, claimed and settlement-linked guest data; changed SQL versions require explicit review. Commercial failure preserves both carts and the grant; success consumes it, with exact committed retry handled by the source receipt. Ecommerce's protected native form renders every account/guest quantity and accepts deliberate final quantities, chosen currency and one coupon. Guest proof/reference/digests never enter the form or auth JSON; only a pending flag and localized recovery destination are public. Current versions/context, cart.mutation CSRF/origin, transport bounds and SQL rate limits guard POST. Cart/account notices keep recovery discoverable. Bounded numeric drafts apply only to unchanged source/destination snapshots. An unavailable source offers account continuation, session-only discard and protected receipt-only checking, which cannot transfer an uncommitted source. Consumed-grant duplicate responses can continue from the account cart; wider concurrent/browser checks remain open. Extended isolated qualification passes 201 cases / 4,324 assertions; the operational session cap and deployment retention still need controller review.

Internal explicit resolution now requires current source/destination versions, authenticated context, operation ID and complete row choices. Every identity appears once and zero is deliberate removal; omitted/injected rows never silently change shopping selections. Retained rows reprice in the chosen currency with explicit account/guest/none coupon intent through existing commercial authority. Destination checks run under the same locks, and reviewed receipts bind exact normalized intent to the authenticated browser so changed retries conflict. Apply Cart Version20261012130000 before activation; queued column additions and a separate unique index preserve historic receipts and FK dependants. Historical automatic receipts keep null review metadata. Rollback disables the new flow while retaining accepted metadata; down refuses to erase it. No tenant was migrated. Resolution/migration/core checks pass 63 cases / 2,114 assertions and actual retail checks five cases / 152 assertions, plus scoped PHPStan. Canonical native markup and DM1/LTS1 wrappers now deliver conflict presentation without scripts in all eight themes. Tenant migration/activation and representative viewport/provider/remote SQL qualification remain separate; no build was run.

Public commerce projections now rehydrate bounded search identities from current public SQL and whitelist response fields/metadata. Page guards direct canonical reads and HTTP 200 rewrite paths against private/bearer URLs. Isolated temporary SQL/HTTP checks cover sitemap, JSON-LD, search, Discovery adapters, vector backend port documents and Customer-only MCP refusal; all-eight head render fixtures omit private account/cart/reset metadata. Provider/deployed qualification and future merchant feeds remain separate. The Ecommerce owner records the exact boundary and evidence; no module/provider is activated by this delivery.

Ecommerce registration, account profile and backoffice forms share module-owned raw email/name/phone validation, existing character bounds and CustomerService's password minimum. Native profile refusal renders escaped nonsecret drafts and IT/EN field errors with ARIA links; its JSON adapter returns codes without contact data. Profile POST requires current session CSRF and origin guards. Input errors precede writes; accepted profile/password saves remain separate. Core/import and country/fiscal rules stay outside this HTTP validator. The Ecommerce owner records isolated HTTP, all-eight rendering and Default/DM1 desktop/mobile evidence.

Account create/update addresses and optional backoffice initial addresses share raw text, UTF-8/control and schema-bound preflight. The backoffice validates before creating a customer. Account create/update/delete require current session CSRF and origin assertions; owned partial updates preserve omitted values, while native hidden zero plus checkbox supports clearing the default flag. Canonical/DM1 forms retain escaped drafts and linked IT/EN errors only in the rejected form. Country remains free text and the account postal field is not universally mandatory; admin keeps its existing conditional postal requirement. No country-specific delivery/fiscal qualification or importer policy is implied. The address owner records actual temporary-Kernel and all-eight native rendering evidence.

Customer login, registration and logout now validate session CSRF (shop_password) and explicit origin/fetch metadata before changing identity. Native and existing JSON submissions carry the token; invalid assertions return private JSON 403 or native post/redirect/get with an inline IT/EN reload notice. Password link issue/ consumption shares that origin policy. Native account logout carries the same token and redirects with 303. Changed authentication/overview payloads are declared; login/register/password/overview responses containing session fields are private/no-store, noindex/nofollow and no-referrer. This protects the Customer cart handoff above; failed transfer does not reverse authentication. The old guest cookie is cleared on successful sign-in/logout. Logout removes the browser generation without reading Customer/cart storage after identity removal; subsequent verified reads create a fresh generation. Successful login/registration/reset/logout also append the random host-only Secure __Host-k0smos-shop-auth invalidation cookie, expiring after 60 seconds. It contains no identity, cart, credential or destination. Visible compiled readers observe cookie changes plus a one-second local check, clear old presentation and re-read authorized SQL; idle checks issue no network requests. Refusals and forgot-link requests emit none. Arbitrary native redirect destinations and JSON migrated-password notices retain their contracts. Hidden/departed readers stop observation, and notifications never mutate or automatically retry. HTTPS and owner-built bundles are required; actual session/HTTP/device/deployed qualification and remaining legacy browser cache cutover stay separate. The Ecommerce/Cart owners document the repeatable actual-Chromium, synthetic-shopping transport diagnostic.

The 25 subsequent isolated HTTP cases now exercise two diagnostic PHP servers with the actual Kernel/native SessionMiddleware/SapiEmitter on full-schema temporary SQLite: destination mutation versus merge in both orders, merge versus merge, source mutation before capture, native login/logout/account switching, body/header loss and deliberate loss of a persisted owned session. Correct sums, one receipt per source, guest revocation, exact mutation replay and unchanged financial/inventory hashes are verified. Run node tools/benchmarks/cart-handoff-http-smoke.mjs; its fixture-only entrypoint and repository barriers add no production hook, and cleanup verifies removal of only owned sessions/tenant artifacts. Pending stock conflicts retain both SQL selections; native review survives a lost login body and fresh resolution survives a lost response. Lost authenticated session authority cannot be reconstructed from a guest cookie: re-login restores the account selection only. Stale source/destination versions refuse and discard stale drafts before fresh review. Controlled production-service mutation after capture runs inside one diagnostic request; it does not prove a simultaneous guest HTTP writer. An actual native session write refusal after unchanged-source resolution preserves old session bytes while SQL commits; receipt-only finish recovers without a second transfer. Newer reviewed source plus failed-write completion reproduced a 409; dedicated authenticated receipt-only recovery now completes it without shopping effects. It retains original SQL guest authority, current owner/expiry and exact reviewed browser binding with complete metadata. Automatic receipts and ordinary merge/ resolve retain exact source versions; recovery cannot create owner slots, transfers or receipts and returns current owned SQL. It adds no schema change or direct-JSON review prerequisite.

Two additional cases now exercise genuine guest HTTP writes after capture through the production PHP entrypoint on both servers. Fixture-only native save-handler barriers prove holder/waiter FLOCKs on the exact owned PID/FD/device/inode before rotation; there is no copied session state or substituted production DI. The waiting guest reads its already opened old file after rotation. Writer-first advances the source and requires fresh native review; merge-first rejects the old mutation with409. Lost resolution body/replay/re-login preserve one receipt and unchanged financial/inventory evidence. Both pauses are independently free of SQL writer locks. The runner requires Linux /proc/locks before allocation; this files/SQLite controlled schedule does not qualify remote SQL, other session backends, HTTPS/browser delivery or deployment. All25 scenarios pass; ten use production entrypoints, focused merge/recovery/session regression72/635 passes.

The portable runner's --checkout --cart-http mode now reuses the full25-case HTTP matrix on owned MySQL/PostgreSQL after complete fresh migration/rerun and sequential checkout. Shared exact container guards protect every HTTP/inspection connection; child HTTP lifecycle never owns parent SQL cleanup. Three exact engine-observed blocker/waiter pairs, native FK anti-joins, stable financial/ stock hashes and unchanged final schema digest supplement the native-session inode proofs. Explicit fixture IDs are below existing parent seeds and do not reset counters. No runtime DSN, production hook or deployed/provider guarantee is added. Ecommerce records exact native evidence and remaining scope.

Authentication (including registration/reset/logout) and successful explicit merge now persist native session state before success emission. A refused write previously returned HTTP 200 and failed at shutdown outside private request handling. It now returns a fixed private 503 before the authentication signal; merge's existing JSON failure/native review preserves receipt recovery. Core captures only a boolean warning during native close, lets cleanup finish, restores the previous handler and exports no transport exception chain. Startup and regeneration failures refuse downstream identity/merge handling. Already committed Customer/credential/cart SQL is retained, with no automatic retry or rollback. The active-session headers-sent guard refuses identity assignment; it cannot replace a status after bytes have already been emitted.

Eight cases use a third server delegating to actual public/index.php after all fixture guards, before diagnostic DI decorators: production PHP error/bootstrap, native login/review, refused session reads/regeneration/login writes and JSON/ native resolution-write recovery are qualified. Other session backends, remote SQL and deployed web-server/proxy/HTTPS/assets/devices remain open. Ecommerce/Cart own the exact scope and command. The focused isolated merge selection passes 48 cases / 448 assertions, including three new methods with adverse proof/receipt and no-effect checks; six production/diagnostic PHPStan paths pass. Both PHP and native HTTP failing-before evidence confirms the fix. Current persistence qualification additionally passes 56 auth/reset cases / 2,040 assertions, 63 session/merge/presentation/DI cases / 2,142 assertions and 24 existing error/privacy cases / 7,277 assertions. Twelve production/test/ diagnostic PHPStan paths pass. Native commit tests use separate PHP processes and private OS-temp files; the in-process Kernel fixture reopens persisted state before emulating subsequent session edits. No runtime tenant or build operation.

Verification: 14 new isolated real-Kernel handoff scenarios pass / 380 assertions. The final reset/conversion tranche passes six cases / 100 assertions, including the fifteenth new handoff scenario (a current Guest converted to an account), native/JSON reset and origin refusal. Existing protected auth checks pass eight cases / 332 assertions. Engine/DI/account/template/footer regression passes 783 cases / 12,783 assertions; native review/fallback markup is checked across eight themes and all IT/EN URL policies. Scoped production PHPStan and 29 touched PHP syntax checks pass. Tests use disposable databases only; the two existing PHPUnit XML deprecations remain. These runs establish source/native HTTP behavior, not representative viewport, deployed assets or remote SQL.

Account address edit/delete now recheck the expected Customer under a current-row SQL write lock. Previously an independent ownership transfer after the page's check let the old account edit/delete the new owner's address. Core CustomerService::updateOwnedAddress() / deleteOwnedAddress() use existing CustomerAddressRepositoryInterface::withinOwnedWrite(id, customerId, callback). The callback receives the complete current owned row; missing/transferred rows invoke no callback. MySQL/PostgreSQL retain FOR UPDATE through replacement, multirow default selection or deletion; SQLite reserves the writer before reads. Trusted import/admin persistence stays separate, with no unsafe account fallback.

This boundary owns its transaction, rejects an existing outer SQL unit and never retries automatically. Known failed local writes roll back together. Native BEGIN, COMMIT, engine rollback and callback-depth failures preserve their original cause; uncertain boundaries receive best-effort physical rollback, connection retirement and existing CustomerWriteBoundaryLost. An uncertain commit requires reviewed reconciliation, not an assumption that no write committed. Custom address adapters must implement these contracts. CSRF, validation and account-list redirects remain.

Two isolated real-Kernel transfer regressions and seven focused SQL boundary cases verify normal/refused writes, default rollback, rejected outer transactions, actual BEGIN IMMEDIATE contention, failed COMMIT under a SHARED reader, engine rollback and explicit next-request recovery. Account/reset/service/architecture selection passes 70 cases / 5,746 assertions; 18 scoped PHPStan paths pass. Owned MySQL 8.4.11/PostgreSQL 17.6 full fresh/rerun and real sequential checkout remain green. --checkout --address-writes adds four independent-process native writer blockers (edit/delete/transfer/default), fresh-connection recovery, rollback and two old-owner refusals with unchanged checkout effects. It uses generated IDs and introduces no post-workflow counter adjustment. These writer probes do not qualify full concurrent acceptance, native HTTP, deadlock recovery or deployment; the Ecommerce owner and maintained diagnostic document exact scope.

The native --checkout --checkout-races mode now also qualifies full acceptance against selected shipping and distinct billing edit/delete/transfer in both orderings: twelve cases per owned MySQL 8.4.11/PostgreSQL 17.6 engine. Cart locks and MySQL's earlier snapshot coexist with current locked address reads. Committed edits require renewed review; missing/transferred sources refuse without checkout effects. Six actual child writer timeouts establish overlap after address review and before stock reserve; a fresh child request completes after acceptance commit. Independent financial/stock/ID and complete ten-field postal checks, unchanged receipt replay and independent committed readback pass. A selected-address timeout requires review and succeeds on a later explicit acceptance after release. Seven orders/starts and 28 observed post-commit events leave stock 26 from 40, committed zero. The outer diagnostic still reports the earlier fresh installation snapshot, 242 tables / 145 versions / 520 rows, before workflow fixtures.

Actual PostgreSQL lock_timeout55P03 had escaped DBAL's retryable-only classification. Checkout now maps it to review409 only after certain rollback. Acceptance owns guarded BEGIN/COMMIT/depth/cleanup within deferred stock-event collection and rejects an existing outer transaction. Actual SQLite BEGIN contention, failed COMMIT under a SHARED reader and engine rollback exercise uncertain native state. Best-effort raw rollback and physical connection retirement precede a fixed, chain-free reconciliation response: JSON503 or native303 to an IT/EN Cart warning. No reprice, replacement quote, layout render, event or provider start occurs in that failed acceptance. Original quote/receipt SQL stays retained: a lost commit acknowledgement can already have persisted the purchase, so do not assume every uncertain result has no SQL effects or automatically retry payment/acceptance. Operator reconciliation remains required. Seven focused SQL/controller cases pass 144 assertions after the final native redirect refinement. The broader checkout checkpoint passed 347 cases / 6,200 assertions before that refinement; 12 canonical retry-render cases / 144 assertions and 20 scoped PHPStan paths pass. Two existing PHPUnit XML deprecations remain. These are full service/address-ordering checks; native deadlocks, Cart mutation/merge/cleanup, authenticated HTTP, other payment/fiscal matrices and deployment remain separate. See Ecommerce for exact evidence.

Full checkout versus guest/Customer add/clear is also qualified on owned MySQL 8.4.11 and PostgreSQL 17.6 through --checkout --cart-mutations: eight actual service/process orderings per engine. Held complete mutation locks force checkout timeout; the committed change then invalidates its original grant/quote. Explicit fresh review can buy the updated three-unit selection; clear leaves an empty cart. In the opposite order, a child mutation times out before stock reserve and its original command refuses after conversion without creating a replacement cart or receipt. Eight SQL timeouts, four stale-checkout and four converted-command refusals, and two fresh reviewed recoveries produce six accepted orders/starts and 24 observed post-commit events. Independent EUR 244/EUR 44 tax or EUR 366/ EUR 66 tax/captured EUR intents, reciprocal IDs, single shipment/reservation, effect-free mutation/checkout replay and committed readback pass. Stock ends at 10 from 24, committed zero; final schema/sequence facts pass. All canonical tables and real DI/listeners remain, external HTTP is denied and the gateway stays local.

Cart mutation now owns guarded BEGIN/COMMIT/depth/rollback cleanup and refuses caller transactions. Certain rollback maps DBAL retryable/generic PostgreSQL55P03 to private409 without automatically repeating pricing or mutation. Uncertain state gets best-effort raw rollback and physical connection retirement with a chain-free private503. Retain the original operation/receipt for explicit reconciliation: a lost commit response may already have committed. The compiled consumer already retains uncertain 5xx commands; native product-add now skips product lookup and redirects directly to the existing verification warning. No SQL reopens in that failed response. Cart recognizes mutation and checkout reconciliation reasons. Mutation/merge/architecture passes 128 / 5,450, focused Ecommerce consumers 26 / 279 and 22 scoped PHPStan paths pass. Two existing PHPUnit XML deprecations remain. Native merge/cleanup overlap and their failure matrices, other intents, real deadlocks, authenticated HTTP and provider/fiscal/deployment remain separate; see the Cart and Ecommerce owners for exact scope. Existing native IT/EN product/cart forms also pass two Kernel cases / 182 assertions; the shared contender's twelve MySQL address-ordering regressions pass.

Automatic merge versus checkout of its guest source or Customer destination now passes four actual service/process orderings per owned MySQL 8.4.11/PostgreSQL 17.6 engine through --checkout --cart-merges. Complete held merge lock plans and checkout before-reserve barriers establish four actual SQL timeouts. Merge first invalidates either old checkout grant and permits explicit fresh review of the three-unit union. Checkout first refuses transfer of its converted guest source; after Customer checkout a separate live guest can be adopted as the next active cart. Accepted cart/order/payment facts stay unchanged. Merge replay and receipt-only finish, plus checkout replay/resume, remain effect-free. Four orders/ gateway starts and 16 observed post-commit events leave stock 30 from 40, committed zero. Independent money/tax/captured EUR intent/reciprocal-ID/quantity/ shipment/reservation checks, committed readback and final schema facts pass. Real DI/listeners remain, the gateway is local and external HTTP is denied.

Merge, receipt recovery and pending-source reads now share guarded owned SQL with mutations. Only certain rollback maps retryable/generic PostgreSQL55P03 to private 409; uncertain BEGIN/COMMIT/engine state retires SQL with chain-free503, preserving the original proof/session/receipt. A lost commit acknowledgement may already have committed; reconciliation must remain explicit. Failed merge review returns fixed JSON503/native303 without subsequent currency/owner/layout SQL, keeping bounded draft choices and the pending grant. Existing unchanged-version gates still control draft application. Authentication still survives cart handoff failure and commits its native session before success. Final mutation/merge/ architecture passes 140 / 5,595, auth/handoff 58 / 2,056 and 24 scoped PHPStan paths pass. The existing 23 actual HTTP/native-session SQLite scenarios also pass, including eight guarded production PHP entrypoint cases. The shared transaction/ hold-worker extraction preserves native MySQL's eight mutation cases. Native explicit resolution, cleanup/deadlock overlap, other HTTP/SQL/session/provider/ fiscal configurations and deployment remain separate.

Expired-draft quote pruning versus valid checkout now passes four native guest/Customer × either-winner orderings per owned MySQL 8.4.11/PostgreSQL 17.6 engine through --checkout --cart-pruning. Both use one wall clock: each live cart has its original valid reviewed quote plus a separate already-expired draft. Actual complete cart locks and checkout barriers produce four SQL timeouts. Pruning first leaves the original quote/grant usable after release; checkout first allows fresh pruning of only the expired draft. Exact quote inventories prove valid quotes were neither replaced nor reissued. Four expired drafts are removed, four original valid quotes survive and accepted shopping/settlement/ mutation/merge-receipt fingerprints stay unchanged by pruning. Four orders/starts and 16 observed post-commit events leave stock 16 from 24, committed zero. Independent EUR 244/EUR 44 tax/captured EUR intent/reciprocal-ID/shipment/ reservation facts, exact receipt replay, committed readback and final complete schema/sequence checks pass. Fresh/rerun and sequential checkout precede the matrix, with real DI/listeners, a local gateway and denied external HTTP. Expired-cart closure, other pruning phases, deadlocks, authenticated HTTP and full backend/provider/fiscal/deployment behavior remain separate gates.

Expired active-cart closure now passes six actual service/process orderings per owned MySQL 8.4.11/PostgreSQL 17.6 engine through --checkout --cart-retention: guest/Customer × cleanup first, expiry during acceptance, or acceptance first. Each short fixture expiry is set once before review; actual wall time governs every service call. Cleanup first blocks checkout then invalidates its grant/ draft. Expiry during acceptance blocks cleanup, then the final conversion check rolls back all attempted stock/order/purchase/shipment/postal effects before provider start/events. Acceptance first converts while live, waits past actual expiry before outer commit and causes cleanup to time out on the old committed active row. Fresh cleanup resumes its pass and preserves the converted purchase. Only diagnostic decorators supply the barriers; production remains unchanged.

Six actual timeouts, four closed carts and two expiry rollbacks leave two orders/ starts and eight observed post-commit events. Stock ends at 20 from 24, committed zero. Exact full Cart/lifecycle/credential/slot transitions preserve original clocks, shopping fields, retained revocations and stable empty slots advancing once; exact whole quote inventories remove only closed-cart drafts. Independent EUR 244/EUR 44 tax/captured EUR intent/reciprocal-ID/shipment/reservation facts, financial/stock/mutation/merge-receipt fingerprints, terminal replay/resume and committed readback pass, with final full native schema/sequence checks. Terminal replay correctly uses the local receipt completion URL, which can differ from the initial local gateway redirect. Fresh/rerun and sequential checkout precede the matrix; real DI/health/listeners remain, gateway is local and external HTTP is denied. Nineteen scoped PHPStan paths pass. Recently converted carts remain ineligible for aged closed retention: minimization/deletion, unresolved payment holds, other pruning phases, deadlocks, authenticated HTTP and full backend/provider/fiscal/deployment behavior remain separate qualification gates.

An ambiguous gateway start previously left an accepted Ecommerce receipt indeterminate even after a public terminal return settled its payment and inventory. Explicit receipt recovery now reconciles known prepared, starting, indeterminate or reconciliation_required state only after successful current terminal publication. It CASes the original quote/cart/purchase/intent/binding/ state, stores the clean receipt-completion URL and clears the lease. Started responses/unknown states stay untouched; unresolved evidence remains protected. Publication or receipt-write failure remains explicitly recoverable without a new provider start. Four original regressions were red; seven focused cases pass 183 assertions, full checkout 355 / 6,387 and 25 scoped PHPStan paths pass.

--checkout --payment-holds qualifies eight owned native cases per MySQL 8.4.11/ PostgreSQL 17.6 engine: guest/Customer × Pending/ambiguous start × Captured/ Cancelled public return. Declared Cart lifecycle/closed-retention, draft/retry and hold expiry history is aged only before measurement; all services use actual wall time. Financial/payment timestamps, signed URLs/hash/expiry and immutable receipt fields remain original. This does not qualify returns using a 31-day-old provider capability. Real DI/health/stock/financial/postal/listeners remain, gateway/returns are local and external HTTP is denied; full fresh/rerun and sequential checkout precede the matrix.

Preview is fully read-only. Cleanup and generic/explicit stock expiry preserve accepted unresolved holds. Capture commits two units once; cancellation releases them once. Explicit receipt recovery permits coherent minimization, preserving the original Cart ID/token/purchase stub and all financial/postal/receipt facts. Whole inventories protect unrelated carts; stable empty epochs and revoked credentials remain. Exact receipt accept/resume and repeated passes are inert. Two additional old unprotected guest/Customer carts delete only their own headers/children, retaining all eight accepted stubs and financial facts. Eight orders/starts/public returns, four captures/four cancellations, eight minimizations, two deletions and 28 observed post-commit events leave stock 32 from 40, committed zero. Independent money/tax/intent/reciprocal-ID/hold/movement, committed readback and final full schema/sequence checks pass. Concurrent payment publication/cleanup, other outcomes/families, expired provider capabilities, deadlocks, HTTP/session/provider/fiscal/deployment remain separate gates.

--checkout --payment-races now verifies eight native guest/Customer × Captured/ Cancelled × either-winner publication/eligible-cleanup orderings per owned MySQL 8.4.11/PostgreSQL 17.6 engine. Cleanup first retains the real family/receipt/ current-intent coherence locks through outer commit: public return may append exactly one audit, then times out on intent CAS without financial/shopping/stock/ observed-event changes. After protected cleanup commits, explicit public return retry resolves using the same capability. Publication first pauses after real local SQL effects/before owning commit; cleanup times out on the family guard. After publication commits, fresh cleanup resumes its original pass and minimizes. Remaining-batch preview counts are added to earlier committed pass counts; a timeout does not reset progress. Barriers exist only in the guarded diagnostic.

Eight actual timeouts, eight orders/starts, 12 public return calls, four captures/ four cancellations, eight minimizations and 28 observed post-commit events leave stock 32 from 40, committed zero. Exact financial/hold/movement/whole-inventory/ receipt replay, two unprotected deletions retaining prior accepted stubs, independent committed readback and final full native schema/sequence checks pass. The shared fixture's eight sequential MySQL cases also pass. Original payment timestamps/capabilities remain; only declared premeasurement shopping/retry/hold ages are synthetic. Real fresh/rerun/sequential checkout, DI/health/listeners remain; gateway/returns stay local, external HTTP is denied. PHPStan passes 26 paths.

These native cases qualify certain SQL timeout/rollback under normal autocommit. Payment now guards owned BEGIN/COMMIT/depth and retires an uncertain physical connection with fixed, chain-free PaymentWriteBoundaryLost. Publication rejects non-autocommit before schema/hint reads; joined family operations retain outer commit ownership. Receipt start/terminal recovery and direct HTTP return/notify stop without new SQL or retry layouts. JSON checkout/callbacks return503; native checkout uses its existing warning303. Webhook privacy preserves the marker, skips failure storage and executes inline fallback once. Infrastructure translates the marker to Queue-owned WorkerRestartRequired: no ack/fail/notifier/heartbeat or next reserve follows; a fresh runtime reclaims the reserved job. Lost COMMIT acknowledgement may already persist effects; recovery retains original identities and does not promise post-commit event delivery. Owned SQLite tests cover actual BEGIN/COMMIT/engine rollback/lost reply, joined ownership, HTTP and SQL worker reclaim. Remote uncertain commits, real providers and deployed HTTP remain open.

Trusted sync/coupon/checkout/repository writes share browser-slot hash order, owner tuple order and ascending cart IDs, then lifecycle/commercial records. Multi-cart operations plan the full set before locking; lifecycle versions advance on owner/browser reassignment and locked current reads reject stale plans. SQLite reserves its writer before hints; MySQL current reads avoid stale REPEATABLE READ snapshots. Lock acquisition does not renew activity or expiry. Cart Version20261012110000 prepares durable browser slots and an ordinary actor/operation unique receipt index without rebuilding receipt rows/dependants. Conversion/unprotected deletion release pointers while retaining slot epochs; cleanup must invalidate the generation before deleting its replay tombstone. This migration and Version20261012120000 (durable abuse counters) are applied to staging tenant 31 after online backup/copied-data qualification, preserving 232 tables' original rows and zero FK violations. Live remote-engine concurrency remains to qualify; these locks alone grant no public cart authority.

GET /cart/bootstrap now reads only the current verified Customer cart slot or a hashed guest credential bound to the tenant and opaque PHP-session generation. It ignores query tokens and browser selections, returning canonical rows, numeric/commercial revisions, expiry, context and CSRF without creating/touching a SQL cart or renewing a credential. Bootstrap is private/no-store, noindex/ nofollow and no-referrer. Guest access excludes owned, closed, expired and settlement-linked carts. Internal guest creation commits a new priced selection and random hashed credential atomically; its cookie adapter uses Secure, HttpOnly, SameSite=Lax and session-only lifetime. Legacy public writes and remaining browser bootstrap adoption are still being replaced. Native cart/product forms now consume current SQL assertions. Owner switches rotate browser linkage; account restoration needs a verified current slot independently of guest state. Private commerce roots are catalog-declared sensitive even when modules are disabled. Their opt-in private-response metadata finalizes private/ no-store, noindex and no-referrer headers at the Kernel boundary for successes, redirects, middleware errors and dependency failures, independently of themes. Public catalog neighbours and other sensitive owners retain their own policy. The complete in-process Kernel/SQLite authentication/recovery suite now passes 56 cases / 2,040 assertions, including focused cookie, reset-session/tab-key, fixed-expiry/revocation, IT/EN correction and legacy re-entry checks. It uses a closed migrated schema with independent temporary database copies. Raw-JSON logout now honors the same internal continuation as native forms; shared login/register/logout checks refuse literal backslashes and ASCII controls before locale handling to prevent browser-normalized external destinations. Native HTTP session persistence/concurrency, remote SQL and deployed-build acceptance remain separate; this is not a browser/session deployment certification.

Password-reset links now exchange through a private 303 into an exact session-owned clean form, with bounded fixed-expiry references and native/JSON recovery. All eight owner bundles provide the shared compiled enhancement; native forms remain usable before rebuilding. Reset completion now commits its exact claim, password, imported-hash removal and sibling revocations together, then publishes events. Independent-process SQLite and approved PHPUnit rollback/ contention checks qualify that boundary. Profile/password, reset issuance, retention and connector refresh now coordinate current Customer writes and atomic related state; remote SQL/native sessions remain separate. Payment results exchange into bounded session-owned selectors with original signed expiry/current intent checks. Private webhook scopes export only fixed queue failures. Reset/Notification email also scope private enqueue/fallback and lazy worker dependencies, exporting fixed chain-free errors. Queued-content retention and proxy/access-log qualification stay open. See the Ecommerce owner for exact evidence.

Native /cart now renders only authorized SQL, ignoring query references and browser cache, without refreshing shopping clocks. Localized product add and cart update/remove/clear/checkout forms submit required version/context/operation IDs and CSRF to the same mutation service, with bounded input, origin and abuse checks. Native product forms remain visible without optional scripts; per-session controls make product responses private/no-store while preserving public SEO. Canonical, DM1 and LTS1 adapters preserve native semantics; Default/DM1/LTS1 source control/layout changes require owner compilation. Remaining browser cache, Legacy import, LTS1 header/cart-sheet and managed coupon source adoption are delivered; owner-built full journeys/cutover remain gates. Legacy coupon HTTP actions and /cart/sync are retired; token-only checkout shopping/fresh settlement is also retired while accepted bound receipt recovery stays supported.

POST /cart/mutate now applies explicit add/replace/remove/clear/restore to current SQL under verified Customer or guest authority. Required version 0 creates a new cart without a client token; existing writes require the current positive version and an internal reference assertion. Bootstrap context and a 64-character hexadecimal operation ID bind normalized intent to a durable receipt. Mandatory CSRF/origin checks, 128 KiB/depth-16 bounds and SQL abuse limits (60/generation/minute, 120/tenant/IP/minute) precede shopping writes. Selection, slot assignment, hashed credential and receipt commit together. Replays return current SQL plus original acceptedVersion without adding quantities/repricing again. Exact first-operation recovery can replace a lost cookie in the same live session; no-cookie bootstrap cannot restore it. Receipt replay is bounded to a proposed 24 hours/cart expiry, with at most 16 outstanding credentials. Explicit restore refreshes activity once; polling/replay do not. SQL currency is preserved unless explicit intent selects another currency. Counter hashes are pseudonymous; one-hour cleanup eligibility and returning-actor sweeps do not replace pending global scheduled cleanup/controller review. Legacy unmanaged paths, remaining browser/cart-sheet/cache adoption and authentication merge remain open. Native cart/product forms use current SQL and strict mutation intent. Checkout now prices an authorized SQL snapshot in its stored currency; transitional unmanaged quotes retain ambient currency. Verification passes 103 isolated protocol/checkout/migration/DI cases / 3,031 assertions and two fresh real-Kernel HTTP cases / 44 assertions; scoped production PHPStan passes. These checks do not establish complete storefront acceptance.

Managed /checkout restores a verified Customer slot (including another device) or bound guest cookie without a cart reference in the URL. GET/preview leave shopping clocks unchanged. Native confirmation includes the internal reference assertion, positive numeric version, context, CSRF and session quote; access and version are rechecked under the shared lock plan and after repricing, before stock/coupon/financial effects. The digest covers numeric authorization changes as well as commercial data. Every POST, including preview/coupon removal/payment resume, enforces CSRF/origin and 128 KiB/depth-16/64-field bounds. GET/POST share durable abuse counters; handled responses are private/no-store, noindex/nofollow, no-referrer and vary on Cookie. Canonical/DM1 forms carry the trusted controls; managed login/currency return navigation omits the cart reference. No theme build is required for these markup changes.

The review's opaque quote ID is retained in the PHP session before confirmation. After shopping closes, canonical reload uses it to find the accepted SQL receipt with the same session binding; the pointer alone grants no recovery. A current authorized new cart takes precedence. Exact accepted replay works after cookie revocation/gateway disablement, and native payment retry includes CSRF while reusing one purchase/intent/provider key. Historical receipt request hashes stay compatible. Checkout adoption does not complete browser/native cart migration, coupon adoption, authentication merge or retention/legal qualification.

Checkout/protocol/DI/theme verification passes 139 isolated cases / 3,646 assertions, six fresh real-Kernel protocol/native IT/EN cases / 124 assertions and scoped production PHPStan. Two existing PHPUnit configuration deprecations remain. These checks use disposable storage and do not qualify live remote SQL concurrency or external payment providers.

Cart/Psapi additive schemas are applied to authorized staging tenant 31 after online backup and copied-data qualification on 2026-10-12. Prior data across 226 tables, including 2 carts / 1 line and 22 runs / 93 checkpoints, remains unchanged except the specified cart-state normalization. No foreign-key violation is introduced; Doctrine dry-run is read-only. Other tenant and asset rollout is separate.

  • 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 shared runtime rejects redirects, HTML, malformed JSON and missing success status/message. Expired async sessions return 401 JSON and leave input intact. 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. All eight public layouts preserve supplied head metadata: CC1 publishes canonical/robots/JSON-LD with the shared variant-update marker; Default/DM1/DM2/PC2 also publish supplied robots. Flat page values take precedence over normalized core SEO fallback. Canonical/native category and DM1 product pages explicitly pass preview noindex, nofollow across the layout boundary. Source rendering and isolated Kernel checks qualify this handoff; deployed indexing, rich-result eligibility, mobile performance and accessibility remain separate checks. Published commercial facts remain authoritative. Catalog/category default-sort canonicals now identify each result page (base URL for page one, ?page=N later), including numeric category aliases and enabled Page source paths. Category identity redirects preserve only normalized search/sort/price/page intent. Internal search, alternative sort and price-filter listings use noindex, follow without canonical/collection metadata. Valid empty first pages remain 200/noindex; unavailable numeric categories and out-of-range pages return 404. Native pagination retains the source path and effective selection, including DM1. Explicit empty canonical/schema overrides theme fallback; missing values retain it. Product and Blog detail owners now publish qualified reciprocal hreflang through the existing typed SEO root. Exact visible base/translated text and current same-tenant native/source identity precede Page's canonical/route checks; missing, empty, metadata-only, private, shadowed, noindex and stale-self candidates cannot reach any of the eight heads. The navigation map remains independent. Unsupported locale inventories publish none before identity reads. Blog retains whole-row overlays, including default locale; Ecommerce retains its field-wise nondefault policy and ignores default rows. Indexable catalog/category pages now select identical bounded SQL slices across locales in base-name/ID order. Every rendered card requires an explicit translated name; secondary nullable fields retain fallback. Category members also require visible owner text and public native/source ownership. Slugless categories qualify only through proven source canonicals; numeric access stays usable without alternates. Missing hydration/translation withdraws the actual slice; complete other pages remain eligible. Two qualified nondefault members omit x-default. Public GET/HEAD catchall prefixes now reach layout/widget locale resolution before rendering, including first visits and conflicting sessions; concrete route/private/ write behavior is preserved. Compatibility browse retains requested missing pages for 404 instead of admin-query clamping. Variants and deployed indexing remain open. See the shared presentation contract and owner docs. 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 and gallery. Variant names share requested/default/ base translation fallback with search. Simple products retain Product/Offer and BreadcrumbList; variant families publish static complete ProductGroup/hasVariant graphs with direct owned selection URLs, actual converted offers and safe images. Saved scalar custom attributes retain their names, including zero/false; only observed differences define generic PropertyValue axes. Approved family reviews stay on the family, and unknown brand/GTIN/merchant policies are omitted. Plain Product graph updates remain supported. Merchant/rich-result/deployed qualification is separate. 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.

Search within a category uses the same primary/additional membership and descendant selection as its landing page. SQL uses an existence predicate so multiple memberships never duplicate products, totals or page slots. Remote documents carry sorted direct category_ids; localized ancestry includes all memberships for text search and projection invalidation. Category facets retain the primary category_id, so adding a collection does not inflate facet totals.

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. Optional query/click measurements require the currently declared search_measurement service and an observable saved boolean cookie grant. Without it, predictive results remain usable, correlation is null and selection POST returns 204 before payload/limiter/logging. Catalogue retrieval has no measurement dependency. Permitted query events contain hash/length, provider/duration/count/zero-result and correlation; click transport contains only correlation, position 1–8 and product/category group. Its 512-byte bound covers raw and decoded JSON. The shared client checks permission at start/completion/click and clears old correlations on revocation/departure/restoration. Responses are private/no-store with Cookie variation. A typed Ecommerce recorder rechecks permission at delivery and writes fixed bounded fields to a separate locked local JSON stream at log/search-measurement.log, with 0600 creation mode and a schema-v1 hashed tenant channel. It inherits no generic/debug/reporting sinks; delivery errors preserve requested search and navigation. Hashes remain pseudonymous. Early privacy classification excludes raw application HTTP/SQL/debug/error and provider-failure content for /shop/search always and other /shop paths when q is present in URI or PSR parameters. Core public /search has the same conditional query rule. Empty/array input, disagreement, locale prefixes, errors and disabled modules remain protected; query-free catalogue policy is unchanged. External provider/proxy/access logs, historical records, retention and versioned consent evidence remain separate; localStorage-only preferences cannot currently permit server measurement. Qualify owner-built delivery and actual deployment.

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.

When upgrading an existing modern index to additional-category filtering, pause storefront traffic, retain its modern provider configuration and run the bounded rebuild with --force before reopening traffic. Older documents lack the new category_ids field and would otherwise miss category-filtered searches. Elasticsearch/OpenSearch mappings, Meilisearch filterable settings and Typesense optional integer-array fields are updated additively; incompatible Typesense fields fail explicitly. Do not disable modern search before rebuilding: that selects the SQL no-op writer. Verify category searches, locale fallback and the completed rebuild before resuming traffic; live remote-provider acceptance is a deployment check.

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 modules/Ecommerce/src/AI.Ecommerce.md#catalog-contract; 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. One transaction checks the rendered version and writes stock, threshold, ledger and projections. Durable request receipts recognize exact retries, including threshold-only/no-op actions, before version checks. Successful writes reload the current filtered URL so membership, sorting, page totals and KPIs agree; focus/scroll feedback is restored where possible. A stale version returns 409 and on hand cannot 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).

Adjustment/history controls require JavaScript and are disabled with a visible explanation without it; native filters and export remain available. Tenant migration Version20260927110000 creates durable adjustment receipts; retain these records to preserve duplicate-write protection.

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
cookie_key - Optional cookie key of the source shop; write-only, stored encrypted, only its presence is shown
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

Customer passwords are carried over when the webservice returns them. A bcrypt hash is verified as it is; the older digest of shops upgraded from PrestaShop 1.6 also needs cookie_key (the cookie_key parameter in app/config/parameters.php, or _COOKIE_KEY_ in config/settings.inc.php on 1.6). The hash is stored apart from the customer and never logged. At the first successful sign-in k0smos stores its own hash and deletes the imported one; the customer stays flagged as migrated until the password is changed. A customer without a usable hash is flagged as needing a new password and is told so at sign-in, where a reset link can be requested by email. A password set in k0smos always wins over the source. "Test connection" reports whether the password field is readable, a dry run of the customers entity prints the count per outcome, and the import status page shows the sign-in summary of imported customers. Unused imported hashes are removed by ecommerce:customers:purge-imported-credentials (default 365 days).

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.

Single-record fetchOne() requests omit the collection-only display=full parameter: a detail GET already returns complete fields, while some shops return an incomplete plural envelope when that parameter is present. Collection pages retain display=full. This distinction is required for currency dependency checks and order imports; changing connector permissions does not repair an incorrect detail response shape.

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. Explicit zero product ids left by source deletions are skipped without inventory writes; malformed required fields remain failures
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.

Psapi customer checkpoints also store a closed count-only credential-outcome map. job.summary.outcomes on the status API derives from that entity snapshot, including running/failed runs; /admin/psapi/import-status?job_id=<run-id> and settings polling expose the same reasons in IT/EN under Default/Sober. The separate sign-in aggregate describes current accounts. Historical NULL means unrecorded and remains unknown when a partly committed old run resumes. New page transactions save cumulative reasons with destination writes and the cursor. Retried jobs reuse completed/partial entities, including dry runs. Queue/CLI capture one claim generation; core page commits and durable entity/run completion reject superseded workers. Images and extended refresh destination writes retain their existing contracts. Migration Version20261012090000 must precede deployment of readers/workers.

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

Fresh non-SQLite installations run MigrateCommand's portable bootstrap: apply the complete discovered migration set to temporary SQLite, translate its schema, copy seed data, mark the canonical versions and backfill admin/theme/locale state. MySQL schema and sequence DDL run outside the owned seed-DML transaction because MySQL implicitly commits DDL. An active caller transaction is refused before bootstrap effects. Boolean seeds use explicit DBAL Boolean bindings; restored unnamed SQLite unique-index names use deterministic hash suffixes when needed to fit the 63-byte portable limit. Autoincrement columns retain their effective SQLite non-null contract even when introspection omits the explicit flag; ordinary nullable keys are not rewritten. These fixes do not make MySQL schema DDL rollbackable. A failed fresh attempt may leave schema without a complete seed/version state; preserve diagnostics and use a reviewed database recovery procedure before retrying. No automatic destructive cleanup is performed by the production command.

Qualification on 2026-10-15: Ai's two oversized path UNIQUEs are replaced by bounded exact-identity hashes with retained full-value guards. Its explicit Version20261015100000 migration requires quiesced writers/workers and a reviewed backup; see modules/Ai/src/AI.Ai.md for framing, collision refusal and rollback. The Document/Chat/migration/bootstrap selection now passes 77 / 1,462. Owned native MySQL 8.4.11 checks pass the owner-table utf8mb4 projection, long byte-distinct paths, current reads after an old snapshot, 16 adverse-key refusals, transactional replacement rollback and a real legacy-latin1 migration/unchanged rerun. The subsequent owner-upgrade regression reproduces retired SQLite ID reuse during NOT NULL rebuilding. Both tables now capture and queue restoration of their AUTOINCREMENT high-water marks, including empty retired tables. Native SQLite preflight precedes lazy DBAL introspection for both tables and refuses custom partial/expression/collated/descending indexes, trigger/CHECK/generated behavior, noncanonical primary keys and incoming/outgoing FKs. FK name matching respects SQLite's case-insensitive identities. Thirteen owner cases / 141 assertions and scoped PHPStan pass; MySQL canonical owner fresh/legacy upgrade checks pass again. This native custom-schema refusal is SQLite-specific. Previously lost high-water marks require reviewed backup recovery; the fix cannot reconstruct deleted history. Whole-tenant upgrades and native concurrency remain separate qualification.

AppSettings' MySQL physical owner contract now stores the unchanged setting_key as LONGBLOB, with a generated stored BINARY(32) SHA-256 identity and a unique index. SQLite/PostgreSQL retain their complete TEXT key. Existing two-column SQL inserts and full-key updates/deletes remain compatible; no application key bound or normalization is introduced. AppSettings::set() performs current locked reads on MySQL/PostgreSQL, recovers a same-key insert race only after savepoint rollback and refuses unrelated uniqueness failures without changing its cache. Exact-key predicates on the MySQL blob may scan/lock multiple settings rows; this is not a per-key concurrency or performance qualification.

Fresh MySQL bootstrap shares the versioned owner projection with Base Version20261015110000. For existing MySQL, back up and quiesce all settings writers/web requests/workers before applying that nontransactional migration. It accepts only the canonical two-column legacy shape, ordinary VARCHAR/VARBINARY primary key and nullable TEXT-family value without custom defaults, indexes, generated expressions, CHECK constraints, triggers, foreign keys or partitioning. Custom schemas require reviewed migration instead of silently losing behavior. The queued shadow copy preserves key bytes as returned under the active results charset and leaves values/envelopes in SQL. One atomic rename switches the tables; the previous table is then dropped. A failed copy leaves the live table intact. Either _app_settings_mysql_v1_next or _app_settings_mysql_v1_previous forces reviewed recovery before retry: inspect the source/shadow/version state and backup before any manual cleanup. MySQL DDL is not rollbackable. Retain the upgraded schema on code rollback; restoring an old bounded key can lose identities. SQLite/PostgreSQL migration directions are no-ops.

The settings selection passes 55 PHP cases / 1,208 assertions and eight scoped PHPStan paths. Owned MySQL checks pass fresh complete Unicode/case/trailing/NUL/ binary keys, raw SQL compatibility, credential binding, UTF-8 and latin1 actual dry-run/upgrade/unchanged rerun, six custom-schema refusals and copy-failure/ artifact recovery refusal. These checks do not qualify native PostgreSQL/MariaDB or concurrent full-backend operation.

Bookmark, Invoice and Kit each own Version20261015120000: five nonunique MySQL lookup indexes use 191-character prefixes while every URL/title/status/ source/date value remains complete TEXT. Invoice prefixes preserve integer customer IDs and both source columns. Full SQL comparisons and ordering still read complete values; common-prefix rows remain distinct. These indexes do not establish uniqueness, accelerate arbitrary contains searches or guarantee a full-string ordered index scan. GUID/order/remote-document identity is unchanged.

PortableSchemaProjectionMigrationInterface lets discovered migrations declare pure target-platform schema projection in canonical order after SQLite introspection. MysqlTextLookupIndexMigration shares only the matching prefix mechanics; each owner retains its exact index declarations. Base's AppSettings projection uses the same boundary, replacing its hardcoded bootstrap branch. No DI key or optional module service is added. The architecture decision records the matching consumers and boundaries. Existing MySQL upgrades use Doctrine's queued schema diff and may lock tables / implicitly commit; back up and schedule reviewed migration operations. Each owner refuses missing or incompatible unique/primary/reordered/flagged indexes before its DDL; deliberately different prefix tuning is normalized to 191. The whole multi-module plan is not atomic. Completed index changes are safe to retry, and code rollback must retain valid prefixes. No row/export payload or runtime writer changes are required. SQLite/PostgreSQL directions are no-ops.

The lookup selection passes 27 PHP cases / 2,792 assertions and the expanded module-neutral boundary adds 62 assertions. Owned MySQL checks exercise all owner columns and FK dependencies, common-prefix repository queries, actual dry-run, upgrade and unchanged rerun. The former architecture failures are now resolved: active modules contribute typed locale backfill definitions instead of core constructing Documentation services, and the DDL classifier recognizes capitalized migration directories. Runtime adapters remain scanned. The complete architecture selection passes; these corrections do not qualify provider or concurrent migration execution.

Esapi's Version20261015130000 retains unrestricted complete entity types and all checkpoint/counter/timestamp/ID fields, adding a SHA-256 identity column and UNIQUE before dropping the unbounded TEXT identity index. Back up and quiesce all sync writers/workers before applying it; deploy matching readers/writers together and retain the upgraded schema on code rollback. Data backfill and SQLite sequence restoration are queued, so dry-run performs no writes. SQLite preflight rejects custom partial/expression indexes before DBAL introspection and refuses triggers, CHECK/generated/strict/custom schema; retired AUTOINCREMENT IDs are not reused. Native custom trigger/schema/index shapes also require reviewed migration. SQLite incoming FK matching is case-insensitive; an uppercase reference previously evaded refusal and allowed cascade deletion during rebuilding. Source must retain AUTOINCREMENT without a separate primary index: custom DESC/non-generated identity semantics now refuse before introspection. Three actual-Doctrine regressions preserve schema, all rows/sequences/history and FK integrity on refusal. Dependants already deleted by an earlier upgrade require reviewed backup recovery; the guard cannot reconstruct them. PostgreSQL quoted-name semantics are unchanged. The repository validates retained full identities/hash values, uses current locked write reads and recovers same-key insert conflicts only after savepoint rollback. It captures generated IDs before releasing the insert savepoint, as required by the real PDO MySQL boundary. Existing full-checkpoint replacement semantics remain; this is not monotonic import-run/counter coordination. Import dry-runs stay read-only. Owned MySQL checks pass complete Unicode/case/trailing/empty identities, current reads after an old snapshot, actual UNIQUE/savepoint recovery, four damaged-key refusals, caller preservation and UTF-8/latin1 legacy upgrade/dry-run/rerun.

The real complete command initially exposed two further core failures after schema creation. Version recording now initializes tenant metadata before using Doctrine's version command. Migrate/generate share platform-bounded metadata names: existing SQLite names and valid MySQL names up to 64 bytes stay unchanged; only overlong target identifiers get a deterministic suffix from the complete tenant ID. A detected existing PostgreSQL 63-byte truncated legacy table causes an explicit refusal requiring reviewed recovery instead of opening a new history. No automatic rename/deletion of old metadata is performed.

The actual complete commands on invocation-owned empty MySQL 8.4.11 and PostgreSQL 17.6 databases and their unchanged second executions now pass. Preflight has no structural index candidates across 241 application tables, 807 indexes and 190 foreign keys. The actual fixture has 242 tables including its tenant version table, 145 recorded versions and 520 total rows. Checks cover column names/order, index identities/ prefixes, FK identities, minimum seed counts, admin permissions, theme/locale backfill, native strictness/encoding, FK enforcement, sequences and inactive transaction state. The second command preserves all row/schema fingerprints and configuration revisions. This does not compare every column/default/FK-action semantic or every canonical seed value. PostgreSQL uses UTF-8, read-committed isolation and ordinary validated FK enforcement. Index comparisons use native identifier values rather than SQL display quoting; sequence reads do not advance them. Full existing-tenant upgrades, native MariaDB, commerce lifecycle/concurrency, providers and deployment remain separate. The maintained owned diagnostic is documented in tools/benchmarks/portable-schema.md.

Its --upgrade mode additionally passes actual complete MySQL 8.4.11 and PostgreSQL 17.6 commands from reconstructed all-module legacy fixtures. All 135 prior migrations really execute on owned SQLite, followed by complete schema/seed translation and transfer of the real execution ledger. MySQL's usable legacy physical variants are explicit: bounded AppSettings key, Latin1/BINARY Ai indexed identity columns with Unicode payloads, Esapi TEXT uniqueness prefix and five nonunique lookup prefixes. Pending projections are not applied early, and this is not evidence that the historical portable MySQL installer could create that schema unaided. The native plan must contain exactly ten AppSettings/Ai/Bookmark/Invoice/Kit/ Esapi/ToDo/Cart/Ecommerce/Payment versions. PostgreSQL records the four MySQL-only AppSettings/lookup directions without physical changes; the other owners perform their native upgrades. PostgreSQL retains complete TEXT keys, Unicode and full indexes without MySQL substitutions. Its fixture includes a key over 190 bytes and Unicode paths. Generated-sequence positions are initialized after explicit-ID copy/private preparation, then captured before the command; no post-command counter repair is allowed. All 151 sequence names, last_value and is_called states remain unchanged through upgrade and rerun, including an attachment counter beyond MAX(id) and an empty merge-receipt counter at 19999. Identity-sequence OIDs change as described below. The real command avoids fresh bootstrap and preserves every original native field/row/ID and all 135 ledger rows. Synthetic customer/password, address, cart/item/owner/lifecycle/mutation receipt, order/postal/invoice and Ai/ Esapi data are retained; production Customer/Cart/Invoice/postal readers verify them after upgrade. Postal fixtures use the real transactional capture API. Final inventory is 242 tables, 145 versions and 536 total rows; a second full command preserves schema, rows and revisions. MySQL diagnostic sequence checks use live engine counters with session-local statistics expiry disabled after DDL.

Whole-command preservation still does not prove every historical schema semantic. ToDo's owner migration now repairs the partial predicate lost by SQLite introspection. MySQL uses a nullable signed virtual generated key, preserving user SET NULL; PostgreSQL/SQLite retain UNIQUE(user_id) WHERE ended_at IS NULL. Multiple completed/anonymous entries remain valid and one active timer per user is enforced across tasks. Index-only upgrades preserve history and retired IDs. Native MySQL 8.4.11/PostgreSQL 17.6 repository and actual Doctrine upgrade/dry-run/ rerun probes pass; custom generated identities and invalid/not-ready/NULLS NOT DISTINCT indexes refuse before effects. SQLite keeps existing CHECKs without a table rebuild; general portable CHECK/default parity is not established. Use reviewed backup and quiesce time writers for Version20261015140000; retain the identity on code rollback. See the ToDo owner for exact evidence. Other historical versions/custom schemas, native lifecycle/contention and deployment also remain unqualified; completeBackendQualification stays false.

Cart, Ecommerce and Payment each own Version20261015150000, repairing six copied parent keys that SQLite introspection had incorrectly projected as native generated integers: lifecycle cart ID, merge source cart ID, checkout receipt cart ID, postal order ID, imported-credential customer ID and purchase-family lock ID. Pure discovered projections restore explicit native keys on fresh installations; queued upgrades preserve rows, PKs and FK columns/actions. SQLite is unchanged, and real entity ID generators remain intact. Every owner's columns preflight before any SQL is queued; native migrations leave the caller's schema untouched.

MySQL removes AUTO_INCREMENT in one atomic ALTER per table, dropping and replacing affected FKs under bounded deterministic names. Actual native failures showed that MODIFY alone is rejected for FK columns and reusing the same FK name in that ALTER is also rejected. FK enforcement stays enabled; custom incoming dependants refuse. New FK names can differ between fresh and upgraded installations.

The installed PostgreSQL DBAL platform emits GENERATED BY DEFAULT AS IDENTITY. PostgreSQL DROP IDENTITY removes its internal sequence. For canonical identities, the owner transaction therefore snapshots its logical name/configuration/last_value/is_called, drops the identity and recreates the same-named plain integer sequence with ordinary column ownership and the captured state. The sequence OID changes. This does not preserve cached client references to that object. Legacy SERIAL only loses its default and keeps its original sequence/OID. Custom identity ALWAYS, sequence configuration, owners/schema, explicit/default grants, comments, labels, extension membership and catalog dependants refuse rather than silently lose semantics. Dynamic sequence callers are not fully discoverable through catalog dependencies.

Before applying these native upgrades, take a reviewed backup, quiesce application writers and all sequence callers, then restart clients/connections before resuming traffic. Custom refusals require a reviewed owner migration; do not edit catalogs or force counter repair. Retain explicit keys on code rollback. MySQL DDL is not transactionally rolled back across owner tables; review partial execution/history and rerun after recovery. Each per-table ALTER is atomic. PostgreSQL owner DDL runs transactionally, including recreation and positioning of replacement sequences.

Complete owned native fresh and reconstructed upgrades, full command reruns and real sequential checkout pass without copied-key diagnostic exceptions. Fresh PostgreSQL has 145 sequences; upgraded fixtures retain all 151 logical states. Dry-run preserves the full native baseline on both engines. Three PostgreSQL forced failures after all owner SQL restore schema, rows, original sequence OIDs and positions, including used/unused/retired counters. Five PostgreSQL custom variants and a MySQL incoming FK refuse without queued partial SQL. Quoted-role and legacy SERIAL/original-OID probes pass. Seventeen focused PHP/architecture cases / 4,845 assertions and 22 scoped PHPStan paths pass. General CHECK/default parity, other historical custom schemas, native contention/HTTP and deployment remain separate. See the owner documents for the affected key declarations.

The diagnostic also provides --checkout (with --postgresql for that engine). After full fresh migration and unchanged rerun, it exercises sequential guest/new and Customer-context/saved-address checkout through actual DI/event listeners and local manual payment. Independent SQL checks qualify EUR244/EUR44 tax for two EUR122 units, a captured intent, reciprocal IDs, one shipment/reservation, final stock 16 from 20, postal captures, unchanged receipt replay/resume and changed-command/stale-price refusals. Closing the writer and opening another native connection rechecks all effects and committed order/payment/postal facts. Post-workflow schema facts also pass. Outer row totals describe the installation before this workflow, not a post-checkout row count.

This exposed PostgreSQL boolean-vs-integer predicates in inventory and reservation reads. The bounded checkout/currency/tax/promotion/coupon/shipping path now uses portable TRUE/FALSE, retaining SQLite/MySQL semantics. Both native engines and 367 focused PHP cases / 6,205 assertions pass; 19 PHPStan paths pass. The owner repairs above remove copied-key counter exceptions: all six explicit native declarations are checked, and every physical PostgreSQL sequence enters rerun comparisons without post-workflow counter repair. External HTTP is denied, mail delivery is disabled and CustomerSession is memory-backed. These are service workflow checks, with HTTP/concurrency/other payment or fiscal combinations, providers and deployment separately qualified. Exact scope lives with Ecommerce.

MigrationDependencyFactory uses a lazy schema diff provider that removes only table changes whose original definitions are strictly identical before DBAL normalization. SQLite's unnamed foreign keys can otherwise produce false table rebuilds that cascade into dependent rows. Genuine column, default, index, foreign-key, create and drop changes still reach platform SQL. This does not qualify explicit foreign-key replacements or necessary table rebuilds: owners must verify dependent data and constraints under actual foreign-key enforcement. Backfill DML belongs in queued migration SQL rather than direct post-hooks, which Doctrine invokes even during dry-run. Cart's additive metadata DDL is generated through the Schema API and queued before its backfill.

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
modules/Psapi/migrations/Version20261012090000.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. Automatic migration and backup discovery excludes disposable phpunit-isolated-* descriptors and aliases resolving to them, including files left by interrupted tests. When TENANT_ENV is set, only that tenant is migrated; explicit selection can still target a disposable tenant. 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.

A full, non-baseline migrate also runs the canonical theme and locale data backfills after successful Doctrine execution, including the portable fresh-schema path. This is the same flow installation and demo installation use. When the DB theme selection is absent, only its selected wrapper/dependency states (including installed deployment route overrides) are prepared, atomically with insertion. Existing selections, revision tokens, locale values and unrelated operator module states are preserved. Tenants with no allowed theme wrappers skip theme creation while retaining applicable locale migration.

--module and --baseline omit configuration backfills. --all-or-nothing applies to Doctrine's schema phase; it does not make the entire update one transaction. A later data failure returns nonzero; completed schema migrations remain recorded and a rerun is safe. No global module synchronizer runs on every update. The explicit theme/locale commands remain available for preview and maintenance. Asset manifests are not a data-migration prerequisite; tenant:theme:diagnose verifies them separately before serving traffic. See Tenant configuration boundary.

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 publishes a count-only XML index. Non-localized GET /sitemaps/{source}/{sitemap_locale}/{page}.xml reads one active pages, blog or catalog provider and one exact-locale slice of at most 1,000 scalar identities. Published tenant Pages exclude email/editor data; catalog requires active identities and real nondefault translations; Blog preserves existing publication and translation-overlay semantics. Native slug shadows are excluded. These SQL reads are independent of search offset ceilings and hydrate no bodies, variants, images or personal shopping state.

Enabled canonical HTTP 200 rewrites replace only eligible exact-locale batch identities. First-id, stripped-path/prefixed-source, published Page and concrete route precedence are rechecked before publication. Unsafe/private/machine or unsupported-native URLs are omitted; no redirecting native URL substitutes for an invalid canonical. Native redirects use the tenant language-prefix generator, and catchall rendering receives the prefix locale on its request. Configuration alone does not add native routes for other languages.

Bounds are 32 active providers/locales each, 50,000 index references, 1,000 candidate records per slice, loc values below 2,048 bytes and XML at most 52,428,800 bytes. Sources fail explicitly on excessive counts; they never silently truncate or fall back to eager inventory. Unknown, exhausted and fully filtered slices return 404, as does an empty index. Candidate index references can become unavailable after filtering/withdrawal; stable identity pagination does not promise a cross-request snapshot. Resolved tenant authority supplies absolute URLs and output is UTF-8 XML escaped.

Valid persisted Page dates and complete base/translation Blog clocks supply optional lastmod; unknown/invalid dates remain absent. Catalog omits dates because variants, inventory and collections lack one complete page clock. Rewrite/generation time never substitutes for content time, and index dates are omitted. Current isolated owner/HTTP qualification covers these source contracts. Deployed robots/CDN/.htaccess, large-catalog SQL performance and indexing acceptance remain separate gates.

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.0 Backoffice acceptance and shared settings

Default owns the generic rich-text/revision and model-settings partials under template/default/tpl/admin/ai/; Sober reuses them through ordinary fallback. Blog remains editable when private Ai is inactive. Only revision controls need that module; core settings services enumerate active owner providers without activating private chat or calling AI services. Containing pages register the model-settings script before Alpine. Saves require nonredirected JSON, valid unique fields, all submitted fields returned and matching saved_keys before success. Failures preserve drafts; pending writes block duplicate/reset work; read failures offer retry and feedback uses a persistent live status. Payment validation focuses the invalid input through the panel's Alpine $root.

Run node tools/benchmarks/backoffice-acceptance.mjs [output-directory] with Node 22+, PHP, Chromium and existing operator-built theme assets. It owns and removes temporary SQL/tenant/browser/server fixtures and tests actual admin routes at 390/768/1440 px, native browser 200% zoom, modal autofocus/cancel/Escape, quoted Storage profiles, rich editing and settings recovery. It records manifest hashes, geometry, screenshots and native accessibility trees; no build occurs.

tools/benchmarks/backoffice-screen-reader.sh [output-directory] adds Xvfb, a private D-Bus/AT-SPI session, genuine Orca and Speech Dispatcher. Dependencies must be available locally; K0SMOS_ORCA_PACKAGE_ROOT may point to an extracted Debian package tree with compatible system GI/AT-SPI libraries. The wrapper checks both themes' search and payment announcement sequence in Orca's speech presentation and dispatcher output logs, saving screen-reader-results.json. A silent sink captures delivery; this is bounded platform evidence, not an acoustic/user study, complete WCAG claim or acceptance of public interfaces.

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: plain, dots (12px repeat) or grid (24px repeat) applies immediately to Default public margins/examples and admin margins, independently of Save; reading/work surfaces remain uniform. Existing same-origin Default tabs and settings selections follow storage changes. The inherited page in Sober uses the same previews without texturing Sober itself.
  • 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 connector editor: tenant enablement, configuration, save and version probes | | 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
  • favicon activation accepts the raw JSON used by both native interfaces as well as parsed forms; cleanup previews preserve JSON dry-run intent. Preparing an active source updates generated URLs atomically. Successful company-picker changes reload the document head; failures stay visible inside the dialog
  • 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() supplies permissions and the runtime-module inventory in its initial declared page payload, avoiding a late inventory fetch that expands the page. Settings models initialize once, and Social Login/Maps scripts are included by the parent page before Alpine. Editable settings retain their existing 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 preserved configuration, validation, preset and revision
POST /api/settings/klaro/preview system.write Validates a current-revision draft without writes; scoped CSRF header required
POST /api/settings/klaro system.write Atomically saves a validated current-revision draft with scoped CSRF and audit
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 connector settings or tenant enablement (enable_tenant); omitted fields are preserved
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
  • admin_menu_unlocked (off by default) unlocks the Default admin sidebar: each operator can drag entries to reorder them among their siblings (Alt+ArrowUp/ArrowDown from the keyboard) and pin link entries into a group above the navigation. The arrangement is stored in that operator's browser and never changes the tenant's menu; switching the flag off shows the canonical order again and keeps the stored arrangement. Activation steps and operator instructions: Personal admin menu

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. The resolver's repository is an optional constructor parameter and is therefore wired explicitly in SystemDefinitions; left to autowiring it stayed null and stored states were ignored.

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. migrations/Base/Version20261010090000.php is a portable insert-if-missing example with typed parameters.
  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 passed as CLI permission flags. /admin/settings/agents now provides tenant enablement, editable connector cards and saved-configuration version probes under Default and Sober. Set the instance key in root .env.local (or the service environment), then use Enable for this tenant, configure the two CLIs and Save, followed by Verify. The tenant secret is derived on the server; no manual API call or tenant JSON edit is needed. Verification checks the binary version, not login or model execution. JavaScript is required for editor writes. Saving also requires the instance encryption keyring (K0SMOS_SECRET_CURRENT_KEY_ID and K0SMOS_SECRET_KEYS), independently of the connector enablement key. If encryption is unavailable, the API returns 503 JSON and the editor explains the missing prerequisite without saving or activating the tenant. Provision/recover keys using the secret management runbook. Cookie-authenticated save/test requests use the agent.connectors.settings CSRF intention. Both runners send prompt bytes via stdin; Codex approval flags precede exec. Full reference: src/App/Integration/AgentConnector/AI.AgentConnectors.md.

Agent connector environment and worker requirements

Tenant activation and encrypted storage use separate instance secrets:

Variable Purpose
K0SMOS_AGENT_CONNECTORS_KEY Authorizes connector activation and derives each tenant's enablement secret.
K0SMOS_SECRET_CURRENT_KEY_ID Selects the current encryption key by its identifier.
K0SMOS_SECRET_KEYS JSON map of key identifiers to base64-encoded 32-byte encryption keys; protects the persisted connector registry.

If only K0SMOS_AGENT_CONNECTORS_KEY is configured, keep it and configure the encryption keyring before enabling or saving connectors. Even with CLI-native authentication, the registry contains the derived tenant enablement secret and must be encrypted. These keys do not authenticate the CLI with its provider.

For a new instance with no existing encryption keyring, generate independent encryption key material:

openssl rand -base64 32

Add the following to the root, gitignored .env.local, replacing the placeholder with the generated value:

K0SMOS_SECRET_CURRENT_KEY_ID=instance-1
K0SMOS_SECRET_KEYS='{"instance-1":"BASE64_ENCODED_32_BYTE_KEY"}'

instance-1 is an operator-chosen identifier and must match the JSON entry. Reuse an existing instance keyring when one is already provisioned; preserve keys needed to read existing encrypted values. Before the first encrypted write, follow the database backup and separate encrypted keyring escrow requirements in the secret management runbook.

PHP and any queue workers must receive the same instance secrets. Web requests load .env.local; existing process environment variables take precedence. Restart long-running workers after environment changes. Managed services must also reload their process environment when secrets are supplied there.

A queue worker is not required for connector settings. The requirement depends on the operation:

Operation Queue worker required?
Enable/disable the tenant and save connector settings No; handled in the HTTP request.
Check version No; PHP invokes the saved CLI executable directly.
Single prompt through POST /api/settings/agent-connectors/run No; synchronous execution within the HTTP request, subject to PHP/web-server timeouts as well as the connector timeout.
Send/retry a Brainstorming conversation turn Yes; an active worker on the default queue is required. The panel rejects new execution when no recent worker heartbeat is available.

Without a queue worker, the executable, working directory and CLI authentication must be accessible to the OS user running web PHP. Brainstorming additionally requires them under the worker's OS identity; see its setup below and the queue operations guide.

If Brainstorming reports executable_unavailable, check the worker's PATH and executable permissions. A successful Check version in the settings page checks web PHP's environment and does not prove that the worker can launch the same command name. Prefer absolute executable paths when service environments differ. Changing a connector path requires a new conversation, because its execution configuration is frozen when participants are selected. Correcting only the worker PATH preserves the binding and permits retrying the old attempt.

Agent connector form fields

Enable for this tenant controls the tenant-wide execution gate. Each CLI also needs its own Enabled checkbox selected. Disabling the tenant retains the connector settings. Neither switch authenticates the CLI with its provider.

Field Meaning
Identifier Stable connector ID, displayed for reference; not editable in the form.
Name Descriptive label, such as “Codex project workspace”.
Executable codex or claude if available on the PHP/worker process's PATH, or an absolute executable path. Do not include arguments. A command available in your terminal may be absent from the service's PATH.
Working directory Existing project directory from which the CLI starts, accessible to the OS user running PHP/workers. New cards default to the current k0smos project root.
Authentication Local CLI session or server-provided token, as detailed below.
Execution mode CLI-specific permission arguments, as detailed below; the same label does not imply identical restrictions across providers.
Timeout (seconds) Maximum duration of a prompt run: default 120, allowed 1–3600. The runner stops the process on timeout.
Output limit (bytes) Combined captured stdout and stderr: default 1,000,000 bytes (about 1 MB), allowed 1,024–10,000,000. When output exceeds the budget, it is truncated and the runner stops the process. This is not a token or billing limit.

Authentication choices:

  • Local CLI session (cli_native) uses the CLI credentials provisioned for the OS user running PHP or the worker. A backoffice login does not log that server-side CLI in; credentials belonging to another terminal user are not automatically available.
  • Official server token (official_token) reads CODEX_API_KEY for Codex or CLAUDE_CODE_OAUTH_TOKEN for Claude from the server environment. The form does not accept or store these provider tokens. They are separate from K0SMOS_AGENT_CONNECTORS_KEY, which controls instance enablement.

Execution modes as currently configured by k0smos:

Mode Codex Claude Code
Read only (read_only, default) Requests --sandbox read-only. Requests --permission-mode plan.
Patch only (patch_only) Also requests --sandbox read-only; proposed changes are not applied to the workspace. Requests --permission-mode acceptEdits --disallowedTools Bash: file edits are allowed, while the built-in Bash tool is blocked. This is not an unapplied-patch-only mode.
Workspace write (workspace_write) Requests --sandbox workspace-write, using the configured working directory as the workspace. Requests --permission-mode acceptEdits. The working directory is a starting location, not an equivalent filesystem sandbox.

The connector does not create an isolated worktree or copy of the project. Codex receives the global approval policy -a never before exec. Installed CLI argument acceptance has been checked; provider authentication and actual permission enforcement require separate live acceptance. In particular, do not treat Claude's “Patch only” label as a guarantee that files will remain unchanged.

Save configuration persists the cards in the tenant database. Check version uses the saved executable and working directory, so a new or edited card must be saved first. It runs the version command with a separate fixed 15-second timeout and 64,000-byte output cap; the prompt-run limits above do not change this probe. A successful check establishes that the CLI version command runs, not that authentication, model access or a conversation works. It does not call a model and can be used while the tenant or connector is disabled.

Brainstorming conversations

The optional brainstorming module adds private conversations at /admin/brainstorming, using the configured local CLI connectors. It has no ai module dependency and starts disabled. Each conversation belongs to its creator; even another administrator cannot read it through this surface.

Quick setup:

  1. Apply the tenant migrations, then enable Brainstorming in runtime module settings. The tenant's module inventory must include brainstorming.
  2. Grant brainstorming.view to read the panel and brainstorming.chat to create, rename, delete or stop the user's own conversations. Sending and retrying also require agent.run. The migration grants the two new permissions to the existing admin role without replacing custom grants.
  3. Configure and enable the CLI cards at /admin/settings/agents, including the instance/tenant gate described above. Use absolute executable paths when the service cannot resolve codex or claude on its own PATH.
  4. Provision native CLI authentication under the worker's OS identity and HOME, or supply the chosen official token in its service environment. Check codex login status and claude auth status --json under that identity; the panel's version check alone does not establish authentication.
  5. Run a default queue worker with a reclaim interval longer than the 120-second maximum discussion execution, for example queue:work --queue=default --reclaim-after=180. Keep whole-service child cleanup enabled, such as systemd KillMode=control-group. Restart workers after deployment so they load the new message handler. The panel refuses new execution when it cannot find a recent worker heartbeat.

Create a conversation with one to eight configured connectors. Its participants are fixed at creation. For each manual message select one, several or all recipients; they receive the same frozen transcript, including prior replies from either agent. Their results appear independently. A new turn waits until all attempts of the current turn have reached a terminal state. Questions from an agent are normal messages: answer through the composer and address the appropriate recipient. The panel requires JavaScript and polls approximately once per second.

Finite discussion workflows and permanent plans:

Choose the hybrid preset to run independent analysis, comparison by each agent in the selected order, and a designated author's synthesis. Parallel comparison instead gives all reviewers the same completed analyses. An optional final review is attached to the exact plan revision. Moderated mode waits for Advance at step boundaries; automatic mode executes the explicitly started finite sequence. Custom ordered steps can be supplied through MCP, with separate responders and context policies. Synthesis and review require previous_steps; an analysis-only review is rejected because it would omit the document being reviewed. Saved definitions are revalidated before launch. A legacy review already dispatched without the synthesis blocks as Invalid output and leaves the saved plan intact; start a new workflow to review it with corrected context. These are discussion workflows, not executable project tasks.

Pause prevents later dispatch while current answers finish. Stop also cancels current work. Sending a message during a workflow saves a pending intervention and pauses the sequence; resume applies it after current attempts settle. Errors or truncated answers block dependent steps. Continue with partial results is an explicit choice and preserves the missing evidence in later input. A synthesis must be complete before it becomes a result plan. Limits are twelve steps, thirty-two attempts including retries, and one hour per execution.

The Result plans library stores the full agent-authored Markdown, revisions, author and sources permanently in the tenant database. Closing a discussion or deleting its transcript does not delete its result plans. Deleting a permanent plan is a separate action. New versions use optimistic conflict checks; approval applies only to the exact version approved by the human operator. Agent review and human approval are separate. Plans can be searched, read and revised without any worker running, and reused as explicit context in later discussions.

Hermes and other authenticated MCP clients can start/poll workflows and discover, read or write permanent plans through the brainstorming_* tools. See the MCP guide; a compact participant/client setup guide also lives in modules/Brainstorming/src/README.Brainstorming.md. No external Memory service is required: SQL is the permanent memory. Optional caller-supplied Memory excerpts are frozen once with their source IDs/revisions and delivered equally to each analysis agent. Selecting a responder does not make that message private from later shared turns. The interface states the context policy separately from recipient selection.

The conversation list collapses to release chat width, and agent activity uses the shared accordion while status and stop remain available. Both Default and Sober use the same accessible controls and permanent plan library.

Brainstorming always uses its own discussion-only policy, independently of Execution mode on the connector card. Codex requests a read-only sandbox and disables command/tool features; Claude uses safe mode and an empty tool set. User/project instructions, hooks and MCP configuration are isolated by provider-specific flags. Administrator-managed CLI policy still applies and must not introduce tools or hooks that violate this policy. Native interactive sessions are not attached or resumed: each run is ephemeral and replays bounded SQL history. Existing one-shot connector behavior is unchanged.

Control or state Behavior
Recipients Any selected participants for this message; it does not change the conversation's participant bindings.
Send Commits the user message and addressed attempts before dispatching identity-only queue jobs. Repeating the same request key returns the original turn.
Stop Immediately cancels queued work; running work receives a cancellation request checked by the runner. A late completion cannot append a second reply.
Retry Creates a new linked attempt for the original turn and recipient, using its frozen transcript. It never inserts the user message again. Only the latest unsuccessful attempt can be retried, while the conversation is idle.
Worker unavailable New attempts are rejected without losing the composer draft. Unclaimed manual attempts fail after 180 seconds; workflow attempts allow 1,140 seconds so a single worker can service up to eight participants. Failed attempts need explicit retry.
Partial reply Text received before timeout, truncation or cooperative stop remains visibly incomplete; a reply arriving after cancellation or permission revocation is discarded.
Interrupted The run's 30-second lease expired, for example after a worker died. Recovery marks it terminal; it never silently reruns a provider request.
Binding changed The saved binary, directory, type, authentication, mode or environment no longer matches the conversation. Create a new conversation after reviewing settings. Old history remains readable.
Rename / Delete Rename changes the title. Delete removes this conversation's SQL history and fences late results; it does not erase provider-side records or backups.

The discussion budget is the smaller of 120 seconds and the saved connector timeout. Combined captured output is capped at the smaller of 1,000,000 bytes and the saved connector limit. Neither budget is a token or cost limit. The composer accepts up to 16,000 characters; the API bounds user messages at 65,536 UTF-8 bytes. Replay includes at most 200 recent whole messages within a 240,000-byte envelope; each attempt records the included sequence range and omitted count. Omitted history is not summarized. Long conversations may therefore need a fresh user summary.

SQL retains history until its owner deletes the conversation; disabling the module keeps it. Restoring access makes retained history readable again. The worker rechecks module activation, user/module permissions, tenant enablement and the frozen connector binding at claim and during execution. Revocation stops further processing. HTTP responses are private/no-store; prompts, replies and provider diagnostics are excluded from generic HTTP, SQL and worker telemetry. Brainstorming path protection remains in force for old browser requests after module disablement or tenant exclusion. Queue payloads contain only opaque run identities. Provider retention and privileged database/backups access remain outside this UI's ownership.

Polling reconciles expired running and stale queued attempts in bounded batches. For unattended recovery on an enabled tenant, run:

TENANT_ENV=k0smos.example.com php bin/console brainstorming:recover

Each pass handles at most 100 expired leases and 100 stale queued attempts and prints counts only. It also reconciles at most 100 saved workflows and can redispatch never-claimed attempts or enqueue a newly ready authorized step. Repeat for larger backlogs. It never invokes a CLI directly or replays an already claimed provider call. An already queued job arriving after recovery is harmless. The command appears only while Brainstorming is active; queued messages still resolve to a minimal handler after module disablement and fail safely.

The discussion runner currently requires Linux, POSIX process support and /usr/bin/setsid; other platforms refuse execution. SQL lease fencing prevents late writes, while the supervisor must terminate descendants after an uncatchable worker kill. Initial native verification used Codex 0.157.1 and Claude Code 2.1.283; the 2026-10-03 isolated queued chat and reviewed workflow/MCP acceptance also passes on Codex 0.160.0 and Claude Code 2.1.287. Revalidate the protocol after CLI upgrades. Shared inline controls support Default and Sober without requiring an asset rebuild for this panel.

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, pc2, and cc1 themes load Klaro locally from the npm package, without external CDNs.
  • The validated public registry rejects testing: true, executable callbacks, HTML copy, optional defaults/opt-out and hidden reject/customize controls. The admin preview validates data without executing it.
  • 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.
  • PC2 imports the canonical standalone Default consent helper only when configured, starts it immediately and queues footer requests through pending startup. Failed loads/setup announce a localized retryable state; shared provider watchers remain pending until the success-only readiness event. The theme retains its own entry and one Alpine instance. This does not change consent policy or managed markup.
  • All eight public entries share one consent lifecycle: await DOM/vendor setup, publish only a usable API, coalesce pending demand and show translated retry. Native footer buttons use compiled handlers and associated live status. DM1 gains its missing action; PC2/PC3 keep it when company legal identity is hidden. Permanent departure fences late work; restored cached pages reconcile saved boolean preferences before provider notification. Absent/invalid preferences stay denied without rewriting the saved record, while initialized controls can repair safe reconciliation errors. Pending LTS1 Play is cancelled on departure. Default/PC2/DM2/LTS1 retain lazy vendor imports; other themes retain eager ones. No theme imports another application entry or Alpine. Real BFCache admission, deployed bundles and full privacy qualification remain separate gates.

Validated configuration and rollout

The Default/Sober settings editor keeps the current operator text and an explicit JSON draft. Reading and previewing never saves. A successful preview of the unchanged draft enables save; conflict, authentication or network failures keep it. Refreshing the current revision preserves the draft and requires a new preview. Both POST routes require X-CSRF-Token scoped to klaro.settings, including requests with Authorization headers. Responses are private/no-store. Saves own one revision-fenced transaction for both settings and the field-name-only audit. Invalid drafts return 422, concurrent changes 409 and unavailable storage 503 with fixed localized feedback.

Each optional service declares a unique name, title, purposes, vendor name and HTTPS policy URL, plus exact cookie/localStorage/sessionStorage keys and integer lifetimes. This is illustrative metadata, not a configured or recommended vendor:

{
  "default": false,
  "services": [{
    "name": "example",
    "title": "Example optional service",
    "purposes": ["analytics"],
    "vendor": {"name": "Example vendor", "policyUrl": "https://vendor.example/privacy"},
    "storage": [{"type": "cookie", "key": "example_key", "lifetimeSeconds": 86400, "path": "/"}]
  }]
}

Input and canonical output are limited to 65,536 bytes, 64 services and 64 storage entries per service. Session storage lifetime is zero for the browser session. Other bounded integer lifetimes describe the reviewed inventory; they do not establish lawful retention or enforce vendor cleanup. Cookie names are exact, not regexes. The empty preset activates nothing and invents no inventory. Necessary sessions and purchase processing remain outside optional consent.

Unsupported legacy JavaScript stays stored for review but is never emitted publicly. All eight public shells receive only validated script-safe JSON. Missing, malformed, unmanaged or pending provider configuration cannot permit optional media. LTS1 still requires play; shared/DM1/DM2 watchers remove frames on withdrawal or service removal. Local films retain their independent behavior.

DM1's server markup always omits YouTube/Vimeo iframe src, independently of Klaro settings or legacy false/absent consent flags. Its compiled activation adapter always requires a configured boolean grant. CC1's Bebas/Font Awesome and PC1's Font Awesome are pinned local CSS-only entries; external head links and CC1 preconnects are removed in source. Deploy complete owner-built asset trees with those heads; old manifests lack the new entries. Default maps now require exact configured osm_leaflet/google_maps boolean permission before tiles/SDKs. Local coordinates, preferences and explicit retry remain; withdrawal/removal and late SDK work are fenced, and restoration awaits reconciled saved choices. Deploy owner-built Default assets with helper markup and the reviewed registry; actual provider/network/storage/BFCache acceptance remains separate. Predictive query/selection measurements now have server cookie and shared client permission gates while requested search stays usable. LocalStorage-to-server evidence, measurement retention, external proxy/provider logs and owner-built rollout remain open. Typed bounded local measurement delivery and early application diagnostic exclusion are source-tested. Authored Page/Widget HTML/CSS and supplied media URLs need their own inventory. Classify CAPTCHA form protection explicitly and preserve protected forms/checkout on optional refusal. The bounded inventory and source qualification live with the Consent owner; they do not establish deployed network acceptance or legal conformity.

Apply queued Base Version20261012200000 before the new save path; it inserts missing lock anchors without rewriting existing configuration. Coordinate reviewed JSON conversion and owner-built affected themes with the new public projection: old bundles still permitting unmanaged providers are incompatible with that rollout. No tenant migration, setting activation, theme compilation or provider request was performed for source delivery. Deployed shared lifecycle acceptance, network/font/storage inventory gating, consent evidence, accessible native provider acceptance and server/third-party cleanup remain open. The source owner is src/Application/Consent/AI.Consent.md.


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/OmniRoute/9router 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 configured token-envelope cost before traffic; gateway-side retries and cap translation can make upstream charges differ. 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)

The owned --checkout --payment-workers diagnostic qualifies 12 native SQL queued webhook boundary/recovery cases per engine. Real queues/heartbeats and the lazy production handler stop on retirement, preserving the reserved job and private received event. Fresh DI/SQL reclaims the genuinely stale original job, completes its second attempt and settles once. Full-row oracles preserve accepted facts. This isolated in-process path does not qualify default producer routing, CLI supervision, HTTP notify/providers or durable event delivery; Ecommerce records exact scope and evidence.

The owned --checkout --payment-deadlocks mode qualifies four constructed native cycles per engine: exact family blocking, real native deadlock, required parent victim and fresh recovery with the original capability. MySQL retires its handle when engine rollback loses nested savepoints; PostgreSQL preserves known rollback. Receipt, commercial/item/postal facts and stock stay intact until explicit fresh recovery. This is an intentionally reversed diagnostic contender against Payment publication; other production cycles, HTTP/SAPI/providers and durable post-commit event delivery remain separate. The Ecommerce owner records exact scope.

Payment publication uses a dedicated Sales payment-header update, writing only status, gateway and reference. Accepted purchase item IDs, related foreign-key records and commercial/postal snapshots survive Pending start, terminal return and replay. A complete Purchase save is reserved for explicit commercial writes. The owned native --checkout --payment-boundaries diagnostic exercises exact session termination before BEGIN/COMMIT and a controlled lost acknowledgement after a real successful COMMIT. Public return stops with fixed503 without SQL reopening or result exchange; independent readback and a fresh DI runtime recover the original signed capability without another provider start or stock movement. This does not establish real network reply loss, HTTP/SAPI, deadlocks, native webhook workers or durable post-commit event delivery. The Ecommerce owner records exact per-engine evidence and remaining gates.

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.

Native settlement coherence now has a typed Cart port and an Ecommerce SQL implementation. It checks receipt, current intent family, native financial snapshots and complete historical reservation/movement evidence. Preview is read-only; locking joins the owner's Cart transaction and retains the family and current evidence locks through its effects. Malformed, unresolved, inconsistent, multiple-intent and oversized dependencies defer. Reconfirmed catalog prices need not match historical Cart prices. Apply Cart and Ecommerce Version20261012180000 lookup indexes plus the Payment family-guard migration before use. Core Cart DI now selects only the runtime-active settlement owner. Cleanup minimizes coherent converted shopping after closed retention and retry horizons, preserving original recovery ID/token, currency, clocks and accepted financial facts. Runtime rollout and remote-SQL qualification remain pending.

Native creation and publication also serialize the complete purchase family, including an empty set, through retained payment_purchase_locks rows. Apply Payment Version20261012170000 before deploying the writers. The isolated withinPurchase() contract owns or joins local SQL; native creation participates intrinsically and preparation keeps the guard through signed token persistence. Multiple attempts remain supported. The new native settlement evaluator additionally locks/reads selected intents currently and checks coherent receipt/financial/inventory facts; the family guard alone freezes membership.

Native SQL publication now owns a short transaction through PaymentIntentRepositoryInterface::synchronized(): lock/reload the current intent before publishing Purchase/Order/Inventory on the same connection. Old callback snapshots cannot downgrade newer per-intent captured/refunded state. Storage/inventory failure rolls back local publication; accepted receipt recovery and unchanged CAS can repair it without another provider start. Unchanged purchase lines and advanced order status survive replay. Order status publication uses an atomic expected-status SQL update of only status/update time, leaving commercial snapshots and item IDs intact even when an administrator advances the order concurrently. Custom Order repositories implement that transitionStatus() contract. The shipped Ecommerce coordinator collects stock events, so order/payment/inventory events and failure notifications follow commit. Custom coordinators must implement deferred event collection. Provider I/O stays outside SQL; external delivery has no new durable exactly-once guarantee. Multiple intents and inconsistent receipt/order/hold/movement evidence remain reconciliation concerns, and protected shopping minimization uses that evidence while runtime rollout and remote/provider qualification remain separate.

Retail confirmation never trusts browser amounts. Current catalog, variant, currency, discounts, shipping and tax feed an expiring quote; stale confirmation has no purchase side effects. Accepted snapshots and retries keep the same amount and intent. Zero totals need no provider. Ambiguous starts retain stock until verified resolution; expired attempts require operator reconciliation. Selected owned saved shipping/billing recipient/postal/contact facts now enter the quote digest: sequential changes under the same address ID require renewed review before checkout effects. Alias/default preferences remain cosmetic. Internal resolved arrays stay out of command projections; validated flat native controls and the declared private address-review roots intentionally display the postal facts. JSON returns readiness/error codes without postal values and quote SQL remains hash-only; receipt-first replay does not reread current addresses. Acceptance now also captures immutable postal facts inside its existing transaction, including absent roles. Order detail and Invoice assembly prefer captures; only historical orders without capture retain live-address fallback. Core owns typed read transport and one active-offer selection key; Ecommerce owns append-only SQL and additive Version20261013120000. Apply it before deployment; keep the table during code rollback. No historical backfill or runtime tenant operation. Guest/registered checkout now supports new/saved/order-only edited shipping and same/new/saved/edit billing, once-only owned source loading, bounded draft recovery and complete postal review without JavaScript. Fresh current shipment-producing acceptance requires valid delivery before effects; historical receipt replay stays compatible. Twelve independent-process SQLite cases now qualify saved-address edits, deletion and ownership transfer before/during acceptance: stale/foreign sources refuse before effects, a writer is blocked during the live transaction, and later writes preserve both accepted postal captures and exact receipt replay. The module-owned selected reader now consumes complete current owned facts in ascending primary-key order, using FOR UPDATE inside MySQL/PostgreSQL transactions. Shared mode parsing ignores new/same IDs and keeps order-only edits owned; receipt replay still precedes address access. Twenty-four independent- process MySQL 8.4.11 cases qualify real reader/review/capture fencing, including old-snapshot visibility, selected write blocking and unrelated writable rows. A real reader timeout through DBAL transactional rolls back prior fixture writes; acceptance's retryable refusal requests renewed review before provider effects. The frozen selection passes 488 PHP cases / 9,343 assertions and five scoped production PHPStan paths. These checks do not qualify full MySQL/PostgreSQL checkout, install/upgrade/lifecycle, simultaneous authenticated HTTP or deployed deadlock/timeout recovery. Those gates, country rules, fiscal snapshots and reviewed retention/export/erasure remain open. The complete contract and rollout checklist are maintained in modules/Ecommerce/src/AI.Ecommerce.md#checkout-integrity.

HTTP flow:

GET /checkout         -> CheckoutController::handle()    - cart summary + gateway selection
POST /checkout        -> CheckoutController::initiate()  - preview/reconfirm; accept once, prepare intent, then start/resume provider
[gateway page]        -> customer pays
GET /checkout/payment/return/{gateway} -> PaymentController::return() - verifies token/completes provider return, 303 to session-owned result
GET /checkout/payment/done -> PaymentController::done() - exchanges legacy bearer URLs; clean session selector or bound receipt reads local 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.
  • Payment result URLs exchange before rendering into at most eight independent tenant/session selectors. Sessions retain only intent/purchase IDs, token digest and original signed expiry. Every view rechecks current SQL/hash/expiry; clean reload never calls the provider and retains recovery after transient SQL errors. Existing accepted checkout receipts keep their own session binding. Initial provider URLs still contain the signed capability; upstream/history qualification is separate and exchange does not renew expiry or revoke the original return.
  • Webhook dependencies resolve lazily inside payment.webhook privacy scopes; direct/inline retained-event processing is scoped too. Storage and exceptions use only payment_webhook_processing_failed, without a previous exception chain, so generic queue handling cannot retain provider error text. Ordinary tracing resumes afterward. Reset/Notification email own analogous producer/worker scopes, with safe fixed fallback alerts and one inline attempt per call. Queued-content retention, native sessions, remote SQL and deployed provider/ proxy logs retain separate qualification.
  • 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, but no feature module depends on the selected theme. GET|POST /api/settings/modules carry the revision and stale writes return HTTP 409. Each module row also carries theme_template_gap (theme, missing, total, templates, or null): the front page templates the selected public theme does not fork, which render with the standard module templates. It is advisory only and is rendered as a note line, with the page list as its title, in both module panels. Front theme choices carry the same data as template_gaps.
  • 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. Standalone manifest CSS entries join JS-associated stylesheets in the same deduplicated hash/raw/gzip checks. 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 its own Node component tests), and tests/Unit/Theme/FrontendBuildPolicyContractTest.php derives both rules from the canonical inventory.
  • Sober's shared admin controls/workspaces require a 100,000-byte raw CSS ceiling (93,136 raw / 16,508 gzip measured on 2026-09-27); the 20,000-byte gzip ceiling is unchanged. Its GrapesJS editor remains a separate dynamic dependency within the existing JS chunk budget. The measured budget rationale is recorded in Frontend delivery and verification.
  • 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'], applies immediately to Default's decorative margins/examples, and synchronizes across already-open same-origin tabs. It does not change the server preference or Sober workspace.
  • 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.