The Invariant
A business mutation either commits atomically — FSM transition, data-link write, audit row, CDC outbox entry — or it does not exist. There is no half-applied state: a failed constraint rolls back the transaction and leaves only the audit evidence. The same Idempotency-Key always returns the same outcome.
The Contract
POST /api/v1/actions/:trigger on the edge is the single mutation surface. Every request crosses the same fail-closed pipeline, in order:
auth → idempotency → RBAC → constraint → FSM → data-link → audit → commit
A rejection at any stage aborts the request — nothing downstream runs.
The Flow
- auth — operator PIN or device key resolves tenant + roles from the authenticated session, never from payload fields.
- idempotency — the
Idempotency-Keyheader is deduplicated; a replay returns the original response. - RBAC — the manifest
rbac_enginepermissions map roles → allowed triggers;OPERATOREcannot fireQC_PASS. - constraint —
pre_commit_rulesbound to the trigger are evaluated; a violation returns422 CONSTRAINT_VIOLATED. - FSM — the trigger must match a declared
from → totransition for the aggregate’s current state. - data-link — the manifest query for the trigger executes inside the same transaction.
- audit + commit — audit row and
ax_cdc_outboxentry are written with the commit; sync drains later.
Payload Example
# Quality operator closes an inspection — the constraint engine checks the measure
curl -X POST http://edge:8291/api/v1/actions/QC_PASS \
-H "Authorization: Bearer $EDGE_TOKEN" \
-H "Idempotency-Key: qc-L0043-001" \
-H 'content-type: application/json' \
-d '{
"payload": {
"piece_id": "PZ-9044",
"misura": 0.018
}
}'
# → 200 { committed: true, to_state: "CONFORME" }
#
# The same call with "misura": 0.026 violates `tolleranza_collaudo`
# → 422 { error: "CONSTRAINT_VIOLATED", rule: "tolleranza_collaudo" }
# → no commit, no business row — the audit chain keeps the attempt
Known Failures
| Symptom | Cause |
|---|---|
401 |
Missing/expired session — login again via /api/v1/auth/login |
403 |
Role lacks the trigger in rbac_engine.permissions — it is a denial, not a bug |
409 |
FSM transition not declared from the current state (e.g. QC_PASS before START_MEASURE) |
422 CONSTRAINT_VIOLATED |
A pre_commit_rule failed — the payload is outside the declared envelope |
| Retry loop succeeds silently | Same Idempotency-Key returned the cached original response — by design |