CITADEL Integration
CITADEL is the governance and audit layer shared by every opensecstack platform. It provides two services that platforms plug into:
- MARSHAL — the deterministic 5-gate decision engine. Before any high-impact operation executes, the platform submits a Kerkese (governed action request) and waits for one of three verdicts:
EXECUTE,REFUSE, orHARD_STOP. - WORM chain — the append-only, immutable audit ledger. Every significant state change is emitted as an evidence packet, triple-hashed (BLAKE3 + SHA-256 + SHA-512), Ed25519-anchored, and permanently recorded.
For the governance engine internals (gate logic, SoD enforcement, AUGUR, VIGIL) see Governance (CITADEL).
Overview
The integration flow for any platform is always the same five steps:
- Platform detects a high-impact operation is about to run.
- Platform submits a Kerkese JSON payload to CITADEL MARSHAL via
POST /api/v1/marshal/evaluate. - MARSHAL evaluates 5 gates and returns a verdict (
EXECUTE/REFUSE/HARD_STOP). - Platform acts on the verdict: proceed, log rejection, or halt and alert.
- Platform emits evidence packets to the WORM chain via
POST /api/v1/ingest(or via the Connector SDK).
All outcomes — including REFUSE and HARD_STOP — are written to citadel.log by CITADEL automatically. The platform does not need to log MARSHAL verdicts itself.
Submitting a governed action (Kerkese)
A Kerkese is the standardised JSON request format that represents a proposed governed action. It carries the action type, the actor identity, supporting evidence, and the SoD (Separation of Duties) assignment.
POST {CITADEL_API_URL}/api/v1/marshal/evaluate
Content-Type: application/json
Authorization: Bearer <jwt> "cm"># or HMAC headers for connector authExample Kerkese payload (ThreatFlow bulk IOC ingestion):
{
"kerkese_version": "1.0",
"project_id": "threatflow",
"execution_id": "poll-otx-2026-03-31",
"action": {
"type": "bulk_ioc_ingest",
"label": "Ingest 247 IOCs from alienvault-otx feed"
},
"actor": {
"user_id": 0,
"role": "group_sig_operator"
},
"evidence": {
"feed_name": "alienvault-otx",
"ioc_count": 247,
"confidence_base": 70
},
"sod": {
"operator_user_id": 0,
"verifier_user_id": 0
},
"dry_run": false
}Required fields in every Kerkese:
| Field | Description |
|---|---|
kerkese_version | Schema version — currently "1.0" |
project_id | Must string-exact match a whitelisted project in CITADEL (Gate 2). Platforms use their own service name (e.g. "threatflow", "opencsirt"). |
execution_id | Unique identifier for this specific request invocation |
action.type | Operation identifier checked against MARSHAL's authority rules (Gate 1) |
actor.role | Must be group_sig_operator or group_sig_verifier; SoD requires operator ≠ verifier |
evidence | Operation-specific evidence evaluated at Gate 4 |
sod | Separation of duties assignment. If operator_user_id === verifier_user_id, MARSHAL returns HARD_STOP immediately |
MARSHAL verdicts
MARSHAL always returns exactly one of three outcomes. There are no partial verdicts, no warnings, and no heuristics — the result is deterministic.
| Verdict | Meaning | Required platform action |
|---|---|---|
EXECUTE | All 5 gates passed. The operation is authorised. | Proceed with the operation. |
REFUSE | One or more gates failed (insufficient authority, out of scope, incomplete evidence, schema validation failure). | Do not execute the operation. Log the refusal in the platform's own audit trail. Surface the MARSHAL reason to the operator. |
HARD_STOP | A critical violation was detected: SoD breach (same user as operator and verifier), scope spoofing, or contradictory evidence. CITADEL automatically creates an incident. | Immediately halt the operation and any related processes. Raise a VIGIL RED alert. Do not retry without manual review. |
The MARSHAL response envelope carries the verdict and per-gate status:
{
"meta": {
"schema_version": "2.0",
"project_id": "threatflow",
"execution_id": "poll-otx-2026-03-31",
"ts_utc": "2026-05-23T10:00:00Z"
},
"decision": {
"outcome": "EXECUTE", class=class="str">"cm">// EXECUTE | REFUSE | HARD_STOP
"severity": "INFO", class=class="str">"cm">// INFO | WARNING | CRITICAL
"gates": [
{ "gate": 1, "name": "authority", "status": "PASS", "reason": "" },
{ "gate": 2, "name": "scope", "status": "PASS", "reason": "" },
{ "gate": 3, "name": "determinism", "status": "PASS", "reason": "" },
{ "gate": 4, "name": "evidence", "status": "PASS", "reason": "" },
{ "gate": 5, "name": "schema", "status": "PASS", "reason": "" }
],
"reasons": []
}
}outcome=EXECUTE, the required_evidence array in the response is always empty. If it is non-empty, treat the response as a REFUSE regardless of the stated outcome.Forwarding evidence to the WORM chain
After an operation completes, the platform emits one or more evidence packets to the CITADEL WORM chain. Packets are append-only — once written, they cannot be modified or deleted. CITADEL computes the TripleHash and Ed25519 chain anchor automatically on ingest.
The recommended approach is the Connector SDK (citadel-connector Python package), which handles canonical JSON serialisation, triple-hash computation, Ed25519 signing, and retry logic with persistent queueing.
pip install "citadel-connector>=1.0.0,<1.1.0"
from citadel_connector import CitadelConnector connector = CitadelConnector( endpoint="https:class=class="str">"cm">//citadel.internal.example.org", api_key="svc-<your-service-api-key>", service_name="my-platform", dry_run=False, timeout_seconds=10, retry_max_attempts=5, retry_backoff_factor=2.0, persistent_queue_path="/var/lib/my-platform/citadel-queue" ) result = connector.emit( event_type="incident.created", payload={ "incident_id": "INC-2026-0412", "severity": "HIGH", "affected_system": "web-proxy-01", "detected_at": "2026-05-10T14:23:00Z" } ) # result.packet_id — UUID assigned to the emitted packet # result.chain_id — WORM chain this packet was written to # result.block_seq — block sequence number
Platforms that use languages other than Python send packets directly over HTTPS. The wire format is the same JSON event body with HMAC-SHA256 authentication headers.
Transport & authentication
All platforms authenticate to CITADEL as connectors using HMAC-SHA256 request signing. The exact header names vary slightly per platform (each prefixes with its own service name), but the signing scheme is uniform — the same scheme used for Webhooks & Events between platforms:
POST {CITADEL_API_URL}/api/v1/evidence
Content-Type: application/json
X-Source: <platform-name>
X-Signature: HMAC-SHA256(<CITADEL_HMAC_SECRET>, body)
X-Timestamp: <unix epoch seconds>CITADEL enforces a ±5-minute replay window on the X-Timestamp header. Events carry a stable UUIDv4 id field; CITADEL ingest is idempotent on (source, id) so at-least-once delivery from the platform outbox is safe.
Environment variables
Each platform uses a prefixed variant of the same three variables. The table below shows the canonical names; substitute the platform prefix (e.g. THREATFLOW_, OPENCSIRT_, OPENSCRUB_) for your platform.
| Variable pattern | Required | Description |
|---|---|---|
*_CITADEL_API_URL | Yes (in production) | Base URL of the CITADEL API, e.g. https://citadel.internal:8099. When empty, all CITADEL calls are no-ops (development mode). |
*_CITADEL_API_KEY or *_CITADEL_HMAC_SECRET | Yes (in production) | Service API key or HMAC shared secret issued by the CITADEL administrator for this platform. |
*_CITADEL_DRY_RUN | No | Set to true to enable dry-run mode — packets are validated but not written to the chain, and MARSHAL evaluations return simulated verdicts. Default: false. |
Example configuration for OpenCSIRT:
OPENCSIRT_CITADEL_API_URL=https://citadel.internal:8099
OPENCSIRT_CITADEL_HMAC_SECRET=<secret-from-citadel-admin>
OPENCSIRT_CITADEL_DRY_RUN=false # set true for stagingDry-run mode
Dry-run mode is the recommended configuration for local development, integration tests, and CI pipelines. In dry-run mode:
- Evidence packets are sent to
/api/v1/ingest/validateinstead of/api/v1/ingest. CITADEL validates the schema, triple-hash, and signature but does not write anything to the chain. - MARSHAL evaluations hit
/api/v1/marshal/evaluatewith"dry_run": truein the Kerkese payload. The response is a simulated verdict; no log entry is created incitadel.log. - VIGIL does not receive the packets. ARBITER rule evaluation is not triggered.
Enable dry-run via the SDK flag or the environment variable override:
"cm"># Environment variable — affects all connectors in the process
export CITADEL_DRY_RUN=trueA successful dry-run validation response looks like:
{
"dry_run": true,
"packet_id": "<uuid-that-would-have-been-assigned>",
"validation": "PASSED",
"triple_hash_verified": true,
"signature_verified": true,
"estimated_chain_id": "<chain-id-that-would-receive-this-packet>",
"warnings": []
}dry_run_only for CI pipelines. This prevents accidental writes to the live chain even if CITADEL_DRY_RUN is accidentally omitted.citadel-cli apikey create --service-name my-service-ci --scope dry_run_only --expiry 365dPlatform examples
The following table summarises how the opensecstack platforms use CITADEL. Every platform that emits to WORM follows the same wire pattern.
| Platform | MARSHAL-gated operations | WORM event types |
|---|---|---|
| ThreatFlow | Bulk IOC ingestion (>100), feed source addition, STIX export, confidence override | threatflow.ioc.ingested, threatflow.ioc.revoked, threatflow.bundle.imported, threatflow.bundle.exported, threatflow.sighting.created |
| OpenScrub | — | openscrub.mitigation (per drop window), openscrub.rule_change (per rule insert/withdraw/expire) |
| OpenCSIRT | — | opencsirt.incident_opened, opencsirt.incident_closed, opencsirt.advisory_published, opencsirt.escalation_sent |
| APIGuard | Scan requested (Gate 1 authority + Gate 2 scope) | Scan completed + findings linked to citadel.evidence |
| IRFlow | — | Incident created → citadel.incident auto-created |
| NIS2 Compass | — | Assessment evidence → citadel.evidence |
Any HARD_STOP verdict — regardless of the originating platform — causes CITADEL to automatically create an incident entry and trigger a notification cascade.
Disabled mode (local dev)
When a platform's CITADEL_API_URL variable is empty, all CITADEL calls become no-ops:
- WORM events are silently discarded (not queued, not written).
- MARSHAL evaluations return an implicit
EXECUTEwithout network I/O. - Platform operations proceed without governance checks.
CITADEL_API_URL. Use dry-run mode for staging to keep governance checks active without writing to the live chain.