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.0development is open from 2026-10-11.0.35.0is 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
- 2. Technology Stack
- 3. Core Architecture
- 4. Multi-Tenancy
- 5. Module System
- 6. Theme System
- 7. Menu System
- 8. Error Handling
- 9. Queues And Jobs
- 10. AI And LLM Integration
- 11. Search Engine
- 12. Authentication And Locale
- 13. Environment Configuration
- 14. Developer Tools
- 16. Ecommerce Catalog
- 17. Psapi / PrestaShop Integration
- 18. Admin Settings And Permission APIs
- 19. Privacy And Cookie Consent (Klaro)
- 20. Key Namespaces
- 21. Key Patterns (Code Reference)
- 21.1 ModuleInterface
- 21.2 Route Definition
- 21.3 MigratableModuleInterface
- 21.4 SearchableModuleInterface
- 21.4.1 TemplateAwareModuleInterface
- 21.4.2 TemplateIntegrationModuleInterface
- 21.4.3 FrontViewDataProviderModuleInterface
- 21.5 Controller Pattern (ViewModel)
- 21.6 Module Menu Definition
- 21.7 Queue System
- 21.8 Data Fixtures
- 21.9 Error Throwing
- 21.10 AI Chat
- 21.11 AI Text Revision
- 21.12 Plates Template Syntax
- 21.13 Repository Pattern (DBAL)
- 22. Payment System (Native)
- 23. Module Runtime Control
- 24. Direct User Permissions
- 25. JWT And API Authentication
- 26. Theme Asset Rules
- 27. MCP Server (Model Context Protocol)
- 28. Multilingual Data Exchange
- 29. Documentation Publishing
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_ENVis set, it overrides the HTTPHostheader. - CLI runtime: if
TENANT_ENVis empty,bin/consoleuseslocalhost. - Normal multi-tenant HTTP deployments should leave
TENANT_ENVunset 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 tenantmodule_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/anonymizerPOST /api/admin/anonymizer/settingsPOST /api/admin/anonymizer/uploadGET /api/admin/anonymizer/statusGET /api/admin/anonymizer/historyGET /api/admin/anonymizer/entity-presetsGET /api/admin/anonymizer/debug-textGET /api/admin/anonymizer/downloadGET /api/admin/anonymizer/mappingPOST /api/admin/anonymizer/normalizePOST /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(defaultitin the UI)ocr_strategy: one of the runtime-exposed OCR strategies; omitted when the OCR selector is not shownretain_mapping: sent as"true"only when enabledentity_preset: explicit backend preset id orautodocument_type: backend category/alias orautoentities,custom_redact_list,custom_allow_list: JSON string arraysplaceholder_policy:deterministic_by_typeorlegacy_type_only
Standalone responses keep a normalized JSON payload with:
request_idrequested_engineanonymization_engineworkflow_modestatuscurrent_stepnext_stepwarningswarnings_countresult_pdf_availableresult_pdf_download_urlrequested_entity_presetrequested_document_typeresolved_entity_presetentity_preset_sourceeffective_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):
- Read
AI.md,agents/rules.md, and any local module/shared-service AI docs that apply. - Create
modules/YourModule/with thesrc/,templates/, and (optional)migrations/subtrees. - Add the PSR-4 autoload entry to
composer.jsonand runcomposer dump-autoload. - Implement
ModuleInterfaceand optional interfaces (TemplateAwareModuleInterfaceto expose templates,TemplateIntegrationModuleInterfacefor slot partials,MigratableModuleInterface,SearchableModuleInterface). - Declare the module in
modules/YourModule/src/Config/module.php(auto-discovered). - Define routes in
modules/YourModule/src/Routes/routes.php. - Implement the Domain layer (Entity, ValueObject, Repository interface).
- Implement Application Services as orchestration.
- Implement Infrastructure Repositories through DBAL.
- Implement Controllers that delegate to services and return
ViewModelorResponseInterface. - If the module has admin UI, define
src/Config/menu.phpwith permissions and translation keys. - If the module has admin UI, seed or migrate module permissions and admin grants.
- If the module owns persistent schema, add Doctrine migrations under
modules/YourModule/migrations/(namespaceApp\Migrations\Module\YourModule). - 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. - 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 classfor Value Objects - always use Dependency Injection; avoid
newinside 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@sourcescan 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 aDocumentationNavigationItemtree, never$body/$content;PageFrontControllersuppliespage.rendered_htmlandpage.rendered_css, neverpage.body;BlogPostViewDataFactory::forCard()suppliesslug, neverurl/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-selectandk-textarea(k-input--sm), andk-btnwithk-btn--primary,--ghost,--dangerand--sm. They are declared in each theme's foundation CSS and shipped inline throughadmin/_partials/_admin_controls_fallback_css, inside@layer components, so a per-instance utility such asw-28still applies. The retirederp-*classes existed only in default (sober forms lost their borders) anderp-btnin neither;erp-inputremains only as a storefront bridge. - Partials (default versions, which sober overrides in its own idiom or
inherits):
_table: columnsfield => labelor['field', 'label', 'html', 'align'];htmlcells are pre-escaped by the caller;_filters,_empty_stateand_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, replacingwindow.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, postingname[];.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 behaviourskAdminForm(async submit, field errors,k-savedevent),kIdPicker,kHtmlFieldandkCrudDrawer(table rows edited in ak-drawer). - Plates helpers
money(amount, currency),number(value, decimals)anddatetime(value, 'datetime'|'date'|'time')format for the current locale. _page_headeraccepts atrail(breadcrumb), abacklink and ametaslot (string or list) rendered as.k-page-meta. The slot is not calledbreadcrumbs: the kernel shares a route-derivedbreadcrumbsarray with every template, and a partial parameter with that name would render it on every page..k-workspacefills 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-tonedanger, warning, info or success,is-active),k-table-shell,k-table-scrollandk-table(th[aria-sort],is-num,is-muted,tr.is-selected,k-table-sub),k-bulkbar,k-drawer(a right-side modal<dialog>withk-drawer-header,-bodyand-footer),k-fieldandk-field-hint,k-segmented(buttons witharia-pressed),k-toast(data-tone),k-timelineandk-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
styleattributes only asvar(--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
Version20260927090000adds 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 (findByHookloads 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 viaparentId, and a group without visible children never renders. defaultEnabledis 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 fromConfig/menu.phpkept their former default.supersedeslists entries of other owners that start hidden while this entry exists. For example, the customer account replaces the backofficefront.login/footer.loginon shops. Like any default, it never overrides an operator's explicit choice.
FrontMenuEntryCatalog accepts a declaration only when:
- its route is a public page: registered, answering GET, outside
/admin,/kiosk,/apiand/preview, without_auth_required,_permission*or_route_type: apidefaults, and without required path parameters other than_locale; - its block type is registered for the
menucontext and the default payload validates; - its parent is an accepted group of the same channel (declared by any module);
- 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:
- Recursion: filter submenus first.
- Permission / feature on current node: evaluate parent access.
- Parent without access and without visible children: discard it.
- Parent without access but with visible children: keep it as a non-clickable group and remove only the route.
- Cleanup: remove items without route and without children.
- Sorting: by ascending
order, then alphabetically byid.
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 theintl_route variant when the locale is active, for example/it/blog. Internal custom paths (item_type: path) take the tenant locale prefix throughLocalizedUrlGenerator(§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 rooturl), 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-levelurl: 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]);
UserFeatureFlagCheckerdelegates toFeatureFlagResolver, which resolves global (per-tenant) flags — not per-user ones. The bound$useris 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
pendingrows with a futureavailable_at; no separate structure is needed. reserve()claims a job atomically with a conditionalUPDATE ... 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 (
failwhile attempts remain) requeue the row; exhausted attempts move it tostatus='failed'(dead-letter).reclaimStale()requeues rows stuck inreservedpast the timeout. - Repositories fail fast via
SchemaGuardwith aphp bin/console migrateinstruction 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_idmessage_classmessage_payloadcorrelation_idmax_attemptsdelay_secondsdispatched_atavailable_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:
InAppis synchronous and immediately persists intomodule_notificationsEmailis asynchronous and dispatchesSendNotificationEmailMessageon thenotificationsqueue/api/settings/notificationsexposesemail_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:
TicketAssignedTicketStatusChangedTicketCommentAddedProjectOwnerAssigned
Authenticated REST API:
GET /api/notificationsGET /api/notifications/unreadGET /api/notifications/preferencesPOST /api/notifications/preferencesPOST /api/notifications/read-allPOST /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
notificationdomain - template locale aligns with the user's
preferredLocalewhen available - notification email workers must consume the same queue stored in
notifications.channels.email.queue, for exampleTENANT_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, nottask1. - 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:
- Open
/admin/settings/ai, add an integration client of type OmniRoute (omniroute) or 9router (9router), and enable it. - 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,/v1bases and full/chat/completionsURLs are supported. - 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 toai.defaultor the relevant private scope. - For Discovery, save explicit models on
discovery.interpretand/ordiscovery.relations, then review their separate tariffs and positive quotas/budgets in Discovery settings.discovery.explainremains 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:
- Low-level backend (
SearchEngineInterface) - SQL / modern providers, tenant-configurable. - 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: trueverification_method: "totp"when the user has TOTP enabledverification_method: "email"when the user has no TOTP secretpending_idfor 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:
- URL prefix
/{_locale}for localized root or/{_locale}/pathfor normal intl routes, intercepted bySymfonyRoutingMiddlewarethroughintl: true; updates the session. - Query param
?lang=xx, validated, saved in session, then redirected without the param for GET requests. - Session value (
_localekey). - Tenant default (
i18n.default_localefrom the early DB snapshot, or legacy JSON while the key is absent).
Locale and menus:
frontchannel links use theintl_variant, producing locale-prefixed URLs such as/it/blog.backadmin 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).LocaleMiddlewareanswers 301 from an unprefixed public GET/HEAD path to/{default}/…when its route has anintl_variant. API routes,/admin,/api,/kiosk,/previewand asset paths, and file-like paths such as/manifest.webmanifestare 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:
- Per-request memory cache for the current
domain + localepair. - PSR-16 cache key for the override map.
module_translation_overridesrows for the current domain and locale.- 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
.envor.env.test - set
.env.localpermissions to600 - follow
doc/public/en/security/secret-management.mdbefore 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.ymlbuilds the runtime image fromdocker/frankenphp/Dockerfile- the host Git checkout is mounted into
/app - Composer dependencies must exist on the host before starting the web container
app.envsetsTENANT_ENV=app- Redis is managed by Compose as the
redisservice - Caddy listens on port 80 inside the container
- the host port defaults to
8080and can be changed withK0SMOS_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 withphpunit --group integration. - Tests must never run against production or runtime tenant databases.
phpunit.xml.distselectstests/Support/isolated-tenant-bootstrap.php, which derives a temporary tenant from the completelocalhostconfiguration, 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. Seedremains 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 testruns the configured PHPUnit suites.composer qa:testruns them with fresh JUnit XML and compact JSON reports and fails on an empty selection.composer ci:test:integrationselectstests/Integrationdirectly.
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_DSNis configured. - PHP DebugBar as a dev-only surface for request-local diagnostics.
- Clockwork as a dev-only browser profiler at
/clockwork, backed by/__clockworkmetadata. K0smos\Debug\DebugLoggeras a safe application adapter over DebugBar collectors.K0smos\Debug\HttpDebugTraceras 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:
DebugBarMiddlewareinjects the toolbar whenAPP_DEBUG=trueand the response istext/html.ClockworkMiddlewaredelegates to the optional Clockwork PSR-15 middleware only whenAPP_DEBUG=true; otherwise it is a no-op and/clockworkis not exposed.- The same middleware can attach AJAX DebugBar headers to traceable JSON responses.
Routerautomatically instruments every controller invocation with route, controller, method, timing, and result metadata.HttpDebugTracerfeeds a dedicated DebugBar panel with sanitizedrequest,dispatch,result,response, and exception snapshots.- Clockwork receives the same sanitized lifecycle snapshots under a
k0smosuser-data section and shows Monolog entries plus DBAL queries when available. Kernelcompletes tracing by recording the final renderedresponse, 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
DebugBarin 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
DebugBaror Clockwork into controllers only for generic tracing - use Monolog for persistent logs,
DebugLoggerfor dev request-local diagnostics, andHttpDebugTracerfor 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:
- Provision a GlitchTip project and copy its DSN.
- Set
GLITCHTIP_DSNthrough.env.local, the process environment, or the service manager environment; never store it in a versioned file. SetGLITCHTIP_REPORT_4XXonly when handled 4xx responses must be reported. - Start k0smos in an isolated environment with
APP_ENV=productionandAPP_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_DSNin.env.local,app.env, or the service manager environment, never in committed.env - run with
APP_ENV=productionandAPP_DEBUG=false - trigger a controlled non-public 500 and confirm the event arrives with
environment,release, stack trace, andk0smos.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
errorrecord carrying anexceptioncontext 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.neonfor PHPStan static analysis with strict rules and cache invar/cache/phpstanphpstan-tests.neonfor a separate level-5 gate over 40 selected unit-test files, with cache invar/cache/phpstan-tests.php-cs-fixer.dist.phpfor@PER-CS2.0style withstrict_typesand explicit safe/risky fixesrector.phpfor advisory dry-run refactors onsrc/phpunit.xml.distfor PHPUnit suites, diagnostics and the isolated tenant bootstrapagents/qa-review.mdfor the agent workflow that reads reports and proposes fixes
Generated reports:
reports/phpstan.jsonreports/phpstan-tests.jsonreports/cs-fixer.jsonreports/rector.jsonreports/metrics.jsonreports/metrics/(HTML)reports/phpunit.xml(native PHPUnit JUnit output)reports/phpunit.json(summary and test-case details)reports/admin-sidebar-latency-*.jsonandreports/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.cachevar/cache/phpstan/var/cache/phpstan-tests/var/.phpunit.cache/reports/remains gitignored except forreports/.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:
0errors at configured level — not currently met: the 2026-08-24 baseline reports 485 findings onsrc - PHPStan test scope:
0errors across 40 files — met on 2026-09-14; the fulltests/tree has 288 level-5 findings and is not in this gate - PHP-CS-Fixer dry-run:
0violations — not currently met: 730 files would be rewritten - PHPUnit: a non-empty selection,
0failing 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.shmeasures 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.phpcreates the throwaway tenants and SQLite file it needs — it refuses any database path outsidevar/db/benchmark-*or the system temp directory — andphp-server-router.phpis thephp -Sshim that serves built assets, never a deployment target.admin-sidebar-report.mjsrenders two runs into the evidence tables ofdoc/public/en/quality/admin-sidebar-performance.md;admin-sidebar-screenshots.mjscaptures 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:
bootstrap.phptypo fix — correctsuse PHPParser\Node→use PhpParser\Nodeso rector's "already loaded?" guard evaluates correctly.preload.phpguard — inserts aninterface_exists(\PhpParser\NodeVisitor::class, false)early-return before rector's bundled php-parserrequire_oncecalls. Without this guard,phpmetrics/phpmetrics(autoloaded viaautoload_files) loads the project'snikic/php-parserfirst, 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 listshows different commands per tenant. A lite tenant without Ecommerce has noecommerce:*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'sservices.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-allrunning 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 infixtures.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/productsperforms DB-level filtering, stable sorting, and pagination throughProductCatalogQueryInterface./admin/catalog/products/createand/admin/catalog/products/{id}/edituse a server-rendered PRG editor;/admin/catalog/products/{id}/variantsowns variant management./admin/catalog/categoriesplus 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-storeand 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);/adminroutes 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 byecommerce.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=1and/{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 staffecommerce.customersin addition to page access throughecommerce.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 withecommerce.view, save with that permission plussystem.writeorecommerce.settingsandcart.lifecycleCSRF. 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
(
outat available ≤ 0,lowup 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-settingsand is visible only to users with theadminrole - 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
adminrole - password and email changes require the current password first, then TOTP or email OTP when tenant bootstrap policy
features.require_change_verificationis 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 effectiveusers.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 throughhtml[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) andk0smos.adminContentMaxWidth, applied by theadminlayout throughhtml[data-k0smos-admin-content]and--k-admin-content-max-width; it is available in/admin/theme/default/settingsand in the topbar quick toggle - background pattern is exposed only by implementing modules (
default) and remains browser-only withk0smos.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.densityandk0smos.sober.sidebarCollapsed - the shared visual editor engine (
quillortiptap) is a theme technical preference saved only in the browser withk0smos.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_treelists the active root categories with their subcategories (depth 1–3, 1–60 nodes filled breadth-first, position order).ecommerce.category_childrenlists 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_productsshows the newest active products, optionally within one category and its active descendants (category_id0 means the whole catalog).ecommerce.on_saleshows 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, andsettings/edituse 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
settingscatalogue; 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
debugblock 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 summaryinfo()loads users with roles, roles with permissions, runtime modules, features, and permissions in a read-only tableapp()exposes the runtime module application page rendered at/admin/settings/appedit()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-flagssubpage; flags are read/written throughFeatureFlagsSettingsApiController(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:
Settingsis a static non-clickable sidebar group owned byFront- 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), andSystem info(/admin/settings/info) Translationis contributed by theTranslationruntime module under the sharedsettingsroot- 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 pageinfo.tpl.php- dynamic ACL/runtime inventory with live DB dataedit.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, andwidgetare 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
modulesarray, UI and API show it as tenant-blocked show_legal_footercontrols visibility of the company legal metadata block in public footers; policy links and the Klaro trigger remain route-driven and tenant-awareadmin_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
- 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.phpis a portable insert-if-missing example with typed parameters. - If the flag must stay enabled even when its row is missing (fresh installs, DB errors), add it to
FeatureFlagDefaults::DEFAULTS. - Consume it: read
featureFlags['my_flag']in a template, set'feature' => 'my_flag'on a menu item, or setfeatureFlag: 'my_flag'on aTemplateIntegration.
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:
- 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 inDbalUserRepositoryintoUser::$enabledFeatures(its reserved carrier). - 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). - 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) readsCODEX_API_KEYfor Codex orCLAUDE_CODE_OAUTH_TOKENfor Claude from the server environment. The form does not accept or store these provider tokens. They are separate fromK0SMOS_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:
- Apply the tenant migrations, then enable Brainstorming in runtime module
settings. The tenant's module inventory must include
brainstorming. - Grant
brainstorming.viewto read the panel andbrainstorming.chatto create, rename, delete or stop the user's own conversations. Sending and retrying also requireagent.run. The migration grants the two new permissions to the existing admin role without replacing custom grants. - 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 resolvecodexorclaudeon its ownPATH. - Provision native CLI authentication under the worker's OS identity and
HOME, or supply the chosen official token in its service environment. Checkcodex login statusandclaude auth status --jsonunder that identity; the panel's version check alone does not establish authentication. - Run a
defaultqueue worker with a reclaim interval longer than the 120-second maximum discussion execution, for examplequeue:work --queue=default --reclaim-after=180. Keep whole-service child cleanup enabled, such as systemdKillMode=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_permissionstable - Storage:
user_enabled_modulestable - 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
adminrole. users.writealone 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
ONfor all runtime modules until an override is explicitly saved inuser_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)
- tenant JSON module inventory (
- 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,
UserModuleAccessPolicystill denies permissions for a module disabled for that user. - Non-runtime modules such as
systemandusersremain 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.readandroles.writeare dedicated permissions in the baserolesmodule.roles.readenables menu, dashboard, and role list;roles.writeenables 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, andmanagersystem 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
409when another request changed the role after the page was loaded - the
adminrole 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.writebut without theadminrole cannot assign or remove theadminrole from other accounts - an account cannot remove the last
adminrole 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-/admindashboard: shortcuts + system/operational summaryAdminSettingsController.php-/admin/settings(view/info/edit)FeatureFlagsSettingsApiController.php- REST/api/settings/featuresPermissionApiController.php- REST/api/permissions(CRUD)AdminSearchController.php/FrontSearchController.php- admin/front search
Search sources in src/Infrastructure/Search/:
BlogSearchSource-frontcontext,module_blogtableProductSearchSource-admincontext, delegates toSearchEngineInterface
Search contracts in src/Domain/Search/:
SearchSourceInterface.php- contract for pluggable sourcesSearchEngineInterface.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
19. Privacy And Cookie Consent (Klaro)
- Klaro is configured per tenant through
AppSettingskeysklaro.enabledandklaro.config. - Frontend bundles for
default,pc1,pc2, andcc1themes 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
Infomodule with canonical routes/info/privacy-policy,/info/cookie-policy, and/info/terms-and-conditions;/privacy-policyand/cookie-policyaliases remain available. - Base
Infoseeds prepare starter content forprivacy-policy,cookie-policy, andterms-and-conditionsinit/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 useshow_legal_footerto show or hide the company legal block. - cc1 renders
company.contact_emailthrough its server-side obfuscation partial (@→[at],.→[dot]) on every contact surface; it emits neither the clear address nor amailto: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:
SearchableModuleInterfaceis for business/content search sources exposed by a module./admin/searchalso 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;SearchEngineFactorydepends on a provider registry, not concrete Elasticsearch classes. - Search backend failover: if
search.engine=modernbut the external backend is disabled or not effectively configured,SearchEngineFactoryimmediately 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 allTemplateAwareModuleInterfacemodules, keyed by side; non-existent directories are silently dropped.ThemeEngineFactoryautoloads the activeModuleRegistryand injects the side-specific list intoThemeEngine.- Co-located modules typically declare
__DIR__ . '/../templates/admin'(relative tomodules/{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:
TemplateIntegrationis a readonly value object withslot,template,side(admin/front/shared),priority(default 100, ascending order), optionalpermission, optionalfeatureFlag,requiredflag, andcontextarray.- Contributions are aggregated by
ModuleRegistry::collectTemplateIntegrations(), indexed per slot byTemplateIntegrationRegistry(DI:RenderingDefinitions), and rendered throughTemplateSlotRendererwhich honors ACL/feature-flag gating via injected resolver callables. - Templates render a slot with the
renderSlot($slot, $params = [], $side = 'front')Plates helper (registered inPlatesHelperRegistry, built per request with the themed engine and the same ACL/feature-flag resolvers asmenu_hook()). It returns''when no module contributes, so callers can wrap conditionally. Reference call site:template/default/tpl/front.tpl.phprendersfront.footer.widgetsin the footer. Other front themes must add the samerenderSlot()call to surface footer slots. - Missing templates are silently skipped in production unless
required: trueor debug mode is active, in which caseTemplateSlotRendererthrows. - Prefer slot contributions over patching foreign templates: the owner exposes stable extension points, other modules contribute without coupling.
- Reference implementation: the
OneUptimeApimodule contributes a public status widget tofront.footer.widgets; its real gating lives in the public proxy routeGET /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.RenderingDefinitionsresolves them from DI intoFrontViewDataProviderRegistry.SpaControllermerges the registry payload into the public ViewModel. Thetenantandinitialkeys 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:
BlogFrontViewDataProviderexposes the three latest published posts asblogFeed, using the sameBlogPostViewDataFactoryas 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
richTextEditorbridge (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-modeplusdata-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_eventsbefore processing, then handled by the typed queue messageProcessWebhookEvent. - 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.webhookprivacy scopes; direct/inline retained-event processing is scoped too. Storage and exceptions use onlypayment_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/Paymenthas 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, timestampspayment_attempts: append-only provider operation log for start, return, and webhook processing with sanitized payload JSONpayment_webhook_events: retained raw webhook payloads/headers, event id, processing status, failure reason, timestamps- Added
purchasescolumns:payment_status VARCHAR(32) DEFAULT 'pending',payment_gateway VARCHAR(64) NULL,payment_reference VARCHAR(255) NULL,client_id INTEGER NULL(optional logical FK tomodule_clients.idin 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
paymentblock; 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()(keypayment.configured) flips to1on first save; afterwards tenant JSON gateway credentials are ignored. token_secretno longer needs a JSON entry:PaymentDefinitionsfalls back to theK0SMOS_PAYMENT_TOKEN_SECRETenv 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 insidePaymentSettings. - The offline/mock
manual_testmethod 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 exposehas_*booleans; clear text is never returned. - Saves preserve the stored secret when the incoming value is blank or
***, and clear it only through an explicitclear_<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\ModuleCatalogis 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
modulesarray; per-module technical settings belong inmodule_config. - Runtime state is persisted per tenant in
enabled_modules, but DB toggles are always limited by the tenant module inventory and mandatory modules. ModuleBootstrapperis used by HTTP, CLI, queue and cross-tenant commands to resolve the DB theme/default-locale overlay and active modules as one snapshot beforeContainerFactory->init(...).ActiveModuleResolvercomputes:- 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
ModuleManagementServiceexposestenant_enabled,requested_enabled, andeffective_enabled; a module blocked by tenant JSON cannot be re-enabled by DB/UI.- Theme/module writes share the
theme.selectionrevision guard; the selected wrappers and dependencies cannot be disabled, but no feature module depends on the selected theme.GET|POST /api/settings/modulescarry the revision and stale writes return HTTP 409. Each module row also carriestheme_template_gap(theme,missing,total,templates, ornull): 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 astemplate_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 throughModuleSettingsApiController(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. /adminis 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_permissionsand managed from/admin/users/{id}. - Per-user runtime module access is stored in
user_enabled_modulesand managed from/admin/users/{id}viaPOST /api/admin/users/{id}/modules. - Only users with role code
admincan grant/revoke direct permissions or user module access;users.writealone 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 byLoginController::submit(). - JWT TTL: 28800 s, configured in
SecurityDefinitions. StrictValidAtis always enforced; expiry/nbf/iat are verified on every request.JwtAuthMiddlewareresolves auth in this order: Bearer JWT -> Bearer personal API token (only when_route_type=api) -> JWT cookiek_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_tokenstable: multiple named tokens per user with an optionalscopesallow-list (permission codes; NULL = full authority),expires_at, and revocation. Managed viaApiTokenServiceand/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 returnsUser::withScopedPermissions(userPerms ∩ scope)(roles dropped) so — because every voter readspermissionCodes()— 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). Seesrc/App/Auth/AI.Auth.md.
- Legacy PAT — one full-authority token per user on
- 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 throughuser_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.phploads thesoberVite bundle and is admin-only; keeptheme.frontondefaultor another public theme whentheme.adminissober.- 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-visibleandk0smos:admin-sidebar-interactivemarks plus thek0smos:admin-sidebar-readymeasure.admin-sidebar-first-visibleresolves 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 CSSvisibilitybefore hydration and runtimeinert/aria-hiddenafterwards. The docked desktop rail is never marked hidden, and server markup never shipsinert/aria-hiddenso a failed bundle cannot strand the navigation.prefers-reduced-motionsuppresses 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/sharednever import npm packages themselves: the theme injects them (for examplebootstrapProductGallery({ loadViewer })), so dependencies resolve from the theme's ownnode_modulesand 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'sAI.theme.md(PhotoSwipe 5.4.4, MIT, indefaultandlts1). - The
Storagemodule is the current reference:storageManageris registered beforeAlpine.start(), whileElFinderis loaded afterward and must never block sidebar or admin layout. - Every
npm run buildcommand emits a Vite manifest and deterministic build metadata, then callsscripts/check-theme-asset-budget.mjs; the rootnpm run build:themescommand runs that same fail-fast contract for all nine supported themes.config/quality/frontend-asset-budgets.jsonowns 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) auditspublic/buildas a whole: a supported theme that was never built, a directory owned by a retired theme, a missing manifest orbuild-metadata.json, a surviving unhashedjs/app.jsorcss/app.css, a published.map, or metadata naming another theme all fail the release gate. It is the final step ofnpm run deploy:themes. The root aggregates enumerate every supported theme,test:themesenumerates exactly the themes that declare a test script (lts1declares its own Node component tests), andtests/Unit/Theme/FrontendBuildPolicyContractTest.phpderives 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 thedefaulttheme; 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-settingsthroughlocalStorage['k0smos.tooltips']and applied tofrontandadminlayouts throughhtml[data-k0smos-tooltips]. - Non-default admin themes that render fallback default/module templates, such as
sober, must provide.k-tooltip-*CSS, restorehtml[data-k0smos-tooltips]fromlocalStorage['k0smos.tooltips'], and register AlpinetooltipButton()beforeAlpine.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.
defaultandsoberconsumetemplate/shared/asset/js/tooltip.js: Alpine teleports panels tobody, 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-descand panel descriptions carry.k-panel-desc(emitted byAdminPanelExtension::panelHeader()). Text-lean admin themes (sober) hide both withdisplay:none, so every element carrying either class MUST have a siblingtooltip()with equivalent content — the description is never the sole carrier of information. Keep inline descriptions to one production-tone sentence. - The
tooltip()helper (TooltipExtension) andAdminPanelExtensionare translator-aware: their own labels (trigger accessible name, debug headingsRoute/Permissions/Note/Notes, permission descriptions, boolean values, save-permission notice) resolve throughtooltip.*/admin.panel.save_permission_requiredkeys intranslation/messages.{it,en}.phpwith English fallbacks. Callers still pass their owncontent/label/titlealready 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 withroutes/permissionsdebug payloads. - User theme preferences (
theme_palette,theme_mode) are persisted server-side and serialized intofrontandadminlayouts ashtml[data-palette][data-mode]; the background pattern remains client-side inlocalStorage['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.phpexposes a shared.admin-content-shellcontainer: inadaptivemode it follows localk0smos.adminContentMode/k0smos.adminContentMaxWidth, but individual templates can forcecontentContainerMode => 'fluid'|'fixed'when UX requires it.template/sober/tpl/admin.tpl.phpexposes.sober-content-shellwith the samedata-admin-content-modecontract and keeps.sober-topbar-actionspinned to the top-right edge; user and notification popovers open right-aligned.- Avoid unnecessary desktop
transformon 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 towidth: 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/mcpreturningapplication/json. No SSE, no sessions.GET/DELETEare 405 (POST-only route). - JSON-RPC 2.0. Methods:
initialize,ping,tools/list,tools/call, andnotifications/*(answered with HTTP 202, no body). Batches are rejected (-32600); malformed JSON is-32700(HTTP 400). - Protocol-version negotiation with baseline
2025-11-25and compatibility for2025-06-18,2025-03-26, and2024-11-05; capabilities{"tools": {"listChanged": false}};serverInfo.name = k0smos.
27.2 Module Opt-In
- A module implements
McpToolProviderModuleInterface::getMcpToolProviderClasses()returning provider classes (McpToolProviderInterface), each bundlingMcpToolInterfacetools. McpToolRegistryis assembled from the activeModuleRegistryonly, so tools of inactive modules (tenant-JSON allowlist,enabled_modulestoggle, 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
McpToolMetadataInterfaceto enrichtools/listwith 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.mdaligned, 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 successfulstructuredContentfromtools/call; list/detail/report envelopes are explicit while rich module-owned objects may keepadditionalProperties._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/domainand_meta.k0smos/resultShape: module-owned grouping hints for diagnostics and client presentation.
27.3 Gating (all must pass)
- Kill switch —
mcp.enabledAppSetting (DB-backed, default off). Off → HTTP 403. No tenant JSON key. Toggled from the admin panelGET /admin/settings/mcp(GET/POST /api/settings/mcp,system.read/system.write). - Endpoint ACL — route
_auth_required+_permission = mcp.use, over bearer PAT or JWT. - Active-module filtering — via
ModuleRegistry(§27.2). - Per-tool ACL — each tool's
requiredPermission();tools/listhides ungranted tools,tools/callon 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/mcpand configure Claude Code with--header "Authorization: Bearer k0s_pat_...". - OAuth: configure Claude Code as an HTTP MCP server with an
oauthobject. k0smos supports public DCR, PKCES256, metadata discovery through/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-server, and loopback HTTP redirect URIs such ashttp://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 overProjectServiceand 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 reusefinance.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.useand the domain permission its/admincounterpart requires.AuthorizationMiddlewareenforces this through_permission_all, a conjunctive list that adds to_permissionrather 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, andKioskCapabilityCatalogaccepts it only when the route is registered, is a backoffice route (/adminor/kiosk), really enforces every permission the declaration names — read from_permission,_permission_alland_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 byrejected()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 areGET-only. kiosk.useis a gate, not a grant: alone it opens the shell and the account page and nothing else. Thekioskrole preset adds the minimum website codes and deliberately excludessystem.write, the single code that unlocks every tenant-wide settings page. Instead the settings routes accept delegated alternatives:settings.companyandsettings.front_contenteach cover one public group,/api/settings/companyand/api/settings/front-contentaccept either them orsystem.writethrough_permission_any, and the preset carries both. Each/kiosk/sitepanel 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-derivedviewer/contributor/managerpresets,kioskis an explicit code allowlist; it grantsblog.deleteandmedia.deletebut withholdspage.deleteandinfo.delete, because pages are structural and information pages carry the legal copy./kioskis the second path prefixSideDetectionMiddlewareresolves 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 sluggedkiosknotstays public. Kiosk routes outrank the Page/{slug}catch-all.- The kiosk layout is a separate small shell under
template/default/tpl/admin/kiosk/, whichsoberscans 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
backmenu item under Tools, gated bykiosk.use. - Confinement is opt-in. A role marked
kiosk_confinedkeeps its holders inside the surface: any other backoffice path redirects to/kiosk, an API call answers 403. It changes no permission —KioskConfinementMiddlewareruns afterAuthorizationMiddleware, 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 requirekiosk.useto 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/DbalAuditedSettingsWriterto commit settings and asettings.updateaudit 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.