Security — Architecture

The receipt spine, in detail.

You can't audit what you can't verify. Thunderline's trust is one cryptographic chain: sealed receipts, lineage edges, and the policy gate that governs them. This page is for your security team.

The 17-field seal

Every consequential agent action in Thunderline produces one ActionReceipt row, written by Thunderline.Thunderbolt.ExecutionRecorder. The seal is computed over exactly 17 fields. Any post-write modification of those fields changes the hash. The sealed hash is stored in the same row and checked on every lineage-edge traversal.

The write path: the recorder calls compute_seal_hash/1 before inserting the row. After insertion, Thundervine.Resources.LineageEdge persists a lineage edge linking the receipt into the causal chain. On read, verify_receipt_seal/1 recomputes the hash and compares; then AcornAuthorityDecisionCorrelation.verify_persisted_edge/4 cross-checks the lineage edge itself.

The 17 sealed fields (@seal_fields)

These fields are passed to compute_seal_hash/1 in the order below. Three additional fields on the receipt row sit outside the seal — noted at the end of the table.

Field Role in the receipt
receipt_id UUID v4 primary key. Uniquely identifies this receipt row. Included in the seal so the hash binds to this exact row, not a copy.
envelope_version Schema version integer. Lets the classifier know which hash algorithm and field-set to expect. Mismatches route to legacy paths, not false positives.
action_id Identifies the Crown executor module that produced this receipt. Links the receipt to the policy-gate action definition.
domain The Thunderline domain that owns this action (e.g., :acorn, :comsec). Part of the authorization perimeter.
mission_id Parent mission UUID. Anchors the receipt to its operational context. Required for the lineage-edge cross-check.
saga_run_id Saga run UUID if this action belongs to a multi-step saga. Enables saga-level replay and rollback verification.
step_id Monotonically ordered step within the saga. Together with saga_run_id, identifies position in the causal chain.
correlation_id Request correlation token propagated from the inbound event. Links to distributed traces and audit logs outside Thunderline.
causation_id The receipt_id of the receipt that caused this action. Enables full causal graph traversal without external log correlation.
actor Identity of the principal that authorized this action. Bound by the policy gate's ActorProvenance resolution — not the raw request actor claim. See §4.
inputs_hash SHA-256 of the canonicalized input payload. Locks what evidence the action saw. Tampered inputs change this hash and break the seal.
status Terminal outcome atom (:completed, :failed, :compensated, etc.). Sealed so status cannot be upgraded post-write without detection.
attempt Retry count integer. Part of the seal so idempotency keys are replay-safe — a replayed attempt at a different count produces a different seal.
traceparent W3C Trace Context header value. Included in the seal to bind the receipt to a specific distributed trace span.
timing Map of wall-clock timestamps (started_at, completed_at). Seals the observed execution window; retroactive timestamp edits break the seal.
cost Token or compute cost record for AI invocations. Sealed so cost cannot be scrubbed from the audit trail.
artifacts List of artifact references (hashes + storage keys) produced by the action. Seals the output set so artifacts cannot be silently swapped.
Three fields outside the seal: governance, output_redacted, and input_redacted are stored on the receipt row but are not included in @seal_fields. This is the design basis for the authority-decision cross-check described in §2: because output_redacted sits outside the seal, a one-column update of authority_decision_status to :current_valid would not break the seal hash — only the lineage-edge cross-check catches it.

Keyed vs unkeyed seals

Seals are unkeyed per the open issue AUTHORITY_LINEAGE_KEYED_SEAL_V0 (GH #3173). They raise the price of tampering — they do not forbid it. A party with write access to the receipts table can recompute a valid seal after editing a sealed field.

The current design hashes the 17 fields using a deterministic canonical encoding without a secret key. The hash proves internal consistency: the receipt row has not been modified since compute_seal_hash/1 ran. It does not prove authenticity against an adversary who has database write access.

What actually defends ACORN authority status

The lineage-edge cross-check at AcornAuthorityDecisionCorrelation.verify_persisted_edge/4 is the primary defense for ACORN authority decisions — not the seal alone.

Here is why the distinction matters: output_redacted sits outside the seal. A single-column UPDATE of authority_decision_status to :current_valid on an ActionReceipt row would not change the seal_hash — because that field is not in @seal_fields. The seal check would still pass. The cross-check does not: it verifies the lineage edge independently. An authority decision without a corresponding, valid lineage edge is rejected regardless of the seal status.

The defense model is therefore: seal as tamper evidence for the 17 sealed fields, plus lineage edge as an independent structural proof that the decision was produced by the governed flow, not injected post-hoc.

GH #3173 (open): Adding a server-side HMAC key to compute_seal_hash/1 would make the seal unforgeable without the key. This is tracked but not shipped. Keyed seals would close the write-access gap; they are not a replacement for the lineage cross-check.

AcornReceiptSealClassification.classify/1

Not all receipts in the database were written by the current encoder. The Thunderline codebase has gone through at least two distinct seal encoding regimes. The classifier returns one of four atoms to distinguish them:

:current_valid

The receipt was written by the current encoder. The seal hash recomputes correctly against the stored fields using the current canonical encoding. This is the only value that means the record has been verified against the current standard.

:legacy_v0_consistent

The seal is internally consistent with the V0 encoding that was in force when the row was written. The row has not been modified since it was written — but it was written under a different encoding than the current standard.

:legacy_k1_v0_consistent

The same as :legacy_v0_consistent but written by the K1 cell's own receipt writer under the K1-specific V0 encoding. Structurally consistent with the encoding in use at write time.

:invalid

The seal hash does not match under any known encoding. The row has been modified after the seal was computed, the encoder used at write time is unknown, or the row is corrupted.

Three rules your security team must enforce

The legacy list is closed. :legacy_v0_consistent and :legacy_k1_v0_consistent are not extensible verdict classes. New encoding dialects go through a migration, not through adding a new legacy arm. An open legacy list would let a tampered row claim "consistent with unknown encoding X."
unavailable is not :invalid. If the classifier cannot run — because the receipt row is missing fields, the hashing module is unavailable, or the database query times out — the result is unavailable, not :invalid. Treat unavailable as a circuit breaker: surface the error, do not route to a fallback that silently accepts the record.
A legacy verdict must never render as VERIFIED. :legacy_v0_consistent means "internally consistent with the encoding in force when the row was written." It does not mean authentic. It does not mean the row is unmodified relative to the current standard. Operator UI must display legacy receipts with a clearly distinct label — not the same green checkmark used for :current_valid.

Policy gate flow

All agent actions in Thunderline pass through Thundercrown.PolicyGate.validate_and_execute/2. The gate enforces the 4-tier capability model, resolves actor provenance, and emits a sealed ActionReceipt on success or failure.

The 4 capability tiers

:read

Observe state. No mutations. Permitted to all authenticated principals within their scope. Read receipts are still sealed to prove what evidence the agent saw.

:mutate

Modify durable state (write to memory, update records). Requires explicit grant in the action's capability declaration. Each mutation produces a sealed receipt.

:admin

Modify policy, role, or boundary configuration. Restricted to operator-level principals. Admin actions require the :admin capability and go through the authority decision path.

:execute

Run code in a hermetic execution cell (K1 WASM or equivalent). The most constrained tier. Zero network, bounded compute, sealed I/O manifest. See §5 for the K1 cell constraints.

42 executor modules — the real count

As of the codebase canon, Thundercrown contains 42 executor modules under Thundercrown.Actions.Catalog. Each module declares its capability tier, required actor claims, evidence references it consumes, and the receipt shape it produces. The policy gate loads this catalog at startup and refuses to execute any action not in the catalog. Undeclared actions cannot produce receipts; they simply do not run.

Actor provenance: the authz vs telemetry distinction

The actor: field in an ActionReceipt is resolved by ActorProvenance inside the policy gate. It is not taken from request.actor as supplied by the caller. A caller claiming to be a different principal does not change the sealed actor field: the gate resolves provenance from the verified session context, then stamps the result.

This matters for telemetry correlation. A telemetry event may carry a session-scoped actor label that differs from the governance-grade actor in the receipt. The sealed actor in the receipt is the governance truth. Telemetry actor labels are for operational visibility, not audit.

The validate_and_execute/2 contract

# Simplified call shape (Thundercrown.PolicyGate)
def validate_and_execute(action_ref, opts) do
  # opts[:actor] → resolved via ActorProvenance, not trusted verbatim
  # opts[:evidence_refs] → hashed into inputs_hash for the seal
  # opts[:mission_id] → required; gates lineage-edge creation
  #
  # Returns {:ok, %ActionReceipt{}} or {:error, reason}
  # Never raises; all failures produce a sealed failure receipt
end

K1: hermetic WASM execution

The K1 cell runs untrusted WASM modules in a hermetic execution environment. It is implemented as a wasmtime Rustler NIF — not wasmex. The distinction matters: wasmtime gives us direct control over StoreLimits, fuel metering, and epoch interruption from the host side.

Zero-import verification

Before a WASM module executes, the K1 cell verifies that the module declares zero imports. A module that requests host functions is refused at load time with :wasm_imports_present. This is a hard gate: there is no allowlist of "safe" imports. Zero imports means zero surface area for a guest module to reach host state.

Resource bounds

The StoreLimits applied to every execution:

# K1WasmCell — StoreLimits configuration
@max_memory_pages  17         # 17 pages × 64 KB = 1,088 KB max linear memory
@max_memories     1          # one Memory instance; multiple is a vector for side-channels
@max_instances    1          # one module instance per execution; no recursive spawning
@fuel_limit       10_000_000 # wasmtime instruction fuel; exhaustion = :fuel_exhausted
@wall_clock_ms    5_000      # epoch_interruption deadline in ms; hit = :wall_clock_exhausted

Interruption and termination atoms

The K1 cell distinguishes four terminal error conditions. Each maps to a distinct atom. Collapsing them to a single generic error would hide whether a module was malicious (importing, trapping) or merely expensive (fuel, wall-clock):

  • :wasm_imports_present — module declared imports; refused at load, before any execution.
  • :fuel_exhausted — instruction fuel consumed before completion; execution halted by wasmtime.
  • :wall_clock_exhausted — epoch interruption deadline exceeded; execution halted by host epoch tick.
  • :wasm_trap — the module produced a WASM trap (unreachable, out-of-bounds, etc.); the module itself signaled failure.
Guest-entry refusal is a TERMINAL state. A K1 execution that returns any of the four error atoms above is not retried, not reclassified, and not routed to a fallback executor. The policy gate seals a failure receipt with the error atom as the status. There is no "try again with lower bounds" path: the module is either safe and compliant, or it is not run.

Entry point: Thunderblock.Runtime.K1WasmCell.execute/3

# K1WasmCell.execute/3 — surface contract
Thunderblock.Runtime.K1WasmCell.execute(
  wasm_binary,     # <> — the WASM module; imports checked first
  input_payload,   # map — serialised to linear memory; included in inputs_hash
  opts             # [fuel: integer, wall_clock_ms: integer] — override limits
)
# Returns:
#   {:ok, output_payload}
#   {:error, :wasm_imports_present}
#   {:error, :fuel_exhausted}
#   {:error, :wall_clock_exhausted}
#   {:error, :wasm_trap}

COMSEC: recommendation-only governance

The COMSEC traffic-budget lane is the canonical example of a governed-recommendation pattern in Thunderline. It demonstrates a key architectural constraint: an agent lane can produce governance evidence without being permitted to act on it.

What the lane does — and does not do

The COMSEC budget lane computes a traffic recommendation based on fixed-point basis-point arithmetic applied to current traffic state. It persists governance evidence (one sealed ActionReceipt, one Thundervine :used lineage edge). It does not — by design — mutate traffic, keys, sessions, or topology.

Recommendation-only is not a weaker pattern. It is a deliberate authority boundary. The operator retains the mutate step; the agent lane proves what it recommended and why. This is the pattern for any lane where the blast radius of an incorrect automated action is unacceptable.

Determinism properties

The budget computation uses fixed-point basis-point arithmetic (not floating-point) so the result is reproducible across nodes and versions. The input payload is canonicalized deterministically before hashing so the inputs_hash in the sealed receipt is stable for identical inputs.

Idempotency is enforced at the database level via pg_advisory_xact_lock on the budget key. A duplicate computation for the same budget period acquires the same lock, finds the existing receipt, and returns it without writing a second row.

Evidence footprint per execution

  • One sealed ActionReceipt with the full 17-field seal.
  • One Thundervine lineage edge with relationship type :used, linking the budget computation receipt to the traffic-state evidence it consumed.

An auditor querying for all COMSEC budget decisions in a time window gets both the recommendation and the exact state snapshot the recommendation was based on, via the lineage edge. Nothing is implicit.

29-agent refute-by-default sweep

On 2026-07-30, Thunderline's trust spine was subjected to a 29-agent adversarial anneal with a refute-by-default mandate: each agent tried to break, bypass, or falsify some part of the receipt chain. The full run surfaced 31 real defects.

29

Agents

31

Real defects

0

Critical

11

High

14

Medium

6

Low

Defect classes and current trunk status

Seal-hash multi-dialect inconsistency

PARTLY FIXED

Multiple writers were computing seal hashes using different serialization methods (:erlang.term_to_binary/1, Kernel.inspect/1, canonical JSON). Only the first three produced identical hashes for identical inputs. As of trunk, the three main writers have been normalized to the canonical encoder. Two open hazards remain: thunderwall/seal.ex still uses term_to_binary/1 (non-portable across node versions); thunderchief/receipts/agent_action_receipt.ex still uses inspect/1 (non-deterministic for maps with floating-point values).

CI authorization coverage

PARTLY CLOSED

The anneal found authorize?: false widespread in the test suite, meaning test-time actions bypassed the policy gate entirely. The lean merge gate now enforces: 5 governed-path steps with authorize?: true plus a negative canary test that asserts rejection. The wider test suite still has widespread authorize?: false outside the lean gate. This is an open coverage gap, not a production runtime gap — but it means tests can pass while covering ungoverned paths. See also §8 non-claims.

Upserts carrying authority into ON CONFLICT

OPEN · GH #3073

Several upsert patterns were using ON CONFLICT DO UPDATE clauses that included authority-status or state columns. This means a race-write could overwrite a valid authority decision with a conflicting one without triggering the lineage cross-check, because the upsert bypasses the Ecto changeset pipeline. GH #3073 is open.

Fail-open at membranes

OPEN · GH #3081

Several membrane enforcement points returned :ok when the boundary check raised an exception rather than returning an explicit verdict. A boundary that fails open under error conditions is not a boundary. GH #3081 tracks the systematic fix across all membrane modules.

What we are not claiming

The following are verbatim non-claims. They are here because a security team doing diligence deserves to know the limits of the model before they see its strengths.

  • Seals are not unforgeable (unkeyed; GH #3173).
  • No formal verification claim.
  • No SOC2 Type II audit. Roadmap target: Q1 2027.
  • authorize?: false is still widespread in the wider test suite outside the lean merge gate.
  • Fail-open at membranes remains an open hazard class (GH #3081).
  • Upserts carrying authority into ON CONFLICT remains open (GH #3073).
  • Thunderline is not AGI. It governs agent actions, not agent reasoning.
  • A legacy seal verdict means internally consistent with the encoding in force when the row was written — not verified, not authentic. It must never render as VERIFIED to an operator.

Report a security issue

If you find a vulnerability in Thunderline's receipt spine, policy gate, K1 cell, membrane enforcement, or any component described on this page, contact us directly.

Security contact

We aim to acknowledge within 2 business days. Do not publish details publicly until we have had a chance to assess and remediate. There is no current bug-bounty program.

PGP key fingerprint: TBA — email first; we'll exchange keys out-of-band.

For general inquiries, reach Mo at [email protected]. For platform access and ThunderBlock onboarding, see thunderline.net/blockstrap.

Last reviewed: 2026-10-16

This page is grounded in Thunderline codebase canon and adversarial anneal findings as of the review date. Claims are updated when the underlying architecture changes. Open issues are tracked in the private Thunderline repository; GH numbers reference that tracker.