Skip to content
Docs/CITADEL Governance/MARSHAL Engine
Edit on GitHub

MARSHAL Engine

MARSHAL is CITADEL's 5-gate cryptographic authorisation engine. Every privileged action from every platform in the opensecstack ecosystem passes through it before execution. The outcome — EXECUTE, REFUSE, or HARD_STOP — plus the gate-by-gate reasoning is unconditionally appended to the WORM audit chain at Gate 5, regardless of whether prior gates passed.

Note: MARSHAL was called ARBITER in early CITADEL designs. The renaming happened before v1.0.0. If you encounter "ARBITER" in an older ADR or external reference, it refers to this engine. All code, logs, and metrics use MARSHAL exclusively.

Overview

Each governance request — called a Kerkese — flows through five sequential gates. Gates 1 through 4 can short-circuit the outcome verdict, but evaluation always continues through to Gate 5. Gate 5 always runs: an unrecorded rejection is worse than an unrecorded execution because it leaves no trace of attempted abuse. The final outcome is the maximum severity observed across all gates.

MARSHAL is invoked by calling POST /api/v1/marshal/evaluate on port 8099. See the CITADEL Integration guide for how platforms register and connect. Every platform connector must include an X-Citadel-Signature HMAC-SHA256 header computed over key_id + timestamp + body_hash. Requests outside a ±300-second timestamp window are rejected before any gate runs.

Gate sequence

MARSHAL gate sequence
Bash
Kerkese → Gate 1 (AuthN) → Gate 2 (AuthZ) → Gate 3 (NDS) → Gate 4 (AUGUR) → Gate 5 (WORM)
          (session)     (RBAC)         (SoD)        (heuristics)   (audit — always runs)
                                                                           ↓
                                                                    Final decision written
GateNameResponsibilityFail verdict
1AuthNEd25519 session verification — confirms actor.user_id has a valid session and the claimed role matches the session's recorded roleREFUSE
2AuthZRBAC permission check — confirms the actor's role is permitted to perform the requested action.typeREFUSE
3NDSSeparation of Duties — operator_user_id ≠ verifier_user_id and the two identities must belong to different role groupsHARD_STOP
4AUGURBehavioural heuristics — flags off-hours actions, high-frequency submissions, and DATA_EXPORT without a linked incidentWARN / HARD_STOP
5WORMUnconditional append — writes the full decision (Kerkese, outcome, all gate results, reasons) to the immutable audit chain regardless of the outcomeWARN (does not reverse prior outcome)

Gate 1 — AuthN (Authentication)

Gate 1 asks: does actor.user_id have a valid session, and does the claimed role match what the session recorded?

Implementation: Store.SessionExists(ctx, userID) returns the session's recorded (role, roleGroup, exists, err) tuple. If the lookup returns an error, or no session exists, or the claimed role in the Kerkese does not equal the session role, Gate 1 fails.

ConditionReason string
Session lookup errorAUTH_ERROR: session lookup failed for user_id=N
No session existsAUTH_FAIL: no valid session for user_id=N
Claimed role ≠ session roleAUTH_FAIL: claimed role "X" does not match session role "Y"

Outcome on fail: REFUSE. Approximate latency on an in-memory store: ~0.8 µs.

Gate 2 — AuthZ (Authorisation)

Gate 2 asks: is actor.role permitted to perform action.type? The check is implemented as an in-code RBAC map (roleAllowed(role, actionType)). Typical mapping: admin can perform any action; operator can execute CONTAIN, PATCH, CREATE_INCIDENT; an verifier can execute VERIFY.

If the role/action pair is absent from the map, Gate 2 returns REFUSE with reason AUTHZ_FAIL: role "X" is not permitted to perform "Y". Approximate latency: ~0.3 µs.

Gate 3 — NDS (Separation of Duties)

Gate 3 enforces the two-key principle: the action must be backed by two distinct identities that belong to different role groups. The checks run in order:

internal/marshal/marshal.go (Gate 3 sketch)
Go
"cm">// 1. Same-identity check — HARD_STOP
if sod.OperatorUserID == sod.VerifierUserID {
    return HARD_STOP("NDS_SAME_IDENTITY: operator and verifier are the same user")
}

"cm">// 2. Both parties must have valid sessions
opSession := store.SessionExists(operatorUserID)
vfSession  := store.SessionExists(verifierUserID)

"cm">// 3. Role-group check — HARD_STOP
if opSession.roleGroup == vfSession.roleGroup && opSession.roleGroup != "unknown" {
    return HARD_STOP("NDS_SAME_GROUP: operator and verifier are both in role group X")
}
ConditionStatusReason
Operator ID == verifier IDHARD_STOPNDS_SAME_IDENTITY: operator and verifier are the same user
Either user lacks a valid sessionFAILNDS_FAIL: operator/verifier has no valid session
Both users in the same role group (not "unknown")HARD_STOPNDS_SAME_GROUP: operator and verifier are both in role group "X"

Same-identity is a HARD_STOP, not a REFUSE, because passing the same user ID for both parties is a deliberate attempt to defeat the separation control. Downgrading it to REFUSE would invite a retry loop. A HARD_STOP escalates to IRFlow's P1 incident auto-creation and a project freeze. Approximate Gate 3 latency: ~1.2 µs.

Gate 4 — AUGUR (Behavioural Heuristics)

Gate 4 applies pre-emptive behavioural analysis. AUGUR reads from a read-only mirror database and does not modify state. Three rules are evaluated in sequence; rule_03 can override the result of rules 01 and 02.

RuleTriggerStatus
rule_01Action initiated outside 07:00–19:00 UTCWARN
rule_02Same actor performed more than 10 actions in 5 minutesWARN (appended to existing reason)
rule_03action.type == "DATA_EXPORT" AND action.incident_id is emptyHARD_STOP

rule_03 overrides anything set by rules 01 and 02 because data exfiltration without an incident context is the highest-risk pattern MARSHAL tracks. A WARN status from rules 01 or 02 does not block execution on its own — Gate 4's contribution is additive. The final outcome becomes REFUSE only if an earlier gate said so; HARD_STOP from rule_03 takes precedence unconditionally. Approximate Gate 4 latency: ~1.5 µs.

Gate 5 — WORM (Audit)

Gate 5 commits the full decision to the append-only WORM chain:

Gate 5 append call
Go
Store.AppendWORM(ctx, source, eventType, projectID, payload)
"cm">// payload = full JSON of the decision:
"cm">//   kerkese, outcome, all five gate results, reason strings, latencies

If the WORM append fails (e.g. the database is temporarily unreachable), Gate 5 logs a WARN result on itself but does not reverse the outcome of gates 1–4. The decision still stands; only the audit trail for that specific entry is lost. The metric citadel_worm_append_failures_total increments and an alert fires — Ops must resolve this urgently.

The project_id field defaults to "citadel" when the Kerkese carries none. This lets auditors partition chain queries per tenant, service, or regulator jurisdiction without scanning the full log.

Gate 5 is the dominant performance cost. With a synchronous PostgreSQL 16 write the typical latency is 4.22 ms, dwarfing the sub-2 µs total of gates 1–4. Candidates for v1.1+ optimisation: batched appends and per-project_id sharded chains (v2.0).

Kerkese schema

A Kerkese (Albanian: request) is the canonical envelope every caller sends to POST /api/v1/marshal/evaluate. The schema is at v2.0.

FieldTypeRequiredNotes
execution_idUUIDRecommendedCaller-supplied idempotency key. CITADEL generates one server-side if absent, but callers should always provide their own so retries across CITADEL restarts remain coherent.
project_idstringNoLogical WORM chain partition. Defaults to "citadel".
actor.user_idint64YesPrimary key into CITADEL's session store. Gate 1 looks up the session.
actor.rolestringYesClaimed role. Gate 1 rejects if it does not match the session; Gate 2 uses it for RBAC.
sod.operator_user_idint64ConditionalThe initiating user. Must equal actor.user_id. Required for SoD-sensitive action types.
sod.verifier_user_idint64ConditionalThe approving counterparty. Must differ from operator_user_id and belong to a different role group.
action.typestringYesCanonical verb: CREATE_INCIDENT, CONTAIN, RESTORE, DATA_EXPORT, VERIFY, etc.
action.incident_idstringConditionalScope binding. Required for DATA_EXPORT (AUGUR rule_03 causes HARD_STOP if absent). Recommended for every mutation.
action.payload_hashstringNoDigest of the caller's action payload (e.g. sha256:...). Not used for gate evaluation — anchored to WORM for forensics.
dry_runbooleanNoWhen true, MARSHAL returns a full Decision but skips the Gate 5 WORM append. Never true on a production path.
ts_utcRFC3339YesTime the caller built the Kerkese. AUGUR rule_01 uses the hour portion to check operating-hours compliance.

At ingress, CITADEL validates: JSON parses; actor.user_id > 0; actor.role != ""; action.type != ""; ts_utc is valid RFC3339. All other checks are gate-level.

Worked example

The following Kerkese represents IRFlow asking CITADEL to authorise a containment action on incident inc_2026_0123. Operator user 42 (role: operator) initiates; verifier user 77 (a different role group) approves.

POST /api/v1/marshal/evaluate — request body
Bash
{
  "execution_id": "7e9a9a7e-2a1f-4c13-9f60-5a1f2e0d1a98",
  "project_id":   "prod",

  "actor": {
    "user_id": 42,
    "role":    "operator"
  },

  "sod": {
    "operator_user_id": 42,
    "verifier_user_id": 77
  },

  "action": {
    "type":         "CONTAIN",
    "incident_id":  "inc_2026_0123",
    "payload_hash": "sha256:abcd1234ef567890abcd1234ef567890abcd1234ef567890abcd1234ef567890"
  },

  "dry_run": false,
  "ts_utc":  "2026-04-19T10:12:03Z"
}

Verdicts and response envelope

MARSHAL returns one of three verdicts for every Kerkese evaluation:

VerdictMeaning
EXECUTEAll gates passed — the action is authorised and has been WORM-logged. The caller may proceed.
REFUSEA gate check failed (insufficient role, AUGUR advisory) — the action is denied and logged. The caller must not proceed.
HARD_STOPA critical violation was detected (SoD breach, DATA_EXPORT without incident, spoofing) — the action is denied, a P1 incident is auto-created in IRFlow, and the chain is anchored immediately. Irreversible.
Warning: A HARD_STOP verdict is irreversible. It locks the action context and creates an immutable incident record in the WORM chain. Operators must resolve the incident through normal governance channels — there is no administrative override.

The response envelope for the worked example above (all gates passing) looks like:

Decision response (EXECUTE)
Bash
{
  "execution_id": "7e9a9a7e-2a1f-4c13-9f60-5a1f2e0d1a98",
  "outcome":      "EXECUTE",
  "dry_run":      false,
  "ts_utc":       "2026-04-19T10:12:03.412Z",
  "gates": [
    { "gate": 1, "name": "AuthN",  "status": "PASS", "latency_ms": 0.84 },
    { "gate": 2, "name": "AuthZ",  "status": "PASS", "latency_ms": 0.21 },
    { "gate": 3, "name": "NDS",    "status": "PASS", "latency_ms": 1.12 },
    { "gate": 4, "name": "AUGUR",  "status": "PASS", "latency_ms": 1.43 },
    { "gate": 5, "name": "WORM",   "status": "PASS", "latency_ms": 4.22 }
  ],
  "reasons":       [],
  "worm_entry_id": "wo_0000017234"
}

reasons is empty when outcome == EXECUTE. Otherwise it contains the concatenated reason strings from all failing gates in gate order. The worm_entry_id is present whenever Gate 5 succeeded — callers can quote this ID when later retrieving the WORM entry for forensics. The entry is present even when gates 1–4 rejected the call, because Gate 5 always runs.

A REFUSE from Gate 2 (wrong role) would look like:

Decision response (REFUSE)
Bash
{
  "execution_id": "9b3c1f2a-0011-4e78-b42a-d88e9cf10003",
  "outcome":      "REFUSE",
  "dry_run":      false,
  "ts_utc":       "2026-04-19T11:05:44.107Z",
  "gates": [
    { "gate": 1, "name": "AuthN",  "status": "PASS",   "latency_ms": 0.79 },
    { "gate": 2, "name": "AuthZ",  "status": "REFUSE",  "latency_ms": 0.18,
      "reason": "AUTHZ_FAIL: role \"viewer\" is not permitted to perform \"CONTAIN\"" },
    { "gate": 3, "name": "NDS",    "status": "SKIP",   "latency_ms": 0.00 },
    { "gate": 4, "name": "AUGUR",  "status": "SKIP",   "latency_ms": 0.00 },
    { "gate": 5, "name": "WORM",   "status": "PASS",   "latency_ms": 4.19 }
  ],
  "reasons":       ["AUTHZ_FAIL: role \"viewer\" is not permitted to perform \"CONTAIN\""],
  "worm_entry_id": "wo_0000017235"
}

Outcome resolution

The final outcome is the maximum severity observed across gates 1–4:

Severity ordering
Bash
EXECUTE  <  REFUSE  <  HARD_STOP

If any gate escalated to HARD_STOP, the outcome is HARD_STOP — even if a later gate would have returned REFUSE or EXECUTE. Reason strings from every failing gate are concatenated into decision.reasons[] for debugging and forensic review.

Dry-run mode

Setting dry_run: true in the Kerkese instructs MARSHAL to produce a full Decision without calling the Gate 5 WORM append. Use cases:

  • Caller-side integration tests.
  • Policy validation before rollout ("what would happen if…?").
  • Reproducing an audit finding locally without polluting the live chain.
Warning: Dry-run decisions leave no trace. worm_entry_id will be absent from the response. Never set dry_run: true on a production path — doing so means the action will happen with no audit record.

Performance

Measured on Go 1.24.4, Intel Core i7-7600U, in-memory mock store unless noted.

StepLatencyNotes
Gate 1 — AuthN~0.8 µsIn-memory session lookup
Gate 2 — AuthZ~0.3 µsStatic RBAC map lookup
Gate 3 — NDS~1.2 µsTwo session lookups + group check
Gate 4 — AUGUR~1.5 µsThree rule evaluations in sequence
Gate 5 — WORM (PostgreSQL 16, sync)4.22 msIncludes immutability trigger and fsync
Total evaluate (mocked store)7.55 µsAll 5 gates, no real DB
Total evaluate (real DB)~5 msDominated by Gate 5 PostgreSQL append

SIN — Security Intelligence Network — Open-source cybersecurity & compliance for the EU Digital Decade.

Apache-2.0 & AGPL-3.0 · GitHub · Contribute