Il manifest è il “bytecode” dichiarativo del verticale. Viene validato a caricamento contro uno JSON Schema, verificato nella firma crittografica e vincolato a kernel_min_version. Tutto ciò che il kernel esegue deriva da questo documento — non esiste logica di dominio nel motore.
Struttura
{
"$schema": "https://arqen.dev/schema/v1/layout-manifest.json",
"meta": {
"tenant_id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
"vertical_code": "RETAIL_POS_STANDARD",
"version": 1,
"kernel_min_version": "1.0.0"
},
"engines": {
"formula_engine": { "...": "espressioni" },
"action_engine": { "states": [], "transitions": [] },
"data_link_engine": { "queries": {} },
"rbac_engine": { "roles": [], "permissions": {}, "ui_visibility": {} },
"constraint_engine":{ "pre_commit_rules": [] }
}
}
meta.version è monotona: il Control Plane accetta solo versioni strettamente maggiori di quella attiva, e gli edge rifiutano versioni vecchie o manomesse.
Sintassi delle espressioni
Namespace riservati risolti dal kernel a runtime:
| Prefisso | Sorgente |
|---|---|
$payload.* |
dati della richiesta corrente |
$context.* |
contesto di esecuzione (tenantId, operatorId, deviceId…) |
$state.* |
stato corrente dell’aggregato (letto da sys_fsm_state, mai dal client) |
$db.* |
lookup lettura su DB (solo SELECT whitelisted) |
$formula.* |
output delle espressioni del formula_engine |
Operatori consentiti: aritmetici (+ - * / %), confronto (== != < <= > >=), logici (&& || !), ternario ? :. Mai eval() o new Function(): le espressioni passano da un parser sicuro dedicato.
Motori
formula_engine
Record<nome, espressione> — es. "tax": "subtotal * (tax_rate / 100)". I risultati sono esposti come $formula.nome a regole e mapping. Le formule girano nella pipeline dell’azione, prima del passo Data-Link.
action_engine
{
"states": ["INITIAL", "PROCESSING", "COMPLETED", "CANCELLED"],
"transitions": [
{ "from": "INITIAL", "to": "PROCESSING", "trigger": "CREATE_ORDER" },
{ "from": "PROCESSING", "to": "COMPLETED", "trigger": "FINALIZE_PAYMENT" },
{ "from": "PROCESSING", "to": "CANCELLED", "trigger": "CANCEL_ORDER" }
]
}
Lo stato corrente è persistito in sys_fsm_state (chiave: aggregate_id). Il client non può dichiarare uno stato arbitrario — ogni transizione è un passo imposto dal kernel.
data_link_engine
"queries": {
"CREATE_ORDER": {
"type": "INSERT",
"table": "biz_orders",
"mapping": { "total": "$formula.grand_total", "operator_id": "$context.operatorId" }
}
}
Regole di sicurezza:
tabledeve appartenere alla whitelist dello schema del tenant;tenant_idè iniettato dal kernel, mai dal manifest;- tipi consentiti:
SELECT | INSERT | UPDATE | DELETE; - le
SELECTsono stateless: non richiedono una transizione FSM; - ogni scrittura genera l’evento
sys_cdc_outboxnella stessa transazione; - opzionale
"required_plugin": "MODULE_ADV_INVOICE"→ gate licensing prima dei motori.
Optimistic locking (version_column): su UPDATE/DELETE dichiara la colonna versione della tabella. Il kernel aggiunge AND <col> = $payload.<col> al filtro e, su UPDATE, incrementa <col> = <col> + 1 in automatico — il manifest dichiara l’intento, il kernel garantisce la meccanica:
"COMPLETE": {
"type": "UPDATE",
"table": "fs_work_orders",
"mapping": { "status": "COMPLETATO" },
"filter": "id = $payload.workOrderId",
"version_column": "version"
}
- il payload DEVE portare
payload.<col>intero (la versione letta dal client) — mancante o non intero → errore fail-closed; - record esistente ma versione non corrispondente →
VersionConflictError→ HTTP 409 (lost update intercettato, non sovrascrittura silenziosa); - query senza
version_columnrestano invariate.
rbac_engine
{
"roles": ["WAITER", "CASHIER", "STORE_MANAGER"],
"permissions": { "CASHIER": ["CREATE_ORDER", "FINALIZE_PAYMENT"], "STORE_MANAGER": ["*"] },
"ui_visibility": { "btn_apply_discount": ["CASHIER", "STORE_MANAGER"], "btn_void_order": ["STORE_MANAGER"] }
}
ui_visibility guida il rendering del client; permissions è enforcement lato kernel — il client non è trusted.
constraint_engine
"pre_commit_rules": [
{ "id": "chk_stock", "expression": "$payload.qty <= $db.product_stock",
"error_message": "Quantità superiore alla disponibilità.",
"triggers": ["CREATE_ORDER"] }
]
Fail-closed: una sola false → abort prima di qualsiasi write. triggers è opzionale: se presente la regola si applica solo a quei trigger, altrimenti è globale.
Insidie pratiche (scoperte sul secondo verticale)
Regole empiriche per chi scrive manifest, emerse implementando un verticale field-service sullo stesso kernel:
||ritorna booleano, non coalesce. Per i valori di default usa il ternario:$db.x == null ? 0 : $db.x(l’uguaglianza è lasa:null == undefined).NUMERICarriva da PostgreSQL come stringa.+concatena se un operando è stringa → un lookupSUM(...)va castato::float8se alimenta una formula additiva (*//coercizzano a numero,+no).- Lookup per risolvere FK dal payload:
filterammette subquery — es.id = (SELECT technician_id FROM fs_work_orders WHERE id = $payload.workOrderId). - Self-transition (
from == to, es.IN_LAVORO → IN_LAVOROsuADD_PART) modella azioni “dentro lo stato” senza cambiare la FSM. - Multi-
fromstesso trigger: una diramazione comeANNULLATOda tre stati si esprime con tre transizioni col medesimotrigger.