Public SDK scaffolding via mix thunderblock.new is in beta cutover; during the current design-partner window, new ThunderBlock workspaces are provisioned through the hosted Blockstrap flow and bound to a local workspace config.
# Local workspace bootstrap (CLI preview —coming soon for general SDK)
# Provision a managed cell today at https://thunderline.net/blockstrap
$ mix new my_governed_hive --sup
$ cd my_governed_hive
# config/runtime.exs — bind your ThunderBlock cell & operator grant
import Config
config :thunderblock, :cell,
block_id: System.fetch_env!("THUNDERBLOCK_ID"),
blockstrap_endpoint: "https://thunderline.net/blockstrap",
default_capability_floor: :read,
require_receipt_seal: true
Every governed operation in Thundercrown.Actions.Catalog.Manual.* declares an explicit capability tier (:read, :mutate, :admin, or :execute) so the policy gate can reject unauthorized calls before any executor code runs.
defmodule Thundercrown.Actions.Catalog.Manual.CasePacketCommit do
@moduledoc """
Commits a reviewed case packet to workspace state.
Capability tier must be one of :read | :mutate | :admin | :execute.
"""
alias Thundercrown.Actions.Action
@spec definition() :: Action.t()
def definition do
%Action{
id: "crown.manual.case_packet_commit",
domain: :acorn,
capability: :mutate,
executor: Thundercrown.Executors.CasePacketExecutor,
require_evidence_refs?: true,
seal_receipt?: true,
schema: %{
mission_id: {:required, :uuid},
packet_sha256: {:required, :string},
disposition: {:required, {:enum, [:approved, :escalated, :rejected]}}
}
}
end
end
PolicyGate.validate_and_execute/2 resolves the authorization actor (kept strictly distinct from telemetry actor claims), checks capability grants against the action tier, and seals both the ActionReceipt and LineageEdge before returning.
alias Thundercrown.PolicyGate
alias Thundercrown.Actions.Catalog.Manual.CasePacketCommit
action = CasePacketCommit.definition()
context = %{
authz_actor: %{principal_id: "op_91a4", role: :operator, capabilities: [:read, :mutate]},
telemetry_actor: %{agent_id: "agt_synthesis_02"},
mission_id: "c3a8d104-7d4b-4e11-9c20-8a1f00e44b19",
saga_run_id: "f1208b6e-2c91-44a8-8b10-0019c871d2e0",
step_id: "step_04_commit",
evidence_refs: [
%{kind: :source_bundle, sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}
],
inputs: %{
mission_id: "c3a8d104-7d4b-4e11-9c20-8a1f00e44b19",
packet_sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
disposition: :approved
}
}
case PolicyGate.validate_and_execute(action, context) do
{:ok, %{result: result, receipt: receipt, lineage_edge: edge}} ->
{:ok, receipt.receipt_id, receipt.seal_hash, edge.edge_id, result}
{:error, {:policy_denied, reason, denial_receipt}} ->
{:denied, reason, denial_receipt.receipt_id}
end
Every ActionReceipt computes its deterministic hash across 17 canonical @seal_fields in fixed order. Three operational columns (governance, output_redacted, input_redacted) sit outside the seal and are protected via the lineage-edge cross-check.
defmodule Thundercrown.Receipts.ActionReceipt do
use Ecto.Schema
# Verbatim 17-field seal specification. Order is canonical and immutable.
# Note: governance, output_redacted, and input_redacted sit outside @seal_fields.
@seal_fields [
:receipt_id,
:envelope_version,
:action_id,
:domain,
:mission_id,
:saga_run_id,
:step_id,
:correlation_id,
:causation_id,
:actor,
:inputs_hash,
:status,
:attempt,
:traceparent,
:timing,
:cost,
:artifacts
]
@spec seal_fields() :: [atom()]
def seal_fields, do: @seal_fields
@spec compute_seal_hash(map() | t()) :: String.t()
def compute_seal_hash(receipt) do
canonical_payload =
@seal_fields
|> Enum.map(fn field -> {Atom.to_string(field), Map.get(receipt, field)} end)
|> :erlang.term_to_binary([:deterministic])
:crypto.hash(:sha256, canonical_payload)
|> Base.encode16(case: :lower)
end
end
Thundervine.Resources.LineageEdge persists the causal link between parent evidence, the authorization decision, and the resulting ActionReceipt. Because both tables carry independent seal hashes, a single-table UPDATE cannot forge a valid authority correlation.
defmodule Thundervine.Resources.LineageEdge do
@moduledoc """
Causal lineage edge binding an ActionReceipt to its parent mission and authority decision.
"""
@edge_seal_fields [
:edge_id,
:mission_id,
:source_ref,
:target_receipt_id,
:target_receipt_seal_hash,
:relationship,
:authority_decision_status,
:recorded_at_unix_us
]
@spec compute_seal_hash(map()) :: String.t()
def compute_seal_hash(attrs) when is_map(attrs) do
canonical =
Enum.map(@edge_seal_fields, fn k ->
{Atom.to_string(k), Map.get(attrs, k)}
end)
|> :erlang.term_to_binary([:deterministic])
:crypto.hash(:sha256, canonical)
|> Base.encode16(case: :lower)
end
end
When an auditor asks "who authorized this action, what inputs did it see, and does the causal chain hold?", the answer is a deterministic query joining action_receipts to thundervine_lineage_edges ordered by step and causation chain.
-- Replay the full governed receipt + lineage chain for mission $1
WITH RECURSIVE receipt_chain AS (
SELECT
r.receipt_id,
r.causation_id,
r.step_id,
r.action_id,
r.domain,
r.actor,
r.inputs_hash,
r.status,
r.seal_hash,
e.edge_id,
e.authority_decision_status,
e.edge_seal_hash,
1 AS chain_depth
FROM action_receipts r
INNER JOIN thundervine_lineage_edges e
ON e.target_receipt_id = r.receipt_id
AND e.target_receipt_seal_hash = r.seal_hash
WHERE r.mission_id = $1
AND r.causation_id IS NULL
UNION ALL
SELECT
child.receipt_id,
child.causation_id,
child.step_id,
child.action_id,
child.domain,
child.actor,
child.inputs_hash,
child.status,
child.seal_hash,
ce.edge_id,
ce.authority_decision_status,
ce.edge_seal_hash,
parent.chain_depth + 1
FROM action_receipts child
INNER JOIN receipt_chain parent
ON child.causation_id = parent.receipt_id
INNER JOIN thundervine_lineage_edges ce
ON ce.target_receipt_id = child.receipt_id
AND ce.target_receipt_seal_hash = child.seal_hash
WHERE child.mission_id = $1
)
SELECT * FROM receipt_chain ORDER BY chain_depth ASC, step_id ASC;
Untrusted or synthesized skill modules run inside Thunderblock.Runtime.K1WasmCell.execute/3 via a wasmtime NIF that rejects any WASM binary with host imports (zero-import verification) and enforces strict instruction fuel and wall-clock budgets.
alias Thunderblock.Runtime.K1WasmCell
wasm_bytes = File.read!("priv/skills/normalize_field_intake.wasm")
opts = [
max_fuel: 2_500_000,
wall_clock_timeout_ms: 150,
allow_host_imports: false,
memory_limit_bytes: 16 * 1024 * 1024
]
case K1WasmCell.execute(wasm_bytes, %{notes: raw_notes}, opts) do
{:ok, %{output: normalized, fuel_consumed: fuel, module_sha256: mod_hash}} ->
{:ok, normalized, %{fuel: fuel, wasm_sha256: mod_hash}}
{:error, :non_zero_imports_rejected} ->
{:error, :hermetic_violation}
{:error, :fuel_exhausted} ->
{:error, :budget_exceeded}
end
Intra-workspace events travel over Thunderflow.EventBus, which persists envelopes to a local Mnesia transaction log and rejects unsealed or capability-mismatched publications at the bus gate.
defmodule Thunderflow.EventBus do
@moduledoc """
Mnesia-backed event bus with pre-publish receipt & capability gate.
"""
@spec publish(atom(), map(), keyword()) :: {:ok, reference()} | {:error, term()}
def publish(topic, %Messages.Envelope{} = envelope, opts \\ []) do
with :ok <- verify_message_seal(envelope),
:ok <- verify_topic_capability(envelope.actor, topic) do
:mnesia.transaction(fn ->
:mnesia.write({:thunderflow_bus_log, envelope.message_id, topic, envelope, System.monotonic_time()})
end)
|> case do
{:atomic, :ok} ->
Phoenix.PubSub.broadcast(Thunderflow.PubSub, Atom.to_string(topic), {:bus_event, envelope})
{:ok, make_ref()}
{:aborted, reason} ->
{:error, {:mnesia_abort, reason}}
end
end
end
end
Before an inter-agent message crosses a link boundary, Thunderline.Thunderlink.MessageReceipt.seal/2 binds the message payload to its exact step_id, domain, and action_id so messages cannot be replayed into a different domain or saga step.
alias Thunderline.Thunderlink.MessageReceipt
envelope_meta = %{
step_id: "step_02_evidence_gather",
domain: :acorn,
action_id: "crown.manual.evidence_collect",
sender_actor: "agt_researcher_01",
correlation_id: "corr_88412a"
}
payload = %{
findings: ["tower_sector_b_azimuth_drift", "ground_lug_corrosion_photo_04"],
source_count: 2
}
{:ok, sealed_msg} = MessageReceipt.seal(payload, envelope_meta)
# => %MessageReceipt{
# step_id: "step_02_evidence_gather",
# domain: :acorn,
# action_id: "crown.manual.evidence_collect",
# payload_hash: "4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
# message_seal: "a91f03c8...e721"
# }
Rather than silently treating legacy or unverified rows as valid, AcornReceiptSealClassification.classify/1 returns one of four explicit atoms—distinguishing current verified 17-field seals from legacy v0 encodings and invalid/tampered records.
defmodule AcornReceiptSealClassification do
@moduledoc """
Classifies persisted ActionReceipt rows without inflating legacy records to current validity.
"""
@type classification ::
:current_valid
| :legacy_v0_consistent
| :legacy_k1_v0_consistent
| :invalid
@spec classify(ActionReceipt.t()) :: classification()
def classify(%ActionReceipt{envelope_version: 2} = receipt) do
expected = ActionReceipt.compute_seal_hash(receipt)
if Plug.Crypto.secure_compare(expected, receipt.seal_hash || "") do
:current_valid
else
:invalid
end
end
def classify(%ActionReceipt{envelope_version: 1, domain: :k1_wasm} = receipt) do
if LegacyK1V0Verifier.consistent?(receipt), do: :legacy_k1_v0_consistent, else: :invalid
end
def classify(%ActionReceipt{envelope_version: 1} = receipt) do
if LegacyV0Verifier.consistent?(receipt), do: :legacy_v0_consistent, else: :invalid
end
def classify(_), do: :invalid
end