Files
eagle0/docs/SQLITE_ACTION_RESULT_DECOMPOSITION.md
T

30 KiB
Raw Blame History

SQLite Action Result Decomposition

Follow-up to SQLITE_HISTORY_DESIGN.md. This document plans the second wave of the SQLite migration: decomposing the ActionResult proto blob in action_results.payload into native relational tables, deleting the proto representation entirely for everything in scope.

Status — option B adopted (updated 2026-05-17)

This section supersedes the original plan below. Where the body conflicts with this section, this section wins. The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.

The original plan (decompose every field into relational tables; "decision locked") was implemented for ChangedProvince first — ~37 child tables, ~1500 lines (PR #6721) — and then rejected on cost/benefit. We adopted option B: apply the "stopping rule" (originally carved out only for Quest) consistently. Shipped and merged as Phase 4.5b in PR #6725:

  • Keep the per-entity top-level table with scalar / entity-reference columns + indexes (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
  • Store the rest of the aggregate as one <entity>_proto BLOB column on that row: lossless round-trip / replay, no child tables for the deep nesting.
  • Scalar columns are pure query denormalization; the proto blob is authoritative. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.

Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing Quest. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need no SQL migration (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).

Refinement — "refined option B" for shallow entities (updated 2026-05-19)

This section further supersedes the body and the Status section above where they conflict; it wins. It records the pattern actually shipped for ChangedHero in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.

Option B as originally stated keeps one <entity>_proto BLOB per row for the entire non-scalar residual. For a shallow entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:

  • Tier 1 — scalar / entity-reference columns + indexes. The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
  • Tier 2 — typed columns for sealed value-oneofs. A sealed choice over simple values (e.g. ChangedHeroC's loyalty / vigor StatChange, subtypes StatDelta/StatAbsolute/StatNoChange) becomes a pair of nullable columns (loyalty_delta / loyalty_absolute, …). The column name is the discriminator; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No kind column.
  • Tier 3 — per-row leaf-proto blobs in a child table. A repeated/optional nested aggregate becomes a child table keyed by (action_seq, entity_id, n) storing the leaf proto's bytes one row per element (e.g. action_changed_heroes_new_backstory_events holding EventForHeroBackstory blobs) — not a whole-entity blob.

Consequence (changes Decisions #1#3 and 4.5i): an entity fully covered by Tiers 13 has no whole-entity blob. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (changed_hero.proto / ChangedHeroConverter) become genuinely dead code, deletable in 4.5i. Only the leaf protos used as Tier-3 blob payloads (event_for_hero_backstory.proto) are retained as a wire format. The whole-entity <entity>_proto BLOB is now reserved for deep entities where Tiers 23 would be more work than value (ChangedProvince, the "new entity" snapshots).

Per-entity decision (supersedes Decisions #2): classify each entity as all-scalar (Tier 1 only, no blob — e.g. likely ChangedFaction), refined (Tiers 13, no whole-entity blob, proto deletable — ChangedHero), or deep (Tier 1 + whole-entity <entity>_proto BLOB, proto retained — ChangedProvince, new-entity snapshots). Default to refined for shallow entities; fall back to deep only when the residual is too varied to be worth Tiers 23. Wherever the body or Status says "the proto blob is authoritative," for a refined entity read "the columns + per-leaf-proto blobs are authoritative."

Why

The first wave (Phases 14) stores each action as a single proto blob in action_results.payload. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The action_result_type, round_id, and date columns we denormalized are the only handles SQL has into actions.

Two observations make full decomposition more attractive than the original design admitted:

  1. The proto exists for storage only. 92 Scala files reference ChangedProvince, but exactly one imports the proto type — ChangedProvinceConverter.scala. Everything else (action handlers, AI, utils) operates on ChangedProvinceC. The proto is not in any client-facing RPC contract — it lives under eagle.internal. Same is true for ChangedHero, ChangedFaction, ChangedBattalion, Notification, GeneratedTextRequest, and ActionResult itself. Replacing proto serialization with SQL writes does not lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.

  2. ActionResultC has no polymorphism at the top level. It's a 34-field flat struct — Options and Vectors, but no sealed trait discriminator at the action level. Every action has the same shape; the actionResultType enum tells you which fields are populated. This means the entire structure is relationalizable without per-action-type tables.

The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.

Scope

In: action_results.payload (the whole-ActionResult blob) is gone by the end. Per-entity scalar / entity-reference columns make the queryable surface (deltas, touched entities) native SQL. Under refined option B (see Status): internal/action_result.proto and deep entities' sub-message protos (changed_province.proto, …) are retained as blob payloads; refined entities' protos (changed_hero.proto) are decomposed away and deleted, with only their Tier-3 leaf protos (event_for_hero_backstory.proto) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)

Out:

  • state_snapshots.payload — stays as proto-blob GameState. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
  • Shardok tables — already designed in Phase 3, no changes here. The shardok protos (ShardokActionResult, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
  • Client text — already SQLite-backed (SqliteClientTextStore).

Principles

  1. One table per non-trivial aggregate type. Every Vector[X] or Option[X] where X is a Scala case class with multiple fields gets its own table.
  2. Vectors of primitives → join tables. Vector[Int] becomes a two-column table: (parent_key, value). No JSON arrays, no comma-separated strings — those defeat the point.
  3. Optional aggregates → presence by row existence. Option[X] for aggregate X is a row that may or may not exist with the matching parent key. The natural primary key on (action_seq) enforces 0-or-1-row semantics for Option[X] aggregates and (action_seq, n) for vectors.
  4. Sealed traits get a discriminator column. When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with kind TEXT NOT NULL + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
  5. ON DELETE CASCADE everywhere. Every child table has FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE. truncateTo becomes a single DELETE FROM action_results WHERE action_seq >= ? and the cascades do the rest.
  6. Schema migrations are now real. Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The metadata table's schema_version becomes load-bearing.
  7. (Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it. The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual relational representation to keep in sync.

Top-level: action_results

The payload BLOB column goes away. Scalar fields become columns; aggregate fields move to related tables.

CREATE TABLE action_results (
    action_seq                              INTEGER PRIMARY KEY,
    action_result_type                      INTEGER NOT NULL,
    -- existing scalar denormalizations
    round_id                                INTEGER NOT NULL,
    date_year                               INTEGER,
    date_month                              INTEGER,
    -- new scalar columns (formerly inside payload)
    acting_hero_id                          INTEGER,
    acting_faction_id                       INTEGER,
    province_id                             INTEGER,
    province_id_acted                       INTEGER,
    new_round_phase                         INTEGER,
    new_round_id                            INTEGER,
    last_command_type_for_acting_province   INTEGER,
    resolved_battle                         TEXT,
    new_victor_faction_id                   INTEGER,
    game_ended                              INTEGER,  -- 0/1/NULL
    new_random_seed                         INTEGER,
    new_game_type                           INTEGER
);

-- Vectors of bare IDs become join tables:
CREATE TABLE action_destroyed_battalion_ids (
    action_seq    INTEGER NOT NULL,
    battalion_id  INTEGER NOT NULL,
    PRIMARY KEY (action_seq, battalion_id),
    FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);

CREATE TABLE action_removed_hero_ids (
    action_seq INTEGER NOT NULL,
    hero_id    INTEGER NOT NULL,
    PRIMARY KEY (action_seq, hero_id),
    FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);

CREATE TABLE action_removed_faction_ids (...);
CREATE TABLE action_affected_faction_ids (...);

-- Singleton optional aggregates: a row exists if and only if Option is Some.
CREATE TABLE action_new_battles (
    action_seq INTEGER PRIMARY KEY,
    -- ShardokBattle fields here, columns or sub-tables as needed
    FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);

CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);

Each Vector[Aggregate] field on ActionResultC gets its own table (covered below).

Indexes

We keep the existing indexes (round_id, (date_year, date_month)) and add a few that obvious queries need:

  • idx_action_results_acting_hero_id
  • idx_action_results_acting_faction_id
  • idx_action_results_province_id

Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:

  • idx_action_changed_provinces_province_id
  • idx_action_changed_heroes_hero_id
  • idx_action_changed_faction_faction_id
  • idx_action_changed_battalions_battalion_id

Changed-entity tables (four parallel structures)

action_changed_provinces

ChangedProvinceC has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on action_changed_provinces; aggregate fields become child tables.

CREATE TABLE action_changed_provinces (
    action_seq                            INTEGER NOT NULL,
    province_id                           INTEGER NOT NULL,
    -- resource changes
    gold_delta                            INTEGER,
    food_delta                            INTEGER,
    -- stat changes
    new_price_index                       REAL,
    economy_delta                         REAL,
    agriculture_delta                     REAL,
    infrastructure_delta                  REAL,
    economy_devastation_delta             REAL,
    agriculture_devastation_delta         REAL,
    infrastructure_devastation_delta      REAL,
    support_delta                         REAL,
    -- round properties
    set_has_acted                         INTEGER,  -- 0/1/NULL
    set_ruler_is_visiting_town            INTEGER,
    -- ruling faction changes
    clear_ruling_faction_id               INTEGER NOT NULL DEFAULT 0,
    new_ruling_faction_id                 INTEGER,
    -- misc scalar fields
    new_locked_improvement_kind           TEXT,     -- 'none', 'new'
    new_locked_improvement_value          INTEGER,  -- ImprovementType if kind='new'
    new_province_orders                   INTEGER,  -- ProvinceOrderType enum
    clear_defending_army                  INTEGER NOT NULL DEFAULT 0,
    cleared_pending_conquest_info         INTEGER NOT NULL DEFAULT 0,
    removed_deferred_change_index         INTEGER,
    PRIMARY KEY (action_seq, province_id),
    FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);

CREATE INDEX idx_action_changed_provinces_province_id
    ON action_changed_provinces(province_id);

The vector and optional-aggregate fields of ChangedProvinceC become per-aggregate child tables, all keyed by (action_seq, province_id):

  • action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...) — one row per added hero, with UnaffiliatedHero fields as columns
  • action_changed_provinces_changed_unaffiliated_heroes(...) — same shape
  • action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id) — join table for ID vector
  • action_changed_provinces_new_captured_heroes(...)CapturedHero fields
  • action_changed_provinces_recruitment_attempted_captured_hero_ids(...) — join table
  • action_changed_provinces_removed_captured_hero_ids(...) — join table
  • action_changed_provinces_new_ruling_faction_hero_ids(...) — join table
  • action_changed_provinces_removed_ruling_faction_hero_ids(...) — join table
  • action_changed_provinces_new_battalion_ids(...) — join table
  • action_changed_provinces_removed_battalion_ids(...) — join table
  • action_changed_provinces_new_incoming_armies(...)MovingArmy decomposition
  • action_changed_provinces_removed_incoming_army_ids(...) — join table
  • action_changed_provinces_new_withdrawing_armies(...)MovingArmy decomposition
  • action_changed_provinces_removed_withdrawing_army_ids(...) — join table
  • action_changed_provinces_new_hostile_armies(...)HostileArmyGroup decomposition
  • action_changed_provinces_removed_hostile_army_faction_ids(...) — join table
  • action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status) — small flat aggregate
  • action_changed_provinces_new_defending_armyOption[Army] (singleton row if present)
  • action_changed_provinces_new_incoming_shipments(...)MovingSupplies decomposition
  • action_changed_provinces_removed_incoming_shipment_ids(...) — join table
  • action_changed_provinces_new_incoming_end_turn_actions(...)IncomingEndTurnAction decomposition
  • action_changed_provinces_removed_incoming_end_turn_actions(...) — same shape (these are values, not IDs)
  • action_changed_provinces_new_battle_revelations(...)BattleRevelation decomposition
  • action_changed_provinces_removed_battle_revelations(...) — same shape
  • action_changed_provinces_new_pending_conquest_infoOption[PendingConquestInfo] (singleton row)
  • action_changed_provinces_new_province_eventsVector[ProvinceEvent]
  • action_changed_provinces_new_deferred_changeOption[DeferredChange] (singleton, with discriminator since DeferredChangeT is sealed)

That's a lot of tables — ~25 just under ChangedProvince. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, n (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.

action_changed_heroes, action_changed_factions, action_changed_battalions

Same pattern. Each gets its top-level table keyed by (action_seq, entity_id), plus child tables for nested aggregates and ID vectors. Indexes on the entity id (hero_id, faction_id, battalion_id) so cross-game queries are fast.

ChangedHeroC is a refined entity (see "Refinement" in Status): no whole-entity blob. Its sealed StatChange (StatDelta/StatAbsolute/StatNoChange) loyalty and vigor oneofs are Tier-2 column pairs (loyalty_delta/loyalty_absolute, vigor_delta/vigor_absolute; column name discriminates, both-NULL ⇒ StatNoChange). Its newBackstoryEvents: Vector[EventForHeroBackstory] is Tier-3: per-event EventForHeroBackstory blobs, one row each, in action_changed_heroes_new_backstory_events. Columns + per-event blobs round-trip losslessly, so changed_hero.proto/ChangedHeroConverter become deletable (4.5i) and only event_for_hero_backstory.proto is retained as a blob format.

"New" entity tables

ActionResultC.newProvinces: Vector[ProvinceT], newHeroes, newFactions, newBattalions. These are full snapshots of new entities at creation.

These are bigger and recursive: ProvinceT itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing state_snapshots blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in ActionResultC, which we're decomposing.

Decision: go fully relational here too. Each "new entity" gets its top-level table (action_new_provinces etc.) keyed by (action_seq, entity_id), with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the changed_* family (preferred where the field shape is identical, e.g., MovingArmy, UnaffiliatedHero) or get their own (where they diverge).

This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.

Notifications and generated text requests

CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_new_generated_text_requests (...);
CREATE TABLE action_client_text_visibility_extensions (...);

NotificationT and GeneratedTextRequestT are both sealed traits. Use the discriminator-column pattern: kind TEXT NOT NULL plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.

Schema evolution

We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.

Conventions:

  • metadata['schema_version'] is a small integer, currently 1.
  • A Migrations object holds an ordered Vector[(Int, Connection => Unit)] of migration steps.
  • On SqliteHistory.loaded, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
  • New games start at the latest version.
  • Migration steps are append-only — never edit an existing step.

This is the standard Rails / Flyway / etc. pattern, scaled down to one file.

Read path: reconstructing ActionResultC

The current pattern:

val proto = ActionResult.parseFrom(payloadBlob)
val scala = ActionResultProtoConverter.fromProto(proto)

The new pattern:

val scala = ActionResultDbReader.read(connection, actionSeq)  // assembles from action_results + child tables

ActionResultDbReader.read is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.

For bulk reads (since, all, sinceDate, etc.), the existing replayFrom helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting ActionResultC instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.

Write path

withNewResults becomes:

  1. INSERT into action_results (scalar fields).
  2. For each non-empty aggregate: INSERT N rows into the corresponding child table.

All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 03 provinces, 05 heroes, 010 changed entities total, plus 02 notifications. So the typical insert is ~520 rows. With WAL and a single per-batch transaction, this is cheap.

Replay performance

Today's replayFrom parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.

Deleting the proto

Superseded by refined option B (see Status). Whole-ActionResult payload is removed in 4.5h. After that it depends on per-entity classification: refined entities' per-entity sub-message protos + converters (e.g. changed_hero.proto) do become dead code and are deleted in 4.5i; deep entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. event_for_hero_backstory.proto), and Quest are retained. The original full-deletion plan below is kept for historical context.

Once SqliteHistory writes and reads exclusively from the new tables:

  1. Delete ActionResultProtoConverter.scala, the Changed{Province,Hero,Faction,Battalion}Converter.scala files, the Notification/GeneratedTextRequest/etc. converters.
  2. Delete the proto definitions: action_result.proto, changed_province.proto, changed_hero.proto, changed_faction.proto, notification.proto, generated_text_request.proto, anything else in this scope.
  3. Delete the corresponding _scala_proto Bazel targets.
  4. Gazelle should drop the unused deps from downstream targets.

This is satisfying but should land last, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.

Sequencing

Phase 4 (cutover) lands as planned: GamesManager swaps to SqliteHistory, save dirs get nuked, action data is the single proto-blob payload. Days of work, validates the lifecycle integration without conflating with schema design.

Phase 4.5 (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. Re-planned for option B (see Status): each entity gets the 4.5a scalar/entity-ref columns + a <entity>_proto BLOB; no child-table explosion. Each decomposition PR splits into .1 (schema + backfill over existing payload) and .2 (live withNewResults write path + read path), per the precedent set by 4.5b.

PR Work State
4.5a Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) merged
4.5b ChangedProvince: scalar columns + changed_province_proto BLOB + v3 backfill (option B, #6725) merged
4.5c.1 ChangedHero (refined option B, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + newBackstoryEvents as per-event EventForHeroBackstory Tier-3 blobs in action_changed_heroes_new_backstory_events; no whole-entity blobchanged_hero.proto/ChangedHeroConverter become deletable, event_for_hero_backstory.proto retained. Schema v4 + backfill over existing payload (#6727) merged
4.5c.2 ChangedHero: live withNewResults write path + read path planned
4.5d ChangedFaction: same pattern (mostly scalar already; small/empty blob — see Decisions #2) planned
4.5e ChangedBattalion: changed_battalion_proto BLOB (sole field is the contained BattalionT) + battalion_id index from 4.5a planned
4.5f "New entity" vectors (newProvinces/newHeroes/newFactions/newBattalions): one table per type keyed by (action_seq, entity_id) + a couple of queryable handles + <entity>_proto BLOB. Biggest win for B — these are the deepest snapshots planned
4.5g Notifications + generated text requests: (action_seq, n, kind, <entity>_proto BLOB)kind discriminator queryable, payload blobbed (quest-hybrid shape) planned
4.5h Top-level ActionResultC scalar/entity-ref columns on action_results (acting_hero_id, acting_faction_id, province_id, …) + drop payload BLOB. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) planned
4.5i Delete proto files/targets that became dead code. Refined entities' per-entity protos + converters (e.g. changed_hero.proto/ChangedHeroConverter) are deleted here. Deep entities' protos + leaf Tier-3 protos (e.g. event_for_hero_backstory.proto) + Quest are retained as blob payloads. No longer near-nothing — scope scales with how many entities went refined planned

Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly 1 week, not 34.

After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.

What we gain at the end

  • Single source of truth. Action data lives in tables; no proto representation for the things in scope.
  • Native SQL analytics. Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
  • Inspectable saves. sqlite3 game.db shows readable data. Debugging is normal SQL work, not "parse this binary blob."
  • Cleaner replay path. No proto parsing during replay. The ActionResultProtoConverter (currently called per replayed action in replayFrom) goes away entirely.
  • Less code. ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
  • Schema migrations as a first-class concept. Future field additions are SQL migrations, which is the right shape for a system that owns its storage.

What we lose

  • The "edit one proto" workflow for adding fields. Adding newFooDelta: Double to ChangedProvinceC is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
  • A safety net for unrecognized fields. Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.

Decisions locked

  1. Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule). Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity <entity>_proto BLOB is used only for deep entities where Tiers 23 cost more than they're worth. No child-table explosion for deep aggregates.
  2. Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed"). All-scalar → Tier 1 only, no blob (likely ChangedFaction). Refined → Tiers 13, no whole-entity blob, per-entity proto deletable (ChangedHero). Deep → Tier 1 + whole-entity <entity>_proto BLOB, proto retained (ChangedProvince, new-entity snapshots). Default to refined for shallow entities; fall back to deep only when the residual is too varied for Tiers 23.
  3. What's retained vs. deleted depends on the classification. action_results.payload (the whole-ActionResult blob) still goes away in 4.5h. Refined entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the leaf protos they use as Tier-3 blob payloads (e.g. event_for_hero_backstory.proto) are retained. Deep entities' protos + Quest stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
  4. Sub-phase ordering as in the Sequencing table; each decomposition PR splits into .1 (schema + backfill) and .2 (live write + read).
  5. Phase 4 landed separately, before 4.5a (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.