← Developers

03.2 — Inbound events: POST /actions/:trigger

Spec matching SPEC-DX-01 / api v1

The Invariant

There is one write path and this is it. POST /api/v1/actions/:trigger on the edge node is the sole way a business fact enters the system — from the operator dashboard, a machine integration, or any future client. If you are writing anything that bypasses this endpoint, you are building the bug this architecture exists to prevent.

The Contract

POST /api/v1/actions/:trigger
Authorization: Bearer <operator session>
X-Idempotency-Key: <uuid per logical gesture>     # same key = same gesture, safe retry
Content-Type: application/json

{ "aggregateId": "<optional uuid>",
  "payload": { /* trigger-declared fields only — context comes from session */ } }
type ActionResult =
  | { status: 200, body: object }                        // applied (row, to_state, …)
  | { status: 400, code: 'ACTION_FAILED' }               // constraint/validation — fail-closed
  | { status: 401, code: 'AUTH_REQUIRED' }               // missing/expired session
  | { status: 403, code: 'ACTION_FAILED' }               // RBAC denial
  | { status: 409, code: 'ACTION_FAILED' }               // FSM transition conflict
  | { status: 409, code: 'IDEMPOTENCY_IN_PROGRESS' }     // same key still processing — retry
  | { status: 429, code: 'RATE_LIMITED' }                // honor Retry-After
  | { status: 503, code: 'NO_ENGINE' };                  // no manifest loaded on the node

Payload rules: payload is the trigger’s input — tenant_id, operator_id, device_code come from the session ($context), never the body; expressions read it as $payload.*. There is no version optimistic-lock field: ordering is enforced by the FSM — an out-of-state trigger is rejected with 409.

The Flow

client → trigger + payload + idempotency-key
  → edge: session → RBAC → constraint → FSM → data-link → txn(write+audit+outbox)
  → 200: fact committed, outbox entry already durable
retry on network drop: same X-Idempotency-Key → same outcome, no duplicate fact

Payload Example

# QC_PASS — closes the inspection; the constraint engine checks the measure
curl -X POST http://edge:8291/api/v1/actions/QC_PASS \
  -H "Authorization: Bearer $TOKEN" -H "X-Idempotency-Key: $(uuidgen)" \
  -H 'content-type: application/json' \
  -d '{"payload":{"piece_id":"PZ-001","misura":0.018}}'
# misura 0.026 → the rule `tolleranza_collaudo` rejects: fail-closed, no commit.
// Node — the whole gesture, retry-safe
const r = await fetch(`${edge}/api/v1/actions/QC_PASS`, {
  method: 'POST',
  headers: { authorization: `Bearer ${token}`,
             'x-idempotency-key': gestureKey,   // REUSE the key on retry
             'content-type': 'application/json' },
  body: JSON.stringify({ payload }) });
if (r.status === 409) { /* FSM conflict — the aggregate is in another state */ }
if (r.status === 400 || r.status === 403) { /* read j.error — constraint or RBAC */ }

Known Failures

Code Meaning → corrective action
409 ACTION_FAILED FSM transition not allowed from the current state → inspect the manifest transitions
403 ACTION_FAILED Role lacks the trigger → manifest rbac_engine.permissions
400 ACTION_FAILED A pre_commit_rule or validation failed → read error, resolve the blocker first
409 IDEMPOTENCY_IN_PROGRESS Same key still in flight → back off and retry with the same key
Duplicate facts after retry You changed the X-Idempotency-Key on retry — same gesture, same key
401 AUTH_REQUIRED mid-session Re-login; do not retry the same dead token in a loop