# SQLite Action Result Decomposition

Follow-up to [SQLITE_HISTORY_DESIGN.md](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 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:

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 — `Option`s and `Vector`s, 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.

```sql
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.

```sql
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_army` — `Option[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_info` — `Option[PendingConquestInfo]` (singleton row)
- `action_changed_provinces_new_province_events` — `Vector[ProvinceEvent]`
- `action_changed_provinces_new_deferred_change` — `Option[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

```sql
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:
```scala
val proto = ActionResult.parseFrom(payloadBlob)
val scala = ActionResultProtoConverter.fromProto(proto)
```

The new pattern:
```scala
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 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-`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 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.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 2–3 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 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.
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.
