Database Schema Reference

Purpose: AI-readable reference for the Structs Guild Stack PostgreSQL database. Covers core game state tables, the key-value grid pattern, event categories, and ready-to-use query patterns.

Requires: Guild Stack running locally. See .cursor/skills/structs-guild-stack/SKILL.md for setup.


Schema Overview

PostgreSQL (version belongs to the deployed image) with TimescaleDB extension. Six application schemas (plus cron for pg_cron jobs and the _timescaledb_* internals):

Schema Purpose
structs Game state, current economic ownership and API projections
sync_state Chain indexer state: sync cursor, block log, raw blocks/events, handler error log
cache Compatibility views over sync_state.raw_* (read-only; not an event-sink)
view Computed views (view.player, view.guild, leaderboards, inventories, permissions, work specification)
signer Transaction signing queue (TSA account/role/tx management)
sqitch Schema migration tracking

Full table enumeration: schemas/database-schema.md.

Chain events are ingested by the structs-sync-state service (not structsd). It polls the chain RPC and writes directly into structs.* and sync_state.*.

Database Roles

Role Access Used By
structs Superuser (owner) Administration, migrations
structs_indexer Projection writes on granted structs.*/sync_state.* objects; reads on compatibility views structs-sync-state, structs-grass
structs_webapp Application reads, off-chain state and permitted queue wrappers; indexed permissions are read-only Webapp
structs_tsa Selected reads and guarded signer functions; no direct game/queue mutation grants TSA
structs_crawler Read-only on select tables Crawler

For agent queries, use structs_indexer via the GRASS container (see guild-stack skill).

These are service boundaries, not a guarantee that a legacy deployment has no administrative grants. Inspect pg_roles and actual ACLs. The migration verifies explicit webapp ACLs even on legacy superusers; it does not silently demote them. A non-superuser fixture verifies effective access. structs_tsa must not have administrative role attributes.


Core Game State Tables

structs.player

Column Type Notes
id varchar PK 1-{index} (e.g., 1-142)
index integer Numeric portion of ID
guild_id varchar Guild membership (e.g., 0-1)
guild_rank numeric (uint64) Player’s rank within their guild (1 = highest, 101 = default on join, 0 = unset)
planet_id varchar Home planet (e.g., 2-105)
fleet_id varchar Fleet (e.g., 9-142)
substation_id varchar Substation the player draws capacity from (e.g., 4-5)
primary_address varchar Cosmos address
creator varchar Who created this player
username varchar Player display name (chain UGC; from MsgPlayerUpdateName or signup proxy)
pfp varchar Profile picture URI (chain UGC; from MsgPlayerUpdatePfp or signup proxy)
pfp_client_render_attributes varchar Compacted JSON string describing how a client renders a locally generated pfp (chain Player field 12, added v0.18.0). Untrusted UGC — do not eval.

structs.fleet

Column Type Notes
id varchar PK 9-{index}
owner varchar Player ID
status varchar onStation, away — camelCase. WHERE status = 'on_station' returns 0 rows.
location_type varchar planet
location_id varchar Planet ID where fleet is located
command_struct varchar Command Ship struct ID
space_slots / air_slots / land_slots / water_slots integer Available slots per ambit

structs.struct

Column Type Notes
id varchar PK 5-{index}
type integer FK to struct_type.id (1-22)
owner varchar Player ID
location_type varchar fleet or planet
location_id varchar Fleet/planet ID
operating_ambit varchar space, air, land, water
slot integer Position within ambit (0-3)
is_destroyed boolean Destruction state
destroyed_block bigint Block height when destroyed

structs.struct_type

The authoritative reference for all game balance data (~60 columns). Key columns:

Weapons (primary + secondary prefixed):

Defense:

Other:

_p precision columns are the base; their bare companions are GENERATED. Three pairs on this table are split: generating_rate_p / generating_rate (= generating_rate_p * 1000), build_draw_p / build_draw, and passive_draw_p / passive_draw (both formatted via unit_legacy_format(…, 'milliwatt')). The bare names are still readable — a SELECT generating_rate keeps working — but only the _p column is writable, so it is the one sync-state upserts and the one to use in a WHERE on an exact integer. The same pattern applies to structs.ledger.amount_p, structs.infusion.fuel_p/power_p/ratio_p/defusing_p, and structs.guild.join_infusion_minimum_p. The *_array bitmask-derivative columns (primary_weapon_ambits_array, possible_ambit_array, …) are likewise GENERATED.

structs.struct_attribute

Key-value attributes per struct instance.

Column Type Notes
id varchar PK {attrType}-{objectTypeId}-{objectIndex}
object_id varchar Struct ID (or player ID for typeCount)
object_type varchar struct or player
attribute_type varchar See below
val integer Attribute value
attribute_type Found On Meaning
health struct Current HP
status struct Status bitfield (online/offline/stealth)
protectedStructIndex struct Index of the struct this one defends
blockStartBuild struct Block height the build PoW window opened
blockStartOreMine struct Block height the mining PoW window opened
blockStartOreRefine struct Block height the refining PoW window opened
typeCount player Per-struct-type build count for this player

A row is deleted rather than zeroed when the attribute clears, so absence means zero.

structs.struct_defender

Defense assignments. One row per defender (defending_struct_id is the whole primary key), so a struct defends at most one target at a time.

Column Type Notes
defending_struct_id varchar PK The defender
protected_struct_id varchar The struct being protected
updated_at timestamptz Last assignment change

The planet_activity struct_defense_add / struct_defense_remove detail uses the key defender_struct_id (no -ing) for what this table calls defending_struct_id. Joining the timeline to the table requires translating the name.

structs.planet

Column Type Notes
id varchar PK 2-{index}
owner varchar Player ID
max_ore integer Maximum ore capacity
space_slots / air_slots / land_slots / water_slots integer Slot counts
status varchar active (claimable/inhabited) or complete (ore exhausted)
map jsonb Generated planet map
name text Planet display name (chain UGC; from MsgPlanetUpdateName or optional name on planet-explore)

There is no seized_ore column on structs.planet. Seized ore is tracked per raid on structs.planet_raid and per token movement in structs.ledger.

structs.planet_attribute

Column Type Notes
id varchar PK {attrType}-{objectTypeId}-{objectIndex}
object_id varchar Planet ID
object_type varchar planet
attribute_type varchar See below
val integer Attribute value
attribute_type Meaning
planetaryShield Current planetary shield value
blockStartRaid Raid PoW clock
blockRaiderArrived Set while a visitor heads the fleet queue (raid pause)
blockStartOreMine / blockStartOreRefine Shared planet mine/refine PoW clocks (id prefix 12- / 13-; older docs called these planetBlockStart*)
oreMiningActiveQuantity / oreRefiningActiveQuantity How many extractors/refineries currently hold the planet clock (14- / 15-)
defensiveCannonQuantity Defensive Cannons installed on the planet
lowOrbitBallisticsInterceptorNetworkQuantity LOBIN installations
lowOrbitBallisticsInterceptorNetworkSuccessRateNumerator / ...Denominator LOBIN interception rate

As with struct_attribute, a cleared attribute is deleted rather than stored as 0.

structs.planet_raid

Column Type Notes
planet_id varchar PK Target planet — the entire primary key
fleet_id varchar Raiding fleet
status varchar initiated, ongoing, shieldsVulnerable, raidSuccessful, attackerRetreated, attackerDefeated, demilitarized. There is no completed value. (ongoing = defender restored shields mid-raid; completion blocked — see combat.md.)
seized_ore numeric Ore taken by the raid this row describes
updated_at timestamptz Last status change

planet_raid is latest-state, not history. Because the primary key is planet_id alone, each planet holds exactly one row that is overwritten by every subsequent raid. Do not use this table for raid history or for summing a planet’s cumulative losses — it only answers “what is the most recent raid on this planet.”

Per-raid seized ore: read planet_activity. For raid history, use planet_activity rows with category = 'raid_status'; their detail carries seized_ore alongside planet_id, fleet_id, and status. This key was added to emitRaidStatusActivity recently, so rows indexed before the fix are missing it entirely — on structstestnet-111 roughly half of raid_status rows still lack the key. Treat detail ? 'seized_ore' as false meaning unknown, not zero, and gate on it explicitly:

SELECT time, planet_id, detail->>'fleet_id' AS fleet_id, detail->>'status' AS status,
    (detail->>'seized_ore')::numeric AS seized_ore
FROM structs.planet_activity
WHERE category = 'raid_status'
    AND detail ? 'seized_ore'
ORDER BY time DESC;

Upstream ships a one-time backfill, sync-state/sql/repair-raid-status-seized-ore.sql, that reconstructs the missing values from the ledger. It is an operator task on the guild-stack host, not something an agent should run.

The ledger remains the source of truth for token movement. Seized ore lands as structs.ledger rows with action = 'seized', direction = 'credit', denom = 'ore', credited to the thief with the victim in counterparty. The ledger preserves 0-gram seized rows — a raid that reached the planet but stole nothing (a probe repelled, or ore already refined away) still writes a meaningful seized entry. On structstestnet-111 they are common (~40% of seized credits), not the majority — treat them as real outcomes, not noise. Correlate a ledger row to a raid via block_height plus the thief’s player_address → player.fleet_id.


The Grid Table (Key-Value Pattern)

structs.grid is a key-value store for resource attributes. This is the most common source of query errors.

GRASS publishing: grid changes fire structs.GRID_NOTIFY(), which pg_notifys each change on subject structs.grid.{object_type}.{object_id}.{player_id} with a top-level player_id field (owner resolved via player_object, falling back to planet.owner; noPlayer when unresolved; for object_type='player' the owner is the object itself). The owner segment/field was added 2026-07-07.

Column Type Notes
id varchar PK {attributeType}-{objectTypeId}-{objectIndex}
object_id varchar Player/planet/struct/substation/reactor/provider/allocation ID
object_type varchar player, planet, struct, substation, reactor, provider, allocation
object_index integer Numeric portion of object_id
attribute_type varchar See table below
val numeric The value

Wrong (no ore column exists):

SELECT ore FROM structs.grid WHERE object_id = '1-142';

Correct (filter by attribute_type):

SELECT val FROM structs.grid WHERE object_id = '1-142' AND attribute_type = 'ore';

Multiple attributes (use JOINs):

SELECT p.id,
    COALESCE(g_ore.val, 0) as ore,
    COALESCE(g_cap.val, 0) as capacity,
    COALESCE(g_load.val, 0) as structs_load
FROM structs.player p
LEFT JOIN structs.grid g_ore ON g_ore.object_id = p.id AND g_ore.attribute_type = 'ore'
LEFT JOIN structs.grid g_cap ON g_cap.object_id = p.id AND g_cap.attribute_type = 'capacity'
LEFT JOIN structs.grid g_load ON g_load.object_id = p.id AND g_load.attribute_type = 'structsLoad'
WHERE p.id = '1-142';

Grid Attribute Types

attribute_type Found On Meaning
ore player, planet Ore balance (player = mined/stealable; planet = remaining)
capacity player, reactor, struct, substation Energy capacity
structsLoad player Energy consumed by active structs
load player, provider, reactor, struct, substation Base load on the object
fuel reactor, struct Total ualpha infused
power allocation, struct Energy carried by an allocation / generated by a generator struct
connectionCapacity substation Capacity granted to each connected player — already the per-player share (capacity − load) / connectionCount. Do not divide by connectionCount again; that double-dilutes and understates a player’s real capacity.
connectionCount substation Active connections
ready struct Block height at which the struct’s next action is available
lastAction player Block height of the player’s last action (charge-bar basis)
nonce / proxyNonce player Signing nonces for the player’s own and proxy-signed transactions
checkpointBlock provider Last settlement checkpoint for the provider’s agreements

Per-infusion power is not in the grid — it lives on structs.infusion as power / power_p, keyed (destination_id, address). Grid power on an allocation is the energy that allocation routes; on a struct it is what that generator produces.


planet_activity (Event Log)

TimescaleDB hypertable for all planet-level events.

Column Type Notes
time timestamptz Event time (chain block time; hypertable partition key)
seq integer Per-planet counter starting at 0 — see the trap below
planet_id varchar Planet where event occurred
category structs.grass_category Event type (see below)
detail JSONB Event-specific data
block_height bigint Block height when the event occurred (populated by sync-state)

seq is scoped to a planet, not to the table. It is drawn from structs.planet_activity_sequence, a (planet_id, counter) table whose counter is bumped per planet, so every planet has its own series starting at 0 and thousands of rows share any given seq. A single scalar high-water mark applied across several planets silently skips events on every planet whose counter is behind the largest one. Track one cursor per planet, or order globally on time / block_height and use seq only to disambiguate within a planet.

Event Categories

category is the shared structs.grass_category enum, which also labels GRASS notifications that never become planet_activity rows (block, guild_consensus, guild_meta, guild_membership, player_consensus, player_meta). The planet-scoped subset actually written to this table:

Category Description Key detail fields
raid_status Raid status transition planet_id, fleet_id, status, seized_ore
fleet_arrive Fleet arrived at planet fleet_id
fleet_advance Enum-only — never written by sync-state; movement is fleet_depart + fleet_arrive fleet_id
fleet_depart Fleet departed from planet fleet_id
struct_attack Combat attack event see below
struct_defense_add Defense assignment added defender_struct_id, protected_struct_id
struct_defense_remove Defense assignment removed defender_struct_id, protected_struct_id
struct_status Struct status change (online/offline/destroyed) struct_id, status
struct_move Struct moved between slots/ambits struct_id, ambit/slot
struct_health Struct health changed (damage) struct_id, health values
struct_block_build_start Build PoW window opened struct_id, block
struct_block_ore_mine_start Mining PoW window opened struct_id, block
struct_block_ore_refine_start Refining PoW window opened struct_id, block
shield_change Planetary shield value changed planetary_shield, planetary_shield_old
block_raid_start Planet’s blockStartRaid changed (raid clock) block_start_raid, block_start_raid_old

shield_change and block_raid_start (added v0.18.0) have no dedicated chain event — sync-state derives them from planet_attribute writes for planetaryShield and blockStartRaid, emitting a row only when the value actually changes and carrying both the old and new value. shield_change is high-volume but not second after struct_status on current structstestnet-111 — typical rank is struct_status > struct_health > struct_block_build_start > struct_defense_add > shield_change. Always filter by category; unfiltered pollers drown in status/health/build noise.

struct_defense_remove is emitted from the struct_defender_clear handler, which is the only path that clears a defender’s protectedStructIndex. Before that fix the category existed in the enum but was never written, so defense-removal history is absent from older indexed data even though additions are present.

Polling Pattern (Real-Time Monitoring)

Because seq is per-planet, initialize and carry a cursor per planet:

-- Initialize: one high-water mark per watched planet
SELECT planet_id, COALESCE(MAX(seq), 0) AS last_seq
FROM structs.planet_activity
WHERE planet_id IN ('2-105', '2-127')
GROUP BY planet_id;

-- Poll every ~6 seconds, comparing each planet against its own cursor
SELECT pa.planet_id, pa.seq, pa.category, pa.detail::text
FROM structs.planet_activity pa
JOIN (VALUES ('2-105', $LAST_SEQ_2_105), ('2-127', $LAST_SEQ_2_127))
    AS cur(planet_id, last_seq) ON cur.planet_id = pa.planet_id
WHERE pa.seq > cur.last_seq
ORDER BY pa.planet_id, pa.seq ASC;

A planet with no rows yet is absent from the initialize query, so default its cursor to -1 rather than 0 — seq starts at 0 and seq > 0 would drop that planet’s first event.

For a single-cursor poller across arbitrary planets, use block_height instead, which is globally ordered:

SELECT planet_id, seq, category, detail::text
FROM structs.planet_activity
WHERE block_height > $LAST_HEIGHT
ORDER BY block_height, planet_id, seq;

The detail column for struct_attack carries the attacker at the top level (attackerStructId, attackerPlayerId, attackerStructType, weaponSystem, weaponControl, attacker health before/after) plus an eventAttackShotDetail array with the per-shot breakdown. targetPlayerId and targetStructId exist only inside shot entries, not at the top level — filtering attacks on a target requires descending into the array (e.g. detail->'eventAttackShotDetail' @> '[{"targetStructId":"5-6438"}]'). Each shot entry carries damage, damageDealt, evaded/evadedCause, blocked/blockedByStructId, armourPiercing, damageReduction/damageReductionCause, targetCountered/targetCounteredDamage, targetDestroyed, and evadedByPlanetaryDefenses/Cause.

GRASS publishing: each planet_activity insert fires the structs.PLANET_ACTIVITY_NOTIFY() trigger, which pg_notify('grass', …)s the row with a subject of structs.planet.{planet_id}.{player_id} (the owning player id was appended 2026-07-07; noPlayer when unresolved) plus a top-level player_id field. Payloads over ~7995 bytes are sent as a stub — {subject, planet_id, player_id, seq, category, time, stub:'true'} with no detail — so live consumers pull the full detail from this table by seq/planet_id. See structs-streaming SKILL.

planet_activity_player (per-player attribution)

Seven-day-chunk hypertable written by an AFTER INSERT trigger. Ownership is as-of-event. Columns include player_id, role (attacker, target, owner, planet_owner, defender, protected, fleet_owner), plus the parent key (time, planet_id, seq, block_height, category). Attack targets are expanded from each shot’s targetPlayerId. The Guild API player feed pages side-table keys first and fetches parents with LATERAL; this permits runtime exclusion of compressed parent chunks. It uses block_height (?since_height=) as the cross-planet cursor — seq is still per-planet.

planet_activity_player_reconcile() checks the recent window nightly. Drift is retained for 90 days in planet_activity_player_drift: missing means expected attribution is absent, orphan means the parent vanished, and extra can be normal after ownership changes because current ownership differs from ownership recorded at event time.

Continuous aggregates backing the stats endpoints: planet_activity_hourly (materialized only; recent-window refresh every 10 minutes), planet_activity_daily, and planet_activity_player_daily. Parent planet_activity chunks older than 30 days are compressed by planet, ordered by descending time/seq.


Energy Commerce Tables

Economic ownership and native evidence

Relation Contract
structs.vault Owner ID (player or guild), unique address, exact uint64 account number, nullable account_created origin evidence
structs.asset_owner Current ownership of player/guild vaults and approved retained player addresses; economic identity, not signing authority
structs.owner_reactor_diversity Exact nonnegative power, squared_power, uint64 applied_bonus per owner
structs.reactor_commission_revision slash_revision and reconciled_slash_revision; delayed reconciliation is explicit
structs.reactor_stake_cap Authoritative active/cap-bps/min-validator snapshot; an absent row is unknown
sync_state.native_staking_snapshot Complete address-level native balances and required/source heights, retry and receipt/position completeness
sync_state.native_staking_position Exact bonded/unbonding totals and entry counts per address/validator
sync_state.native_staking_exposure, native_staking_invalidation Validator/redelegation exposure and invalidation watermarks
sync_state.native_staking_claim, native_staking_position_state Readiness-qualified native evidence; an absent position is zero only under a complete address snapshot
sync_state.economy_contract Contiguous complete-block coverage with activation height, last height/hash and contract events-v022-1

Never insert a vault into player_address to make an ownership join work: that table also authorizes login/signing. A guild vault remains a guild asset, not the leader’s personal balance. Nullable account_created is meaningful: only true proves the release created a BaseAccount; false means adopted and NULL means unknown. Neither false nor NULL proves unrestricted spendability.

Keep raw uint64 values as exact integers/decimal strings, including ranks, attribute/work clocks and catalog difficulties. Some economic totals and diversity aggregates exceed uint64 and use unscaled NUMERIC with their own constraints. Display-unit columns are not transaction inputs. Work clients preserve the original block-start anchor; rounding a height or difficulty changes the proof.

Table Key Columns Notes
reactor id, guild_id, validator, owner Links validator address to guild; owner is PlayerId
infusion destination_id, address, destination_type, owner_id, player_id, fuel_p, power_p, ratio_p, defusing_p, commission, generator_disabled Composite PK (destination_id,address). Resolve COALESCE(NULLIF(owner_id,''),player_id) for legacy-compatible ownership; owners can be players or guilds. Stored game fuel/power is distinct from current native staking claims. Bare display columns are GENERATED.
allocation id, source_id, destination_id, controller Energy routing; controller is an owner ID (player or guild), not an address
substation id, owner Power distribution nodes
provider id, rate_amount, rate_denom, access_policy Energy marketplace listings
agreement id, provider/consumer refs, capacity, duration Active purchase contracts

structs.guild

Column Type Notes
id varchar PK 0-{index} (e.g., 0-1)
entry_rank numeric (uint64) Default guild rank assigned to new members (chain default: 101)
name varchar Guild display name (chain UGC; from MsgGuildUpdateName)
pfp varchar Guild profile picture (chain UGC; from MsgGuildUpdatePfp)

structs.guild_meta (off-chain guild config)

Guild presentation and infrastructure metadata — not chain UGC name/pfp (those live on structs.guild).

Column Type Notes
id varchar PK Guild ID, mirrors structs.guild.id
name varchar Legacy display name field (prefer structs.guild.name for on-chain UGC)
description text Guild description
tag varchar Short guild tag
logo varchar Logo URI
socials jsonb Social links
denom jsonb Guild token denomination map
services jsonb Guild API / GRASS / webapp endpoints
domain varchar Guild domain
website varchar Guild website
base_energy numeric Base energy allocation
this_infrastructure boolean Whether this guild stack hosts this guild’s infra

structs.substation (with UGC fields)

Column Type Notes
id varchar PK Substation ID
owner varchar Owner player ID
name varchar Substation name (chain UGC; from MsgSubstationUpdateName)
pfp varchar Substation profile picture (chain UGC; from MsgSubstationUpdatePfp)

structs.permission_guild_rank

Column Type Notes
object_id varchar The object permissions are set on
guild_id varchar The guild whose members receive the permission
permission bigint Single permission bit (power of 2)
rank numeric (uint64) Worst-allowed guild rank; 0 = revoked
updated_at timestamptz Last update timestamp

Primary key: (object_id, guild_id, permission). See permissions.md for the guild rank permission system.

The view.permission_player (keyed by player_id) and view.permission_address (keyed by address) views project structs.permission rows joined with permission_guild_rank, exposing one boolean column per permission bit (perm_play, perm_admin, …, perm_hash_build/perm_hash_mine/perm_hash_refine/perm_hash_raid). The raw integer bitmask (val) lives on the base table structs.permission. The PermAll mask is 33554431 (bits 0..24 set).

sync_state schema (indexer)

Written by structs-sync-state. Key operator tables:

Table Purpose
sync_cursor Per-chain ingest pointer (last_height, status, lag_blocks, tip_height). status is catching_up while behind and current once caught up.
block_log One row per ingested block (tx/event counts, handler error count)
handler_error_log Per-event handler failures (for debugging replay)
unknown_event_log Chain events with no registered handler — first place to look when a new chain version adds an event the indexer does not yet project
genesis_log Genesis import record
raw_blocks, raw_tx_results, raw_events, raw_attributes Raw chain event storage — populated only when SYNC_STATE_MIRROR_RAW=true, which the default Compose sets to false. Do not plan a query path around them without checking.
verification_report Output of sync-state verify runs

structs.current_block mirrors the same cursor (chain, height, status, lag_blocks, tip_height) as an unlogged single-row table, which is the cheaper read when all you need is “what block are we on.”

Monitor indexer health:

SELECT chain_id, last_height, status, lag_blocks, tip_height
FROM sync_state.sync_cursor;

The cache schema exposes four views (blocks, tx_results, events, attributes) over sync_state.raw_* for webapp backward compatibility.

structs.banned_word

Seed data for UGC name validation. The chain rejects any Msg*UpdateName (player, guild, planet, substation) whose name contains a substring matching any row in this table; webapps surface the same list via api/webapp/banned-word.md so client-side forms can preflight.

Column Type Notes
word text PK Banned token (lowercase)

structs.address_tag

Labelled address records. Each (address, label) pair tags a Cosmos address with a human-readable label, plus an entry integer for ordering.

Column Type Notes
address varchar Cosmos address
label text Tag name
entry bigint Sort/insert order within label

Primary key: (address, label); reverse-lookup index (label, entry) (table-address-tag-idx-label-entry).

structs.setting

Live tunables — chain economy and gameplay constants exposed unauthenticated through api/webapp/setting.md.

Column Type Notes
name text PK Setting key
value text Setting value (string-encoded; numeric where applicable)

Seeded keys: REACTOR_RATIO, PLAYER_RESUME_CHARGE, PLANETARY_SHIELD_BASE, PLAYER_PASSIVE_DRAW, PLANET_STARTING_ORE, PLANET_STARTING_SLOTS, STRUCT_SWEEP_DELAY. Treat the table as an open name/value map — keys are added over time.

structs.defusion

structs.defusion stores historical operation receipts, not current unbonding balances or native entry IDs. amount_p is the original receipt amount; completed_at is its scheduled completion time, not evidence of release. Source identity is (chain_id,block_height,tx_index,event_index,denom) when known; a legacy observation height is not fabricated source identity.

structs.defusion_current filters retired receipts and labels pending evidence as pending, unknown, or unidentified. Native snapshot publication retires proven-absent receipts and retains replay tombstones. CLEAN_DEFUSION() is a no-op and its clock-based cron job is unscheduled: held entries can outlive the schedule. Read endpoints are documented in defusion.md.


Aggregated Views

The view schema provides these request-path projections:

View Purpose
view.player / view.guild / view.planet / view.struct / view.substation / view.reactor Object rows pre-joined with their grid attributes, so a single SELECT replaces the multi-JOIN grid pattern below
view.grid Grid rows resolved to object type/owner labels
view.struct_status Struct status bitfield decoded into readable flags
view.guild_bank Per-guild Central Bank position — minted/redeemed token balances, collateral, and outstanding supply, joined from structs.guild, the on-chain bank module, and ledger movements
view.player_inventory / view.guild_inventory / view.address_inventory / view.reactor_inventory Token balances per player, guild, address, and reactor
view.leaderboard_guild / view.leaderboard_player Ranked scoreboards (members, ore mined, planets completed, raids launched, raids successful)
view.leaderboard_provider / view.leaderboard_reactor / view.leaderboard_substation Energy-market scoreboards
view.permission_player / view.permission_address Permission bitmask projected to one boolean column per bit
view.work Outstanding work from structs.api_work, including target and location fields
view.work_live Live BUILD/MINE/REFINE/RAID specification; refresh/reconciliation only, not request paths

Use views, not raw tables, when building leaderboard, inventory, or treasury surfaces — the views absorb the seized_ore, ledger, and infusion joins so the upstream surface stays stable when underlying tables change.

Mine/refine clocks are planet-scoped. view.planet, view.struct, view.work_live, and view.work now resolve planet_attribute.blockStartOreMine / blockStartOreRefine (ID prefixes 12- / 13-). Completing any extractor/refinery resets the shared planet clock; do not treat a struct as owning an independent clock.

structs.guild persists bank_convert_in_fee, bank_convert_out_fee, charter_solver_id, rank-purchase base price/step/minimum rank, and join_by_rank_purchase_enabled. Source coverage must be complete before treating an absent/default value as authoritative. Substations also carry deletion_pending and deletion_migration_substation_id for continuing migration work.


Other Time-Series Tables (TimescaleDB)

Hypertable Purpose Key Columns
ledger Financial transaction log time, id, address, counterparty, amount_p, amount, action, direction, denom, block_height
stat_ore Ore value history time, object_type, object_index, value
stat_capacity Capacity history Same pattern (has object_type)
stat_fuel Fuel history Same pattern (has object_type)
stat_load Load history Same pattern (has object_type)
stat_power Power history Same pattern (has object_type)
stat_structs_load structsLoad over time time, object_index, value — no object_type
stat_connection_capacity Per-connection substation share over time time, object_index, value — no object_type
stat_connection_count Substation connection count over time time, object_index, value — no object_type
stat_struct_health Struct health over time time, object_index, value — no object_type
stat_struct_status Struct status over time time, object_index, value — no object_type

structs.stat_rollup stores bounded hourly totals per (metric, object_type, bucket): sum, population, samples, and source (backfill or snapshot). stat_rollup_metric has exactly 10 mappings. Backfill reconstructs LOCF history; the :02 hourly snapshot reads current grid / struct_attribute state. Population can step at the seam for attributes whose rows disappear (notably struct health/status).

Type-implied stat tables (silent-wrong-row trap). Five hypertables — stat_structs_load, stat_connection_capacity, stat_connection_count, stat_struct_health, stat_struct_status — have no object_type column (verified in structs-pg/deploy/table-stat.sql). The object type is implied by the table itself (e.g. stat_connection_* and stat_structs_load are player/substation series; stat_struct_* are struct series). A generic helper that joins every stat table on (object_type, object_index) will silently read the wrong rows against these five — filter on object_index alone and rely on the table name for the type. This mirrors the note in api/webapp/stat.md.

Ledger Action Types

genesis, received, sent, migrated, infused, defusion_started, defusion_cancelled, defusion_completed, mined, refined, seized, forfeited, minted, burned, diversion_started, diversion_completed

Each row is one side of a movement: address is the party the row belongs to, counterparty is the other side, and direction (credit/debit) gives the sign. amount_p is the raw integer; amount is GENERATED via unit_legacy_format(amount_p, denom) for display. Use amount_p for arithmetic and for matching.

Source identity is all-or-none: chain_id, tx_index, msg_index, event_index. The unique key is (time,chain_id,tx_index,msg_index,event_index,denom): each full denomination is part of the identity. Keep slash-containing denoms intact and parse every coin in a bank event. Legacy rows may have all four coordinates NULL. Event phase is tracked separately for ordered state mutations; begin/tx/end order must not be inferred by sorting a sentinel transaction index alone. Conflicting values for an existing financial identity are errors, not permission to overwrite history.

API current-state projections

structs.api_inventory stores exact address, player and guild balances. Real bank denominations use idempotently inserted ledger deltas. Current owner totals use structs.asset_owner, so guild vaults stay guild assets and revoked addresses do not remain attached to their former player. Do not sum address and owner layers.

Synthetic ualpha.infused is current native bonded stake plus stored generator fuel (including disabled generators); ualpha.defusing is native unbonding. Redelegation is exposure, never a second asset. Missing/incomplete native evidence propagates NULL rather than zero. Full rebuild, incremental updates and api_inventory_reconcile(false) share this current-claims specification; ledger_balance in the drift result is a compatibility column name, not a claim that synthetic balances are ledger sums. The logged reconciler retains drift for 90 days. Total economic holdings are not the vault’s spendable bank balance.

structs.api_work has the nine view.work columns plus source_height and updated_at, keyed by (category, object_id, target_id). Triggers enqueue affected struct/planet/player/fleet keys in api_work_dirty; sync-state calls api_work_refresh() after authoritative block writes. An empty queue takes a fast path, while an hourly full pass repairs and logs missing / extra / stale rows to api_work_drift. api_refresh_state supplies model watermarks. structs.api_readiness(ARRAY['inventory','work']::text[]) also requires matching cursor/block identity, inventory authority and contiguous economy coverage after activation. data_ready and transaction_ready serve different purposes; neither replaces native position completeness or action preflight.

Historical duplicate rows on refined and infused (affects ledger-derived bank balances). Two chain messages each produced two ledger rows for the same movement, because sync-state’s event handler and the Cosmos bank-event handler both wrote one:

Message Correct row Spurious duplicate
MsgStructOreRefineryComplete refined credit, ualpha received credit, ualpha
MsgStructGeneratorInfuse infused debit, ualpha sent debit, ualpha

The duplicate shares the same address, time, block_height, and amount_p as the correct row. Current sync-state suppresses the bank-side row, but the duplicates persist in already-indexed data — inspect the particular database before assuming those rows remain. Any query of the form SUM(CASE direction WHEN 'credit' THEN amount ELSE -amount END) over ualpha therefore double-counts every historical refine and infusion. Until an operator runs the upstream repairs (sync-state/sql/repair-refine-ledger-dupes.sql, repair-infusion-ledger-dupes.sql), exclude the shadowed actions when computing a net position:

SELECT address, denom,
    SUM(CASE direction WHEN 'credit' THEN amount_p ELSE -amount_p END) AS net
FROM structs.ledger l
WHERE NOT (
        l.denom = 'ualpha'
    AND l.action IN ('received', 'sent')
    AND EXISTS (
        SELECT 1 FROM structs.ledger d
         WHERE d.address = l.address
           AND d.time = l.time
           AND d.block_height = l.block_height
           AND d.amount_p = l.amount_p
           AND d.denom = 'ualpha'
           AND d.action = CASE l.action WHEN 'received' THEN 'refined'::structs.ledger_action
                                        ELSE 'infused'::structs.ledger_action END
    ))
GROUP BY address, denom;

seized and mined were never duplicated, so ore analytics are unaffected.


Signer Schema (Transaction Signing Agent)

The TSA (structs-tsa) consumes requests made through the guarded v022-1 wrappers. Each request binds an acting player and required authority; SQL and native preflight recheck current key grants.

Table Purpose
signer.role, signer.account Existing player roles and signing-key pool; a reserved account remains reserved while its outcome is uncertain
signer.tx, signer.tx_authority Queue request and explicit authorization requirements
signer.tx_submission Immutable signed bytes/hash/account state plus prepared, sending, unknown, accepted, rejected, committed, cancelled lifecycle
signer.local_key Public inventory of retained local keys and their verification state
signer.account_request, account_registration, account_pool_source Idempotent key requests, native registration and exact source-grant evidence
signer.role_key_request, role_operator_request, role_proxy_attempt Role bootstrap/operator identity and approval evidence
signer.tx_registration_proof, tx_proxy_proof, tx_unsent_abort Bound proofs and unsent cancellation history

SQL persists signed identity before transport. The TSA volume journal is written before the one-use SQL send permit. Admission (accepted) is not inclusion; committed retains the actual DeliverTx result, including failure. Timeout or lost acknowledgement never permits replacing or resending the transaction. Recovery observes the same hash/bytes in authoritative block results. Back up the full SQL database and key volume together; do not reset pending states to free a key.

Startup/periodic inventory reconciliation supports legacy flat and nested keyring layouts and restores only keys whose live registration agrees with the ready index. Unknown/conflicting/held keys remain inactive. It recovers key inventory, not a lost business queue. See TSA lifecycle.

signer.signer_tx_type

Seven values cover the UGC chain message types:

Enum Value Wraps
guild-update-name MsgGuildUpdateName
guild-update-pfp MsgGuildUpdatePfp
player-update-name MsgPlayerUpdateName
player-update-pfp MsgPlayerUpdatePfp
substation-update-name MsgSubstationUpdateName
substation-update-pfp MsgSubstationUpdatePfp
planet-update-name MsgPlanetUpdateName

signer.tx_* wrappers

The signing layer ships 12 PL/pgSQL wrapper functions that queue UGC updates into signer.tx. They split into two groups by permission preflight only — both groups ultimately broadcast the same chain messages (MsgPlayerUpdateName, MsgPlayerUpdatePfp, MsgPlanetUpdateName, MsgSubstationUpdateName, MsgSubstationUpdatePfp):

There is no MsgGuildModerate* chain message — moderation is the same Msg*Update* message, gated by the actor’s PermGuildUGCUpdate on the owner’s guild. The chain emits a ugc_moderated event whenever the actor differs from the target object’s owner.

New account requests require an explicit permission mask and native registration proof context. There is no default full grant. Queue growth copies one eligible key’s exact current grant, never the union of several keys; the cap is 40 keys per role including pending keys. A new role’s primary key uses the protocol-required 33554431, but creating additional roles is a manual action and does not grant them authority over another player or guild.

Wallet wrappers carry explicit acting-player and asset-owner authority. Reactor operations route an omitted source to the selected owner’s vault, or validate an explicit retained delegator address. Generator infusion burns the acting player’s vault funds. alpha is the supported display alias for ualpha; other coin denoms remain exact, with no guessed prefix or scale. Guild-bank wrappers use the current message arguments and minimum outputs. Co-version wrappers, enums, worker and release CLI; a successful SQL enqueue is not a committed transaction.

Display formatting

UNIT_DISPLAY_FORMAT() at f66fd81 uses exact numeric scaling and trims trailing zeros. High-unit thresholds are milliwatt TW = 10^15, ore Tg = 10^12, and ualpha Tg = 10^18. Exact zero uses the display unit (0W, 0g, or the guild display token), not the base unit.

PLAYER_PENDING_JOIN_PROXY

The PLAYER_PENDING_JOIN_PROXY trigger threads username and pfp from the pending row through to signer.CREATE_TRANSACTION for the guild-membership-join-proxy command, packaged into the ugc JSONB argument. The webapp’s signup flow is:

webapp signup
  -> structs.player_pending row (with username, pfp)
  -> PLAYER_PENDING_JOIN_PROXY trigger
  -> signer.tx (command=guild-membership-join-proxy, args.ugc={username, pfp, ...})
  -> TSA claims, signs MsgGuildMembershipJoinProxy with playerName/playerPfp
  -> chain validates name/pfp, creates player, sync-state writes username/pfp to structs.player

ID Format Reference

All game object IDs follow {type_prefix}-{index}. See entity-relationships.md for the complete type code table.

Prefix Object Type Example
0- Guild 0-1
1- Player 1-142
2- Planet 2-105
3- Reactor 3-1
4- Substation 4-5
5- Struct 5-1165
6- Allocation 6-10
9- Fleet 9-142
10- Provider 10-2
11- Agreement 11-1

See Also

Verified scope: economic ownership, ranks, native evidence, defusion receipts, ledger identity, readiness, service-role boundaries and signer lifecycle against structs-pg 400e1c1, docker-structs-pg d0b5924, structs-tsa 3258529. Other entity/statistics examples retain their earlier f66fd81 review scope. Historical repair notes describe possible retained data, not a new measurement of every guild.