Skip to content

security

Intended Documentation

Enforcement Lineage

How Intended makes every authority decision provable — the per-tenant SHA-256 audit hash chain, chain verification, and HMAC-signed evidence export.

Enforcement Lineage#

Every authority decision in Intended is recorded in a per-tenant, append-only SHA-256 hash chain. The chain links each entry to the one before it, so the question an auditor actually needs answered — was this record present, unaltered, at the time it claims? — is answered cryptographically rather than by trust in the storage layer.

This page describes the two audit substrates, exactly what the hash chain covers, how to verify it, and how evidence export works. Every claim here is grounded in packages/audit.

Two audit substrates — do not conflate them#

Intended writes two distinct kinds of audit record. They serve different purposes and have different integrity guarantees.

SubstrateModelIntegrityPurpose
Hash chainAuditEntryTamper-evident: SHA-256 content hash + previousHash linkage, written in a Serializable transactionThe authoritative, verifiable decision history
Narrative streamAuditRun / AuditRunEventUnsigned and unchainedHuman-readable replay and export of a run; not a tamper-evidence boundary

Warning

The narrative AuditRun stream is convenient for replay and export, but it is not the integrity substrate. When you need to prove a decision was recorded and unaltered, verify the AuditEntry hash chain — that is the boundary auditors should rely on.

From decision to verifiable record
An IntentRequest flows through interpretation, capability resolution, and the authority engine to an Ed25519 token that an adapter verifies and consumes; each stage emits an AuditEntry linked into a per-tenant SHA-256 hash chain.

Each stage of the authority loop emits audit entries. The entries are chained per tenant, so the path from policy to enforcement is reconstructable and tamper-evident.

Each AuditEntry is a structured record:

json
{
  "id": "ae_8f3a9b2c",
  "tenantId": "tenant_acme_prod",
  "correlationId": "corr_4e1a7c",
  "intentId": "int_def456",
  "actorId": "svc-data-pipeline",
  "actorType": "service",
  "eventType": "AUTHORITY_DECISION",
  "payload": { "decision": "APPROVED", "riskScore": 42 },
  "previousHash": "9c1185a5c5e9fc54612808977ee8f548b2258d31...",
  "entryHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4...",
  "createdAt": "2026-06-07T09:15:32.114Z"
}

The platform emits 40+ eventType values across the lifecycle of an action — for example AUTHORITY_DECISION, TOKEN_ISSUED, EXECUTION_*, ESCALATION_*, and LIM_*. Reading a tenant's chain in order reconstructs the path from interpretation through decision to enforcement.

Note

Token revocation is not an event type in the hash chain. Revocation state lives in the token tables and the event ledger, not in AuditEntry. Do not expect a TOKEN_REVOKED entry when verifying the chain.

How an entry is hashed and linked#

When an entry is appended:

  1. The append runs inside a Serializable transaction, so concurrent writers cannot interleave and produce a forked chain.
  2. The current chain head's entryHash becomes the new entry's previousHash (the first entry's previousHash is null).
  3. entryHash = SHA-256( stableStringify({ tenantId, eventType, payload, …, previousHash }) ), where stableStringify produces canonical, key-sorted JSON so the hash is deterministic regardless of field order.
  4. A database constraint @@unique([tenantId, entryHash]) prevents duplicate entries within a tenant.

Because each entry's hash incorporates both its own content and the previous entry's hash, altering any historical record changes its entryHash, which breaks the previousHash linkage of every entry after it. Tampering is detectable, not merely discouraged.

Verifying the chain#

verifyAuditChain walks a tenant's entries in order and performs two independent checks on each:

  • Content recompute — recomputes entryHash from the entry's own fields and compares it to the stored hash. A mismatch means the record's content was altered.
  • Linkage check — confirms entry.previousHash equals the prior entry's entryHash. A mismatch means an entry was inserted, removed, or reordered.

The result identifies the first broken position:

json
{
  "valid": false,
  "brokenAt": 318,
  "totalEntries": 902
}

A valid: true result with a matching totalEntries is positive proof the chain is intact end to end. You can trigger verification through the Audit API:

bash
# Verify a single tenant's chain
curl -H "Authorization: Bearer $INTENDED_API_KEY" \
  -H "x-tenant-id: tenant_acme_prod" \
  "https://api.intended.so/tenants/tenant_acme_prod/audit/chain-verification"

Note

A cross-tenant sweep endpoint (POST /audit/verify-chain) also exists. In the current build its summary field reports checkedRecords: 0 even when it has verified records — treat the per-tenant chain-verification result as authoritative until that field is corrected. Roadmap: fix the sweep summary field.

Evidence export#

For a single intent, generateEvidenceBundle(tenantId, intentId, signingKey) produces a self-contained EvidenceBundle you can hand to a reviewer:

  • integrityHash = "sha256:" + SHA-256( stableStringify(events) ) — a content hash over the bundle's events.
  • signature = HMAC-SHA256( signingKey, intentId + ":" + integrityHash ) — a keyed signature over the intent ID and integrity hash.

verifyEvidenceBundle recomputes both and compares them with timing-safe equality.

The signature is symmetric HMAC, not a public signature

The "signing key" is the tenant's own stored secret, and the signature is symmetric HMAC-SHA256. Verifying a bundle therefore requires possession of that same tenant secret. An evidence bundle is not publicly or asymmetrically verifiable — a third party cannot validate it without the tenant secret, and possession of the secret is sufficient to produce a valid signature. Plan your evidence-sharing workflow accordingly: distribute bundles to parties you would trust with the tenant secret, or have Intended (the secret holder) perform verification on a reviewer's behalf.

bash
# Export the evidence bundle for one intent
curl -H "Authorization: Bearer $INTENDED_API_KEY" \
  -H "x-tenant-id: tenant_acme_prod" \
  "https://api.intended.so/tenants/tenant_acme_prod/intents/int_def456/evidence" \
  -o evidence.json

Querying lineage#

You can read the recorded lineage through the Audit API and CLI:

GoalHow
Read a tenant timelineGET /audit/:tenantId/timeline
Read a narrative runGET /audit/run/:runId (and /replay, /export)
Read the cross-cutting event ledgerGET /events
Query from the CLIintended audit-query --tenant <id> [--correlation <id>] [--event-type <type>]
Export an intent's evidence bundle (CLI)intended audit-export --tenant <id> --intent <intentId>

Danger

A failed verifyAuditChain result (valid: false) indicates either a system fault or tampering. Treat any non-intact chain as a security incident: capture the brokenAt index, the surrounding entries, and the verification timestamp before taking remediating action.

  • Fail-Closed Controls — what the runtime does when a decision cannot be established
  • Trust Model — the security foundations lineage builds on
  • Audit API — endpoint reference for querying and exporting records
Enforcement Lineage | Intended