Wallet Storage Schema — Clean-Room Design
Design Principles
- Outputs are the primary entity. The outputs table is the wallet’s ledger — the source of truth for “what does this wallet own?” Actions are the events that create and consume outputs.
- State is derived, not stored. An output’s spendability is structural: no
spendableboolean, nostateenum. Spendable = has a row inspendableAND no input row claims it. Spent = an input row exists. Relinquished = nospendablerow and no input row. Action status is derived from structural state + thebroadcast_intentflag — no status column. - The inputs table is the lock mechanism. Claiming an output for a transaction = INSERT into inputs. Releasing it = DELETE (via cascade). The UNIQUE constraint on
output_idenforces single-spend atomically. - Outputs are write-once during their canonical lifetime. The
outputstable is the log — a permanent record of every output the wallet has ever participated in, including derivation data, locking script, and output type. Once an output joins the canonical UTXO set (signalled by apromotionsrow gating itsspendablemembership), it is never UPDATE’d or DELETE’d. Cleanup paths (abort_action,reject_action, reaper) only ever delete rows whose parent action is being torn down in the same atomic transaction — typically rows that never crossed the promotions gate. All mutable state lives in relationship tables: basket membership inoutput_baskets, spending claims ininputs, tags inoutput_tags. Thespendabletable is the wallet — a minimal set of output_ids representing the current UTXO set. Outputs is the log; spendable is the wallet. - The spendable table is the UTXO set. A row in
spendablemeans “this output can be spent.” Pure set membership:{id, output_id, action_id}— no data columns. The presence of a row IS the spendable state. DELETE = spent or relinquished. The hot-path query scans this tiny table, then PK-joins to outputs for data. - Display metadata is vertically partitioned. Application metadata lives in
output_details(including the cosmeticchangeflag). Basket membership lives inoutput_baskets. - BRC-100 drives the vocabulary. Transactions are called “actions” (BRC-100 term). The 28 wallet methods define what the storage must serve.
- Proofs are settlement receipts. A merkle proof proves an action’s transaction is in a block.
action.tx_proof_id IS NOT NULLmeans settled. Block-level data (height, merkle root, block hash) is normalized into theblockstable — one row per known block height, shared across all proofs from that block. - No user table. The wallet is an engine, not a user-facing service. Identity and authentication are layers above. The wallet knows who it is because it was constructed with a key — that’s a runtime parameter, not a database row. Multi-tenant hosting (many users, one database) is a separate concern that can be added via a user-centric schema above the core wallet tables.
- Binary data is bytea. Transaction IDs, block hashes, merkle paths, raw transactions, and locking scripts are stored as
bytea. Sequel models return binary strings (RubyEncoding::BINARY). The entire internal stack — database, models, wallet code, SDK primitives — works with binary. Hex conversion is a presentation concern at the BRC-100 API boundary, not a storage or model concern. No relationships JOIN on txid — all FKs use surrogate bigint PKs. - The database is the last line of defense. Every invariant enforced in code must be backed by a database constraint. Code can be bypassed, refactored, or have bugs. The schema cannot be bypassed. NOT NULL is the default stance; a column should be nullable only with an explicit reason. CHECK constraints encode cross-column invariants, binary field sizes, and range validity.
Enums
CREATE TYPE broadcast_intent AS ENUM ('delayed', 'inline', 'none');
CREATE TYPE spendable_intent AS ENUM ('spendable', 'none');
CREATE TYPE tx_status AS ENUM (
'UNKNOWN', 'QUEUED', 'RECEIVED', 'STORED',
'ANNOUNCED_TO_NETWORK', 'REQUESTED_BY_NETWORK', 'SENT_TO_NETWORK',
'ACCEPTED_BY_NETWORK', 'SEEN_IN_ORPHAN_MEMPOOL', 'SEEN_ON_NETWORK',
'DOUBLE_SPEND_ATTEMPTED', 'REJECTED', 'MINED_IN_STALE_BLOCK', 'MINED', 'IMMUTABLE'
);
broadcast_intent: Immutable, set at action creation. Controls when/whether the transaction is broadcast to the network. Three values, two lifecycles:
| value | lifecycle | meaning |
|---|---|---|
delayed | send path | daemon submits to ARC asynchronously (default) |
inline | send path | wallet submits to ARC synchronously and surfaces the network result |
none | internal path | this action is not destined for the network — incoming BEEF, imported root UTXOs, wbikd address locks, send_payment returning BEEF for out-of-band delivery |
The send path runs the full 4-phase lifecycle (lock → sign → broadcast → promote). The internal path runs Phases 1, 2, and 4 synchronously at create_action time — there is no Phase 3 because the transaction is never broadcast. See Action Lifecycle below for the database-level detail.
spendable_intent (HLR #467): Per-output, stated by the decision-maker at construction time. Replaces the conflated output_type column (which triple-served as kind tag, spendability discriminator, and inference target — the anti-pattern ADR-010 banned in code and HLR #467 removed from the schema). Two values, both decisions the caller already knows:
| value | meaning | example |
|---|---|---|
spendable | the wallet owns this output and it should join the UTXO set when promoted | BRC-42 self-payment, change output, imported root P2PKH |
none | the wallet must NOT track this output as spendable | outbound payment (base58 or BRC-29), provably-unspendable OP_RETURN |
The structural validity rules are enforced by two CHECKs together (controls_all_or_nothing + spendable_recoverable) — see the outputs table for the per-wallet literal mechanism and the 8-permutation matrix. See intent-and-outcomes.md for the principle the constraint encodes.
Migration Order
Tables listed in FK-dependency order — this is the creation sequence.
1. Blocks
Known block headers — the wallet’s local view of the chain. One row per block height. Populated as a write-through cache: the chain tracker checks here first, fetches from the network on miss, and inserts the result. The blocks table is the single source of truth for “what is the merkle root at height N?”
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| height | integer | NOT NULL UNIQUE |
| merkle_root | bytea | NOT NULL |
| block_hash | bytea | |
| header | bytea | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
header: The raw 80-byte block header. Nullable, and the nullability is the discriminator (#335): present ⇒ the row was parsed and PoW-validated locally — its presence is the “validated” signal, there is no status column; absent ⇒ a trusted-service row carrying only the merkle_root fetched from a chain-query Service. The header_root_match CHECK ties the header’s embedded root (bytes 36..67, 0-indexed) to the indexed merkle_root column, so the two cannot drift — the header is the source, merkle_root the extracted answer.
Constraints:
CHECK height >= 0CHECK length(merkle_root) = 32CHECK block_hash IS NULL OR length(block_hash) = 32CHECK header IS NULL OR length(header) = 80CHECK header IS NULL OR substring(header from 37 for 32) = merkle_root— the embedded merkle_root (1-indexed SQL offset 37, length 32) matches the column. (substr(header, 37, 32)on SQLite.)
class Wallet::Block < Sequel::Model
one_to_many :tx_proofs
end
2. Tx Proofs
Merkle inclusion proof — evidence that a transaction is in a block. Independent of whether a wallet action references it (ancestor proofs exist for BEEF construction). Block-level data (height, merkle root, block hash) lives in the blocks table; the proof references it via block_id.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| wtxid | bytea | NOT NULL UNIQUE |
| block_id | bigint | REFERENCES blocks (id) |
| block_index | integer | |
| merkle_path | bytea | |
| raw_tx | bytea | NOT NULL |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
CHECK length(wtxid) = 32— wtxid is always 32 bytesCHECK length(raw_tx) >= 20— minimum valid transaction size (version + input_count + output_count + amount + script_len + OP_1 + locktime)CHECK merkle_path IS NULL OR block_id IS NOT NULL— a path is unverifiable without block context. The reverse (block_idknown,merkle_pathpending) is allowed — that’s the “confirmed but unproven” intermediate state when ARC reports MINED withblockHeightahead of the path.
class Wallet::TxProof < Sequel::Model
many_to_one :block
end
3. Actions
A BRC-100 Action — a Bitcoin transaction throughout its lifecycle from conception to settlement. The wallet’s audit log of “what happened and why.”
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| tx_proof_id | bigint | REFERENCES tx_proofs (id) |
| wtxid | bytea | UNIQUE WHERE NOT NULL |
| reference | uuid | NOT NULL UNIQUE DEFAULT uuidv7() |
| description | text | NOT NULL |
| broadcast_intent | broadcast_intent | NOT NULL DEFAULT ‘delayed’ |
| raw_tx | bytea | |
| input_beef | bytea | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
CHECK wtxid IS NULL OR length(wtxid) = 32CHECK length(description) BETWEEN 5 AND 50CHECK (wtxid IS NULL) = (raw_tx IS NULL)— an action is either unsigned (both NULL) or signed (both set)
reference UUIDv7: Time-ordered UUID (#198/#222). Postgres uses the native uuidv7() function (PG 18+) as the column default. SQLite calls SecureRandom.uuid_v7 (Ruby 3.3+) from Action#before_create. Sequential inserts on the UNIQUE index — no B-tree page splits or fragmentation that random UUIDv4 caused.
Indexes:
idx_actions_broadcast_intenton(broadcast_intent)— worker queries scan for actions pending broadcastUNIQUE (id, broadcast_intent)— composite FK target forbroadcasts.intent(atomically ties broadcast rows to their action’s intent; see Broadcasts below)
No status column. Status is derived from structural state. The send path (broadcast_intent IN ('delayed', 'inline')) and the internal path (broadcast_intent = 'none') share the table, and the derivation distinguishes them via the existence of a promotions row for the action (see Promotions below):
| Structural state | Derived status |
|---|---|
wtxid IS NULL | unsigned — waiting for signAction |
wtxid IS NOT NULL, tx_proof_id IS NOT NULL | completed |
wtxid IS NOT NULL, broadcast_intent = 'none', no tx_proof_id | internal — non-network action (incoming, wbikd, import, send_payment) |
wtxid IS NOT NULL, send path, EXISTS (promotions WHERE action_id = id), no tx_proof_id | unproven — waiting for proof |
wtxid IS NOT NULL, send path, broadcast row has tx_status = 'REJECTED' | failed — network rejected |
wtxid IS NOT NULL, send path, broadcast row exists, no promotions row | sending — broadcast in progress |
wtxid IS NOT NULL, send path, no broadcast row | unprocessed — broadcast pending |
class Wallet::Action < Sequel::Model
many_to_one :tx_proof
one_to_one :broadcast_entry, class: :BroadcastQueue
one_to_many :outputs
one_to_many :inputs
many_to_many :labels, join_table: :action_labels
def derived_status
return :unsigned if wtxid.nil?
return :completed if tx_proof_id
return :internal if values[:broadcast_intent] == 'none'
# A promotions row is recorded only at Phase 4, when the broadcast
# was accepted (#307 / ADR-023) — its existence is the :unproven gate.
return :unproven if Wallet::Promotion.where(action_id: id).any?
return :failed if broadcast_entry&.tx_status == 'REJECTED'
return :sending if broadcast_entry
:unprocessed
end
end
:internal replaces the earlier :nosend label (the rename is a breaking change for consumers of list_actions). 'none' is a load-bearing enum value distinct from the BRC-100 chained-send concept; it marks actions whose transaction is never going to ARC because it doesn’t need to (incoming BEEF, imported root UTXOs, wbikd address locks, send_payment returning BEEF for out-of-band delivery). The BRC-100 noSend / sendWith primitives are deferred to #192 and are not part of this base wallet.
4. Broadcasts
Evidence that a broadcast has been initiated. One row per action. The broadcast record and the network call are tightly coupled — the BroadcastQueue model owns both the row and the POST to ARC. The action doesn’t know or care about broadcast mechanics.
When ARC reports MINED with a merklePath, the broadcast handler creates a tx_proof and links it to the action — the proof arrives for free via the broadcast response.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| action_id | bigint | NOT NULL |
| broadcast_at | timestamptz | |
| callback_token | text | |
| arc_status | integer | |
| tx_status | tx_status | |
| intent | broadcast_intent | NOT NULL |
| block_hash | bytea | |
| block_height | integer | |
| merkle_path | bytea | |
| extra_info | text | |
| competing_txs | text[] | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (action_id)— one broadcast record per actionUNIQUE (action_id, tx_status)— composite UNIQUE serving as the FK target forpromotions(action_id, authorising_status). Trivially satisfied (action_idis already unique), but the pair must be declared as a key for the dependent FK to resolve.FOREIGN KEY (action_id, intent) REFERENCES actions (id, broadcast_intent) ON UPDATE RESTRICT— composite FK ties the broadcast row to its parent action’s intent atomically;ON UPDATE RESTRICTmakes the immutability ofactions.broadcast_intentan enforced schema invariant rather than a code-only conventionCHECK intent != 'none'— actions withbroadcast_intent = 'none'are internal-path and cannot have a broadcast rowCHECK block_hash IS NULL OR length(block_hash) = 32CHECK block_height IS NULL OR block_height >= 0
intent column: Duplicates actions.broadcast_intent for the composite FK target. The pair (intent != 'none', composite FK) is the trigger-free equivalent of “an action with broadcast_intent = 'none' cannot have a broadcasts row” (#198/#221) — chosen over a trigger to avoid per-row procedural overhead at high throughput.
callback_token: Wallet-generated opaque string sent to ARC in the X-CallbackToken header at submission time. ARC’s /events SSE endpoint echoes the token on each status event — the listener uses it to look up the originating broadcast row without round-tripping a txid lookup. Nullable: rows broadcast before the SSE listener landed have none.
tx_status: ARC’s transaction lifecycle status. Postgres uses the tx_status ENUM (CREATE TYPE above) — the canonical vocabulary lives in ARC’s metamorph_api.proto. SQLite gets an equivalent CHECK constraint with the same set. IMMUTABLE is appended for the wallet’s terminal-status set (anticipates an ARC addition; referenced by Broadcast::TERMINAL_STATUSES). Column positioned after arc_status for efficient row padding in Postgres.
Mutable-target consequence for promotions: promotions(action_id, authorising_status) references broadcasts(action_id, tx_status) ON UPDATE CASCADE. As tx_status advances through the lifecycle (RECEIVED → SEEN_ON_NETWORK → MINED), the cascade keeps promotions.authorising_status synced — broadcasts.tx_status stays the single source of truth, with no duplicated “accepted” latch. A flip to REJECTED while a promotions row exists would cascade into auth_not_rejected and fail the CHECK; Store#reject_action therefore deletes the promotions row before the broadcasts row is removed (see reject_action below). See Promotions (§7) for the full FK gate.
ARC tx_status lifecycle:
UNKNOWN → RECEIVED → SENT_TO_NETWORK → ACCEPTED_BY_NETWORK → SEEN_ON_NETWORK → MINED → IMMUTABLE
↘ DOUBLE_SPEND_ATTEMPTED
↘ REJECTED
class Wallet::BroadcastQueue < Sequel::Model
many_to_one :action
end
Action Lifecycle — The Database Perspective
Every createAction is a series of small atomic database transactions. No database transaction is ever held open across a network call.
Phase 1: Lock (atomic, milliseconds)
BEGIN
INSERT INTO actions (broadcast_intent, description, ...)
-- wtxid IS NULL, raw_tx IS NULL — the action is unsigned
INSERT INTO inputs (action_id, output_id, vin, nsequence, description)
ON CONFLICT (output_id) DO NOTHING RETURNING output_id
-- verify we locked enough inputs to fund the transaction
-- if insufficient: ROLLBACK (nothing persisted, nothing to clean up)
COMMIT
Database state after Phase 1:
actions: one new row,wtxid IS NULL(unsigned), noraw_txinputs: one row per consumed output, each locking a UTXO via UNIQUE(output_id)outputs: untouched — change outputs exist only in memoryspendable: untouched — locked outputs are still in spendable but excluded by the NOT EXISTS anti-join on inputs
Contention window: only the duration of Phase 1 (a few INSERTs). A concurrent createAction trying to lock the same output will block briefly here, then see the conflict and move on to other UTXOs.
Phase 2: Sign (atomic, milliseconds for the commit — signing work happens in memory)
UTXO selection, key derivation, template evaluation, ECDSA signing — all happen in memory between Phase 1 and the Phase 2 commit. If signing fails, the action stays unsigned (inputs remain locked, reaper will clean up).
If signAndProcess: false, this phase is deferred — see Deferred Signing — signAction below.
-- signing happens in memory (key derivation, ECDSA, script templates)
BEGIN
UPDATE actions SET wtxid = ?, raw_tx = ? WHERE id = ?
-- send path writes the output rows here. No "promoted" flag — the row's
-- canonical-UTXO membership is gated by the absence of a promotions row,
-- not by a column on outputs.
INSERT INTO outputs (action_id, satoshis, vout, locking_script,
spendable_intent, derivation_prefix, derivation_suffix,
sender_identity_key)
-- broadcasts row appears here too (intent != 'none'), pre-stamped for
-- the daemon push-discovery loop
INSERT INTO broadcasts (action_id)
COMMIT
Database state after Phase 2 (send path):
actions:wtxidset,raw_txset — the action is signed and ready for broadcastoutputs: rows for every output — the immutable record exists, but nopromotionsrow exists yet so the outputs are not in the canonical UTXO setspendable: untouched — thespendable.action_id → promotions(action_id)FK structurally prevents any spendable row from existing without authorisation (Phase 4)broadcasts: one row,broadcast_at IS NULL(the daemon will stamp it before POSTing to ARC)
Internal path (broadcast_intent = 'none') does not produce a Phase 3. Phases 1, 2, and 4 commit synchronously inside create_action (see internalizeAction and the equivalent porcelain paths below). Internal outputs are written, a promotions row is inserted with intent = 'none' and authorising_status = NULL (the promo_path CHECK admits this combination), and spendable rows are inserted in the same transaction. No broadcasts row is ever created.
Phase 3: Broadcast (managed by Engine::Broadcast)
Phase 3 applies to the send path only (broadcast_intent IN ('delayed', 'inline')). Internal-path actions skip it entirely.
The broadcasts row was created during Phase 2 commit. Phase 3 is the network call that drives it through ARC’s lifecycle.
# inline path: the wallet POSTs to ARC synchronously
Engine::Broadcast#submit(action_id:)
# delayed path: the daemon's push-discovery loop finds the row
# (broadcast_at IS NULL) and calls Engine::Broadcast#submit
Engine::Broadcast#submit:
- Stamps
broadcast_at = now()and commits (Store#mark_broadcast_attempted) — the row leaves the push-discovery set and joins the poll set. State flag:broadcast_atis a state marker — NULL means the row is queued for submission; non-NULL means it has been submitted and is awaiting outcome. Thewhere(broadcast_at: nil)predicate inStore#mark_broadcast_attemptedprevents racing re-stamps within a single in-flight attempt;Store#clear_broadcast_attemptedreverts the stamp on a 503 response so the row re-enters the queued state for clean retry. After a 503 + retry,broadcast_atreflects the retry timestamp rather than the first attempt. - POSTs to ARC (network call — outside any DB transaction)
- On accepted ARC response: persists the status fields and triggers Phase 4 (
Store#promote_action_outputs) - On terminal rejection:
Engine::Broadcast#handle_submit_terminalinvokesStore#reject_action, which cascade-unwinds the action and (if a speculative promotion fired) any descendants that consumed its outputs (see reject_action below) - On 503 backpressure:
Store#clear_broadcast_attemptedrevertsbroadcast_atto NULL (guarded against the concurrent-SSE-event race by atx_status IS NULLpredicate); the daemon’spending_submissionsdiscovery picks the row back up next cycle
If the process crashes between steps 1 and 2: the row sits with broadcast_at IS NOT NULL AND tx_status IS NULL. The daemon’s poll loop finds it via Store#pending_polls and calls Engine::Broadcast#poll_status (GET /tx/{txid}) to resolve the outcome.
If the process crashes between steps 2 and 3: same recovery — the poll loop converges the row to its terminal state and triggers Phase 4 or reject_action accordingly.
Engine::Broadcast#poll_status shares the Phase 4 trigger and the terminal-rejection trigger with submit — both call sites invoke Store#promote_action_outputs (idempotent via the promotions row — a second invocation finds the row present and returns immediately) or Store#reject_action. The post-broadcast lifecycle is symmetric across the two entry points.
Phase 4: Promote (atomic, milliseconds — triggered by broadcast acceptance on the send path, synchronous on the internal path)
On the send path, Phase 4 fires from Engine::Broadcast#submit or #poll_status when ARC returns an accepted status (SEEN_ON_NETWORK, ACCEPTED_BY_NETWORK, MINED, IMMUTABLE). The output rows already exist from Phase 2 — the work is to record the authorisation and bring the wallet-owned outputs into the canonical UTXO set:
BEGIN
-- Record promote-authorisation as a row. The composite FK
-- (action_id, authorising_status) → broadcasts (action_id, tx_status)
-- requires that pair to currently exist in broadcasts; the auth_not_rejected
-- CHECK forbids REJECTED / DOUBLE_SPEND_ATTEMPTED.
INSERT INTO promotions (action_id, intent, authorising_status)
VALUES (?, ?, ?) -- intent = the action's broadcast_intent,
-- authorising_status = the current tx_status
ON CONFLICT (action_id) DO NOTHING
-- Wallet-owned outputs join the UTXO set. The spendable.action_id FK
-- to promotions(action_id) means this insert only succeeds because the
-- promotions row was just written.
INSERT INTO spendable (output_id, action_id, spendable_intent)
SELECT id, action_id, spendable_intent FROM outputs
WHERE action_id = ?
AND spendable_intent = 'spendable'
AND id NOT IN (SELECT output_id FROM spendable)
-- If ARC returned MINED + merklePath:
INSERT INTO blocks (height, merkle_root, block_hash)
VALUES (?, ?, ?) ON CONFLICT (height) DO NOTHING
INSERT INTO tx_proofs (wtxid, block_id, block_index, merkle_path, raw_tx)
ON CONFLICT (wtxid) DO UPDATE SET ...
UPDATE actions SET tx_proof_id = ? WHERE id = ?
COMMIT
On the internal path (broadcast_intent = 'none'), Phase 4 is committed inside the same transaction as Phase 1+2 by Store#promote_action. The promotions row is inserted with intent = 'none' and authorising_status = NULL (the promo_path CHECK admits the internal disjunction; the composite FK to broadcasts skips on NULL via MATCH SIMPLE). Spendable rows are inserted alongside. No broadcast acceptance trigger is involved.
Database state after Phase 4 (send path):
outputs: unchanged from Phase 2 — same rows, untouched.promotions: one new row,authorising_status =the broadcast’s current tx_status. The row’s existence IS the canonical-state fact (#307 / ADR-023).spendable: new rows for wallet-owned outputs. Outbound outputs never get a spendable row — declaratively gated by the composite FKspendable(output_id, spendable_intent) → outputs(id, spendable_intent)+ CHECKspendable_intent = 'spendable'(HLR #467, hot-path-design.md). No trigger on the hot path.tx_proofs: proof created if ARC returned MINED (proof arrives for free).actions:tx_proof_idset if proof arrived with broadcast response.
Database state after Phase 4 (internal path):
outputs: new rows for all transaction outputs.promotions: one new row,intent = 'none',authorising_status = NULL.spendable: new rows for wallet-owned outputs.- No broadcasts row, no proof from this path (incoming actions carry their proof; wbikd / send_payment have no on-chain proof requirement at this stage).
The new wallet-owned outputs are live in the UTXO set. They’re immediately available for the next createAction.
Broadcast Failure
If ARC returns a terminal rejection (REJECTED, DOUBLE_SPEND_ATTEMPTED, MALFORMED), Engine::Broadcast#handle_submit_terminal calls Store#reject_action — the unified unwind path. MINED_IN_STALE_BLOCK is not terminal — it emits task.failed reason=stale_beef and is re-discovered on the next scheduler tick (see Events (reference)).
reject_action covers both regimes in one function:
- Phase 4 never fired (rejection on the first ACK) — no promotions row exists, no spendable row exists (the FK gate structurally prevented it), no descendant could have consumed an output. Teardown clears the dependents, the broadcasts row, and the action; CASCADE on
inputs.action_idreleases the locked UTXOs. - Phase 4 already fired speculatively (optimistic promotion on an interim non-rejected status, e.g.
SEEN_ON_NETWORK, that subsequently flipped toREJECTED) — a promotions row exists, spendable rows exist, and descendants may have consumed outputs.reject_actionwalks descendants forward first, then unwinds this action’s promotion (cascading its spendable rows out), then completes the teardown.
The single function discriminates internally on whether a promotions row is present — see reject_action below for the SQL sequence, the FK ordering, and the two refusal guards (CannotRejectInternalActionError, CannotRejectAcceptedActionError).
Under #189 the FK on outputs.action_id is RESTRICT, so the explicit deletes are required regardless of regime — there is no automatic CASCADE on outputs.
Reaper: TTL Cleanup
Two classes of stale actions:
Never signed (wtxid IS NULL — Phase 1 happened, signing never did):
-- No outputs exist yet (send path defers writes to sign time;
-- internal path commits in one transaction so wtxid is never NULL).
-- CASCADE on inputs.action_id frees the locked UTXOs.
DELETE FROM actions a
WHERE a.wtxid IS NULL
AND a.created_at < (now() - interval '?');
Never sent (wtxid IS NOT NULL, send path, no broadcasts row):
-- Action was signed and outputs were persisted but a broadcasts row
-- was never created — sign_action committed but the broadcast intent
-- never reached Phase 3. No promotions row exists (Phase 4 requires
-- a broadcasts row to satisfy the composite FK target), so spendable
-- is empty by construction. RESTRICT FK on outputs.action_id means
-- the output dependents must be cleared explicitly before the action.
DELETE FROM output_baskets WHERE action_id IN (...stale ids...);
DELETE FROM output_details WHERE action_id IN (...stale ids...);
DELETE FROM output_tags WHERE output_id IN (
SELECT id FROM outputs WHERE action_id IN (...stale ids...)
);
DELETE FROM outputs WHERE action_id IN (...stale ids...);
DELETE FROM actions a
WHERE a.wtxid IS NOT NULL
AND a.broadcast_intent != 'none'
AND a.created_at < (now() - interval '?')
AND NOT EXISTS (SELECT 1 FROM broadcasts b WHERE b.action_id = a.id);
Sent but unresolved (broadcast row exists, no promotions row — needs ARC poll):
SELECT a.id, a.wtxid, b.tx_status, b.broadcast_at
FROM actions a
JOIN broadcasts b ON b.action_id = a.id
WHERE NOT EXISTS (
SELECT 1 FROM promotions p WHERE p.action_id = a.id
)
AND b.broadcast_at < (now() - interval '?');
-- The daemon's poll-discovery loop already handles this via
-- Store#pending_polls; the reaper query is the manual-investigation
-- entry point for rows that have lingered beyond the poll TTL.
Internal-path actions (broadcast_intent = 'none') are not visible to the reaper — they commit atomically with their outputs and a promotions row in one transaction, and never enter a “stuck between phases” state. The prevent_internal_action_delete BEFORE DELETE trigger on actions makes this a schema-level fact: any DELETE against an action with broadcast_intent = 'none' AND an existing promotions row is refused (check_violation ERRCODE → Sequel::CheckConstraintViolation). Defence-in-depth mirroring Store#reject_action’s CannotRejectInternalActionError.
Proof Arrival (async, via ARC callback or polling)
BEGIN
INSERT INTO blocks (height, merkle_root, block_hash)
VALUES (?, ?, ?) ON CONFLICT (height) DO NOTHING
INSERT INTO tx_proofs (wtxid, block_id, block_index, merkle_path, raw_tx)
ON CONFLICT (wtxid) DO UPDATE SET ...
UPDATE actions SET tx_proof_id = ? WHERE wtxid = ?
UPDATE broadcasts SET tx_status = 'MINED', block_hash = ?, block_height = ? WHERE action_id = ?
COMMIT
The action’s derived status transitions to completed (tx_proof_id is now set). Can arrive via:
- The broadcast response itself (ARC returns MINED immediately for fast blocks)
- ARC SSE events (
/eventsendpoint — push-based) - Polling ARC
GET /tx/{txid} - Daemon fetch cycle (Action adopts Fetchable — structural queries find actions needing proofs)
abortAction (before broadcast)
BEGIN
-- Abort applies to actions that have not yet been broadcast.
-- It is a no-op if a broadcasts row exists.
-- It is refused (raises CannotAbortPromotedActionError) if a
-- promotions row exists — internal-path actions have no broadcasts
-- row but ARE promoted at create_action time and may already own
-- canonical UTXO history that downstream actions have spent.
-- Under #189 the outputs.action_id FK is RESTRICT, so any deferred
-- output rows must be cleared before the action.
DELETE FROM output_baskets WHERE action_id = ?
DELETE FROM output_details WHERE action_id = ?
DELETE FROM output_tags WHERE output_id IN (
SELECT id FROM outputs WHERE action_id = ?
)
DELETE FROM outputs WHERE action_id = ?
DELETE FROM actions WHERE id = ?
-- CASCADE deletes inputs, freeing locked UTXOs
COMMIT
After a broadcasts row exists, abortAction is a no-op — the network may have the transaction. Store#reject_action (see below) handles cleanup when ARC definitively rejects, whether or not Phase 4 has already fired speculatively.
reject_action — terminal-rejection teardown
Store#reject_action is the unified entry point for tearing an action down on terminal rejection from ARC. It is called from Engine::Broadcast#handle_submit_terminal (the submit path) and from the resolution loop (the poll path) — both surfaces route into the same Store function.
It handles two regimes through one implementation:
- Phase 4 never fired. No promotions row, no spendable row (the
spendable → promotionsFK structurally prevented one), no descendant could have consumed an output. The forward-walk finds no children; the dependent sweep clearsoutput_baskets/output_details/output_tags/outputs(RESTRICT requires explicit deletes); the broadcasts row goes; the action goes (CASCADE oninputs.action_idreleases the locked UTXOs). - Phase 4 already fired speculatively. Promotion fired on an optimistic non-rejected ARC ACK (any status admitted by
auth_not_rejected, e.g.SEEN_ON_NETWORK) andtx_statuslater flipped to a terminal-rejected value. Children may have spent the action’s outputs in the meantime, so the rollback walks the action graph forward and tears every descendant down first.
The pseudocode below is the recursive inner method (do_reject); reject_action wraps it in an outer transaction with an empty visited set.
do_reject(action_id, visited: Set)
# idempotency guard: visited set protects against diamond DAGs
# (a child spending two outputs of a common ancestor) — second visit
# no-ops rather than raising
return if visited.include?(action_id)
# internal-path actions own canonical received UTXO history; rolling
# them back would compound chain divergence rather than reflect it.
raise CannotRejectInternalActionError if broadcast_intent == 'none'
# ARC told us this tx is accepted (SEEN_ON_NETWORK / MINED / etc.);
# deletion would compound a wallet-vs-chain divergence. Operator
# investigation is the right response, not unwind.
raise CannotRejectAcceptedActionError if broadcast.tx_status ∈ ACCEPTED
# forward-walk: tear children down first so outputs.action_id RESTRICT
# doesn't block this action's output deletes
child_actions_of(action_id).each { |c| do_reject(c, visited) }
# tear-down sequence: order matters
DELETE spendable, output_tags, output_baskets, output_details, outputs
DELETE action_labels
DELETE promotions -- BEFORE broadcasts: the composite FK
-- (action_id, authorising_status) → broadcasts blocks
-- the broadcasts delete otherwise
DELETE broadcasts
DELETE actions -- CASCADE inputs, freeing locked UTXOs
The cascade structure earns its keep here: deleting promotions cascades any remaining spendable rows out automatically via the structural FK (§7). The explicit DELETE FROM spendable above is belt-and-braces, kept for clarity and to guarantee zero leftover rows in the same transaction.
Three reasons this is correct-by-construction:
- Idempotent re-entry. Action graphs are DAGs (an input can only spend an existing output — true cycles are impossible), but diamonds occur naturally (a consolidation combining two outputs of a common ancestor). The visited set ensures the second arrival no-ops rather than raising and rolling back the whole cascade.
- Composite FK ordering. The
promotions(action_id, authorising_status) → broadcasts(action_id, tx_status)FK means the broadcasts row cannot be deleted while a promotions row references it. Delete the promotion first, then the broadcast. - CASCADE + CHECK interaction. Because the broadcasts FK is
ON UPDATE CASCADE, an externalUPDATE broadcasts SET tx_status = 'REJECTED'while a promotions row exists would cascadeauthorising_statusto REJECTED and fail theauth_not_rejectedCHECK. The call sequence — delete promotions first, then update or delete broadcasts — keeps this from firing in practice. If it ever did, the wallet would see a constraint violation rather than silent corruption.
Deferred Signing — signAction
When: createAction is called with signAndProcess: false, or when any input declares unlocking_script_length without providing an unlocking_script. The wallet can’t fully sign the transaction — the caller needs to provide unlocking scripts for some inputs.
What’s deferred: Only signing and broadcasting. The deferral is about inputs, not outputs. The outputs are fully known at createAction time — they don’t change between createAction and signAction.
The output rows are written to the immutable log at createAction time so the caller can see them via list_outputs(include_*). They do not join the canonical UTXO set until the eventual signAction is followed by an accepted broadcast (Phase 4 on the send path) — no promotions row exists yet, and the spendable.action_id → promotions(action_id) FK structurally prevents any spendable row from existing until then.
Deferred createAction runs Phase 1 and a partial Phase 2 (stage_action):
BEGIN
-- Phase 1: Lock (same as synchronous)
INSERT INTO actions (broadcast_intent, description, ...)
-- wtxid IS NULL initially
INSERT INTO inputs (action_id, output_id, vin, nsequence, description)
ON CONFLICT (output_id) DO NOTHING RETURNING output_id
-- Build the unsigned transaction in memory.
-- Stage Phase 2 (no signing yet): persist the unsigned raw_tx, its
-- hash as the placeholder wtxid, and the output rows. No promotions
-- row, no broadcasts row — both materialise at signAction time (or
-- on the eventual broadcast ACK in Phase 4 for promotions).
UPDATE actions SET wtxid = ?, raw_tx = unsigned_tx WHERE id = ?
INSERT INTO outputs (action_id, satoshis, vout, locking_script,
spendable_intent, derivation_prefix, derivation_suffix,
sender_identity_key)
INSERT INTO output_baskets (output_id, basket_id, action_id)
INSERT INTO output_details (output_id, action_id, change, description, ...)
COMMIT
Returns { signable_transaction: { tx: unsigned_raw_tx, reference: action.reference } }.
Database state after deferred createAction:
actions:wtxidset (placeholder hash of unsigned bytes),raw_txsetinputs: locked, same as synchronous Phase 1outputs: rows exist — the immutable record is in place but the outputs are not in the canonical UTXO setpromotions: untouched — no authorisation has been recordedspendable: untouched — thespendable.action_id → promotionsFK gates UTXO membership; no row can exist until the promotions row exists at Phase 4broadcasts: untouched — the broadcast row only exists fromsignActiononwards
signAction completes the deferred transaction:
-- In memory: deserialize the unsigned raw_tx, apply caller unlocking
-- scripts, sign remaining P2PKH inputs with derived keys
BEGIN
UPDATE actions SET wtxid = signed_wtxid, raw_tx = signed_tx WHERE id = ?
INSERT INTO broadcasts (action_id) -- send path only (intent != 'none')
COMMIT
-- Phase 3 (broadcast) and Phase 4 (promote on acceptance) — same as
-- the synchronous send path.
signAction only touches the action row and creates the broadcasts row. The output rows persisted by stage_action are not rewritten; the promotion happens at Phase 4 by inserting a promotions row after the broadcast is accepted.
Cleanup for abandoned deferred actions:
If signAction is never called, the action sits with locked inputs and output rows for a transaction that will never reach the network. No promotions row exists (Phase 4 never fired). abortAction is the supported cleanup path — under #189 the outputs.action_id FK is RESTRICT, so abort must clear the output rows before the action row:
BEGIN
-- No spendable rows to clear: the spendable.action_id → promotions
-- FK structurally prevented any insert (no promotions row exists).
-- Just the output rows and their lightweight dependents.
DELETE FROM output_baskets WHERE action_id = ?
DELETE FROM output_details WHERE action_id = ?
DELETE FROM output_tags WHERE output_id IN (
SELECT id FROM outputs WHERE action_id = ?
)
DELETE FROM outputs WHERE action_id = ?
-- Release locked UTXOs and remove the action.
DELETE FROM actions WHERE id = ?
-- CASCADE deletes inputs, freeing locked UTXOs
COMMIT
The reaper handles the same shape for actions abandoned past their TTL (see Reaper: TTL Cleanup above). Either way, no spendable rows existed so nothing has to be removed from the UTXO set — the cleanup is purely about the immutable log and the locked inputs.
Broadcast failure on the send path: if ARC returns a terminal rejection after signAction, Store#reject_action runs the unified teardown described above (see reject_action and Broadcast Failure). If Phase 4 never fired the action has no descendants (its outputs were never in the UTXO set) and the call collapses to a single-action sweep; if Phase 4 fired speculatively, the recursive forward-walk handles any descendants that consumed its outputs. The batch-aware cascade for the future chained-send subsystem is covered by #192.
internalizeAction (incoming) — internal path
BEGIN
INSERT INTO blocks (height, merkle_root, block_hash)
VALUES (?, ?, ?) ON CONFLICT (height) DO NOTHING
INSERT INTO tx_proofs (wtxid, block_id, ...) ON CONFLICT DO UPDATE ...
INSERT INTO actions (tx_proof_id, wtxid, broadcast_intent: 'none', ...)
-- Internal path: outputs written, a promotions row inserted with
-- intent='none' and authorising_status=NULL (the promo_path CHECK
-- admits the internal disjunction), and spendable rows inserted in
-- the same transaction. No Phase 3, no broadcasts row.
INSERT INTO outputs (action_id, satoshis, vout, locking_script,
spendable_intent, derivation_prefix, derivation_suffix,
sender_identity_key)
INSERT INTO promotions (action_id, intent, authorising_status)
VALUES (?, 'none', NULL)
INSERT INTO spendable (output_id, action_id, spendable_intent)
INSERT INTO output_baskets (output_id, basket_id, action_id)
INSERT INTO output_details (output_id, action_id, ...)
COMMIT
Incoming actions arrive with BEEF — the proof is already available. The action is born with tx_proof_id set (derived status: completed) or with broadcast_intent = 'none' and no proof (derived status: internal) depending on the source. Outputs go directly into the immutable log and the UTXO set in one atomic transaction. No broadcasts row, no Phase 3, no broadcast acceptance trigger.
The internal path also serves import_utxo (imported root-key UTXO), wbikd address management (slot locks), and send_payment (porcelain that returns BEEF for out-of-band delivery). All four callers commit Phases 1, 2, and 4 in a single transaction.
relinquishOutput
BEGIN
DELETE FROM spendable WHERE output_id = ?;
DELETE FROM output_baskets WHERE output_id = ?;
COMMIT
The output row stays in the log. The wallet forgets about it — no spendable entry, no basket.
Summary: What Each Table Experiences
| Table | INSERT | UPDATE | DELETE | Character |
|---|---|---|---|---|
| blocks | proof arrival / internalize / chain tracker | never | never | Append-only — known block headers |
| actions | createAction | wtxid (sign), tx_proof_id (proof) | abort, reject, reaper | Mutable — the lifecycle entity |
| inputs | Phase 1 (lock) | never | CASCADE from action delete | Born and dies with its action |
| broadcasts | Phase 2 (send path: alongside sign) | broadcast_at, ARC response updates | abort never reaches here; reject / CASCADE from action | Broadcast lifecycle |
| outputs | Phase 2 (send path) / Phase 4 (internal path, alongside promotions row) | never | abort / reject / reaper (cleanup paths only — never during normal lifecycle) | Append-only log; row-existence in promotions gates UTXO membership |
| promotions | Phase 4 (send path: on broadcast acceptance / internal path: in the create_action transaction) | authorising_status via ON UPDATE CASCADE from broadcasts.tx_status | reject (delete-before-broadcasts-delete ordering) / CASCADE from action | Existence-as-state authorisation gate |
| spendable | Phase 4 (both paths) | never | spend / relinquish / reaper / CASCADE from promotions delete | The wallet — INSERT/DELETE only |
| output_baskets | Phase 2 (send path) / Phase 4 (internal path) | basket move | relinquish / reaper / abort / reject | Mutable membership |
| output_details | Phase 2 (send path) / Phase 4 (internal path) | never | reaper / abort / reject | Immutable metadata (until reaped) |
| tx_proofs | proof arrival / internalize | upsert on re-proof | never | Append-mostly |
5. Baskets
Output grouping with replenishment policy. Baskets are entities, not just string labels.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| name | text | NOT NULL UNIQUE |
| target_count | integer | |
| target_value | integer | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (name)— plain unique (no soft delete)CHECK length(name) BETWEEN 1 AND 300CHECK name != 'default'— the default basket is implicit (no row needed)CHECK target_count IS NULL OR target_count >= 0CHECK target_value IS NULL OR target_value >= 0
class Wallet::Basket < Sequel::Model
one_to_many :output_baskets
many_to_many :outputs, join_table: :output_baskets
end
6. Outputs
The append-only log. A permanent record of every output the wallet has ever participated in — both wallet-owned outputs (with derivation data) and outbound payments (spendable_intent = 'none').
Write-once during canonical lifetime. No per-row UPDATE happens, ever — the table has no mutable columns. Per-row DELETE happens only in cleanup paths (abort_action, reject_action, reaper) and only on rows whose parent action is being torn down in the same atomic transaction. Whether such a delete can reach a row is gated structurally: an output’s parent action either has a promotions row (§7) — in which case it owns canonical UTXO membership and the cleanup paths refuse to touch it — or it doesn’t, in which case the row never crossed the authorisation gate and tear-down is safe. Lifecycle exit at scale is partition drop, not per-row DELETE.
Derivation data (spending authority) lives here because it’s a fact about the output, recorded when the key is derived. This is separate from spendability — an output can have derivation data without being in the UTXO set.
The UTXO set (what’s spendable now) is the spendable table. Queries enter through spendable and PK-join back here for data — the outputs table is never full-scanned.
At scale, partition by id range. Old partitions where all outputs have been spent (no remaining spendable rows) can be detached and archived.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| action_id | bigint | NOT NULL REFERENCES actions (id) ON DELETE RESTRICT |
| satoshis | bigint | NOT NULL |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| locking_script | bytea | NOT NULL |
| vout | integer | NOT NULL |
| spendable_intent | spendable_intent | NOT NULL |
| derivation_prefix | text | |
| derivation_suffix | text | |
| sender_identity_key | text |
Constraints:
UNIQUE (action_id, vout)— an output is uniquely identified by its position in the action that created it.UNIQUE (id, spendable_intent)— composite-FK target. Letsspendabledenormalisespendable_intentand FK against it (the trigger-free replacement forprevent_outbound_spendable).CHECK satoshis >= 0,CHECK vout >= 0,CHECK length(locking_script) >= 1.CHECK controls_all_or_nothing—derivation_prefix/derivation_suffix/sender_identity_keyare all set together or all absent together. Partial derivation is a malformed row.-
CHECK spendable_recoverable— the per-wallet structural rule (HLR #467). The WIF-derived root P2PKH script (OP_DUP OP_HASH160 {hash160(identity_key)} OP_EQUALVERIFY OP_CHECKSIG) is embedded into the CHECK as a binary literal at migration time. The constraint admits exactly the four valid combinations of (root_pattern × controls_present × spendable_intent):root_pattern controls_present spendable_intent valid? T F spendable✓ — root P2PKH we own (chain UTXO) F F none✓ — outbound base58 (recipient owns it) F T spendable✓ — BRC-42 self-payment / change F T none✓ — BRC-29 outbound via derivation to counterparty T F none✗ — root-pattern → we own; nonecontradictsT T * ✗ — hash collision (~2⁻¹⁶⁰), reject as impossible F F spendable✗ — no derivation, not root: no recoverable key The literal is wallet-specific: every wallet’s
pg_dumpshows its own hash in the CHECK definition. Cross-wallet schema dumps differ by design (multi-user pattern per ADR-028). The hash is public information — it’s the wallet’s root P2PKH address visible on chain. WIF rotation = new wallet (the literal can’t change without re-creating the table).Schema integrity:
Store#verify_schema!reads the CHECK definition frompg_constraintat boot and asserts the literal matches the currently-loaded WIF. Catches schema drift, restore-to-wrong-DB, and WIF rotation.App-layer mirror:
Output#validateencodes the same rule as a Sequel model validator so callers see clean field-level errors before the DB rejects. The two layers exist in parallel — the model surfaces the failure earlier with a per-field message, the DB is the structural backstop. Seeintent-and-outcomes.mdfor the principle and full rationale.
Canonical-state marker: there isn’t a column for it. An output is “in the canonical UTXO set” iff its parent action has a row in promotions (§7) and the output has a row in spendable (§8). The promotions row’s existence IS the canonical-state fact (#307 / ADR-023). This replaces the earlier outputs.promoted boolean — which was a per-row UPDATE deviation from append-only — with row-existence in a separate table, gating UTXO membership declaratively.
Cascade: action_id ON DELETE RESTRICT (under #189). Outputs cannot be orphaned by an action delete — the delete is rejected unless the dependent output rows are removed first. The cleanup paths (abort_action, reject_action, reaper) handle this explicitly. The combination of RESTRICT + the spendable→promotions gate makes the safety property mechanical: any output reached by a delete is by construction one whose action had no promotions row (or has just had it deleted as part of an in-flight reject_action cascade).
Note: No updated_at — outputs have no UPDATE path at all. No basket_id — basket membership is in output_baskets. No wtxid — derived via output.action.wtxid (always resolvable under the RESTRICT FK, since the parent action cannot be deleted while output rows remain). Column order optimized for alignment: 8-byte columns first, then variable-width, with 4-byte vout tucked after locking_script to reduce padding.
class Wallet::Output < Sequel::Model
many_to_one :action
one_to_one :spendable_entry, class: :Spendable
one_to_one :detail, class: :OutputDetail
one_to_one :input # the input row claiming this output, if any
one_to_one :output_basket # current basket membership, if any
many_to_many :tags, join_table: :output_tags
dataset_module do
# The UTXO set: outputs in the spendable table and not claimed by any input
def spendable
where(
Sequel.exists(Spendable.where(Sequel[:spendable][:output_id] => Sequel[:outputs][:id]).select(1))
).exclude(
Sequel.exists(Input.where(Sequel[:inputs][:output_id] => Sequel[:outputs][:id]).select(1))
)
end
def in_basket(name)
where(
Sequel.exists(
OutputBasket.join(:baskets, id: :basket_id)
.where(Sequel[:output_baskets][:output_id] => Sequel[:outputs][:id])
.where(Sequel[:baskets][:name] => name)
.select(1)
)
)
end
def min_satoshis(value)
where { satoshis >= value }
end
end
def outpoint
"#{action.wtxid}.#{vout}"
end
def basket
output_basket&.basket
end
def spendable?
spendable_entry && input.nil?
end
end
7. Promotions
Promote-authorisation as a row. The existence of a promotions row for an action IS the canonical-state fact: “this action’s outputs are in the UTXO set.” This replaces the earlier outputs.promoted boolean (#307 / ADR-023), which was a per-row UPDATE deviation from append-only outputs; with promotion modelled as row-existence, outputs returns to pure INSERT-only.
A single row per action — promotion was always a per-action fact, and the PK is action_id (a non-auto PK that is also an FK to actions). The gate is declarative: two CHECKs on the row itself, plus two composite FKs that tie it to its parent action’s intent and (for the send path) to a non-rejected broadcast status. No trigger on the Phase 4 hot path — the hot send path carries only the INSERT + the FK lookups (#221, ADR-019). A separate trigger does exist on the cold actions DELETE path — see Defence-in-depth below — but it never fires under normal traffic.
| col | type | attributes |
|---|---|---|
| action_id | bigint | PRIMARY KEY REFERENCES actions (id) ON DELETE CASCADE |
| intent | broadcast_intent | NOT NULL |
| authorising_status | tx_status | NULL |
CHECKs:
promo_path—(intent = 'none' AND authorising_status IS NULL) OR (intent <> 'none' AND authorising_status IS NOT NULL). Internal-path actions authorise themselves by being internal; send-path actions must name the broadcast status that authorised them. The disjunction is exhaustive.auth_not_rejected—authorising_status IS NULL OR authorising_status NOT IN ('REJECTED', 'DOUBLE_SPEND_ATTEMPTED'). The optimistic-promotion set: any non-rejected status, including interim states (RECEIVED,SEEN_ON_NETWORK). Promote on the first non-rejected ACK;Store#reject_actioncompensates on a later flip.
Composite FKs:
FOREIGN KEY (action_id, intent) REFERENCES actions (id, broadcast_intent)—intenttracks the parent action’s intent atomically (the same composite-FK patternbroadcasts.intentuses, ADR-019). Combined with the unique(id, broadcast_intent)index onactions, this enforces that the recordedintentmatches what the action declared.FOREIGN KEY (action_id, authorising_status) REFERENCES broadcasts (action_id, tx_status) ON UPDATE CASCADE— a send-path promotion can only exist while abroadcastsrow holds the status named inauthorising_status. NULL on the internal path skips the FK match under MATCH SIMPLE — the broadcasts row doesn’t exist for internal actions, and the NULLauthorising_statusmakes this consistent.
ON UPDATE CASCADE — single source of truth. broadcasts.tx_status keeps advancing (RECEIVED → SEEN_ON_NETWORK → MINED → IMMUTABLE). Rather than duplicate “the status that authorised the promotion” as a frozen value on promotions, the FK references the live broadcasts.tx_status and CASCADEs updates. tx_status stays the canonical fact; promotions.authorising_status is a live projection of it. The consequence: a flip to REJECTED while a promotions row exists would cascade authorising_status to REJECTED and fail the auth_not_rejected CHECK. Store#reject_action therefore deletes the promotions row before the broadcasts row is deleted or updated to REJECTED. Correct-by-construction: violations fail loudly with a CHECK error rather than drifting.
Cascade in: action_id ON DELETE CASCADE from actions. Deleting an action removes its promotions row.
Cascade out: spendable.action_id REFERENCES promotions(action_id) ON DELETE CASCADE (§8) — the structural gate on UTXO membership. Deleting the promotions row cascades every spendable row for the action out in one statement.
Defence-in-depth: prevent_internal_action_delete trigger. A BEFORE DELETE trigger on actions refuses any DELETE whose target has broadcast_intent = 'none' AND an existing promotions row. This is canonical received UTXO history (incoming BEEF, imports, wbikd, send_payment) — code that would unwind it has gone wrong, and the schema must refuse the operation even if Store#reject_action’s CannotRejectInternalActionError is bypassed. Postgres raises with check_violation ERRCODE → Sequel::CheckConstraintViolation.
class Wallet::Promotion < Sequel::Model
unrestrict_primary_key # action_id is set explicitly, not generated
many_to_one :action
end
References: ADR-022 (state as a FK row — the general principle this realises), ADR-023 (promotion as a row — the specific application), #307 (the defect this closes), #221 (the composite-FK precedent), ADR-002 (why a hot-path trigger was the wrong backstop), ADR-003 (schema as canonical state — the principle the old app-only enforcement breached).
8. Spendable
The wallet. Pure set membership — each row says “this output is available to spend.” The presence of a row IS the spendable state. No data columns beyond the keys. DELETE = spent or relinquished. ~28 bytes per row. At a typical UTXO pool the entire table fits in PostgreSQL’s buffer cache permanently.
The hot-path UTXO selection query scans this table (in memory), then PK-joins to outputs for satoshis, derivation data, and locking script.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| output_id | bigint | NOT NULL REFERENCES outputs (id) UNIQUE |
| action_id | bigint | NOT NULL — two FKs (see Cascades below) |
| spendable_intent | spendable_intent | NOT NULL — denormalised, see “No trigger on the hot path” below |
Indexes:
- The UNIQUE on
output_idserves as the index (and enforces one spendable entry per output)
Cascades — two FKs on action_id:
action_id REFERENCES actions (id) ON DELETE CASCADE— the denormalised-cascade pattern (#189-era). Deleting an action removes its spendable entries directly. Denormalised (derivable viaoutput_id → outputs.action_id) but justified: 8 bytes per row, set once at creation, enables single-statement reaper cleanup.action_id REFERENCES promotions (action_id) ON DELETE CASCADE— the structural authorisation gate (ADR-023). Aspendablerow cannot exist without apromotionsrow for the sameaction_id. UTXO-set membership is therefore structurally gated on authorisation: writingspendablerequires the promotion to already exist, and dropping the promotion cascades the spendable rows out in one statement.
The two FKs are belt-and-braces. Store#reject_action exploits the second one — it deletes the promotions row first and the spendable rows fall out via cascade before the action delete fires. The first FK then handles any teardown path that goes straight at the action without touching promotions (e.g. abort, reaper).
No trigger on the hot path (HLR #467 / hot-path-design.md): A spendable row’s parent output must carry spendable_intent = 'spendable'. Two declarative constraints together enforce this — the row’s own CHECK spendable_intent = 'spendable' and the composite FK (output_id, spendable_intent) REFERENCES outputs(id, spendable_intent). Attempting to insert a spendable row pointing at an output whose spendable_intent is 'none' violates the FK; attempting to mark the spendable row itself as 'none' violates the CHECK. This replaces the prior prevent_outbound_spendable BEFORE INSERT trigger declaratively — no PL/pgSQL on the highest-frequency write path.
Note: No timestamps — INSERT at output promotion, DELETE at spend/relinquish. The churn pattern is INSERT-heavy (~8 change outputs created per ~2 inputs consumed). Dead tuples from DELETEs are minimal and vacuum is trivial on a table this small.
class Wallet::Spendable < Sequel::Model
many_to_one :output
many_to_one :action
end
9. Output Details
Display and application metadata. Never queried in the UTXO selection hot path. One-to-one with outputs.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| output_id | bigint | NOT NULL REFERENCES outputs (id) UNIQUE |
| action_id | bigint | NOT NULL REFERENCES actions (id) ON DELETE CASCADE |
| change | bool | NOT NULL DEFAULT false |
| type | text | |
| purpose | text | |
| provided_by | text | |
| description | text | |
| custom_instructions | text | |
| script_length | integer | |
| script_offset | integer |
Cascade: action_id ON DELETE CASCADE — deleting an action automatically removes its output details.
change flag: Cosmetic. Tells the UI “this output was change from a transaction you sent.” Never indexed, never queried in the hot path. UTXO selection picks by satoshis and basket, never by change flag. Change outputs are structurally identical to other wallet-owned BRC-42 outputs — derivation triple set, spendable_intent = 'spendable'.
Note: No timestamps — written at output creation, immutable.
class Wallet::OutputDetail < Sequel::Model
many_to_one :output
many_to_one :action
end
10. Output Baskets
Basket membership for outputs. An output belongs to at most one basket at a time. Moving an output between baskets = UPDATE basket_id. Relinquishing = DELETE the row. The outputs table is never touched.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| output_id | bigint | NOT NULL REFERENCES outputs (id) UNIQUE |
| action_id | bigint | NOT NULL REFERENCES actions (id) ON DELETE CASCADE |
| basket_id | bigint | NOT NULL REFERENCES baskets (id) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (output_id)— an output can be in at most one basket
Cascade: action_id ON DELETE CASCADE — deleting an action automatically removes its basket memberships.
Indexes:
idx_output_baskets_basket_satoshison(basket_id)— basket filter in UTXO selection
class Wallet::OutputBasket < Sequel::Model
many_to_one :output
many_to_one :basket
many_to_one :action
end
11. Inputs
The consumption relationship. The lock mechanism. Each row says “this output is being used as input N of this action.” The UNIQUE constraint on output_id enforces single-spend. ON DELETE CASCADE from actions means aborting an action automatically releases all its claimed outputs.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| action_id | bigint | NOT NULL REFERENCES actions (id) ON DELETE CASCADE |
| output_id | bigint | NOT NULL REFERENCES outputs (id) |
| vin | integer | NOT NULL |
| nsequence | bigint | NOT NULL DEFAULT 4294967295 |
| description | text | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (output_id)— an output can only be claimed once (the structural lock)UNIQUE (action_id, vin)— input indexes are unique within an actionCHECK vin >= 0CHECK nsequence BETWEEN 0 AND 4294967295
Indexes:
- The UNIQUE constraints serve as indexes for both the anti-join (spendable query) and the FK lookups
class Wallet::Input < Sequel::Model
many_to_one :action
many_to_one :output
end
12. Labels
Label definitions for categorizing actions. Normalized — the label string is stored once, then referenced via a join table.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| label | text | NOT NULL UNIQUE |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (label)— plain unique (no soft delete)CHECK length(label) BETWEEN 1 AND 300
class Wallet::Label < Sequel::Model
many_to_many :actions, join_table: :action_labels
end
13. Action Labels
Join table: actions to labels (many-to-many).
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| action_id | bigint | NOT NULL REFERENCES actions (id) ON DELETE CASCADE |
| label_id | bigint | NOT NULL REFERENCES labels (id) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (action_id, label_id)
Indexes:
idx_action_labels_label_idon(label_id)— reverse lookup
class Wallet::ActionLabel < Sequel::Model
many_to_one :action
many_to_one :label
end
14. Tags
Tag definitions for categorizing outputs. Same normalization pattern as labels.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| tag | text | NOT NULL UNIQUE |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (tag)— plain unique (no soft delete)CHECK length(tag) BETWEEN 1 AND 300
class Wallet::Tag < Sequel::Model
many_to_many :outputs, join_table: :output_tags
end
15. Output Tags
Join table: outputs to tags (many-to-many).
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| output_id | bigint | NOT NULL REFERENCES outputs (id) |
| tag_id | bigint | NOT NULL REFERENCES tags (id) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (output_id, tag_id)
Indexes:
idx_output_tags_tag_idon(tag_id)— reverse lookup
class Wallet::OutputTag < Sequel::Model
many_to_one :output
many_to_one :tag
end
16. Certificates
Identity certificate headers (BRC-52). Per-field encryption keys live in the fields table.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| type | text | NOT NULL |
| subject | text | |
| serial_number | text | NOT NULL |
| certifier | text | NOT NULL |
| verifier | text | |
| revocation_outpoint | text | |
| signature | text | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (type, serial_number, certifier)
Indexes:
idx_certificates_certifieron(certifier)idx_certificates_subjecton(subject)
class Wallet::Certificate < Sequel::Model
one_to_many :fields, class: :CertificateField
end
17. Certificate Fields
Per-field storage for certificates. Each field has its own master_key for field-level encryption, enabling selective revelation (BRC-52).
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| certificate_id | bigint | NOT NULL REFERENCES certificates (id) ON DELETE CASCADE |
| name | text | NOT NULL |
| value | text | |
| master_key | text | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
UNIQUE (certificate_id, name)
class Wallet::CertificateField < Sequel::Model
many_to_one :certificate
end
18. Settings
Key-value wallet configuration.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| key | text | NOT NULL UNIQUE |
| value | text | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
class Wallet::Setting < Sequel::Model
unrestrict_primary_key # allow upsert by key
def self.get(key)
first(key: key)&.value
end
def self.set(key, value)
update_or_create({ key: key }, value: value)
end
end
19. SSE Cursors
Arcade SSE listener resume points. One row per Arcade callbackToken; the row records the high-water Last-Event-ID the wallet has successfully pushed onto the in-proc status bus. On reconnect, the listener loads the cursor for its token and reconnects with the Last-Event-ID header so Arcade replays events strictly after the cursor — the wallet doesn’t redeliver events it has already handed off.
| col | type | attributes |
|---|---|---|
| token | text | PRIMARY KEY |
| last_event_id | bigint | NOT NULL |
| updated_at | timestamptz | NOT NULL |
No FK on token: the token is an external identifier, not a row in any other wallet table. In practice it is wallet-derived (HMAC-from-WIF via BSV::Wallet::CallbackToken#derive) and supplied to Arcade for callback scoping — the wallet owns it, Arcade just relays callbacks tagged with it.
last_event_id: Arcade emits SSE id: fields as nanosecond timestamps (~19 digits — see Arcade PR #50). bigint accommodates the full range.
Upsert semantics: writes go through INSERT ... ON CONFLICT (token) DO UPDATE. Concurrent listeners booting for the same token (defensive — the daemon should run one) race cleanly; last write wins, no PK violation. The cursor records what has been bus-pushed, not necessarily what has been applied — replay-on-reconnect is the safety net for application failures downstream.
class Wallet::SseCursor < Sequel::Model
unrestrict_primary_key # token is the PK
end
20. Transmissions
Wallet→peer BEEF delivery, at grain (action_id, counterparty) — the wallet’s first per-counterparty persistent state (#385 / ADR-025). A row records that an action’s BEEF was transmitted (or is being transmitted) to a named peer. This is distinct from broadcasts (which ships EF to the anonymous miner network): transmission is to a named peer (BRC-43 identity key) and ships Atomic BEEF for the peer’s SPV. See transactions.md.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| action_id | bigint | NOT NULL, FK → actions(id) ON DELETE CASCADE |
| counterparty | text | NOT NULL, CHECK shape (PG: regex ^0[23][0-9a-f]{64}$; SQLite: length + 02/03 prefix) |
| acked_at | timestamptz | NULL until the peer acknowledges |
| ack_signature | bytea | NULL — reserved for Phase 2 signed-ACK |
| created_at | timestamptz | NOT NULL DEFAULT now() |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
UNIQUE (action_id, counterparty)— the grain. Re-transmitting the same action to the same peer upserts the existing row (idempotent) rather than inserting a duplicate.INDEX (counterparty, id)— composite, drives the per-peer known-wtxids query (filter on counterparty, then JOIN totransmission_txidsby id) without a sequential scan as the table grows.
counterparty is hex, not bytea. It is a BRC-43 identity public key (66-char compressed hex), an identity-shaped pubkey — the deliberate carve-out from the binary-internal principle (see CLAUDE.md “Public Key Convention”). It crosses the BRC boundary as a hex interchange identifier, the same as outputs.sender_identity_key and the certificates identity fields. The shape CHECK lives in migration 003 alongside description_length (structural-CHECK precedent).
ack_signature is reserved nullable from day 1. v1 ACK is a bare HTTP 200 receipt and leaves this column NULL; the Phase 2 signed-ACK protocol (HLR #385) writes a peer signature over the ack’d wtxid here without a schema migration. Carrying the column reservation now avoids a Phase 2 migration of a table that may already be large.
No status column — delivery status is derived. A NULL acked_at means “sent, not yet acknowledged”; a present acked_at means “delivered (peer internalised)”. There is no status column to drift (principle of state — principle-of-state.md).
action_id CASCADE FK gives reaper-cleanup parity with the other action-dependent tables: tearing down an action deletes its transmissions (and their transmission_txids) in the same statement.
Upsert path: Store#record_transmission uses INSERT … ON CONFLICT (action_id, counterparty) DO UPDATE SET updated_at = EXCLUDED.updated_at RETURNING id so concurrent transmits at the same grain converge on one row without a check-then-insert race.
21. Transmission Txids
Pure membership: which wtxids each transmission’s BEEF carried. The per-counterparty known set — what BeefParty trims a future BEEF against — is the union of these rows across all of that counterparty’s transmissions.
| col | type | attributes |
|---|---|---|
| id | bigint | GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| transmission_id | bigint | NOT NULL, FK → transmissions(id) ON DELETE CASCADE |
| wtxid | bytea | NOT NULL, CHECK length(wtxid) = 32 |
UNIQUE (transmission_id, wtxid)— a wtxid appears at most once per transmission.wtxidis wire-order binary (bytea), per the wtxid/dtxid convention — these are structural transaction identifiers, not interchange tokens.- No timestamps: like
spendable, this is a pure membership table, append-only for the lifetime of its parent transmission, and cascades away with it.
Two-phase write — load-bearing. Rows are populated only in Store#mark_transmission_acked, never at record_transmission time. Recording a wtxid the peer never received would over-trim a future BEEF against this peer (BeefParty would drop ancestors the peer cannot verify), so the known-set must reflect what the peer acknowledged, not what we attempted to send. The same call atomically updates transmissions.acked_at and batch-inserts the new wtxids via INSERT … ON CONFLICT (transmission_id, wtxid) DO NOTHING.
The known-set query joins through to transmissions and filters on counterparty, deduplicating across transmissions:
SELECT DISTINCT tt.wtxid
FROM transmission_txids tt
JOIN transmissions t ON t.id = tt.transmission_id
WHERE t.counterparty = $1
Key Queries
Spendable outputs in a basket — the hot path for createAction’s funding loop (the select_inputs primitive). Enters through spendable (the wallet, in memory), PK-joins to outputs (the log) for data:
SELECT o.id, o.satoshis, o.vout, o.action_id, o.locking_script,
o.derivation_prefix, o.derivation_suffix, o.sender_identity_key
FROM spendable s
INNER JOIN outputs o ON o.id = s.output_id
INNER JOIN output_baskets ob ON ob.output_id = o.id
WHERE ob.basket_id = ?
AND NOT EXISTS (SELECT 1 FROM inputs i WHERE i.output_id = o.id)
ORDER BY o.satoshis DESC;
Lock outputs — claim outputs for a new action (atomic, conflict-safe):
INSERT INTO inputs (action_id, output_id, vin, description)
VALUES (?, ?, 0, ?), (?, ?, 1, ?), (?, ?, 2, ?)
ON CONFLICT (output_id) DO NOTHING
RETURNING output_id;
Abort action — cascade deletes inputs, releasing all claimed outputs:
DELETE FROM actions WHERE id = ?;
Stale action recovery — find send-path actions stuck between Phase 2 and Phase 3 (signed, inputs locked, no broadcasts row created), excluding internal-path actions:
-- RESTRICT FK on outputs.action_id means dependents must be cleared
-- before the action delete. The send-path rows reachable here have
-- no promotions row (Phase 4 never fired) and so cannot be in spendable.
DELETE FROM actions a
WHERE a.wtxid IS NOT NULL
AND a.broadcast_intent != 'none'
AND a.created_at < (now() - interval '5 minutes')
AND NOT EXISTS (SELECT 1 FROM broadcasts b WHERE b.action_id = a.id);
List actions by labels — BRC-100 listActions with label filter:
SELECT DISTINCT a.*
FROM actions a
INNER JOIN action_labels al ON al.action_id = a.id
INNER JOIN labels l ON l.id = al.label_id
WHERE l.label = ANY(?)
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?;
List outputs by basket and tags — BRC-100 listOutputs with basket and tag filter:
SELECT DISTINCT o.*
FROM outputs o
INNER JOIN output_baskets ob ON ob.output_id = o.id
INNER JOIN baskets b ON b.id = ob.basket_id
INNER JOIN output_tags ot ON ot.output_id = o.id
INNER JOIN tags t ON t.id = ot.tag_id
WHERE b.name = ?
AND t.tag = ANY(?)
ORDER BY o.created_at DESC
LIMIT ? OFFSET ?;
Resolved Design Questions
changecolumn placement: Onoutput_details(cosmetic display flag). Change outputs are structurally identical to other wallet-owned BRC-42 outputs — derivation triple set,spendable_intent = 'spendable'. Thechangeflag never participates in UTXO selection or constraints.- Soft delete: Removed from baskets, labels, tags, certificates, action_labels, output_tags. Plain UNIQUE constraints replaced partial indexes. Hard delete for cleanup.
relinquishOutput: DELETE thespendablerow (remove from UTXO set). DELETE theoutput_basketsrow (remove from basket). The output row stays in the log.- Default basket: No basket assignment = default basket (implicit).
listOutputs(basket: 'default')queries spendable outputs with nooutput_basketsrow.CHECK name != 'default'prevents explicit creation of a ‘default’ basket entity. - Derivation data placement: On
outputs, notspendable. Derivation data is a fact about the output (recorded when the key is derived), not a statement of spendability. This preserves the state transition model: output rows record spending authority, spendable rows declare availability. Two facts, two moments in time. actions.satoshis: Dropped. Derivable fromSUM(outputs.satoshis). No BRC-100 method returns it at the action level.actions.reference: UUID type (was text). NOT NULL withgen_random_uuid()default.- Two lifecycles, one schema: The
broadcast_intentenum encodes which path an action follows.delayed/inlinerun the 4-phase send path with post-broadcast promotion;noneruns Phases 1, 2, and 4 synchronously insidecreate_action. Both end with output rows present and (for wallet-owned outputs) spendable rows inserted — the routing fact is when Phase 4 commits, and the structural marker is the existence of apromotionsrow for the action (§7). - Promotion as a row (#307 / ADR-023): Promote-authorisation lives in the
promotionstable — row existence IS the canonical-state fact. Replaces the earlieroutputs.promotedboolean. Two upsides:outputsreturns to pure INSERT-only (no HOT-tuple churn, no vacuum debt), and the authorisation invariant — promoted ⟹ internal OR broadcast-accepted — gets a declarative schema backstop via composite FKs and CHECKs, with no trigger on the hot send path (ADR-002, #221). outputs.action_idis NOT NULL RESTRICT: Under #189. Output rows cannot be orphaned. Cleanup paths must clear output rows before the action delete; this is safe because thespendable → promotionsFK gate (§8) ensures cleanup only ever reaches rows whose action lacked a promotions row (or whose promotions row is being deleted in the same transaction).
BRC-100 Transaction Operations Reference
Creation: createAction creates an Action (a Bitcoin transaction + metadata). Requires at least one input or output. Inputs require inputBEEF for SPV context. Outputs without basket are untracked. Can return a signableTransaction reference for deferred signing.
Signing: signAction completes a deferred transaction. The caller provides unlocking scripts for inputs they control; the wallet signs remaining P2PKH inputs with derived keys. Output rows were already written during createAction (no promotions row yet — the spendable→promotions FK keeps them out of the UTXO set); signAction updates the action row (wtxid, signed raw_tx) and creates the broadcasts row. Phase 4 fires later on broadcast acceptance, inserting the promotions row and the spendable rows. See Deferred Signing — signAction in the lifecycle section.
Aborting: abortAction cancels an in-progress action that has not yet been broadcast. Clears dependent rows under the RESTRICT FK, then deletes the action; CASCADE on inputs frees the locked UTXOs.
Internalization: internalizeAction accepts incoming BEEF, verifies proofs, creates output rows, inserts a promotions row (intent='none', authorising_status=NULL), and inserts spendable rows for outputs the wallet controls — all in one transaction. This is an internal-path action (broadcast_intent = 'none'); the prevent_internal_action_delete trigger then protects the canonical UTXO history from later deletion (§7).
Listing: listActions queries by labels (the response includes a :status symbol drawn from the derived-status table above; :internal replaces the previous :nosend label). listOutputs queries by basket/tags. Both are read-only with pagination.
Relinquishment: relinquishOutput releases an output from wallet tracking, even if unspent. Removes the spendable row and the basket membership; the output row stays in the log.
Tags vs Labels: Labels categorize actions (used with listActions). Tags categorize outputs (used with listOutputs). Both are purely organizational.
Chained-send / batching: The BRC-100 noSend / sendWith / noSendChange / knownTxids primitives are not implemented in this base wallet. They are deferred to issue #192 as a separate subsystem. See .architecture/reviews/20260619_noSend-sendWith-design-notes.md for the design analysis.