Skip to content
Docs/Architecture/CITADEL Integration
Edit on GitHub

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, or HARD_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:

  1. Platform detects a high-impact operation is about to run.
  2. Platform submits a Kerkese JSON payload to CITADEL MARSHAL via POST /api/v1/marshal/evaluate.
  3. MARSHAL evaluates 5 gates and returns a verdict (EXECUTE / REFUSE / HARD_STOP).
  4. Platform acts on the verdict: proceed, log rejection, or halt and alert.
  5. 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.

Bash
POST {CITADEL_API_URL}/api/v1/marshal/evaluate
Content-Type: application/json
Authorization: Bearer <jwt>      "cm"># or HMAC headers for connector auth

Example Kerkese payload (ThreatFlow bulk IOC ingestion):

TypeScript
{
  "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:

FieldDescription
kerkese_versionSchema version — currently "1.0"
project_idMust string-exact match a whitelisted project in CITADEL (Gate 2). Platforms use their own service name (e.g. "threatflow", "opencsirt").
execution_idUnique identifier for this specific request invocation
action.typeOperation identifier checked against MARSHAL's authority rules (Gate 1)
actor.roleMust be group_sig_operator or group_sig_verifier; SoD requires operator ≠ verifier
evidenceOperation-specific evidence evaluated at Gate 4
sodSeparation 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.

VerdictMeaningRequired platform action
EXECUTEAll 5 gates passed. The operation is authorised.Proceed with the operation.
REFUSEOne 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_STOPA 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:

TypeScript
{
  "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": []
  }
}
Rule: When 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.

Bash
pip install "citadel-connector>=1.0.0,<1.1.0"
TypeScript
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:

Bash
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 patternRequiredDescription
*_CITADEL_API_URLYes (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_SECRETYes (in production)Service API key or HMAC shared secret issued by the CITADEL administrator for this platform.
*_CITADEL_DRY_RUNNoSet 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:

.env
YAML
OPENCSIRT_CITADEL_API_URL=https://citadel.internal:8099
OPENCSIRT_CITADEL_HMAC_SECRET=<secret-from-citadel-admin>
OPENCSIRT_CITADEL_DRY_RUN=false   # set true for staging

Dry-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/validate instead 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/evaluate with "dry_run": true in the Kerkese payload. The response is a simulated verdict; no log entry is created in citadel.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:

Bash
"cm"># Environment variable — affects all connectors in the process
export CITADEL_DRY_RUN=true

A successful dry-run validation response looks like:

TypeScript
{
  "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": []
}
CI recommendation: Create a dedicated API key scoped to 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 365d

Platform examples

The following table summarises how the opensecstack platforms use CITADEL. Every platform that emits to WORM follows the same wire pattern.

PlatformMARSHAL-gated operationsWORM event types
ThreatFlowBulk IOC ingestion (>100), feed source addition, STIX export, confidence overridethreatflow.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
APIGuardScan 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 EXECUTE without network I/O.
  • Platform operations proceed without governance checks.
Warning: Disabled mode is intended for local development and testing only. Never deploy to a production or staging environment without a valid CITADEL_API_URL. Use dry-run mode for staging to keep governance checks active without writing to the live chain.

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

Apache-2.0 & AGPL-3.0 · GitHub · Contribute