30 KiB
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 BLOBcolumn 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'sloyalty/vigorStatChange, subtypesStatDelta/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. Nokindcolumn. - 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_eventsholdingEventForHeroBackstoryblobs) — not a whole-entity blob.
Consequence (changes Decisions #1–#3 and 4.5i): an entity fully covered by Tiers 1–3 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 2–3 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 1–3, 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 2–3. 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 1–4) 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:
-
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 onChangedProvinceC. The proto is not in any client-facing RPC contract — it lives undereagle.internal. Same is true forChangedHero,ChangedFaction,ChangedBattalion,Notification,GeneratedTextRequest, andActionResultitself. Replacing proto serialization with SQL writes does not lose forward/backward compatibility, because there are no external consumers depending on the proto wire format. -
ActionResultChas no polymorphism at the top level. It's a 34-field flat struct —Options andVectors, but nosealed traitdiscriminator at the action level. Every action has the same shape; theactionResultTypeenum 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-blobGameState. 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
- One table per non-trivial aggregate type. Every
Vector[X]orOption[X]whereXis a Scala case class with multiple fields gets its own table. - 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. - Optional aggregates → presence by row existence.
Option[X]for aggregateXis 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 forOption[X]aggregates and(action_seq, n)for vectors. - 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. ON DELETE CASCADEeverywhere. Every child table hasFOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE.truncateTobecomes a singleDELETE FROM action_results WHERE action_seq >= ?and the cascades do the rest.- 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
metadatatable'sschema_versionbecomes load-bearing. - (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_ididx_action_results_acting_faction_ididx_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_ididx_action_changed_heroes_hero_ididx_action_changed_faction_faction_ididx_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, withUnaffiliatedHerofields as columnsaction_changed_provinces_changed_unaffiliated_heroes(...)— same shapeaction_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)— join table for ID vectoraction_changed_provinces_new_captured_heroes(...)—CapturedHerofieldsaction_changed_provinces_recruitment_attempted_captured_hero_ids(...)— join tableaction_changed_provinces_removed_captured_hero_ids(...)— join tableaction_changed_provinces_new_ruling_faction_hero_ids(...)— join tableaction_changed_provinces_removed_ruling_faction_hero_ids(...)— join tableaction_changed_provinces_new_battalion_ids(...)— join tableaction_changed_provinces_removed_battalion_ids(...)— join tableaction_changed_provinces_new_incoming_armies(...)—MovingArmydecompositionaction_changed_provinces_removed_incoming_army_ids(...)— join tableaction_changed_provinces_new_withdrawing_armies(...)—MovingArmydecompositionaction_changed_provinces_removed_withdrawing_army_ids(...)— join tableaction_changed_provinces_new_hostile_armies(...)—HostileArmyGroupdecompositionaction_changed_provinces_removed_hostile_army_faction_ids(...)— join tableaction_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)— small flat aggregateaction_changed_provinces_new_defending_army—Option[Army](singleton row if present)action_changed_provinces_new_incoming_shipments(...)—MovingSuppliesdecompositionaction_changed_provinces_removed_incoming_shipment_ids(...)— join tableaction_changed_provinces_new_incoming_end_turn_actions(...)—IncomingEndTurnActiondecompositionaction_changed_provinces_removed_incoming_end_turn_actions(...)— same shape (these are values, not IDs)action_changed_provinces_new_battle_revelations(...)—BattleRevelationdecompositionaction_changed_provinces_removed_battle_revelations(...)— same shapeaction_changed_provinces_new_pending_conquest_info—Option[PendingConquestInfo](singleton row)action_changed_provinces_new_province_events—Vector[ProvinceEvent]action_changed_provinces_new_deferred_change—Option[DeferredChange](singleton, with discriminator sinceDeferredChangeTis 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, currently1.- A
Migrationsobject holds an orderedVector[(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:
- INSERT into
action_results(scalar fields). - 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 0–3 provinces, 0–5 heroes, 0–10 changed entities total, plus 0–2 notifications. So the typical insert is ~5–20 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 100–500 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-
ActionResultpayloadis 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), andQuestare retained. The original full-deletion plan below is kept for historical context.
Once SqliteHistory writes and reads exclusively from the new tables:
- Delete
ActionResultProtoConverter.scala, theChanged{Province,Hero,Faction,Battalion}Converter.scalafiles, theNotification/GeneratedTextRequest/etc. converters. - 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. - Delete the corresponding
_scala_protoBazel targets. - 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 blob — changed_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 3–4.
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.dbshows 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 inreplayFrom) 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: DoubletoChangedProvinceCis 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
- 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 BLOBis used only for deep entities where Tiers 2–3 cost more than they're worth. No child-table explosion for deep aggregates. - 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 1–3, 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 2–3. - What's retained vs. deleted depends on the classification.
action_results.payload(the whole-ActionResultblob) 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 +Queststay as whole-entity blob payloads. So 4.5i is no longer near-nothing. - Sub-phase ordering as in the Sequencing table; each decomposition PR splits into
.1(schema + backfill) and.2(live write + read). - Phase 4 landed separately, before 4.5a (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.