Skip to content

Content Lifecycle V2 — Lineage-Based Versioning

Design document for the immutable-publish, lineage-grouped content versioning system. Supersedes the V1 ReplacesId / PendingRetirement approach.

Problem with V1

The V1 lifecycle requires designers to manually set ReplacesId (a GUID text field) to link a new version to the old one, and PendingRetirement to flag items for archival. This is error-prone, doesn't enforce immutability on published content, and provides no version history visibility.

Design Principles

  1. Published content is immutable. You cannot edit a Published entity. You can only create a new version of it.
  2. Versions are grouped by lineage. All versions of the same conceptual entity share a LineageId.
  3. Lifecycle transitions are explicit and atomic. The only way content reaches Published is through a batch publish operation.
  4. Version history is always visible. Clicking a Published entity shows all Dev/Staged/Archived versions in the same lineage.

State Machine

 ┌──────────┐   promote     ┌──────────┐   publish batch   ┌───────────┐
 │   Dev    │ ────────────► │  Staged  │  ────────────────► │ Published │
 │ editable │  ◄──────────  │ read-only│                    │ immutable │
 │ many/lin │   demote      │ max 1/lin│                    │ max 1/lin │
 └────┬─────┘               └──────────┘                    └─────┬─────┘
      │ discard                                    replaced by    │
      ▼                                            new publish    ▼
 ┌──────────┐                                           ┌───────────┐
 │ Archived │  ◄──────────────────────────────────────── │ Archived  │
 │ read-only│                                           │ read-only │
 │ many/lin │                                           │ many/lin  │
 └──────────┘                                           └───────────┘

Transitions

From To Trigger Constraint
Dev Staged Designer promotes Max 1 Staged per lineage. Fails if one already Staged.
Staged Dev Designer demotes Always allowed.
Dev Archived Designer discards Always allowed.
Staged Published Publish batch Only via atomic publish. Old Published in same lineage → Archived.
Published Archived Publish batch Only when a newer version in same lineage publishes.

Never allowed: Direct Dev→Published, Published→Dev, Archived→anything.

Editability Rules

Status Content Fields Lifecycle Actions
Dev Fully editable Promote to Staged, Discard to Archived
Staged Read-only Demote back to Dev
Published Read-only Create New Version (copies to Dev with same LineageId)
Archived Read-only None

Cardinality per Lineage

Status Count
Dev 0..many
Staged 0..1
Published 0..1
Archived 0..many

Data Model Changes

ILifecycleEntity V2

public interface ILifecycleEntity
{
    ContentStatus Status { get; set; }
    Guid LineageId { get; set; }     // Groups all versions of the same conceptual entity
}

Removed: ReplacesId (lineage handles this), PendingRetirement (publish batch handles this).

Added: LineageId (Guid, non-nullable, indexed).

New Entity Behavior

  • First creation: LineageId = Id (the entity is the origin of its lineage).
  • "Create New Version" from Published: Copies all content fields into a new entity with a new Id, same LineageId, Status = Dev, Version incremented.
  • Snapshot query: WHERE Status = Published (unchanged from V1).

Migration

  • Add LineageId column (uuid, non-nullable) to all 9 content tables.
  • Backfill: UPDATE {table} SET lineage_id = id (every existing record is the origin of its own lineage).
  • Drop ReplacesId and PendingRetirement columns.
  • Add index on (LineageId, Status) for efficient version history queries.

Publish Workflow V2

Preview (GET admin/publish/preview)

Query all 9 content tables for Status = Staged. Group by entity type. For each Staged item, check if a Published item exists in the same lineage (this is an "update" vs "new content"). Return the preview.

Publish Batch (POST admin/publish)

In a single transaction:

  1. For each Staged entity across all 9 tables: a. Find the Published entity in the same lineage (if any) → set to Archived b. Set the Staged entity to Published
  2. Create new GameDataSnapshot from all Published-only entities
  3. Commit transaction
  4. Invalidate cache, broadcast to Redis

Publish Preview Model

public class PublishPreview
{
    public List<PublishPreviewItem> NewItems { get; set; } = [];      // Staged with no Published sibling
    public List<PublishPreviewItem> UpdatedItems { get; set; } = [];  // Staged replacing a Published sibling
    public bool HasChanges => NewItems.Count > 0 || UpdatedItems.Count > 0;
}

public class PublishPreviewItem
{
    public required string EntityType { get; set; }
    public required Guid EntityId { get; set; }
    public required string Name { get; set; }
    public Guid? CurrentPublishedId { get; set; }  // The Published sibling that will be archived
}

Removed: RetiringItems list, ReplacesId field. Retirement happens automatically when a new version publishes.

Admin UI Changes

List Views (all 9 editors)

  • Default filter: show Published items (the "live" view).
  • Status filter dropdown: Dev | Staged | Published | Archived | All.
  • Status column shows current state with color-coded chip.

Edit Form Behavior

  • Published entity selected: Form fields are disabled. Two action buttons:
  • "Create New Version" → copies to Dev, opens the new Dev version for editing
  • Version History section below showing all versions in this lineage

  • Dev entity selected: Form fields are editable. Action buttons:

  • "Save" (saves changes)
  • "Promote to Staged" (validates, changes status; fails if another Staged exists in lineage)
  • "Discard" (archives)

  • Staged entity selected: Form fields are disabled. Action buttons:

  • "Demote to Dev" (moves back to Dev for further editing)

  • Archived entity selected: Form fields are disabled. No actions. Historical reference only.

Version History Panel

Below the edit form for any selected entity, show a table:

Version Status Updated Actions
3 Published 2026-03-14 Create New Version
2 Archived 2026-03-10 (view only)
1 Archived 2026-02-28 (view only)
4 Dev 2026-03-15 Edit
5 Dev (draft) 2026-03-15 Edit

Grouped: Published/Staged first, then Dev, then Archived (newest first within each group).

Publish Review Page (replaces Snapshots.razor)

The Snapshots page becomes the Publish Review page:

  • Top section: current Published snapshot info (version, hash, timestamp, counts)
  • Middle section: Publish Preview — table of all currently-Staged items across all entity types, showing entity type, name, whether it's new or replacing an existing Published version
  • Bottom section: Rollback controls (unchanged)
  • "Publish Set" button only enabled when preview has changes

LifecycleFieldsEditor → Removed

The LifecycleFieldsEditor expansion panel with its manual Status dropdown, ReplacesId text field, and PendingRetirement checkbox is removed. Lifecycle transitions happen through explicit action buttons on the edit form, not through editing lifecycle fields directly.

API Changes

Existing Endpoints (modified)

  • POST admin/{entity} — rejects saves for Staged/Published/Archived entities (only Dev is writable)
  • DELETE admin/{entity}/{id} — rejects deletes for Published entities

New Endpoints

  • POST admin/{entity}/{id}/promote — Dev → Staged (validates max-1-staged constraint)
  • POST admin/{entity}/{id}/demote — Staged → Dev
  • POST admin/{entity}/{id}/discard — Dev → Archived
  • POST admin/{entity}/{id}/new-version — Creates a Dev copy of a Published entity, returns the new entity
  • GET admin/{entity}/lineage/{lineageId} — Returns all versions in a lineage

Unchanged Endpoints

  • GET admin/publish/preview — Updated query logic but same endpoint
  • POST admin/publish — Updated transition logic but same endpoint
  • POST admin/rollback/{version} — Unchanged

Implementation Order

  1. Migration: Add LineageId, backfill, drop ReplacesId + PendingRetirement
  2. ILifecycleEntity V2: Update interface, update all 9 model classes
  3. Repository: Rewrite ProcessLifecycleTransitionsAsync for lineage-based logic, add promote/demote/discard/new-version methods
  4. AdminController: Add new endpoints, add write-guard for non-Dev entities
  5. EchoSpireApiClient: Add client methods for promote/demote/discard/new-version/lineage
  6. Admin UI: Replace LifecycleFieldsEditor with action buttons, add version history panel, add status filters to list views, upgrade Snapshots → Publish Review
  7. Tests: Update all 111 admin tests for new lifecycle rules