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.
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.*.
| 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.
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_typeThe authoritative reference for all game balance data (~60 columns). Key columns:
Weapons (primary + secondary prefixed):
*_weapon_control – guided or unguided*_weapon_charge – charge cost per attack*_weapon_ambits – bitmask of targetable ambits*_weapon_ambits_array – JSONB readable form*_weapon_shots, *_weapon_damage – shots per attack, damage per shot*_weapon_blockable, *_weapon_counterable – boolean*_weapon_armour_piercing – boolean; ignores the target’s armour damage reduction (added v0.18.0)*_weapon_recoil_damage – self-damage after firing*_weapon_shot_success_rate_numerator/denominator – per-shot hit rateDefense:
unit_defenses – signalJamming, armour, defensiveManeuver, stealthMode, indirectCombatModule, noUnitDefensesguided_defensive_success_rate_numerator/denominator – evasion vs guidedunguided_defensive_success_rate_numerator/denominator – evasion vs unguidedcounter_attack – counter-attack damage (cross-ambit)counter_attack_same_ambit – counter-attack damage (same ambit)Other:
planetary_shield_contribution – shield value for planet structsgenerating_rate_p – power generation per gram (generators); this is the writable columnore_mining_difficulty, ore_refining_difficulty – PoW difficultybuild_difficulty – PoW difficulty for constructionactivate_charge, build_charge, defend_change_charge, move_charge, stealth_activate_chargecan_defend – chain-owned; fleet types true, planetary types false (including Ore Bunker). Persisted by sync-state from the struct-type event (the structs-pg SQL comment that called it indexer-owned is stale)
_pprecision 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, andpassive_draw_p/passive_draw(both formatted viaunit_legacy_format(…, 'milliwatt')). The bare names are still readable — aSELECT generating_ratekeeps working — but only the_pcolumn is writable, so it is the one sync-state upserts and the one to use in aWHEREon an exact integer. The same pattern applies tostructs.ledger.amount_p,structs.infusion.fuel_p/power_p/ratio_p/defusing_p, andstructs.guild.join_infusion_minimum_p. The*_arraybitmask-derivative columns (primary_weapon_ambits_array,possible_ambit_array, …) are likewise GENERATED.
structs.struct_attributeKey-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_defenderDefense 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_activitystruct_defense_add/struct_defense_removedetailuses the keydefender_struct_id(no-ing) for what this table callsdefending_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_raidis latest-state, not history. Because the primary key isplanet_idalone, 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, useplanet_activityrows withcategory = 'raid_status'; theirdetailcarriesseized_orealongsideplanet_id,fleet_id, andstatus. This key was added toemitRaidStatusActivityrecently, so rows indexed before the fix are missing it entirely — onstructstestnet-111roughly half ofraid_statusrows still lack the key. Treatdetail ? '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.ledgerrows withaction = 'seized',direction = 'credit',denom = 'ore', credited to the thief with the victim incounterparty. The ledger preserves 0-gramseizedrows — a raid that reached the planet but stole nothing (a probe repelled, or ore already refined away) still writes a meaningfulseizedentry. Onstructstestnet-111they are common (~40% of seized credits), not the majority — treat them as real outcomes, not noise. Correlate a ledger row to a raid viablock_heightplus the thief’splayer_address→player.fleet_id.
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(), whichpg_notifys each change on subjectstructs.grid.{object_type}.{object_id}.{player_id}with a top-levelplayer_idfield (owner resolved viaplayer_object, falling back toplanet.owner;noPlayerwhen unresolved; forobject_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';
| 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
poweris not in the grid — it lives onstructs.infusionaspower/power_p, keyed(destination_id, address). Gridpoweron anallocationis the energy that allocation routes; on astructit is what that generator produces.
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) |
seqis scoped to a planet, not to the table. It is drawn fromstructs.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 givenseq. 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 ontime/block_heightand useseqonly to disambiguate within a planet.
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.
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.
| 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_wordSeed 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_tagLabelled 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.settingLive 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.defusionstructs.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.
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, andview.worknow resolveplanet_attribute.blockStartOreMine/blockStartOreRefine(ID prefixes12-/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.
| 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 noobject_typecolumn (verified instructs-pg/deploy/table-stat.sql). The object type is implied by the table itself (e.g.stat_connection_*andstat_structs_loadare 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 onobject_indexalone and rely on the table name for the type. This mirrors the note in api/webapp/stat.md.
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.
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
refinedandinfused(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 MsgStructOreRefineryCompleterefinedcredit,ualphareceivedcredit,ualphaMsgStructGeneratorInfuseinfuseddebit,ualphasentdebit,ualphaThe duplicate shares the same
address,time,block_height, andamount_pas 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 formSUM(CASE direction WHEN 'credit' THEN amount ELSE -amount END)overualphatherefore 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;
seizedandminedwere never duplicated, so ore analytics are unaffected.
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_typeSeven 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_* wrappersThe 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):
PermUpdate (4) on the target object before queueing the tx. These are used when a player updates their own UGC.tx_guild_moderate_player_name, _player_pfp, _planet_name, _substation_name, _substation_pfp) that require PermGuildUGCUpdate (16777216) on the target owner’s guild before queueing the tx. These are used when a guild moderator overrides a member’s UGC.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.
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_PROXYThe 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
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 |
.cursor/skills/structs-guild-stack/SKILL.md – Setup and common queriesknowledge/infrastructure/guild-stack.md – Architecture overviewschemas/database-schema.md – Full structural schema catalogknowledge/entities/entity-relationships.md – Full entity graph and ID formatknowledge/entities/struct-types.md – Struct type stats (mirrors struct_type table)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.