k0smos - Complete Documentation
Modular PHP framework for web applications and AI-ready platforms, based on PSR standards and selected Symfony components.
Version status:
0.30.0is the current completed release, opened on 2026-09-15 and closed on 2026-09-26. Deployment limits and verification are in the root changelog.
Table Of Contents
- 1. Vision
- 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) | Elasticsearch |
| Logging | Monolog |
| Templating | PHP Plates |
| AI / LLM | Native HTTP adapters (Ollama, OpenAI Responses API, Anthropic Messages API, OpenRouter, NVIDIA NIM, AtlasCloud, Kimi/Moonshot, SenseNova, Google AI) via Guzzle |
| Debug | PHP DebugBar + K0smos\Debug\DebugLogger |
3. Core Architecture
3.1 Request Flow
public/index.php -> resolves the tenant from the host -> builds the DI
container -> Kernel::handle() -> middleware pipeline -> router/controller ->
ViewModel/ResponseInterface/scalar -> emitter.
Controllers return a ViewModel (rendered through PlatesEngine), a
ResponseInterface (passed through directly), or a scalar value (wrapped in a
200 response). The Kernel always owns final emission.
After Symfony routing, K0smos\Router\Router resolves the controller from the
container and invokes it. During this step it automatically records route,
controller, method, execution time, and result metadata in DebugBar through
K0smos\Debug\DebugLogger, without requiring base controllers to depend on
DebugBar.
3.2 Dependency Injection
The DI system is based on PHP-DI with Reflection autowiring, explicit definitions, interface bindings, custom factories, production container compilation, and PSR-16 container cache.
K0smos\Container\ContainerFactory is the canonical bootstrap entry point. It
loads named providers from src/Container/Definition and checks duplicate
bindings before registration. Each binding has one owner; cross-module
extensions use explicit registry contracts. ContainerCore remains as a
compatibility facade for older tests and integration code during the refactor.
DI key ownership. Every key has exactly one owner. Module service
definitions are merged into a single array and then merged over the core
groups, so a duplicated key resolves by silent last-writer-wins in module
order rather than by an error. A module therefore never rebinds a core key —
it extends core capabilities through an opt-in contract collected from
ModuleRegistry — and two modules never declare the same key. Wiring for a
class that lives under src/ belongs to the core definition group that owns its
bounded context, so classes shared by several modules are core-owned:
CartDefinitions (cart aggregate and DBAL persistence, used by cart and
ecommerce), ProjectDefinitions (project recap read model and tracking, used
by project and calendar), TicketDefinitions (assistance-contract overview,
used by ticket and project), and the shared hero contract in
RenderingDefinitions (used by every theme). A module's services.php keeps
only what the module itself owns. tests/Unit/Container/DefinitionGroupCollisionTest.php
enforces both directions.
Because PHP-DI's reflection autowiring skips optional (default-valued)
constructor parameters, such dependencies are wired explicitly with
->constructorParameter(...); an object literal default additionally makes the
entry uncompilable under enableCompilation().
Tenant-safe compilation. Compilation is disabled unless the tenant enables
compilation.enabled and leaves compilation.container enabled. The generated
class is tenant- and active-module-scoped, but it intentionally contains no
resolved tenant configuration and no module objects: Tenant, ModuleRegistry,
and app.modules are compile-safe placeholders that ContainerFactory replaces
with request-runtime values immediately after build(). This keeps database,
integration, and other environment-resolved credentials out of generated PHP
and prevents a warm class from retaining an earlier configuration snapshot.
The container cache directory carries a format marker and lock. When the format
version changes, ContainerFactory removes legacy CompiledContainer_*.php
files before producing the new class; concurrent cold boots use atomic PHP-DI
writes. Deployments should additionally run cache:clear --type=container and
then cache:warmup after old application processes have stopped, so a rolling
deployment cannot recreate an artifact using previous code.
3.3 Middleware Pipeline
The PSR-15 middleware pipeline processes every request in order. Each middleware can modify the request/response or stop the pipeline.
| Order | Middleware | Purpose |
|---|---|---|
| 1 | RequestIdMiddleware | Incident tracking with a unique request ID |
| 2 | LoggingMiddleware | Request/response logging |
| 3 | DebugBarMiddleware | HTML toolbar and AJAX DebugBar headers, dev only |
| 4 | SideDetectionMiddleware | Detects front/admin side from the URL path |
| 5 | TenantResolverMiddleware | Tenant context + RenderProfileContext, using _side |
| 6 | SessionMiddleware | Starts the PHP session |
| 7 | SettingsMiddleware | Loads application settings |
| 8 | TrailingSlashMiddleware | Normalizes trailing slash before routing |
| 9 | SymfonyRoutingMiddleware | Matches the route; sets _route, _locale for intl routes, and _route_params |
| 10 | LocaleMiddleware | Resolves active locale: URL prefix -> session -> tenant default; persists it in session |
| 11 | JwtAuthMiddleware | Validates k_token cookie or Authorization: Bearer; _route_type=api also accepts personal API tokens; sets User::class on the request |
| 12 | AuthMiddleware | Redirects protected routes to /login when no authenticated user exists |
| 13 | BreadcrumbMiddleware | Resolves breadcrumb trail |
| 14 | AuthorizationMiddleware | Role-based access control (ACL + PolicyVoter) |
4. Multi-Tenancy
4.1 Tenant Resolution
flowchart TD
A[HTTP Request] --> B{TENANT_ENV env?}
B -->|Set| C[Use TENANT_ENV as host]
B -->|Not set| D[Extract host from HTTP request]
C --> E[Load config/tenants/host.json]
D --> E
E --> E2{alias_of field?}
E2 -->|Yes| E3[Load config/tenants/canonical.json]
E2 -->|No| F
E3 --> E4{redirect_to_canonical true and HTTP host?}
E4 -->|Yes| R[301 Location to canonical host]
E4 -->|No| F
F[TenantResolver.resolveFromHost] --> G[Tenant object — host = canonical]
G --> B1[Read theme and default locale from tenant DB]
B1 --> B2[Resolve active module snapshot]
B2 --> H[ContainerFactory.init with effective tenant and modules]
H --> I[Kernel.handle with TenantContext]
TenantContext affects routing, services, configuration, and the search engine
per tenant.
4.2 Tenant Resolution Inputs
In local development or single-tenant deployments, TENANT_ENV can pin the
active tenant for both web and CLI runtime.
- Web runtime: if
TENANT_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",
"provider": "elasticsearch",
"sql": { "table": "products" }
},
"modules": ["front", "media", "ai", "documentation", "theme_default"],
"module_config": {
"queue": {
"redis": { "host": "127.0.0.1", "port": 6379, "db": 0, "prefix": "k0smos:queue" },
"max_attempts": 3,
"worker": {
"slot": 1,
"reserve_timeout": 30,
"sleep": 3,
"max_idle": 300,
"max_jobs": 100,
"reclaim_after": 600,
"reclaim_interval": 60
}
},
"ai": {
"advanced": {
"queue": "default",
"worker_heartbeat_max_age_seconds": 180,
"python": {
"binary": "python/.venv/bin/python",
"script": "python/ai_worker.py",
"working_directory": "python",
"timeout_seconds": 120
}
},
"redis": { "host": "127.0.0.1", "port": 6379 }
}
},
"features": {
"require_change_verification": true
}
}
The top-level modules array defines the module inventory visible to the
software for the tenant. module_config is limited to classified technical,
deployment, and immutable operator configuration such as queue/AI worker
topology and safety limits. Backoffice-editable Psapi, Wpapi, Esapi, and Google
integration settings are DB-backed and tenant JSON is not a runtime fallback.
Run tenant:config:audit to reject unclassified keys and see
tenant configuration for migration and precedence.
An installed tenant may still carry theme.front, theme.admin,
i18n.default_locale and i18n.documentation_locale as read-only migration
fallbacks. A DB row wins; remove those JSON fields after per-tenant backfill and
readback. All seven configured tenant JSON files now omit these editable
fields after readback or proven default equivalence. New installs can omit
them. The optional compiled JSON cache remains raw and invalidates on a
source-content digest, including same-second edits;
it never stores the DB overlay.
The bundled lite integration tenants have no theme wrappers or Documentation
module; their default theme pair and English Documentation fallback can be
omitted without a DB theme aggregate.
The top-level features block is reserved for bootstrap policies that must be
resolved before DB-backed settings and feature flags load. Runtime feature flags
belong in enabled_features and are managed from /admin/settings/feature-flags,
not in tenant JSON. features.require_change_verification defaults to true
when omitted and requires a second verification step for self-service password
and email changes. Development tenants can set it to false to keep those
changes immediate when mail/TOTP verification is intentionally not part of the
local workflow. Maps provider/default viewport settings and Google Maps keys are
DB-backed AppSettings values managed from the Maps/Client settings panels;
tenant JSON must not include module_config.maps.
Configuration values are classified before storage: bootstrap/environment,
deployable application configuration, tenant-editable settings, credentials,
and runtime state are separate lifecycles. New tenant settings use the typed
schema under src/Application/Settings; runtime cursors, jobs, and health
timestamps use migration-backed repositories and are never another settings
layer. The accepted decision, layer precedence, OneUptime pilot, and rejected
core-taxonomy review are recorded in
ADR 0001. Any new core abstraction must pass the two-consumer
core promotion review.
Tenant databases permanently support:
- SQLite
- PostgreSQL / Supabase
- MySQL
- MariaDB
SQLite connections created by ConnectionFactory enable foreign keys, set a
5-second busy_timeout, and open DBAL transactions with BEGIN IMMEDIATE
through Database\Sqlite\ImmediateTransactionMiddleware. Queue workers write
while imports and requests commit. A deferred transaction that has already read
fails immediately with "database is locked" when it later tries to write,
because SQLite does not run the busy handler in that case. Taking the write lock
at BEGIN lets concurrent writers wait their turn instead.
Values in the database block can use ${ENV} and ${ENV:-default}
placeholders. This keeps the tenant file authoritative while cloud credentials
and DSNs can live in .env.local or app.env.
Some subsystems have built-in defaults, and a few services also support env
fallbacks such as MAILER_DSN; queue transports and tenant databases are
primarily driven by tenant JSON, not env variables.
Current convention:
modules: complete module inventory visible to the software for this 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.
Route attributes:
$defs[] = [
'name' => 'my_route',
'path' => '/my-path',
'defaults' => [
'_controller' => MyController::class,
'_theme' => 'front|admin|utility',
'_template_group' => 'group_name',
'_route_type' => 'html|api',
],
];
6.2 Template Organization
template/
├── default/tpl/ # Tailwind CSS 4 + Alpine.js 3 - canonical full theme
│ ├── front/ # Public pages
│ ├── admin/ # Admin dashboard (+ admin/ai/, admin/modbus.tpl.php)
│ ├── auth/ # Login/auth
│ ├── spa/ # Main SPA template
│ ├── invoices/ # Invoice management
│ ├── phpdoc/ # Documentation viewer
│ └── debug/ # Debug utilities
├── sober/tpl/ # Admin-first theme: admin layout, dashboard, partials
└── ...
Templates use PHP Plates with layout/view/partial separation. Icon handling uses naming conventions and automatic logging of missing icons through Monolog/DebugBar.
The canonical default public navigation keeps dropdowns visually compact:
desktop submenu entries use a single-line label, content-aware width, and a
36px minimum row height. Mobile entries may be slightly roomier for touch, but
must not inherit the dense admin navigation contract. The dedicated
.front-submenu* rules live in template/default/asset/app.css; avoid generic
menu selectors that also alter backoffice navigation.
6.3 Public Module Template Compatibility
Co-located templates under modules/{Name}/templates/front/ are the canonical
public module contract. A public theme can render them through the resolver or
provide an explicit fork under template/{theme}/tpl/front/, but it must keep a
front layout, the shared --k-* token bridge, Alpine 3 when required, and a
CSS pipeline that sees the module markup.
Use php bin/console theme:audit-modules {theme} to inspect active module
templates. missing means the theme has no explicit fork, while
present-identical and present-forked distinguish a canonical mirror from a
native adaptation. The maintained full-coverage themes are verified with:
php bin/console theme:audit-modules cc1 --fail-on-missing
php bin/console theme:audit-modules dm1 --fail-on-missing
php bin/console theme:audit-modules pc2 --fail-on-missing
All three audits report missing=0 (dm1: 21 forks; cc1: 19 forks and 2
identical copies; pc2: 21 forks). The Tax calculator is an identical
Tailwind-compatible fork in cc1 (whose source scan already includes module
templates), a Bootstrap-native fork in dm1 and a plain-CSS fork in pc2.
pc3 is audited separately and reports missing=14: twelve are the Ecommerce
surfaces the compatibility policy deliberately suppresses for that theme, and
the remaining two use the resolver fallback.
dm2 reports missing=20, present-forked=1. Its missing entries are an
intentional canonical-template policy, not unsupported pages: render-through-
ThemeEngine coverage verifies the real Front contact/social, Info, Page,
Documentation and Blog payloads inside the DM2 shell, including one-heading and
top-level scripts-section contracts. Canonical Contact, Info, Documentation
and Blog index declare their page-owned main landmark through local Plates
layout metadata. DM2 delegates that landmark while retaining its styled,
focusable skip target; home, scene routes, Page and Blog articles keep the
layout-owned main. Default and DM1 retain their existing canonical markup.
IT/EN keyboard source previews confirm the layout and Documentation skip
links at desktop/mobile widths. The inline mobile menu also preserves focus
across responsive breakpoints, and the compact booking action retains its
accessible name. Gallery arrows continue from the image reached with Tab.
The loopback-only tools/benchmarks/dm2-keyboard.mjs probe covers these
behaviors, room controls and dialog focus return. Nine source-preview routes
pass mobile landscape and real Chromium 200% page zoom in IT/EN under reduced
motion, without horizontal overflow. Without JavaScript, the same routes retain
their navigation and static content; the canonical contact page explains the
form requirement and offers an email alternative only when a valid public
company email is configured. The footer explains OS-enforced reduced motion,
and manual pause persists across reloads. Media initially deferred by reduced
motion now initialize without a reload when motion becomes eligible, exactly
once and without bypassing manual pause or provider consent. Synthetic
lifecycle and stubbed-provider checks are not real BFCache/provider approval.
Ecommerce is the exception and core
suppresses it while theme.front=dm2. Enabling ThemeDm2 explicitly publishes
its high-priority Studio, Brands and Inspirations routes; equal Page slugs are
therefore shadowed until that module is disabled.
The DM2 theme supplies its own scoped rich-text and documentation styles in
addition to scanning canonical module templates for Tailwind utilities. This
keeps Page/legal prose, code and tables readable and switches Documentation
from a desktop navigation grid to its mobile disclosure without forking module
templates. A synthetic IT/EN source preview covered contact, privacy, Page
and Documentation at six widths with no document overflow or runtime errors.
The document shell also wraps long titles and public paths, while preformatted
Blog/Page content scrolls inside its own bounded block. A further 72 synthetic
IT/EN source-preview cases cover populated/empty Blog lists and HTML/plain
articles across six widths, landscape and no-JavaScript desktop/mobile views.
Localized links, plain-text escaping and loaded images remain intact; the
header liquid effect and media sources are outside these document-only rules.
The dcm fixture code also supplies DM2 home content: three independent
localized sections for showroom facts, four illustrated environments and four
service steps, after the video experience. ContentFixture registers the legacy
DM1 pack and the DM2 companion only when their owning runtime modules are
active; selecting the fixture remains independent of theme.front. New slots
can populate an existing home without overwriting its edited content or hero.
Inspirations displays category totals and the selected image position/title;
mobile arrows and swipes share the same selection, keyboard Home/End work, and
closing the image viewer returns focus to the last viewed image. The supplied
photos are classified by their inspected subject despite reversed filenames.
The DM2 liquid gesture is provider-independent: it reveals local client
photography through hardware WebGL or a Canvas 2D fallback. Its stock cover uses
the locally hosted Cucine LUBE Brera photograph and a light Deco wordmark
reconstructed from the supplied logo. The same SVG bounds feed the static
heading and liquid texture. Previous bundled cover URLs resolve to Brera only
within this presentation; custom widget media/titles and insert-missing data
ownership remain intact. See template/dm2/MEDIA-SOURCES.md for attribution.
Its bounded input
model forms one broad connected fluid body with attached organic lobes and a
tapered directional throw; both renderers retain the same morphology instead of
falling back to thin strokes or isolated dots. The supplied local film is a
progressive layer used by the liquid hero. The scrolling projection
uses its own editable exact-host YouTube link. Its screen now has a neutral
local fallback independent of the liquid hero's photo, with a direct YouTube
link and a deliberate Klaro consent action when needed; no provider thumbnail
is fetched before consent. Once visible with consent granted or unmanaged, the
YouTube iframe loads paused, then the controller mutes and starts it without a
visitor click. It accepts YouTube's infoDelivery playing state, handles
player errors and keeps the iframe visually active beneath an opaque local
fallback until playback is confirmed. The mobile player remains at least 200
px high. Autoplay rejection retains the fallback. Inspirations uses
an accessible circular gallery;
its mobile scroll-snap rail remains independently scrollable without widening
the page, and long localized headlines fit narrow viewports. The closed native
image dialog reserves dimensions but has no image source until opened. Once
opened it covers the whole viewport with the photograph centred above its
caption bar, and the empty area around the photograph closes it like the
backdrop it replaces. The
current local film path is a temporary theme asset; the approved production
master still has to be imported through Media.
When a page supplies no structured-data graph, DM2 emits conservative localized
schema.org JSON-LD for WebSite, the current WebPage and its five visible
navigation destinations. Organization is included only when the tenant supplies
company.name, with optional configured logo, email, legal address and VAT ID.
The graph uses request/canonical URLs and never infers products, offers, prices
or availability; page-owned structured data takes precedence. The Brands page
now ends with a localized price-list consultation block linking to Front's
canonical contact form through the exact-whitelist price-list context. It
offers guidance rather than displaying prices, availability or invented
downloads. DM2's header and Studio consultation actions use the related
consultation context; both lead to an editable four-field POST whose
validation, CAPTCHA and worker-aware delivery behavior remain unchanged.
The large invitation appears only on the localized contact page; other DM2
routes retain a compact legal/consent footer.
Management/site destinations are not implemented yet.
Delivery status (2026-09-13): the owner reports the latest DM2 compilation
complete. This report is distinct from aggregate theme verification, measured
bundle budgets, target-tenant activation/imports and real hardware/provider
approval. The recorded source baseline is 40 Node tests and 55 template tests
(331 assertions). Final Media import, populated Blog real-zoom/admin-preview
checks, intrinsic-image/layout-shift and cross-browser verification remain open.
The durable behavior and owner acceptance contract live in
template/dm2/AI.theme.md and modules/ThemeDm2/src/AI.ThemeDm2.md.
module-{name}::front/... is a supported delegation target, and a theme may
use it for an intentional thin adapter. The alias is derived from the directory
holding templates/, so ThemeEngine::moduleTemplateAlias() canonicalizes the
declared path before splitting it: every getTemplatePaths() returns
__DIR__ . '/../templates/{side}' from a class under src/, and the retained
src/.. segment previously made the alias resolve to the empty string —
registration was skipped for every module, and any delegating template raised
The template folder "module-x" was not found at request time. cc1 renders its
twelve Ecommerce surfaces exclusively through those adapters, so its whole shop
answered 500 until the path was canonicalized;
ThemeEngineTest::testNamedModuleFolderIsRegisteredForAnUncanonicalizedTemplatePath
pins it.
A thin adapter also needs the theme's own CSS to stay out of the utilities'
way. Tailwind v4 emits utilities into @layer utilities, and unlayered CSS
always beats layered CSS whatever the specificity. A theme that keeps an
element reset such as *{margin:0;padding:0} unlayered therefore zeroes every
px-*/py-*/m*-* utility on the canonical module templates it renders — the
page still returns 200 and merely loses all spacing, which is why an audit
cannot catch it. Keep resets inside @layer base; keep [x-cloak] and
.sr-only unlayered so they still win.
Two independent reasons a fork must own its markup outright rather than delegate,
both of which a missing=0 audit cannot detect on its own:
- Styling. The Documentation reader's canonical template uses
docs-*classes that exist only in the default bundle, so neither a source scan nor an identical copy is enough. A Tailwind@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
Capabilities that more than one theme needs live in src/Theme/, never
duplicated per theme and never as a theme-to-theme dependency. A theme module
keeps only what is genuinely its own: the widget type code, the admin editor
partial, and its markup. Their DI wiring is core-owned too — the hero
normalizers in RenderingDefinitions, alongside HookPositionRegistry — so no
theme declares them in its own services.php, where two themes would otherwise
overwrite each other in module order.
| Contract | Location | Owns |
|---|---|---|
| Hook positions | src/Theme/HookPosition, HookPositionRegistry |
Positions each active module/theme declares for the widget editor |
| Hero media | src/Theme/Hero/* |
Hero and hero-slider payload, clamping, provider video, validation |
| Widget locale | src/Theme/Widget/WidgetRenderLocale |
The locale a structured widget renders live content in |
| Storefront width | src/Theme/Layout/FrontContentWidth, FrontContentMode |
The tenant-wide width of the public content shell |
Admin sidebar submenu memory. Which submenus an operator left open is an
operator preference, shared by both admin shells through
template/shared/asset/js/admin/sidebar-menu-state.js and the single key
k0smos.admin.menu.expanded.v1 in localStorage, alongside density, sidebar
collapse, tooltips and the accordion blocks. One key across themes is
deliberate: both shells render the same ACL-filtered menu with the same stable
node ids, so switching theme keeps the operator's menu. An explicit choice wins
over navigation — a group the operator closed stays closed when they open a page
inside it, and the trail highlight marks the path instead. Every read and write
is individually guarded, because reaching localStorage can throw and so can
getItem: losing a preference is acceptable, losing the sidebar is not.
Admin token bridge. Admin templates are written once and rendered by both
admin themes, so they name every colour through the shared --k-* bridge and
both themes must declare every token any of them references. This is not a
graceful-degradation question: an undefined custom property invalidates the whole
declaration at computed-value time, so a missing token paints a transparent
button rather than a differently-coloured one. Alongside the core tokens the
bridge carries the semantic aliases admin pages use directly — --k-primary,
--k-on-primary, --k-input-bg, --k-surface-card, --k-surface-sidebar,
--k-surface-alt, --k-surface-strong, --k-success-soft, and the
--k-{success,warning,danger,info}-border family. Fixed Tailwind palette
utilities (text-red-600, bg-slate-100) and literal semantic hexes are
contract violations because they ignore both the tenant palette and the
light/dark mode; var(--k-token, #fallback) stays correct, and palette swatches,
whose job is to preview a colour, stay literal.
Three surfaces the browser paints itself need an explicit instruction, because
none follows the theme on its own: accent-color on checkboxes and radios,
without which they render in the browser's blue whatever palette the tenant
chose; an explicit background and colour on select option, because styling a
<select> never reaches its OS-level popup; ::file-selector-button, which otherwise
keeps the platform's own "Choose file" button; and ::placeholder, whose browser
default is a fixed grey that ignores both palette and mode. Both, and the bridge itself, ship
inline through admin/partial/_admin_token_fallback_css and
admin/partial/_admin_controls_fallback_css so they hold before the next
maintainer-run bundle build. tests/Unit/Theme/AdminThemeTokenContractTest.php
guards the source and node tools/benchmarks/admin-tokens-smoke.mjs resolves
every referenced token in Chromium for both themes, failing on a transparent
button or a vanished border.
Both bundles were rebuilt and verified against the shipped CSS on 2026-09-16:
every referenced token resolves, a primary-backed control paints in the tenant
accent (default) and in the sober primary (sober), semantic borders survive,
and the checkbox accent, select popup, file-input button and placeholder all read
from the theme. The added rules stay well inside the theme asset budgets —
default 41.9 KB gzip against a 60 KB ceiling, sober 14.1 KB against 20 KB.
Admin heading contract. The backoffice has one heading grammar shared by
default and sober. An admin template declares a role — .k-page-title
(h1), .k-panel-title (h2), .k-section-title (h3),
.k-page-eyebrow/.k-eyebrow for uppercase micro-labels, plus
.k-page-head/.k-page-head-main/.k-page-actions for the header row — and
each admin theme renders that role in its own idiom: default in Inter at
1.875/1.125/1rem, sober in Sora 26px for the title and dense 16/14px below it.
Tailwind typography utilities (text-*, font-*, tracking-*, leading-*,
uppercase) on an admin heading are a contract violation: they override the
theme rule and reintroduce per-page fonts. Layout utilities stay on the element,
and panels keep the inline var(--k-surface)/var(--k-border) styles that the
shared accordion detects.
Shared admin controls and partials. Module admin pages and the default admin templates are written once and rendered by both admin themes, so they use only classes and partials both themes provide:
- Controls:
k-input,k-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/partial/_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.- 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<style>; - 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/partial/_page_header.tpl.php is the canonical page opening in both
themes and AdminPanelExtension::panelHeader() emits the panel roles. The two
subtitle roles are deliberately distinct: .k-page-desc and .k-panel-desc are
micro-help, which sober hides and which therefore require a sibling
tooltip() carrying equivalent content, while .k-page-meta carries record
identity, counts and operational values — data, not help — and stays visible in
every theme with tabular figures. Each theme mirrors the rules into
tpl/admin/partial/_admin_typography_fallback_css.tpl.php, inserted by
admin.tpl.php, so the contract holds before the next maintainer-run bundle
build, exactly like the settings-action fallback. /kiosk is outside the
contract because it owns a self-contained shell and never renders inside
admin.tpl.php. tests/Unit/Theme/AdminHeadingContractTest.php enforces role
coverage in both themes, rejects heading typography utilities, keeps each
inline fallback aligned with its compiled stylesheet, and fails a
.k-page-desc without a tooltip carrier.
node tools/benchmarks/admin-heading-smoke.mjs is the browser contract: it
extracts the real fallback partials, renders the canonical header shape and a
module page shape, and measures in Chromium that both resolve to identical
typography inside a theme — default at 30/18/16/12px in Inter, sober at
26/16/14/11px with the title on Sora — while the description and meta
visibility policies hold.
Panel geometry follows the same principle from the theme side. Module admin
pages are authored against default and spell their corner radius with Tailwind
utilities, so sober inherited 12px, 16px and 24px panels side by side, against
its own dense single-scale contract. sober therefore maps
rounded-xl/rounded-2xl/rounded-3xl onto --sober-radius-lg for elements
matching the documented admin surface convention — an inline style carrying both
var(--k-surface…) and var(--k-border), the same signal _panel_accordion
uses — scoped to body[data-theme-side="admin"] main, excluding pills and
panels that deliberately flatten a corner. default keeps the radii its pages
were authored with; normalising those is a design decision, not a defect, and
must not be done by editing module templates.
Hero media. HeroMediaNormalizer turns raw widget data into HeroSlide and
HeroSliderOptions value objects and back into the persisted array payload, so
every theme hero stores the same keys. VideoSourceNormalizer resolves local,
YouTube, and Vimeo sources into a VideoSource carrying a sanitized provider
id, a privacy-friendly embed URL, and an explicit requiresConsent flag — front
partials must keep provider embeds behind the tenant consent manager. Slide
types are text, image, video_background, and video; validation messages
are shared, and each theme only supplies the "no slide configured" message.
dm1 renders it as a Bootstrap carousel (dm1_media_slider), lts1 as a
single editorial hero (lts1_hero). A theme that auto-advances its hero must
also expose a pause control: rotation that starts on its own and runs longer
than five seconds needs a mechanism to stop it, and a framework's
pause-on-hover/focus is not one — it never reaches a touch visitor and unwinds
as soon as focus leaves. dm1 renders that toggle only when autoplay is
actually configured and treats the visitor's pause as authoritative over its
viewport and video handlers.
Widget locale. A widget row carries two locale notions: locale is the
row's targeting locale (empty means "every language"), while request_locale is
the locale actually being served, propagated to every row by WidgetRenderer.
Live content — catalog names, category labels — must follow the request, so
WidgetRenderLocale::resolve() applies request_locale → locale → tenant
default. For read-side rows that carry no catalog entity,
CatalogLocalizationService::localizeProductNames() and
localizeCategoryNames() translate a name map in one query per entity type and
keep the base name whenever a translation is missing.
Storefront content width. Public pages no longer hardcode a page-level
max-width. FrontContentWidth reads two tenant AppSettings values —
theme.front_content.mode (fluid | fixed) and
theme.front_content.max_width (clamped to 960..2400 CSS px, default 1440) —
and normalizes anything out of range or unparseable back to the documented
default, so a corrupted setting degrades instead of collapsing the layout.
Unlike the admin content width, which is a per-browser localStorage preference,
this value governs what anonymous visitors see. It is therefore resolved
server-side: PublicSettingsViewDataProvider exposes it as the frontContent
view-data key, and every front layout stamps it onto <html> as
data-k0smos-front-content plus the --k-front-content-max-width custom
property. No client script participates, so there is no width flash on first
paint.
Templates consume it through .front-content-shell in
template/shared/asset/css/front-content-shell.css, imported by every theme
entrypoint. The primitive carries two custom properties per tier:
--k-front-content-scale preserves each page's width proportion relative to the
configured base (the previous Tailwind steps expressed as fractions of 6xl),
while --k-front-content-ceiling is an absolute stop so a single-column form
widens with the theme but never exceeds a readable measure. Tiers, widest to
narrowest: base (catalog, product page, account overview), --roomy (orders),
--medium (cart, invoice), --narrow (checkout), --tight (order
confirmation), --compact (registration), --form (login). Below 768 px every
tier returns to full width.
Themes that already own a single site-wide container wire that container to the
token instead of tagging each template: dm1 overrides Bootstrap .container at
the xxl breakpoint, pc2 rewrites .pc2-container, cc1 its
.cc1-container/.cc1-nav-inner/.cc1-footer-inner/.cc1-hero-inner, and
pc1 its two .art-shop-* shells, and pc3 its centred .pc3-shell. Because the shared stylesheet is imported
outside Tailwind's utilities layer, .front-content-shell beats any
max-w-* utility on the same element — do not add it to an element that relies
on a responsive max-w-* override.
Operators edit the value in the front_content panel of
/admin/theme/{code}/settings. That route carries no _permission, so the panel
receives an explicit frontContentWritable state derived from system.write or
the bounded delegated settings.front_content permission and renders read-only
without either. Saving goes through GET/POST /api/settings/front-content,
which accepts the matching broad or delegated permission, clamps the payload
server-side and returns the value actually stored, so the panel always reflects
persistence rather than the local draft. The shared panel also accepts optional
theme-owned translated description, tooltip and note keys when a theme has a
deliberate full-width exception. DM2 uses that seam to state that the setting
controls editorial sections, footer and document pages while its header and
liquid hero remain viewport-wide; its admin overview repeats the same scope.
cc1 hero equalizer and vinyl. The cc1 homepage restores its original decorative 72-band Canvas equalizer inside the hero, preserving the established geometry, pink-to-violet gradient, glow, amplitude and tempo. It is absent from non-home routes, pauses in hidden tabs, avoids duplicate frame chains, and renders one static frame under reduced motion. Separately, every cc1 public route owns one pointer-inert lateral CSS vinyl: its outer stage keeps stable 3D perspective while its inner face follows absolute page scroll through one passive, RAF-coalesced write and performs no idle work. Reduced motion fixes the record at its base angle. The shared vinyl layer intentionally contains no Canvas/SVG edge waveform or full-viewport grid/frame. Both controllers are idempotent and independently clean up listeners and frames; neither uses audio or microphone access, and the Contact page's scoped WebGL lattice remains independent.
External-theme chrome identity. cc1 groups its locale and color-scheme
switchers as adjacent controls at the end of both desktop and mobile public
menus. The pc1, pc2 and pc3 homepage document titles are sourced only from the
DB-backed company.name; an empty value produces an empty title instead of
substituting the theme code, framework name, or theme-owned copy. Explicit
titles on content routes remain unchanged.
Shared pc portfolio content. ContentFixture owns the pc1/pc2/pc3 family's
editable IT+EN portfolio source through the pc package. It contributes three
generic complexity tiers — pc_full, pc_simple, and pc_title — through the
active-module widget-extension contract, exposes their localized admin editors
through the canonical default/sober template hierarchy, and validates links
before rendering. Version-neutral pc_* hooks share CV facts across compatible
themes while keeping pc2 hero/capabilities/skills/stack/CTA and pc3
projects/method in distinct slots. Each public theme maps the same normalized
payloads to native markup under front/_widget/{type}.tpl.php; no fixture stores
theme HTML.
PC3 uses the same tenant content-width shell for its homepage and public chrome.
Its desktop navigation wraps on a separate row; mobile navigation collapses
after hydration and restores focus on Escape. Native locale and nested module
disclosures remain usable without JavaScript. Light/dark mode is available at
320px, tolerates blocked browser storage, and maintains readable contrast in
the inverted capability and contact sections. Since 0.30.0+1 the theme
keeps every label at 0.72rem or above, restores the editorial prose rhythm for
Page/Info/Blog bodies, composes {page} · {company} titles, emits a default
canonical, complete Open Graph/Twitter metadata, noindex on auth and error
pages, and a theme-built schema.org graph (Organization with social sameAs
and capability knowsAbout, WebSite, typed WebPage, plus BlogPosting,
TechArticle, BreadcrumbList and ItemList nodes) built only from tenant settings
and rendered content. See template/pc3/AI.theme.md for the head contract,
source-preview verification and the maintainer-owned production build gate.
6.5 Template Data Contract
Controller and active-module data cross the Plates/JSON boundary through the
immutable contracts in src/Rendering/TemplateData. The request-scoped
TemplateDataComposer owns framework root variables, rejects core-reserved or
duplicate module/page keys with both owners in the diagnostic, and normalizes
declared presentation values without exposing domain objects or services.
LegacyTemplateDataAdapter temporarily preserves existing raw ViewModel
arrays as direct Plates variables during the documented deprecation window.
Declared payloads have identical HTML/JSON shapes, while unsupported top-level
HTML objects fail instead of receiving an accidental data wrapper. Front
providers are registered only from active modules, and flash consumption goes
through SessionStore rather than the rendering layer reading $_SESSION.
The complete root inventory, module migration example, pilot list, and release window are in the Template data contract.
7. Menu System
7.1 Menu Architecture
flowchart TD
A[Active runtime modules] -->|menu definitions| B[SystemMenuSyncService]
L[FrontMenuEntryProviderModuleInterface] -->|typed storefront entries| M[FrontMenuEntryCatalog]
M -->|validated entries| B
J[Backoffice menu library] -->|custom CRUD| C[(menu_collections/menu_items/menu_placements)]
B -->|upsert system menus| C
C --> D[MenuPlacementResolver]
D --> E[MenuRenderer]
E -->|active-module block resolution| K[MenuBlockResolverRegistry]
K -->|ACL + feature filter via MenuBuilder| F[Filtered MenuItem array]
F --> G[PHP Plates Template / menu_hook()]
F --> H[Menu API - JSON]
F --> I[MenuSerializer]
Core Components:
| Component | Namespace | Purpose |
|---|---|---|
| MenuItem | K0smos\Menu\MenuItem |
Immutable DTO (id, label, route/path, optional parentKey, children, permission, feature, icon, order, resolved block metadata/data) |
| MenuHookRegistry | K0smos\Menu\MenuHookRegistry |
Registry of available hooks for menu placements |
| SystemMenuSyncService | K0smos\Application\Menu\SystemMenuSyncService |
Syncs system menus from runtime modules into persistent storage |
| FrontMenuEntry | K0smos\Domain\Menu\FrontMenuEntry |
Typed storefront entry a module offers: route, menu block or group, default visibility, supersedes |
| FrontMenuEntryCatalog | K0smos\Application\Menu\FrontMenuEntryCatalog |
Validates offered storefront entries against the live route table and block schemas; logs rejections |
| SystemMenuSettingsService | K0smos\Application\Menu\SystemMenuSettingsService |
Operator payload and saves for the system menu panel (visibility, order, owning module, availability) |
| DbalMenuRepository | K0smos\Infrastructure\Menu\DbalMenuRepository |
Persistent repository for collections, items, and placements |
| MenuPlacementResolver | K0smos\Application\Menu\MenuPlacementResolver |
Resolves active menus assigned to a specific hook |
| MenuRenderer | K0smos\Application\Menu\MenuRenderer |
Rebuilds the menu tree for a hook/channel, applying order and placements |
| MenuBlockResolverRegistry | K0smos\Application\Menu\MenuBlockResolverRegistry |
Resolves persisted typed blocks through active-module providers with unique type ownership |
| MenuBuilder | K0smos\Menu\MenuBuilder |
Filters by permissions/features and recursively sorts |
| MenuSerializer | K0smos\Menu\MenuSerializer |
Versioned JSON serialization for APIs; v2 includes item_type, block_type, and resolved block_data |
| MenuApiController | K0smos\Controller\MenuApiController |
Read-only API endpoints for system channels (/api/menu/front, /api/menu/footer, /api/menu/back) |
System channels: front, footer, back, user.
Menu hooks: front.primary, front.footer, front.sidebar,
admin.sidebar.primary, admin.sidebar.secondary, admin.topbar, and
admin.user.
System menus start from module declarations: typed storefront entries for
front/footer (§7.2.1) and Config/menu.php for back/user. They are
materialized in the database and then rendered through placements. Custom
menus are created only from the backoffice and can be assigned to one or more
hooks.
Current PHPUnit coverage for the menu subsystem includes MenuHookRegistry,
FrontMenuEntryCatalog, SystemMenuSyncService (defaults, operator overrides,
module deactivation, supersedes), SystemMenuSettingsService,
MenuPlacementResolver, MenuRenderer, DbalMenuRepository,
MenuApiController, and the route/theme contract for /api/menu/*.
7.2 Menu Definition
Modules declare backoffice menus (back, user) in Config/menu.php,
returning an array keyed by system channel. Storefront menus (front,
footer) use typed entries (§7.2.1); the front/footer keys of
Config/menu.php are still read as a compatibility adapter, and a typed entry
with the same id wins. Persistent sync keeps every channel updated while
leaving current order/visibility/placements to storage.
Menu definitions can declare an explicit parent key. This is the preferred
way for runtime modules to attach under stable shared roots owned by Front
(for example api, tools, settings, or user.theme_settings) instead of re-declaring
those roots in multiple modules.
For the admin sidebar, Front owns the static-first top-level anchors:
dashboard, users, workspace, content, business, api, tools,
and settings. User-scoped theme settings use the separate system.user
channel rendered by the admin.user hook.
// modules/Wpapi/src/Config/menu.php
return [
'back' => [
[
'id' => 'wpapi',
'parent' => 'api',
'label' => 'menu.back.wpapi',
'route' => 'admin_wpapi_settings',
'icon' => 'wpapi',
'order' => 10,
'permission' => 'wpapi.view',
'children' => [
[
'id' => 'wpapi.settings',
'label' => 'menu.back.wpapi.settings',
'route' => 'admin_wpapi_settings',
'permission' => 'wpapi.manage',
'order' => 10,
],
],
],
],
];
Persistence rules applied by SystemMenuSyncService on every sync:
- Untouched items follow the declaration. When an item's stored current
visibility (or order) equals its stored default, a changed declaration
updates both. Changing
orderordefaultEnabledtherefore reaches existing tenants. - Operator choices win. When an operator changed visibility or order in
/admin/settings/menus, the stored value is kept whatever the declaration says. - Inactive modules keep their rows. A system item whose module is no longer
active stays stored with
menu_items.is_available = 0: rendering skips it (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) |
footer.blog (on) |
| Ecommerce | shop.front (on), shop.catalog catalog tree block (off), shop.new_products new products block (off), shop.account (on, supersedes front.login), shop.cart (off), shop.wishlist (off) |
footer.shop (on), footer.account (on, supersedes footer.login) |
The system menu panel in /admin/settings/menus shows one tab per channel.
Storefront channels list three sections: entries in the menu (tree order),
entries available but switched off (grouped by the offering module), and
entries of inactive modules (unavailable, read-only).
7.3 Filtering And Building
MenuBuilder algorithm:
- 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".
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 --> NIM[NVIDIA NIM]
HP --> ATL[AtlasCloud]
HP --> KMI[Kimi/Moonshot]
HP --> SNV[SenseNova]
HP --> GGL[Google AI]
OLL --> Resp[Response]
OAI --> Resp
ANT --> Resp
ORT --> Resp
NIM --> Resp
ATL --> Resp
KMI --> Resp
SNV --> Resp
GGL --> Resp
Resp --> Controller
Components:
| Component | Namespace | Purpose |
|---|---|---|
| AiClientInterface | App\AI |
Generic contract for AI text generation and chat |
| AiProviderFactory | App\AI\Provider |
Selects the active HTTP provider from tenant-scoped configuration |
| AiProviderSettingsService | App\AI\Provider |
Resolves serialized provider profiles, global/scope/offload routing, endpoint, model, API key, and system prompt from AppSettings |
| AiScopeSettingsService | App\AI\Provider |
Stores AI scopes that route only to enabled serialized integration clients |
| ChatService | App\AI\Chat |
Stateful chat orchestrator |
| ChatMemoryRepository | App\AI\Chat |
Message persistence interface |
| DbalChatMemoryRepository | App\AI\Chat |
Default driver-agnostic DBAL implementation |
| ChatReplyTaskRegistry | App\AI\Chat |
Advanced reply task state; DBAL by default, Redis+DBAL failover when queue.driver=redis |
| ChatAttachmentProcessingRegistry | App\AI\Document |
Advanced attachment pending state; DBAL by default, Redis+DBAL failover when queue.driver=redis |
| ChatContextBuilder | App\AI\Chat |
Builds the conversation for AiClientInterface (system prompt + last 20 messages + summary) |
| CachingDecorator | App\AI\Decorators |
Caches LLM responses (24h TTL) |
| LoggingDecorator | App\AI\Decorators |
Logs requests/responses with timing |
AI providers are configured from /admin/settings/ai through serialized integration clients. Registry version 2 stores auth mode, connection status, optional account metadata, and masked credentials. OpenAI and Anthropic support API-key clients and externally obtained workload-identity access tokens. ChatGPT Plus/Claude consumer account sessions are listed as unsupported metadata and are rejected for runtime API use because provider docs do not expose them as API credentials. Local connect/disconnect/test API actions manage stored credential state; they do not perform OAuth or token exchange.
SenseNova uses its OpenAI-compatible Chat Completions endpoint with bearer
API-key authentication and non-streaming requests. The catalog defaults to
https://token.sensenova.ai/v1 and sensenova-6.8-flash-lite; operators may
override either value per integration client. The wire contract is documented
by the official SenseNova API reference.
10.2 Chat Service
ChatService manages stateful multi-turn conversations: it loads history, builds
context, calls the LLM, and saves messages.
// Send a message and get a reply
$result = $this->chatService->reply($chatId, $userInput);
// Returns: ['content' => string, 'created_at' => string]
// Retrieve conversation history
$history = $this->chatService->history($chatId, limit: 20);
Memory backend:
- Default:
DbalChatMemoryRepository, persisted via Doctrine DBAL Connection and compatible with SQLite/PostgreSQL/MySQL/MariaDB - Multi-tenant: chat memory is tenant-isolated by the tenant database
Tenant technical configuration, preferably under module_config.ai:
{
"module_config": {
"ai": {
"history_limit": 20,
"chat": { "max_prompt_length": 32000 },
"advanced": {
"queue": "default",
"worker_heartbeat_max_age_seconds": 180,
"python": {
"binary": "python/.venv/bin/python",
"script": "python/ai_worker.py",
"working_directory": "python",
"timeout_seconds": 120
}
},
"upload": { "max_file_size_mb": 20, "max_extracted_characters": 12000 },
"rate_limit": { "reply_per_minute": 10, "document_upload_per_minute": 5 }
}
}
}
10.3 Decorator Pattern
flowchart LR
Client -->|generateText/chat| CD[CachingDecorator]
CD --> LD[LoggingDecorator]
LD --> HP[HTTP AI Provider]
CD -->|cache hit generateText| CachedResult
// Stack: CachingDecorator wraps LoggingDecorator, which wraps the active HTTP provider.
$llm = new CachingDecorator(
new LoggingDecorator($providerClient, $logger),
$cache
);
Additional patterns:
- Rate Limiting through Redis (
llm:ratelimit:{userId}, configurable hourly) - Prompt Sanitization (removes sensitive patterns and limits length)
- Cost Tracking (tracks input/output tokens and per-model cost)
- Graceful Degradation when the LLM is unavailable
10.4 Future Integrations To Evaluate
The current runtime uses native k0smos HTTP adapters behind
AiClientInterface. The following items are candidate integrations only: they
are not active dependencies and must be evaluated through a focused spike before
being added to composer.json, DI wiring, tenant settings, or production
documentation.
| Candidate | Evaluation Scope |
|---|---|
| LLPhant | Assess whether it should return as an optional application-layer adapter for chat, embeddings, retrieval, or tool orchestration. Compare its provider coverage, maintenance status, streaming/tool-call support, error handling, and fit with the existing AiClientInterface contract before any runtime adoption. |
Any future AI integration must preserve tenant-scoped provider settings, explicit auth-mode validation, prompt-context boundaries, and the current direct-provider fallback path so one library cannot become a hidden single point of failure.
11. Search Engine
Optional vector retrieval is a disposable projection over canonical module/SQL content. Qdrant is the reference adapter; the active Documentation module is the only current public allowlist and lexical search remains the unconditional fallback. Tenant filters, embedding identity, deletion state, privacy rules, rebuild command, and the offline evaluation gate are documented in Vector search reference and ADR 0002.
A runtime graph database was evaluated separately and is not adopted. The
Client/Project/Ticket traversal remains a bounded indexed SQL query; the
synthetic proof measured 0.571 ms p95 and did not justify a duplicate Neo4j
projection. The reproducible evidence, operational comparison, and reopening
gates are in
Runtime graph database evaluation and
ADR 0003. This runtime decision
is unrelated to the repository-analysis tool documented in Graphify.md.
The search system has two independent levels:
- Low-level backend (
SearchEngineInterface) - SQL / Elasticsearch, 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=elasticsearch| ES[ElasticsearchEngine]
SQL --> QR[SearchResult]
ES --> QR
The backend is configured in tenant JSON (search.engine). SqlSearchEngine
queries the products table by default; the table is configurable through
search.sql.table.
Key types:
| Type | Purpose |
|---|---|
SearchEngineInterface |
Executes searches on the backend |
SearchQuery |
Term, page, perPage, filters, sort |
SearchContext |
Tenant ID, sources, locale, metadata |
SearchResult |
Status, engine, hits, total, meta |
SearchHit |
id, source, title, snippet, score, attributes |
Built-in providers implement the additive SearchCapabilityProviderInterface
and declare a SearchCapabilities value for optional behavior such as typo tolerance, prefix matching,
highlights, facets, suggestions, multi-source reads, and sorting. Modern and
SQL engines attach the effective declaration to SearchResult::meta; consumers
must use that metadata instead of inferring behavior from the provider name.
When modern search falls back to SQL, the fallback result keeps SQL's
conservative capability declaration. Custom providers that have not yet opted
into the additive contract remain compatible and receive a conservative
all-optional-features-disabled declaration.
Built-in product search engines normalize requests through
SearchFieldContract. It allow-lists category/currency and inclusive price
filters plus relevance/name/price/updated sorting; unrecognized fields
never reach provider query syntax. Requested category_id and currency
facets are capped at 100 values per field and normalized in
SearchResult::meta.facets as ordered value/count rows. SQL grouped counts,
Elasticsearch/OpenSearch aggregations, Meilisearch facet distributions and
Typesense facet counts therefore expose one response shape. If a provider does
not declare requested facet or sort support, ModernSearchEngine selects SQL
before calling it. With no fallback it removes only unsupported options and
reports degraded_capabilities.
11.2 Content Sources: SearchSourceInterface
Modules expose searchable content by implementing SearchSourceInterface. A
source knows which context it supports (front or admin) and owns its query
logic.
// src/Domain/Search/SearchSourceInterface.php
interface SearchSourceInterface
{
public function getName(): string; // 'blog', 'products'
public function getLabel(): string; // 'Blog', 'Products'
public function supports(string $context): bool; // 'front' | 'admin'
public function getListingPath(): ?string; // '/blog', '/admin/catalog'
/** @return SearchHit[] */
public function search(string $term, int $limit): array;
}
A source that answers in the admin context reaches records the public site
never shows, so it also implements PermissionAwareSearchSourceInterface and
names the permission its results require:
// src/Domain/Search/PermissionAwareSearchSourceInterface.php
interface PermissionAwareSearchSourceInterface extends SearchSourceInterface
{
public function requiredPermission(): ?string; // 'page.view', or null when public
}
AdminSearchController skips every admin source the current user is not granted,
and every admin source that declares no permission at all. A backoffice search
therefore never returns more than the listing page its hits link to would; a new
admin source without a declaration returns nothing until it makes its authority
explicit.
Built-in implementations:
| Class | Context | Table | Notes |
|---|---|---|---|
BlogSearchSource |
front |
module_blog |
Queries published posts via DBAL |
PageSearchSource |
front, admin |
module_pages |
The admin context also matches drafts and requires page.view |
ProductSearchSource |
admin |
products |
Delegates to SearchEngineInterface; requires ecommerce.view |
11.3 Registry And Auto-Discovery
SearchSourceRegistry aggregates sources. Its factory resolves them dynamically
from ModuleRegistry:
// src/Application/Search/SearchSourceRegistry.php
final class SearchSourceRegistry
{
public function __construct(SearchSourceInterface ...$sources) {}
/** @return SearchSourceInterface[] */
public function forContext(string $context): array { /* ... */ }
}
In SearchSourceDefinitions, the factory calls ModuleRegistry::collectSearchSourceClasses(),
which iterates all modules implementing SearchableModuleInterface:
SearchSourceRegistry::class => static function (ContainerInterface $c): SearchSourceRegistry {
$classes = $c->get(ModuleRegistry::class)->collectSearchSourceClasses();
$sources = array_map(fn(string $cls) => $c->get($cls), $classes);
return new SearchSourceRegistry(...$sources);
},
Adding a new searchable module requires implementing
SearchableModuleInterface, with no ContainerFactory changes:
final class TodoModule implements ModuleInterface, SearchableModuleInterface
{
public function getSearchSourceClasses(): array
{
return [TodoSearchSource::class]; // resolved by autowiring
}
}
11.4 Search Endpoints
| Route | Auth | Context | Description |
|---|---|---|---|
GET /search?q=term |
Public | front |
Returns hits from all front sources such as blog |
GET /admin/search?q=term |
Required | admin |
Returns ACL-filtered navigation plus the admin sources the user is granted |
Response format:
{
"status": "complete",
"sources": ["blog"],
"hits": [
{
"id": "42",
"source": "blog",
"source_label": "Blog",
"title": "Doctrine and DBAL",
"snippet": "Doctrine DBAL is a powerful database abstraction layer...",
"path": "/blog/doctrine-and-dbal",
"score": null
}
],
"total": 1
}
An empty term returns {"status": "empty", "total": 0, "hits": []}.
12. Authentication And Locale
12.1 JWT Authentication
Authentication is stateless and based on a JWT stored in an HttpOnly cookie.
| Aspect | Value |
|---|---|
| Cookie name | k_token |
| Cookie flags | HttpOnly; SameSite=Lax; Path=/ |
| Cookie duration | Max-Age=28800 (8 hours) |
| JWT TTL | 28800 s, configured in ContainerFactory |
| JWT signature | HMAC-SHA256 (JWT_SECRET env variable) |
| Expiration validation | StrictValidAt, always applied (exp, nbf, iat) |
Login flow: POST /login -> LoginController::submit() ->
AuthService::authenticate() -> emits JWT -> sets k_token cookie -> client
redirects.
Request flow: JwtAuthMiddleware reads the cookie or Authorization: Bearer
-> JwtService::validateToken() -> loads the user from DB -> sets
User::class on the request attribute.
Logout: GET /logout -> LogoutController -> expires the cookie
(Max-Age=0) -> redirects to /login.
Route protection (route definition):
'_auth_required' => true, // redirect to /login if unauthenticated
'_permission' => 'x.action', // also implies authentication
'_permission_all' => ['gate.use', 'x.action'], // optional: every code required
'_permission_any' => ['system.write', 'x.delegated'], // optional: at least one required
_permission_all is conjunctive and adds to _permission rather than
replacing it, so a route declaring both is checked against the union. Use it
when a surface gates its own entry and must still defer to the domain
permission behind each action — kiosk mode is the reference case (§ 30). A
surface gate must never replace a domain permission: that widens the ACL
through the back door.
_permission_any is disjunctive: at least one listed code must be granted. Use
it when a broad administrative code and a narrow delegated code should both
suffice — /api/settings/company accepts system.write or the delegated
settings.company, so an operator can maintain the public company identity
without the one code that opens every settings page. The two defaults compose:
the route is granted when every required code holds and at least one
alternative does, so an alternative can never buy entry past a gate.
12.2 Self-Service Change Verification
Self-service password and email changes in /admin/user-settings are
two-step operations unless the tenant explicitly disables
features.require_change_verification.
Step 1 validates the current password and the requested value. When
verification is required, the server creates a user_pending_changes row with
an encrypted payload and returns:
verification_required: 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.
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, migrations, tenant-theme synchronization, base/demo fixtures, admin-role synchronization, and the final readiness check for the resolved tenant. Add -- --skip-tests to skip its PHPUnit step. |
| composer app:install-demo | Migrates, synchronizes selected wrappers, backfills theme/locales without overwriting DB edits, and loads demo fixtures. With TENANT_ENV it targets that tenant; otherwise it deduplicates aliases and iterates canonical tenant configurations. |
| composer app:migrate | Runs migrations for the selected tenant, or for all canonical tenants when TENANT_ENV is unset. See Module Migrations for option forwarding. |
| composer app:migrate-all | Always runs migrations for all canonical tenants, ignoring an inherited TENANT_ENV. |
| composer app:backup / app:backup-all | Write a verified SQLite copy of each selected tenant database to var/backups (db:backup); run it before migrations or bulk imports. |
| composer app:reinstall | Runs app:install, then an idempotent migration and a forced demo-fixture load for the resolved CLI tenant. This is a local reset/convenience workflow; fixtures can overwrite existing data. |
| composer app:queue | Starts the queue worker; set TENANT_ENV or pass -- --host=<hostname>. |
| Composer lifecycle: post-install-cmd, post-update-cmd | Runs automatically after Composer install/update to patch Rector's bootstrap and copy PHP DebugBar assets. |
Public Markdown documentation
The default-enabled documentation runtime module serves reviewed Markdown at
localized /docs URLs. It is intentionally separate from Page (tenant-authored
database pages) and from the existing PHPDoc iframe (generated developer API
reference). Version-1 JSON manifests own stable cross-locale IDs, localized
paths and SEO copy, parent hierarchy, ordering, draft visibility, and permanent
redirects.
Every renderer input lives physically below doc/public/{locale}. A manifest
source is a lowercase locale-relative .md path and must equal its public path
plus .md; the repository file is the source of truth and the active frontend
theme renders its HTML. The complete relocation is English-only for now: the
Italian catalog is absent, Italian navigation uses
i18n.documentation_locale, and retired Italian slugs redirect to their
matching English stable documents.
The locale filesystem boundary is strict: paths reject traversal, are
normalized with realpath(), and must remain below the selected locale root.
CommonMark runs with raw HTML stripped and unsafe links disabled. Only
visibility: public entries can resolve or enter front search/vector
projection. Request-local navigation/render caches use manifest and source
fingerprints. The canonical default-theme template renders the manifest parent
hierarchy as semantic nested lists and provides breadcrumbs, generated TOC,
previous/next links, locale switching with a visible tenant-configured
documentation-locale fallback, keyboard landmarks, responsive layout,
code/table overflow, a restrained H1–H6 scale, print rules, dark-mode tokens,
and progressively enhanced copy-code controls. CommonMark puts conventional
unprefixed fragment IDs directly on the heading elements, so the TOC, heading
permalinks, same-page links, and cross-document fragments share a target that
clears the fixed header.
Authoring, classification, source, rename/redirect, and retirement rules are
documented in modules/Documentation/src/README.Documentation.md. A focused
coverage test requires every English .md below doc/public/en/ to appear
exactly once in its manifest, no Markdown to remain below doc/ outside
doc/public/, and the retired Italian catalog to remain absent while its former
paths stay covered by English redirects. AI*, HU*,
agents/*, reports, plans, changelogs, tenant configuration, root/module
READMEs, and files outside the approved roots remain unreachable. The authoring
loop is database-free for validation; when visual verification is needed,
documentation:preview binds only to 127.0.0.1, ::1, or localhost and
serves the real application/theme path. The final production theme bundle is
still built explicitly by the project maintainer. The complete repository-root
workflow is:
php bin/console documentation:validate
cd template/default && npm run build && cd ../..
php bin/console documentation:preview
Open http://127.0.0.1:8088/docs, perform the responsive/accessibility, dark
mode, locale, print, code, and table review, then stop the preview with
Ctrl+C. Documentation has no database schema or module-specific migration.
See the next section for the Rector patch details.
OPC UA module
The default-enabled opcua module provides an admin-only industrial device
surface backed by php-opcua/opcua-client 4.4. It owns named server profiles,
a registered node list, retained readings, one-level browse, batch read, typed
scalar write, on-demand recording, and the tenant-scoped opcua:record command.
The dedicated config/tenants/opcua.json lite tenant requests only this module.
Persistence is created exclusively by the module Doctrine migration:
module_opcua_servers, module_opcua_nodes, and
module_opcua_readings, plus insert-if-missing opcua.view, opcua.read,
opcua.write, and opcua.manage permissions and admin grants. Passwords are
write-only; empty edits preserve the stored secret and browser payloads expose
only password_configured. All APIs are authenticated, permission-gated, and
CSRF-protected; endpoints, NodeIds, quantities, and scalar types are validated
before device I/O.
V1 deliberately supports only opc.tcp:// with SecurityPolicy/Mode None and
anonymous or username/password session authentication. Deploy it only on a
trusted or segmented industrial network. X.509, encrypted policies, persistent
session management, subscriptions, queue scheduling, charts, and MCP tools are
future work. The canonical operational and security contract is
modules/Opcua/src/AI.Opcua.md; setup and cron examples are in
modules/Opcua/src/README.Opcua.md.
The repository already excludes QA caches, temp files, and runtime reports
through .gitignore; before committing, verify that generated artifacts were
not force-added.
Post-install vendor patches (ComposerScripts)
post-install-cmd and post-update-cmd invoke
K0smos\Command\ComposerScripts::patchRectorBootstrap, which applies two
idempotent patches to vendor/rector/rector after every Composer install or
update:
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
The runtime ecommerce module depends on cart, page, and media. Its
catalog persistence lives in products, categories, product_images,
product_variants, and product_variant_images; schema changes are delivered
only through the module's Doctrine migrations. The authoritative
field/publication/identity/ownership
specification is modules/Ecommerce/src/CATALOG-CONTRACT.md.
16.1 Backoffice Catalog
The focused catalog surfaces replace the retired all-in-one Alpine workspace:
/admin/catalog answers a permanent redirect to the product list
(/admin/catalog?product={id} to that product's editor).
/admin/catalog/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'.
Product writes through either the editor or /api/admin/ecommerce/products
reuse ProductValidator. It checks required/unique identity, non-negative money
and weight, integer stock, currency shape, category and active-tax references,
catalog content/SEO limits, and HTTP(S)/internal image URLs. Invalid API writes
return HTTP 422 with translated per-field errors; invalid PRG writes re-render
the submitted values. The editor lists active tax rates only, uses Media for
image selection/upload/reordering, keeps static save/cancel labels, and protects
dirty forms on internal navigation and browser/tab exit. Related and
complementary products are picked by search (chips, up to 24 each), the currency
comes from the active currencies, specifications are edited as label/value rows
(the stable code derives from the label), and the description is HTML with a
formatting toolbar and a preview; it is sanitized with CatalogHtmlSanitizer on
save and in the preview (POST /admin/catalog/products/description-preview).
Imported products display their external reference and field-level ownership
state. Source-owned fields refresh on every unlocked import; tenant-owned fields
remain local; tenant-overridable fields follow the source until a user changes
or explicitly protects them. Later source differences remain pending without
overwriting the live tenant value. Enabling import_locked is still the hard
freeze and causes Psapi/Wpapi to skip the product/category, its variants, stock,
and images entirely. Editing an unlocked imported product requires explicit
confirmation but never changes that lock state. The whole-product lock has its
own ACL/CSRF-protected action. A successful non-no-op edit records actor, source
identities and changed field names (never connector payloads) through the
ImportedProductEdited domain event. Separate idempotent listeners write one
entry to the shared audit_log and send one preference-aware in-app
notification to the actor; only a hash of the request identity is retained.
Catalog reads require ecommerce.view; writes require ecommerce.edit plus the
ecommerce.catalog browser-CSRF intention. The canonical JSON namespace is
/api/admin/ecommerce/...; secured legacy aliases remain for one compatibility
window.
Backoffice sidebar and permissions
The E-commerce sidebar groups its pages by task: Catalog (products,
categories, inventory, reviews, import conflicts), Sales (orders, customers,
abandoned carts, and a shortcut to Invoices when that module is active),
Marketing (coupons, promotions) and Configuration (shipping, payments,
VAT rates, currencies, shop settings). A menu link whose route no active module
registers is dropped, and so is a group left empty. Permissions:
ecommerce.orders (orders), ecommerce.customers, ecommerce.shipping,
ecommerce.marketing (coupons, promotions), ecommerce.reviews (moderation),
ecommerce.settings (VAT rates, currencies, shop settings). Migration
Version20260926120000 creates the new ones and grants them, with
ecommerce.settings, to administrators and to every role that held
ecommerce.orders, which gated those pages before.
Backoffice sales, marketing and configuration pages
Every page follows the shared admin contract ("Shared admin controls and partials"): page
header with tooltip and meta, KPI tiles that filter the list, URL filters,
sortable paginated k-table, empty states, translated labels (ecommerce domain)
and localized money and dates. Lists run in SQL through read models; none loads a
whole table to filter it in PHP.
- Dashboard (
/admin/ecommerce): new and processing orders, revenue of the last 30 days (cancelled and refunded excluded), stock outages, reviews to moderate, carts idle for 24 hours, new customers, latest orders, and the last run of each catalog import with the open import conflicts. - Orders (
/admin/orders,OrderAdminQuery): search by reference, name words or e-mail, status, date range; payment and shipment state per row. The order page shows items, totals, customer, addresses, payment and invoice; the status and the shipment (status, carrier, tracking) save without a reload and update the badges and progress timeline. Cancelling or refunding asks for confirmation. - Customers (
/admin/customers,CustomerAdminQuery): order count, spend and last order from one grouped join; registered/guest and active filters. The create/edit form validates e-mail (unique), names, phone, password (8+ unless guest) and the optional first address. - Coupons and Promotions: lifecycle status (active, scheduled, expired,
used up, turned off), shared rule fields (
DiscountRuleInput): discount type and value (a percentage cannot exceed 100), scope with typed targets picked by search (products, categories) or checkboxes (shipping methods), minimum order, validity window. Coupons add a unique code and use limits; promotions a priority. - Shipping, VAT rates, Currencies: tables edited in a drawer (the
server renders it open for
?new=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; the base currency keeps rate 1. - Reviews: pending first, status and rating filters; approve and reject save without a reload.
- Abandoned carts (Cart module): active carts idle for at least 1–72 hours (newest 200), with an e-mail-only filter for recoverable carts.
- Invoices (Invoice module): register with status tiles and search, invoice page with sync state and errors, manual sync, and Fatture in Cloud settings that report invalid fields on the form instead of failing.
Form endpoints answer JSON when asked (Accept: application/json, used by the
async form runtime) and redirect otherwise (post/redirect/get). Without
JavaScript an invalid full form is re-rendered with its errors, while a single
action such as an order status change redirects back without the message.
Controllers share this through K0smos\Controller\Concerns\AdminFormResponses.
The order timeline is cumulative: a parcel picked up, in transit or delivered
marks processing done, also for an order refunded afterwards.
16.2 Public Catalog
/shop renders an active-only, server-paginated catalog with crawlable search,
category, and previous/next links. Canonical details use
/shop/products/{slug} (the numeric id URL redirects while id remains the cart,
wishlist, review, and order identity); categories use
/shop/categories/{slug}. Base slugs are unique per entity type
(uniq_products_slug, uniq_categories_slug); every writer derives them through
CatalogSlugger, which appends -2, -3, … on collision. A replaced base or
translated slug is kept in catalog_slug_history, so its old URL answers 301 to
the current page while the item stays active. Product/category pages emit
canonical links, breadcrumbs, and schema.org JSON-LD. Media-backed list images use batched
responsive variants and accessible alt text with the original URL as fallback.
Active zero-stock products remain visible but are not purchasable; inactive
products return 404 publicly while remaining editable in the backoffice.
Product detail is assembled by a typed, locale-aware application read model, not by template queries. The complete gallery is batch-resolved through Media and exposes responsive variants, accessible alt text, intrinsic dimensions and an original-image zoom target. Structured specifications use stable provider-neutral codes plus locale rows and round-trip through Data Exchange. Explicit related and complementary products are active-only, de-duplicated, positioned and bounded. Shipping/returns links come from DB-backed AppSettings. Variant selection progressively updates URL state, SKU, price, availability, quantity bounds, cart identity, gallery and JSON-LD Offer/image data. Variant galleries are tenant-curated separately from imported product images, resolve in the same Media batch, and fall back to the product gallery. Server-rendered GET selection and POST purchase forms keep the flow operable without JavaScript. Native canonical and structured breadcrumb URLs retain the active locale prefix.
A product belongs to its primary category and to any additional categories (for
example an offers collection) stored in product_categories. A category landing
lists both kinds and, when its rule includes them, the products of its active
subcategories: each category chooses include, exclude or inherit, and
inherit follows the shop-wide default in the Ecommerce settings panel
(/admin/ecommerce/settings, default off). The same panel sets the product page
shipping and returns links. A landing page shows the category name, description
and image, a breadcrumb through its ancestors (also in the JSON-LD
BreadcrumbList) and links to its active subcategories, and uses the category's
meta title and description in the page head.
Every storefront surface links a category only when its landing page exists:
the sidebar tree drops inactive categories with their subtree
(CategoryService::createStorefrontTree()), slug-less categories render as
text, and product cards, the product badge, the detail panel and the lts1
products widget link only active categories. Menu, megamenu and predictive
queries resolve slugs like the storefront: base slug on the default locale,
translated slug elsewhere, blank translations fall back.
The product page gallery keeps an Alpine preview (main image, arrows, counter,
thumbnails, arrow keys, variant switches) and exposes its images through
[data-product-gallery] to the shared full-screen viewer
template/shared/asset/js/front/product-gallery.js (PhotoSwipe 5, MIT): swipe,
pinch/zoom, keyboard, optional Fullscreen API and focus return. Without
JavaScript every image stays a link to its full-size file.
Theme widgets and navigation surfaces obtain bounded category groups through
CategoryService::listActive(parentId, limit). The shared read returns active
categories with a non-empty slug from one exact tree level, ordered by
position/id; dm1 and lts1 use this contract instead of duplicating category
filtering in theme code.
The shop filter remains a canonical server-rendered GET form. Its progressive
enhancement calls /shop/search/predictive?q=... after two characters and
renders at most eight grouped product/category suggestions through an ARIA
combobox/listbox. A bounded DBAL query contributes at most three active,
locale-resolved category matches and preserves locale-aware category paths. The
client debounces, cancels superseded requests, ignores stale responses, and
keeps keyboard selection plus a localized “view all” link. The endpoint applies
a Redis fixed-window limit of 30 requests per minute per tenant and hashed
user/IP identity; it returns HTTP 429 with Retry-After and fails open if Redis
is unavailable. Search logs retain only a term hash/length, provider, duration,
result count, zero-result flag and random correlation id. On selection, the
theme sends only that opaque id, position 1–8 and the product/category group to
the separately rate-limited /shop/search/predictive/selection endpoint; query
text, titles, entity ids and result URLs are not sent.
Ecommerce defines one provider-neutral ProductSearchDocument per product and
tenant-supported locale, with stable {productId}:{locale} identity. Its
bounded DBAL source batches localized product and variant names, SKUs,
localized category ancestry, scalar variant attributes, structured
specifications, decimal-string price range, normalized default-location
availability, tenant identity, and explicit public status. Only active rows
with a resolved name and slug receive a canonical public path. Storefront
queries force tenant, locale, published, and active filters even if a caller
supplies conflicting values; tenant-specific modern index names remain
available through search.modern.index_per_tenant. Authoritative catalog,
localization, Data Exchange and inventory writes enqueue typed projection
messages. A persistent provider/index-scoped fingerprint and category-ancestry
ledger makes Elasticsearch/OpenSearch, Meilisearch and Typesense updates and
deletes retry-safe. Operators can reconcile the complete derived index with
php bin/console ecommerce:search:rebuild --batch-size=100; repeated runs skip
unchanged documents, remove stale rows, and --force rewrites current rows.
SQL search is an explicit successful no-op because it has no remote index.
Storefront filtering, sorting and facets use the core allow-listed field contract. SQL and all four modern providers return the same ordered facet shape for category and currency; provider-specific relevance/price field names are mapped internally. Unknown fields are discarded before remote filter strings are assembled. Optional facet/sort capability gaps select SQL first, or are reported as explicit deterministic degradation when no fallback is configured.
16.3 Localized Catalog Content
Default-locale product and category text remains in products / categories.
Optional overrides live in product_translations, category_translations, and
the name-only product_variant_translations, keyed by entity plus locale with
locale-scoped product/category unique slugs. Every nullable
translated field falls back independently to its base value; a missing or
partial translation therefore never removes a valid active entity from the
storefront. CatalogLocalizationService batches list/category resolution and
also resolves translated product slugs before base slugs.
Persisted product/category editors expose all tenant-supported locales with a
default/missing/partial/complete status. Non-default locale forms edit only
translatable content, show source-language provenance, allow fields to remain
empty for fallback, and offer an explicit copy-from-default action. Stable
numeric ids continue to own cart, wishlist, review, variant, and order links.
LocalizedProductContent and LocalizedCategoryContent expose deterministic
catalog.{entity}.{id}.{locale} keys for a future DataExchange provider without
introducing a module dependency.
16.4 Import Ownership And Conflict Review
ImportMergePolicy is the provider-neutral pure merge contract. Ecommerce's
CatalogImportOwnershipService applies the product/category/variant/image field
matrix in CATALOG-CONTRACT.md; both Psapi and Wpapi consume it. Esapi currently
imports clients and invoices rather than catalog entities and must use the same
contract if catalog synchronization is introduced.
Explicit owners are normalized in catalog_import_field_ownership by entity,
id, locale, and field. catalog_import_conflicts stores the source name and the
two compared field values only — never a raw connector payload. Repeated open
differences coalesce. Choosing Keep my edit acknowledges the current source
value until it changes; choosing Use source value applies it and returns the
field to source ownership. A later local edit supersedes stale pending conflict
metadata.
The product, category, and variant editors render the compact ownership panel;
/admin/catalog/conflicts lists every unresolved conflict (filter by source,
item type, field) and resolves a selection in bulk: keep the catalog value, or
accept the source value after a confirmation (up to 200 per request).
The field-protection and conflict-resolution POST routes require
ecommerce.edit and the ecommerce.catalog CSRF intention, then redirect back
to the editor. Translated product/category content has independent locale-scoped
ownership. Existing rows follow the source by default; historical non-source
product/category images are adopted automatically as tenant overrides.
16.5 Normalized Inventory
Ecommerce inventory is normalized into sellable items, locations, optimistic
levels, append-only movements, and expiring reservations. A variant is the
sellable identity; products without variants use an explicit product fallback.
InventoryService is the sole normalized write boundary: adjustments and
reservation transitions update level state and record their movement in one
transaction, reject conflicting reuse of an idempotency key, and prevent
oversell or commit after expiry. Ecommerce catalog repositories route admin and
Wpapi saves through its stock projector, Data Exchange reconciles it directly,
and Psapi consumes the core CatalogInventoryWriterInterface. The service also
owns the temporary stock_quantity projection, which mirrors default-location
on-hand transactionally until remaining catalog readers move to normalized
levels.
The ACL/CSRF-protected /admin/ecommerce/inventory workspace is where stock
quantities change; the product and variant editors show stock read-only and
link to it filtered on the SKU (their create forms keep an initial stock, which
becomes the first ledger movement). The workspace offers:
- KPI tiles for the location (out of stock, low stock, tracked SKUs, incoming units), each linking to the matching filter;
- filters kept in the URL: search on SKU, product or variant name, status
(
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. Saving needs no reload; a stale row version returns the fresh row, and on hand can never drop below committed;
- a movement history drawer (reason, date, source, author, note);
- a CSV export of the filtered view (
;separator, UTF-8 BOM, 10,000 rows).
Ledger-only reasons (reconciliation, import, reservations, order transitions)
are refused from the backoffice. Routes: GET /admin/ecommerce/inventory,
GET …/movements and GET …/export need ecommerce.view;
POST …/adjust and POST …/thresholds need ecommerce.edit and the
ecommerce.inventory CSRF scope (form field or X-CSRF-Token header).
POST …/adjust answers JSON when asked (Accept: application/json) and
otherwise redirects back to the filtered list. Checkout reserves aggregated
sellable identities; the core payment service resolves Ecommerce's optional
shared coordinator and commits captured purchases or releases failed/cancelled
ones without redefining the payment service in the module.
Operators must schedule the tenant-aware command below. It processes a stable expiry/id batch of 1–500 active reservations and is safe to retry:
TENANT_ENV=k0smos.example.com php bin/console ecommerce:inventory:expire-reservations --limit=100
17. Psapi / PrestaShop Integration
The Psapi module syncs data from a PrestaShop Legacy Webservice /api/
into the current Ecommerce and Media bounded contexts. Payload handling
targets PrestaShop 1.7, 8, and 9, while real-shop certification remains a
separate environment-dependent check. The runtime module depends on both
bounded contexts and exposes an ACL-protected admin settings/status surface, a
queue-backed back-office import action, plus import and redacted diagnostics
CLI commands.
The 0.19.0 baseline includes canonical profiles, durable reload-safe queued
runs, atomic checkpoints, dependency quarantine, stable external identity,
authoritative stock, Media lifecycle ownership, Ecommerce admin/shop acceptance,
shared field-level catalog ownership/conflict review, entity-level hard locks,
legacy-table audit, and redacted compatibility probes.
Imported products and categories also preserve their indexed source URLs as
canonical through the opt-in Page URL rewrite subsystem (see the Page module),
so PrestaShop link_rewrite (and WooCommerce slug) paths keep serving after an
import instead of losing their search ranking.
The Page back office validates tenant-specific PrestaShop/WooCommerce path
patterns while retaining safe defaults. Native catalog and Blog detail URLs
redirect directly to imported canonicals; deleting an authoritative Page,
product, category, or post converts its non-manual URLs to HTTP 410, including
replace-mode Data Exchange cleanup. Manual redirect rules remain untouched.
17.1 Architecture
modules/Psapi/
├── migrations/Version20260218000000.php
├── migrations/Version20260711113000.php
├── migrations/Version20260711170000.php
├── migrations/Version20260711210000.php
├── migrations/Version20260712133000.php
├── src/{Config,Controller,Routes}/
├── src/PsapiModule.php
└── templates/admin/psapi-*.tpl.php
src/
├── Application/Import/Ownership/ # Provider-neutral catalog merge policy
├── Application/Psapi/ # Settings, shared runner, typed job launch/status
├── Command/Psapi{Import,Diagnostics}Command.php
├── Domain/Psapi/ # Sync, quarantine, and external-identity contracts
└── Infrastructure/Psapi/
├── Client/ # PSR-18 Legacy Webservice client
├── Diagnostics/ # Redacted compatibility and legacy audit probes
├── Import/ # Nine entity importers + ImportResult
├── Queue/ # Typed back-office import handler
└── Sync/ # DBAL sync, quarantine, and identity repositories
17.2 Client And Pagination
PrestaShopWebserviceClient talks to the PS Legacy Webservice through HTTP
Basic authentication (API_KEY:) and requests JSON output:
GET /api/{resource}?output_format=JSON&display=full&limit={offset},{pageSize}
Configuration is read only from tenant-scoped AppSettings (psapi.*). Import
legacy module_config.psapi values with tenant:config:migrate-editable; the
runtime never falls back to tenant JSON. New deployments save credentials through
/admin/psapi/settings; do not add the API key to tenant JSON.
| Key | Default | Purpose |
|---|---|---|
base_url |
- | Root URL of the PrestaShop shop |
api_key |
- | PS Webservice key; masked in the admin response |
lang_id |
1 |
Language ID for multilingual fields |
source_locale |
tenant default | Enabled tenant locale represented by lang_id |
page_size |
100 |
Records per API page |
default_currency |
EUR |
Currency for imported products/orders |
allow_private_network |
false |
Explicit local-network destination opt-in |
allow_insecure_http |
false |
Explicit unencrypted HTTP opt-in |
order_state_mapping |
{} |
Source order-state ID to k0smos status mapping |
Product/category imports persist the selected text in Ecommerce's locale rows
with source=prestashop and source_language_id=lang_id. When
source_locale differs from the tenant default, the importer refreshes the
translation while preserving existing base-locale text; a new entity seeds the
base row so default fallback is always renderable. Preserved source URL rewrites
use the same mapped locale. Product/category importers discover the PrestaShop
language table once per run and register every localized link_rewrite; the
configured lang_id/source_locale mapping remains the fallback when language
discovery is unavailable.
Outbound requests stay under the configured same-origin /api path. The
client resolves all destination addresses before construction and every retry,
rejecting private/reserved networks and HTTP unless their independent
AppSettings flags are enabled. Transport failures, 429, and 5xx responses retry
at most three times with bounded backoff; authentication and redirect responses
are not retried or followed. The dedicated module transport uses a 5-second
connect timeout and a 30-second total request timeout.
Core dependency failures are persisted in metadata-only psapi_quarantine
records. Products require their non-root default category; orders require a real
imported customer, every referenced product, valid source currency data, and a
known/configured positive state ID. Blocked rows are not saved with null catalog
links, synthetic @import.local email addresses, product id 0, or unknown-state
fallbacks. Quarantine stores no remote payload or duplicated PII. Once the parent
is available, rerun the affected entity without --since; a successful save
removes its quarantine record. Dry-run reports candidates without persisting them.
The settings page and both status endpoints require psapi.view; all mutations
require psapi.manage. POST /admin/psapi/settings,
POST /api/admin/psapi/test-connection, and POST /api/admin/psapi/import opt in to the shared
psapi.settings CSRF intention. Cookie-authenticated browser calls submit a
session synchronizer token in X-CSRF-Token (with a parsed form-field fallback),
while explicit Bearer authentication remains available for non-ambient API
clients. Full-kernel tests cover anonymous 401/redirect behavior, read/manage
permission separation, and valid/invalid CSRF paths.
The settings page includes a translated import control for the core and
extended profiles plus dry-run. It saves the current form before launching the
job, so a reload renders the persisted AppSettings values instead of defaults.
PsapiImportRunner is shared by CLI and back-office execution and preserves the
canonical dependency order. The back office dispatches PsapiImportMessage
through the worker-aware typed queue bridge and falls back to the same handler
synchronously when no worker is active. Queue payloads contain only job id,
profile, and dry-run; credentials remain in tenant AppSettings.
GET /api/admin/psapi/import-status returns a requested or latest
credential-free run. psapi_import_runs and psapi_import_run_entities are the
authoritative status, counter, fixed-watermark-window, and per-entity checkpoint
store. PsapiImportJobStore coordinates that repository and writes only the
latest run id plus a lightweight credential-free projection to AppSettings.
Only one pending/running run per profile is accepted; a duplicate launch returns
HTTP 409 with the existing run so the browser can adopt it.
The settings controller resumes polling automatically after a reload, labels worker-queue versus synchronous fallback execution, retries transient status failures with bounded backoff, stops on a bounded timeout or stale run, and keeps a manual refresh action. Worker-queued execution is independent from the browser; synchronous fallback must finish the launch request before the page is closed.
The client exposes generators for ordinary offset iteration and durable source-ID cursor iteration, keeping memory constant regardless of dataset size:
// PsapiClientInterface
public function fetchAll(
string $resource,
array $filters = [],
int $pageSize = 100,
int $startOffset = 0,
): \Generator;
public function fetchAllAfterId(
string $resource,
array $filters = [],
int $pageSize = 100,
int $afterId = 0,
): \Generator;
// Usage in an importer
foreach ($this->client->fetchAll('products', $filters) as $batch) {
foreach ($batch as $data) { /* process record */ }
}
Date-capable resources currently use PS date-range syntax:
?filter[date_upd]=[2024-01-01 00:00:00,2026-12-31 23:59:59]
PrestaShop requires date=1 when date filters are used. The client adds it
automatically and resolves envelope/watermark behavior through
PsapiResourceCapabilityMap. Customers, categories, products, and orders use a
closed date_upd interval whose upper bound is captured when the run starts.
Inside that interval, durable runs sort by [id_ASC] and repeatedly request
offset zero with filter[id] advanced beyond the last committed source ID.
This avoids offset shifts when already-seen source rows change between pages.
The destination page and its source-ID checkpoint commit atomically; the stored
offset remains only a progress counter. Because combinations/carriers do not
expose a portable date_upd contract, those extended importers deliberately
perform a full idempotent refresh when --since is supplied. Selective imports
run PsapiSelectiveDependencyPreflight first, using capability dependencies and
metadata-only quarantine records to report missing parents without exposing
source payloads.
17.3 Importers
All eight importers share the first three arguments. The four resumable core importers add the optional durable run context shown here:
public function import(
?SymfonyStyle $io = null,
?\DateTimeImmutable $since = null,
bool $dryRun = false,
?PsapiImportRunContext $runContext = null,
): ImportResult
| Importer | PS resource | Authoritative destination | Notes |
|---|---|---|---|
CustomerImporter |
customers |
ecommerce_customers |
Upsert by source id |
AddressImporter |
addresses |
ecommerce_customer_addresses |
Resolves customer/country/state; durable identity survives alias and postal changes |
CategoryImporter |
categories |
categories |
Skips PrestaShop root ids <= 2 |
ProductImporter |
products |
products |
Resolves the default category |
VariantImporter |
combinations |
product_variants |
Resolves option labels; durable identity survives SKU changes |
StockImporter |
stock_availables |
inventory_items, inventory_levels, inventory_movements |
Uses the provider-neutral inventory writer with psapi provenance; base stock updates product inventory, combinations resolve durable variant identity, and legacy columns are refreshed only as a service-owned projection |
ImageImporter |
images/products/*, images/categories/* |
product_images, categories.image_url, module_media |
Republishes validated binary assets through Media and records source media identity |
CarrierImporter |
carriers |
ecommerce_shipping_methods |
Durable identity survives labels; tenant shipping prices/rules remain manual and are preserved |
OrderImporter |
orders |
ecommerce_orders, ecommerce_order_items |
Normalizes one or many order rows |
Order rows preserve historical commercial snapshots but deliberately contain no
image URL or binary snapshot. OrderItemMediaResolver looks up the current
primary catalog image once per distinct product for admin order details,
customer order history, and payment confirmation. The presentation falls back
cleanly when the product was deleted or has no image, while imported Media
binaries remain owned by the product catalog.
The implemented required/defaulted fields and rerun ownership rules are
maintained in modules/Psapi/src/CORE-FIELD-MATRIX.md. For catalog entities it
distinguishes source-owned, tenant-owned, tenant-overridable, and never-imported
fields plus the entity hard lock; for customer/order data it retains derived
configuration, mapped dependencies, historical snapshots, and preserved
enrichment. The shared conflict policy is Ecommerce-owned and Wpapi consumes the
same core merge service rather than maintaining a second matrix.
ImportResult is a final readonly Value Object:
final readonly class ImportResult
{
public function __construct(
public string $entity,
public int $imported,
public int $updated,
public int $skipped,
public int $failed,
public array $errors = [],
public int $quarantined = 0,
) {}
public function merge(self $other): self { /* ... */ }
public function total(): int { /* includes quarantined */ }
}
17.4 Durable Runs And Sync Log
Each importer still records cumulative compatibility/status state in
psapi_sync_log after a run (skipped with --dry-run). Per-run counters and
resumable progress do not use this table: they live in psapi_import_runs and
psapi_import_run_entities. Migration Version20260711210000 removes the old
last_page column.
CREATE TABLE psapi_sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type VARCHAR(50) NOT NULL UNIQUE,
last_sync_at DATETIME NULL,
records_imported INTEGER NOT NULL DEFAULT 0,
records_failed INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
SyncLogRepositoryInterface / DbalSyncLogRepository follows the standard
repository pattern: interface in Domain/, implementation in Infrastructure/.
DbalImportRunRepository owns durable run/entity records. A run captures mode,
profile, dry-run flag, execution mode, fixed watermark window, status,
timestamps, aggregate counters, and a bounded redacted error summary. Each core
resumable entity stores its last committed source ID, committed-row offset, and
counters. The source ID is the authoritative resume cursor; the offset is not
sent back to the source for durable runs.
PsapiImportRunContext::commitPage() wraps destination writes and checkpoint
advancement in one DB transaction. Duplicate queue deliveries cannot claim a
fresh/completed run twice; failed or stale deliveries resume from the committed
checkpoint.
Rows whose destination model has no native ps_id use psapi_entity_map.
The table stores only source type/id and local type/id pairs. Addresses,
combinations, carriers, and product/category media adopt previously imported
records through their legacy fallback on the first post-migration run, then use
the durable source link when mutable business fields change.
17.5 Module Migrations
The Psapi module owns its Doctrine migration class in:
modules/Psapi/migrations/Version20260218000000.php
modules/Psapi/migrations/Version20260711113000.php
modules/Psapi/migrations/Version20260711170000.php
modules/Psapi/migrations/Version20260711210000.php
modules/Psapi/migrations/Version20260712133000.php
namespace App\Migrations\Module\Psapi
config/migrations.php auto-discovers co-located directories under
modules/{Name}/migrations and maps them to
App\Migrations\Module\{Name}. The initial Psapi migration creates the
legacy sync structures; the later migrations add psapi_quarantine with a
unique entity/source key and dependency lookup index, then psapi_entity_map
with unique source identity and local-record lookup indexes.
Version20260711210000 adds durable import runs/entities with the
one-active-run-per-profile key and removes cumulative last_page checkpoint
semantics. The initial migration also creates the legacy ps_customers table
for backward compatibility; current importers do not write to it. After a
static runtime-usage audit and a zero-row audit on the locally resolved tenant,
Version20260712133000 reversibly archives that table as
ps_customers_legacy instead of deleting potentially deployed data.
The read-only audit and redacted compatibility probe are available through:
TENANT_ENV=k0smos.example.com php bin/console k0smos:psapi:diagnostics --audit-legacy-customers --json
TENANT_ENV=prestashop9.example.com php bin/console k0smos:psapi:diagnostics --smoke-version=9 --json
The smoke probe reads at most one row from each required JSON resource plus one
product image listing and emits only version/resource status metadata.
PrestaShop 1.7, 8, and 9 compatibility must be recorded from real shops;
mock-client coverage is not staging evidence. The live certification procedure
is documented in modules/Psapi/src/README.Psapi.md.
$moduleGlob = ROOT_DIR . DIRECTORY_SEPARATOR . 'modules'
. DIRECTORY_SEPARATOR . '*'
. DIRECTORY_SEPARATOR . 'migrations';
foreach (glob($moduleGlob, GLOB_ONLYDIR) ?: [] as $dir) {
$moduleName = basename(dirname($dir));
$migrationPaths['App\\Migrations\\Module\\' . $moduleName] = $dir;
}
MigrateCommand supports --module to run migrations for one module only:
php bin/console migrate --module=psapi
The deployment-level migration entry point is composer app:migrate. Without
TENANT_ENV, its wrapper discovers all canonical configurations under
config/tenants/, de-duplicates aliases, and runs MigrateCommand once per
tenant. When TENANT_ENV is set, only that tenant is migrated. Additional
options are forwarded after Composer's argument separator, for example:
composer app:migrate -- --allow-no-migration
TENANT_ENV=k0smos.example.com composer app:migrate -- --module=psapi
composer app:migrate-all explicitly forces the canonical all-tenant scope,
even when the calling process or a loaded .env file defines TENANT_ENV. It
uses the same wrapper and forwards options in the same way:
composer app:migrate-all -- --allow-no-migration
Direct php bin/console migrate execution remains single-tenant and falls back
to localhost when TENANT_ENV is absent.
17.6 Tests With MockPsapiClient
Importer tests use isolated in-memory or temporary SQLite databases and a
MockPsapiClient. Four representative resources are loaded from JSON fixtures;
the remaining resource payloads are supplied by focused tests.
tests/
├── Fixtures/Psapi/
│ ├── customers.json # 10 customers in PS format
│ ├── categories.json # 5 categories (ids 1,2 skipped as PS roots)
│ ├── products.json # 8 products with multilingual name array
│ ├── orders.json # 5 orders, some with single-object order_row
│ └── core-commerce.json # dependency-complete core dataset imported twice
├── Unit/Psapi/
│ ├── MockPsapiClient.php # Implements PsapiClientInterface from fixtures
│ └── ImportResultTest.php # Unit tests for ImportResult VO
└── Integration/Psapi/
├── {Customer,Address,Category,Product}ImporterTest.php
├── {Variant,Stock,Image,Carrier,Order}ImporterTest.php
├── PsapiImportCommandTest.php # canonical order and CLI summary
└── CoreCommerceImportAcceptanceTest.php # stable IDs and no degradation across reruns
The current focused Psapi suite passes 164 tests and 773 assertions. It covers the importer layer, canonical core/extended command profiles, real HTTP-client URL/envelope/capability contracts, outbound destination policy/retries, product/category media lifecycle, source order currency/rate/state mappings, failed-row exit behavior, settings, controller 409/422/502 response contracts, shared CSRF middleware, full-kernel authentication/ACL/CSRF behavior, dependency quarantine/preflight paths, inclusive timestamp boundaries, full-page failure and resume, source changes between pages, cross-resource cursor wiring, a twice-imported dependency-complete core fixture, Ecommerce catalog visibility/editing, official address/carrier semantics, authoritative stock, redacted diagnostics, settings persistence, and the typed import job lifecycle. Four executable jsdom regressions inside the 16-test default-theme frontend suite cover reload polling, duplicate-run adoption, transient polling recovery, and bounded repeated-failure stop.
DBAL 4-compatible setup pattern:
protected function setUp(): void
{
$tenant = new Tenant('psapi-test', 'k0smos.example.com', [
'database' => ['driver' => 'pdo_sqlite', 'url' => 'sqlite:///:memory:'],
]);
$this->conn = (new ConnectionFactory())->create(new TenantContext($tenant));
// Apply Psapi migration inline
$migration = new Version20260218000000(/* ... */);
$migration->up(new Schema());
}
17.7 Wpapi WordPress/WooCommerce Import
Wpapi consumes the shared import HTTP transport and outbound URL policy, then
executes entity importers through WpapiImportRunner. The runner is the single
owner of dependency order and exposes woo-core, wp-content, and full;
selective CLI --entity runs preserve that order. wp-content resolves no
WooCommerce importer/client, and the CLI returns failure when any importer
reports failed rows.
WooCommerce product/category/variant/image importers use the same Ecommerce
field matrix and ImportMergePolicy as Psapi. They do not own parallel lock or
conflict tables. Source-owned stock refreshes; tenant-overridable local edits
survive and create reviewable compared-value conflicts; import_locked remains
the hard product/category freeze. Historical non-Woo product images are adopted
as tenant overrides.
The settings panel launches those same profiles through a credential-free
typed message on the shipped default logical queue. The payload contains
only job id, profile, and dry-run; the handler resolves tenant AppSettings at
execution time. If no active worker heartbeat exists, the dispatcher invokes
the same handler inline and reports synchronous mode.
POST /api/admin/wpapi/import requires wpapi.manage plus the
wpapi.settings CSRF intention; GET /api/admin/wpapi/import-status requires
wpapi.view.
Lightweight AppSettings snapshots claim one active job per profile, support
HTTP 409 adoption, and let the shared Psapi/Wpapi admin controller resume
bounded polling after reload. They contain no credentials/source payloads and
are an interim lifecycle mechanism; durable run/entity/checkpoint tables stay
in the next reliability phase.
Source URL continuity uses Page's existing subsystem, exactly like Psapi. The
WooCommerce product/category and WordPress post importers receive
UrlRewriteWriterInterface explicitly and upsert canonical rows in
module_page_url_rewrites under the configured wpapi.default_lang.
Ecommerce renders product/category; Blog contributes the published-only
blog_post renderer. Page resolves flat and nested/dotted source paths through
its existing dispatcher/catchall. Wpapi owns neither a rewrite table nor a
parallel router, and a rewrite row never makes a draft Blog post public.
Localized WooCommerce translation values add per-language canonical rows while
the base payload retains default_lang. Source URL patterns are managed by Page,
not duplicated in Wpapi.
GET /sitemap.xml combines canonical HTTP 200 rewrites with active-module
providers for published native Pages, catalog products/categories, and Blog
posts. Redirect and gone rows are excluded, and a native entity URL is omitted
when that entity already owns an imported source canonical.
17.8 Esapi EasyStore Import
Esapi imports EasyStore contacts into Client and issued invoices into Invoice;
those modules remain the authoritative domain owners. Esapi owns tenant-scoped
connection settings, external-ID mappings, provenance, and synchronization
state. Its canonical execution order remains anagrafica before fattura, and
dry-run execution must not mutate destination or synchronization records.
The client is created only through EasyStoreApiClientFactory over the shared
bounded/no-redirect PSR-18 import transport. Public HTTPS is the default;
private/reserved networks and insecure HTTP require separate warning opt-ins.
Destination policy is checked before every attempt, JSON responses are capped
at 10 MiB, and only transport errors, HTTP 429, and 5xx responses receive at
most three attempts with capped exponential jitter/backoff. Settings save and
connection probe require esapi.manage plus the esapi.settings CSRF
intention. Operator responses are translated and never contain upstream bodies,
API keys, target identifiers, or transport exception details.
The upstream contract is not yet certified. The evidence matrix and precise
unblocking requirements live in modules/Esapi/src/VENDOR-CONTRACT.md. Current
paths, envelopes, and status handling are legacy implementation observations;
pagination, timezone, incremental-window, quota, schema, idempotency, webhook,
and sandbox rules must not be invented until an approved versioned vendor
export, sandbox, and redacted fixtures are archived.
18. Admin Settings And Permission APIs
18.0 Admin Dashboard
The /admin route is the administrative home, not an ACL inventory.
- it shows contextual shortcuts to profile, settings, system info, settings editing, users, and roles based on effective permissions
- it exposes system/operational summaries; the full personal ACL summary moved
to
/admin/user-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.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 - 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 connectors overview (host-gated; read/write through /api/settings/agent-connectors) |
| admin_theme_sober | /admin/theme/sober | system.read | Sober admin theme overview contributed by theme_sober |
| admin_theme_sober_settings | /admin/theme/sober/settings | authentication | Sober user preferences: mode, density, and initial sidebar state |
UI notes:
- admin pages
settings,settings/info, 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
- 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()loads permissions and modules; editable settings are then managed through Alpine.js APIsfeatureFlags()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 Klaro configuration |
POST |
/api/settings/klaro |
system.write |
Saves Klaro |
GET |
/api/settings/social |
system.read |
Reads social links |
POST |
/api/settings/social |
system.write |
Saves social links |
GET |
/api/settings/modules |
system.read |
Reads requested/effective runtime modules |
POST |
/api/settings/modules |
system.write |
Saves runtime module state |
GET |
/api/settings/features |
system.read |
Reads global feature flags |
POST |
/api/settings/features |
system.write |
Saves existing global feature flags |
GET |
/api/settings/agent-connectors |
system.read |
Reads the local agent CLI connector registry + enablement-gate status |
POST |
/api/settings/agent-connectors |
system.write |
Saves the connector registry (enable_secret, connectors) |
POST |
/api/settings/agent-connectors/test |
system.write |
CLI version health probe (does not require gate unlock) |
POST |
/api/settings/agent-connectors/run |
agent.run |
Executes a non-interactive prompt (gate-enforced) |
Operational note:
- feature flags are logical/global toggles read from
enabled_features - they do not replace runtime modules and do not stop bootstrap, routes, or services
- plain module-name feature flags such as
blog,bookmark, 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-aware
Scope and evaluation paths
Feature flags are global per tenant, not per-user. The enabled_features
table holds one row per flag for the whole tenant database (code is unique);
there is no user dimension — configured_by is audit only ("who toggled it"),
not "for whom".
All runtime gating resolves through a single service,
K0smos\Application\System\FeatureFlagResolver, which applies the canonical
defaults in K0smos\Domain\System\FeatureFlagDefaults over the global state read
from the repository. Centralising here means defaults and state are computed in
one place and cannot drift between call sites, and the store being unavailable
degrades to defaults instead of failing the render.
| Path | Entry point | Consumers |
|---|---|---|
| Template globals | FeatureFlagsViewDataProvider → FeatureFlagResolver::resolve() → featureFlags view key |
Theme templates (e.g. show_legal_footer in the front footer) |
| Menu / slot gating | UserFeatureFlagChecker → FeatureFlagResolver::isEnabled() |
MenuItem::$feature gating, TemplateIntegration::$featureFlag slot gating (TemplateSlotRenderer) |
| Admin CRUD | DbalFeatureFlagRepository (findAll/findStates/findKnownCodes/updateStates) |
GET|POST /api/settings/features, admin UI; findStates() also backs the resolver |
User::$enabledFeatures / User::hasFeature() are not a gating path: they
carry a read-time snapshot of the global set (computed with the same
FeatureFlagDefaults) used only by display surfaces (user settings, debug
tracer) and are the carrier reserved for future per-user overrides.
Default semantics: a code listed in FeatureFlagDefaults defaults to its declared
value; an explicit row in enabled_features always overrides the default; codes
not listed there default to disabled when absent. The admin API only writes
known codes (findKnownCodes()), i.e. codes already present in the table — it
cannot mint arbitrary flags.
Adding a new feature flag
- 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. - 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
enforced via verified CLI sandbox flags. Full reference:
src/App/Integration/AgentConnector/AI.AgentConnectors.md.
18.3 Permission APIs
RESTful API for permission management (PermissionApiController):
| Method | Path | Permission | Action |
|---|---|---|---|
GET |
/api/permissions |
system.read |
Lists all permissions with module info |
POST |
/api/permissions |
system.write |
Creates permission (module_id, action, name, description) |
PUT |
/api/permissions/{id} |
system.write |
Updates permission (action, name, description) |
DELETE |
/api/permissions/{id} |
system.write |
Deletes permission |
Permission code is automatically generated as {module_code}.{action} at
creation. All responses are JSON.
18.4 Direct User Permissions And Runtime Modules
Admin user management now supports both direct per-user permissions and per-user runtime module access, separate from permissions inherited from roles:
- Page:
/admin/users/{id} - API:
POST /api/admin/users/{id}/permissions - API:
POST /api/admin/users/{id}/modules - Storage:
user_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, andcc1themes load Klaro locally from the npm package, without external CDNs. - Recommended default for
testingisfalse; settrueonly for preview/debug. - 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. - Canonical footer pattern for reopening Klaro: a button conditional on
klaro.enabled+klaro.configthat callswindow.klaro.show().
20. Key Namespaces
| Namespace | Purpose |
|---|---|
K0smos\* |
Core framework (Kernel, Middleware, Router, Menu, Http\Error) |
K0smos\Tenant\* |
Multi-tenancy (TenantContext, TenantResolver, Tenant) |
K0smos\Theme\* |
Theme engine (ThemeEngine, ThemeEngineFactory) |
K0smos\ViewModel\* |
Response rendering (ViewModel) |
K0smos\Breadcrumb\* |
Breadcrumb system (BreadcrumbManager, BreadcrumbTrail) |
K0smos\Rendering\* |
View data aggregation (BaseViewDataAggregator, PlatesHelperRegistry) |
K0smos\Menu\* |
Menu system (MenuItem, MenuBuilder, MenuSerializer, MenuHookRegistry) |
K0smos\Http\Error\* |
Error handling (HttpException, ErrorResponseGenerator, ErrorHandlerFactory) |
K0smos\Domain\AccessControl\* |
Auth domain (User, Role, Permission, AuthorizationChecker, PolicyVoter) |
K0smos\Application\ModuleRuntime\* |
Runtime module state resolution and admin toggles |
K0smos\Domain\ModuleRuntime\* |
Module runtime persistence contracts |
K0smos\Domain\Queue\* |
Queue contracts (QueueManagerInterface, QueueInterface, JobInterface, MessageBusInterface, QueueMessageInterface) |
K0smos\Domain\Search\* |
Search contracts (SearchEngineInterface, SearchSourceInterface, SearchQuery, SearchResult, SearchHit) |
K0smos\Application\Search\* |
Search registry (SearchSourceRegistry aggregates sources by context) |
K0smos\Infrastructure\Search\* |
Search sources (BlogSearchSource, ProductSearchSource, Sql/SqlSearchEngine) |
K0smos\Application\Queue\* |
Queue workers + typed bus (QueueWorker, JobHandlerInterface, MessageHandlerInterface, registries, AsyncMessageBus) |
K0smos\Infrastructure\Queue\* |
Queue implementations (DBAL, Redis, Redis-to-SQL failover) |
K0smos\Module\Notification\* |
Notification bounded context (in-app/email channels, preferences, listeners, API contracts) |
K0smos\Domain\Sales\* |
Sales domain (Purchase VO, PurchaseItem VO, PaymentStatus enum, PurchaseRepositoryInterface) |
K0smos\Payment\* |
Isolated native payment kernel contracts, DTOs, status guard, token strategy |
K0smos\Application\Payment\* |
Payment orchestration (PaymentCheckoutService) |
K0smos\Infrastructure\Payment\* |
Native gateway adapters, DBAL payment intent/attempt/webhook repositories |
K0smos\Domain\Psapi\* |
Psapi domain (SyncLog, SyncLogRepositoryInterface) |
K0smos\DataFixtures\* |
Fixture system (FixtureInterface, FixtureRunner; group-aware, tenant-aware, ordered, transactional) |
App\AI\* |
AI module core (AiClientInterface, native HTTP providers, ChatService, text-revision services, Decorators) |
21. Key Patterns (Code Reference)
Discovery is an optional, disabled-by-default content navigator, depending on
Media and Widget while booting without Ai or optional content owners. Its
Default/Sober backoffice at /admin/discovery manages draft/published collections,
selected result/evidence sources and typed visitor limits. Source writes require
discovery.manage_sources; publishing or changing an already public collection
also requires discovery.publish, including policy-voter approval. Settings
require discovery.configure. Administrative writes use discovery.admin CSRF
protection and optimistic revisions prevent lost source edits.
GET /explore displays the minimal Default-compatible navigator; native POST
forms work without JavaScript and share retrieval with POST /api/discovery/search.
POST requests use discovery.public CSRF protection. Search text is sent in the
body, responses are no-store, and the explore slug is reserved while Discovery
is active. Products and posts are distinct results; Pages are selectable as
supporting evidence only. Active-owner interfaces preserve localized publication,
current canonical URLs, tax-inclusive base-currency prices and normalized
inventory. Product constraints apply to one variant and do not become editorial
requirements. Missing values never satisfy a constraint or an exclusion.
Retrieval chooses at most 200 qualifying candidates per source, scanning bounded 201-row owner batches up to 10,000 rows before requiring more specific search text. Exact public titles/SKUs rank first, then matched criteria and reciprocal lexical order with stable identity ties. Counts explicitly describe this bounded selection. Final hydration rechecks source fingerprints and publication; a changed corpus/result revision rejects pagination with HTTP 409. Canonical records stay authoritative, including imported content; no public request contacts a connector.
Revisioned visitor preferences and idempotent conversation now use temporary SQL
state owned by the browser PHP session. The Default page and structured
discovery_chat widget share state per collection and locale. Public AI requires
an explicitly enabled discovery.interpret scope, a supported Ollama/OpenAI
model, reviewed tariffs and positive quota/budget. Until operator-preset guided
questions replace it, the free-text turn is restricted to authenticated holders
of discovery.configure (administrators by default). Clients receive a
server-computed free_text flag, and other visitors use editable criteria and
native search. Every call reserves its maximum
configured cost before traffic; timeouts retain reservations. Reset invalidates
pending replies without refunding daily usage. Manual navigation remains usable
without AI or JavaScript. Office/PDF/text ingestion now uses Media originals and
shared bounded extraction, including real OCR and structural citations. The
Default/Sober document panel offers private version history, selective approval,
current canonical links, replacement, retry and revocation. Only complete current
extractions can publish approved chunks; Media originals retain their own access
policy. The enforced document worker currently requires Linux; macOS/Windows use
a Linux container. See src/App/AI/Document/AI.DocumentExtraction.md for parsers
and deployment. Hybrid retrieval combines current document/graph evidence with
immutable semantic candidates and canonical result checks. Public embeddings use
shared durable budgets and visitor-owned reference caching. Partial retrieval is
distinguished from an exhaustive empty result, and graph updates run independently
of vector activation. Projection and graph administration now expose bounded
recovery, parity, reviewed switching/rollback and separate relation approval.
discovery:work inspects status by default; periodic --advance recovers lost
wakeups, due retries and cleanup. discovery:projection shares explicit operator
actions. Optional AI relations require explicit discovery.relations, separately
confirmed tariffs and positive daily ceilings, and create only proposals from
selected current evidence. Human publication remains independent. The reviewed
54-query IT/EN comparison passes with all four expected document citations and
zero degraded queries; the persisted activation report enforces those conditions.
Ordered SQL and point identity indexes bring the 10,000-content synthetic fixture
to 31 ms graph / 367 ms retrieval p95, excluding embedding. Real-owner SQL capacity,
model qualification and production asset checks are separate deployment concerns;
methods and limits are recorded in the module's evaluation document.
Qdrant stores derived vectors and identity metadata in one collection per immutable
Discovery generation. The tenant SQL connection owns the graph, review decisions,
projection ledgers and temporary preferences; Media owns original files. The
measured Qwen3-Embedding 0.6B Q8 / 1,024-dimensional Ollama configuration is
separate from the visitor interpretation model. Interpretation produces validated
preference patches; relation generation produces proposals for human review.
Saving limits makes no AI call. Doctrine owns schema; canonical content and original Media remain
with their existing owners. See modules/Discovery/src/AI.Discovery.md and the
activation gates in doc/public/en/architecture/vector-search.md.
Content edits/imports now write identity-only intent in the canonical transaction.
Ecommerce projection handoff and Discovery freshness have independent committed
checkpoints and retries. The default worker drains at most 100 deliveries every
five seconds; worker failure leaves intent queued. Core recovery replays bounded
canonical IDs and cleans up missing tracked identities with a durable cursor.
content:changes exposes trusted CLI diagnostics/retry/recovery. Discovery's
admin update panel operates only its subscriber; source replay also requires
blog.edit, page.edit or ecommerce.edit. Migration order, recovery semantics
and the remaining external-write fencing limit are documented in
src/Application/ContentChange/AI.ContentChange.md.
21.1 ModuleInterface
// src/Module/ModuleInterface.php
interface ModuleInterface
{
public function registerRoutes(RouteCollection $collection): void;
public function getServiceDefinitions(): array; // PHP-DI definitions
public function getMenuDefinitions(): array; // Menu items by channel
}
21.2 Route Definition
// config/routes.php
$defs[] = [
'name' => 'route_name',
'path' => '/path',
'defaults' => [
'_controller' => MyController::class,
'_method' => 'handle', // Controller method to call
'_theme' => 'front|admin|utility', // Theme resolution (explicit wins)
'_template_group' => 'group_name', // Template subdirectory
'_route_type' => 'html|api', // 'api' skips theme
'_auth_required' => true, // Require authentication
'_permission' => 'module.action', // Required permission
'_permission_all' => ['a.use', 'b.read'], // Optional: all codes required
'_permission_any' => ['a.write', 'b.own'],// Optional: at least one required
],
'methods' => ['GET'],
'priority' => 1000, // Higher = matched first
'intl' => true, // Enable i18n prefix
'requirements' => ['id' => '\d+'], // Param constraints
];
Core and active-module routes are merged and then stably ordered by this
priority across the complete collection. Ties retain registration order and the
framework catch-all is appended last. This global pass is required for specific
module paths such as /it/docs to win over broad routes such as
/{_locale}/{slug} even when the broad route's module registered first.
When no route matches, the framework catch-all consults a single core-owned
K0smos\Router\ChainUnmatchedPathResolver before rendering a 404. The chain is
built from the resolvers that active modules contribute through
UnmatchedPathResolverProviderModuleInterface; they run in module order and
the first non-null result wins, so an empty chain simply declines and the normal
404 renders. Page's PageUnmatchedPathResolver — URL rewrites for multi-segment
and dotted source URLs — is the reference implementation.
A module must never rebind UnmatchedPathResolverInterface in its own
services.php. Module definitions merge into one array, so a second contributor
would silently overwrite the first, and overriding a core-owned key also makes
ContainerFactory::guardDuplicateDefinitionKeys() log a permanent boot warning
it cannot distinguish from an accidental collision.
21.3 MigratableModuleInterface
// Implement to expose Doctrine Migrations paths for auto-discovery
interface MigratableModuleInterface
{
/** Returns ['Namespace' => '/absolute/path'] for Doctrine Migrations */
public function getMigrationPaths(): array;
}
// Module registration exposes module-owned migration paths, usually
// ROOT_DIR/migrations/{Name}.
// Run only module migrations: php bin/console migrate --module=psapi
21.4 SearchableModuleInterface
// Implement to auto-register searchable content in SearchSourceRegistry
interface SearchableModuleInterface
{
/** @return array<class-string<SearchSourceInterface>> */
public function getSearchSourceClasses(): array;
}
// SearchSourceRegistry factory calls ModuleRegistry::collectSearchSourceClasses()
// and resolves each class from the DI container automatically.
// Built-in content sources: BlogSearchSource (front context), ProductSearchSource (admin context).
// Admin navigation search is also built from MenuRenderer + MenuBuilder.
// SearchHit can carry a canonical per-hit path; controllers fall back to getListingPath() only when path is null.
// Endpoints: GET /search (public, front), GET /admin/search (auth required, admin).
Notes:
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)
Architecture
The payment system uses a native k0smos payment kernel isolated under
src/Payment. Gateway adapters live under
src/Infrastructure/Payment/Native/Gateway, while
PaymentCheckoutService is the application boundary that maps payment results
back to Sales Purchase and Order state.
HTTP flow:
GET /checkout -> CheckoutController::handle() - cart summary + gateway selection
POST /checkout -> CheckoutController::initiate() - creates Purchase + native PaymentIntent -> redirect to provider
[gateway page] -> customer pays
GET /payment/return/{gateway} -> PaymentController::return() - verifies local payment_token, refreshes/captures provider status
GET /payment/done -> PaymentController::done() - reads local Purchase status
POST /payment/notify/{gateway} -> PaymentController::notify() - retains webhook, queues processing, always 200
Security and isolation:
- Return and cancel callbacks trust only the local signed
payment_token. - The token carries the local intent and purchase ids plus an expiry claim; the stored intent hash must match the received token before a return is accepted.
- Provider query parameters are treated as hints and are never authoritative by themselves.
- Webhooks are stored in
payment_webhook_eventsbefore processing, then handled by the typed queue messageProcessWebhookEvent. - 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.GET|POST /api/settings/modulescarry the revision and stale writes return HTTP 409. - Disabling a runtime module removes it from bootstrap on the next request, so its routes, menus, services, and search sources are not registered.
- The dedicated App panel at
/admin/settings/app("App" item under "Settings") shows tenant-filtered modules with summary cards, filter tabs (all/active/inactive/blocked), toggle controls, and a DB-backed "activate all" action for the tenant-visible module inventory; it persists 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. 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 none), andtests/Unit/Theme/FrontendBuildPolicyContractTest.phpderives both rules from the canonical inventory. - 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']. 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.