Event Promotion Design: State-Based Reconciliation with Composite Keys
Overview
Traditional event sourcing replication involves copying raw events from one environment to another. However, this fails when the target environment has diverged (e.g., hotfixes), causing aggregateVersion conflicts. Additionally, strict global UUID constraints can prevent reusing the same ID across environments (Tenants). Finally, partial promotions can fail if parent dependencies (referential integrity) are missing in the target.
To resolve this, we adopt a State-Based Reconciliation approach (Semantic Replay) combined with Composite Keys for identity and Recursive Dependency Resolution for integrity.
Core Strategy: State-Based Reconciliation
Workflow
- Export (Lower Environment):
- Query the current state (Snapshot) of the entity from the Lower Environment (LE).
- Produce a “Canonical State Snapshot” (JSON).
- Import & Diff (Higher Environment):
- Read the LE Snapshot.
- Query the current state of the representative entity in the Higher Environment (HE).
- Compare:
- New? -> Generate
XxxCreatedEvent. - Changed? -> Calculate Delta -> Generate
XxxUpdatedEvent. - Same? -> No-op.
- New? -> Generate
Advantages
- Conflict Immunity: No
aggregateVersionconflicts; we always append new events. - Self-Healing: Automatically synchronizes diverged states.
Identity Strategy: Composite Keys
The Problem: Global UUID Uniqueness
In a multi-tenant system shareing a single database, a standard Primary Key UUID (e.g., user_id) is globally unique. This prevents us from having “User Steve” with UUID 123 in both the “Dev Tenant” and “Prod Tenant” if the DB enforces strict uniqueness on that column.
The Solution: Composite Keys (host_id + aggregate_id)
We scope all identity by the Tenant ID (host_id).
-
Schema Change:
- Primary Keys: Change from
PK(id)toPK(host_id, id). - Uniqueness: Change unique constraints (e.g., email) from
UK(email)toUK(host_id, email). - Event Store: Change unique constraint from
UK(aggregate_id, version)toUK(host_id, aggregate_id, version).
- Primary Keys: Change from
-
Promotion Benefit:
- Dev Tenant:
host_id=DEV, user_id=123 - Prod Tenant:
host_id=PROD, user_id=123 - Matching entities is trivial (compare
iddirectly).
- Dev Tenant:
Data Integrity: Recursive Dependency Resolution
The Problem: Missing Dependencies
Promoting a child entity (e.g., API Configuration) fails if its parent (e.g., API Instance) does not exist in the target environment (Higher Env).
The Solution: Deep Promotion (Recursive Bundling)
The exporter must be “Topology Aware”.
-
Dependency Metadata: Every Entity Type must declare its dependencies.
ApiConfigdepends onApiInstance.ApiInstancedepends onGatewayInstance.GatewayInstancedepends onHost.
-
Export Workflow (Recursive): When a user selects
ApiConfig-123for promotion:- System checks
ApiConfig-123-> ParentApiInstance-456. - System checks
ApiInstance-456-> ParentGatewayInstance-789. - Export Package: Includes
[GatewayInstance-789, ApiInstance-456, ApiConfig-123](Ordered by dependency).
- System checks
-
Import Workflow (Ordered): The Importer processes the list in order:
- GatewayInstance: Exists in Prod? Yes. (Skip).
- ApiInstance: Exists in Prod? No. Action: Create
ApiInstance. - ApiConfig: Exists in Prod? No. Action: Create
ApiConfig.
Dry Run Technical Implementation
Purpose
To guarantee the promotion will succeed without actually modifying the Higher Environment (Production).
Option 1: Application-Layer Simulation (Fast, Recommended for Planning)
- Logic: The Importer queries the DB (read-only) to fetch the current state of all entities in the package.
- Result: It calculates the “Diff Plan” purely in memory.
- Output: “Plan: Create API Instance (New), Update API Config (Diff)”.
- Pros: Very fast, zero DB locks.
- Cons: Does not verify deep database constraints (e.g., complex triggers or check constraints) that only trigger on write.
Option 2: Transaction Rollback (Robust, Recommended for Validation)
- Logic:
- Start a Database Transaction:
connection.setAutoCommit(false); - Simulate Execution: Perform the actual SQL Inserts and Updates generated by the Plan.
- Insert
ApiInstance… - Insert
ApiConfig…
- Insert
- Check for Errors: If any SQL Exception occurs (e.g., FK violation, unique constraint violation), catch it.
- Rollback: Regardless of success or failure, always call
connection.rollback().
- Start a Database Transaction:
- Output: “Validation Successful: The detailed plan is valid and safe to execute.” OR “Validation Failed: FK Violation on Table X”.
- Pros: 100% certainty that the data is valid according to the database schema.
- Cons: Slightly heavier key locks, but acceptable for admin operations.
Recommendation
Use Option 1 (App Simulation) for the UI preview to show the user “what will happen”. Before append, revalidate every planned aggregate version in the canonical event store. A changed version makes the persisted plan stale and execution fails closed; the operator must run dry-run again. The event-store and outbox writes, item statuses, and promotion status are committed atomically.
Sibling Deletion: Handling Orphaned Items
The User Case
When promoting a collection of items (e.g., “10 Config Properties” in HE vs “8 in LE”), simply creating or updating the 8 matching items from LE is insufficient. We must identify the 2 extra items in HE that likely need to be deleted to match the LE state.
Design Pattern: Scoped Reconciliation
To handle this, the import logic must be aware of the “Parent Scope” of the entities being promoted.
-
Export (Snapshot with Siblings):
- When promoting
ApiConfig-123, we fetch ALL associated properties for that config in LE. - LE Snapshot:
Properties = {P1, P2, ... P8}(Total 8).
- When promoting
-
Import (Set Difference Logic):
- Query ALL associated properties for
ApiConfig-123in HE. - HE State:
Properties = {P1, P2, ... P8, P9, P10}(Total 10). - Logic:
HE_Only = HE_Set - LE_Set=>{P9, P10}.
- Query ALL associated properties for
-
User Decision (Interactive Mode):
- The Dry Run Plan reports:
Updates:8 items synced (P1..P8).Deuntions (Potential):2 items exist in Prod but not Dev (P9, P10).
- Default Action: Do nothing (Safe Mode).
- Option: “Sync Deletes” -> Checkbox to delete extras?
- Strict Mode: Mirror exact state (Automatically schedule
ConfigPropertyDeletedEventfor P9, P10).
- The Dry Run Plan reports:
Implementation Checklist
- Product Version exports include the full supported child sets.
- Product Version planning fetches the complete target child sets and records target-only rows as
DELETEcandidates. - Apply the same explicit orphan-boundary contract to each additional selective entity type before enabling its execution.
UI and Service Design
Entity Dependency Graph
The exporter must be “Topology Aware”. When exporting an entity, all parent and child dependencies are included. Starting with instance_t as the primary promotable entity:
host_t
└── instance_t
├── instance_property_t
├── instance_file_t
├── instance_api_t
│ ├── instance_api_property_t
│ └── instance_api_path_prefix_t
├── instance_app_t
│ ├── instance_app_property_t
│ └── instance_app_api_t
│ └── instance_app_api_property_t
└── deployment_instance_t
└── deployment_instance_property_t
Promotion Modes
Two promotion modes are supported:
- Cross-Instance (JSON): Export entity snapshots as JSON files, then import them into a different environment/database instance. Used when source and target are in separate databases.
- Same-Instance (Data Table): Use
promotion_tandpromotion_item_ttables for tracking promotions between hosts within the same database. Source and target hosts share the same database.
Database: Promotion Tracking Tables
These tables are for same-instance promotions to persist the authoritative plan and its items. The canonical DDL is portal-db/postgres/ddl.sql; deployment init.sql files are generated from it. Existing databases must apply patch_20260828_01_product_version_promotion.sql followed by patch_20260828_02_promotion_lifecycle.sql before the services are deployed.
promotion_t stores the immutable source snapshot and digest, plan version and summary, dependency blockers, append transaction, projection lifecycle, timestamps, and failure diagnostics. promotion_item_t stores deterministic plan order, aggregate identity, expected and observed projection versions, source/target snapshots, diff, event identity, and per-item status. promotion_recovery_t is an append-only audit ledger for recheck, reconcile, and replan requests.
The append lifecycle starts with PLANNED or BLOCKED, then reaches APPEND_ACCEPTED. The independent projection lifecycle is NOT_STARTED, PENDING, COMPLETED, FAILED, or TIMED_OUT. A promotion advances from APPEND_ACCEPTED to the terminal promotion status COMPLETED, FAILED, or TIMED_OUT after a durable evidence check. APPEND_ACCEPTED still means only that the event-store/outbox transaction committed.
Service API Contracts
All promotion services are implemented in the user-command module (net.lightapi.portal.user.command.handler) alongside the existing ExportPortalEvent and ImportPortalEvent handlers.
Export Snapshot (Query)
Exports the current state of selected entities and all their children as a canonical JSON snapshot.
- Service:
user - Action:
exportSnapshot - Request Data:
sourceHostId(UUID) – The host to export from.entityType(String) – e.g.,"instance".entityIds(Array<String>) – IDs of entities to export.includeChildren(Boolean) – Recursively include child entities.includeSiblings(Boolean) – Include full sibling sets for orphan detection.
- Response: Canonical State Snapshot JSON containing all entities ordered by dependency depth, with nested children. The nested format is preferred over flat-with-references because the tree depth is bounded (max 4 levels for
instance_t), making it self-contained and easy to process depth-first during import.
{
"exportVersion": "1.0.0",
"sourceHostId": "...",
"exportTs": "2026-03-09T20:00:00Z",
"entities": [
{
"entityType": "instance",
"entityId": "...",
"data": { },
"children": {
"instance_property": [ ],
"instance_file": [ ],
"instance_api": [
{
"data": { },
"children": {
"instance_api_property": [ ],
"instance_api_path_prefix": [ ]
}
}
],
"instance_app": [
{
"data": { },
"children": {
"instance_app_property": [ ],
"instance_app_api": [
{
"data": { },
"children": {
"instance_app_api_property": [ ]
}
}
]
}
}
],
"deployment_instance": [
{
"data": { },
"children": {
"deployment_instance_property": [ ]
}
}
]
}
}
]
}
Import Dry Run (Command)
Performs an application-layer simulation (Option 1) to calculate the diff plan without modifying the database.
- Service:
user - Action:
importDryRun - Request Data:
targetHostId(UUID) – The host to import into.snapshot(Object) – The exported canonical snapshot JSON.
- Response: A persisted, server-authoritative diff plan with summary counts and per-item actions. For Platform, Pipeline, and Product Version,
executableis true only when dependency and identity checks pass.
{
"promotionId": "...",
"planVersion": 1,
"promotionStatus": "PLANNED",
"executable": true,
"summary": { "create": 5, "update": 3, "noop": 2, "orphan": 1 },
"items": [
{
"entityType": "instance",
"entityId": "...",
"action": "UPDATE",
"diff": { "instance_name": { "from": "old-name", "to": "new-name" } }
},
{
"entityType": "instance_property",
"entityId": "...",
"action": "CREATE",
"diff": null
}
]
}
Import Execute (Command)
Executes supported promotion plans, applying changes to the target host through event sourcing.
The current implementation supports global migration snapshots whose payload contains a top-level tables object. These snapshots are imported through the existing event-based global import pipeline, which converts table rows into ordered events and writes them to event_store_t and outbox_message_t.
Selective execution is enabled for platform, pipeline, and product_version.
Execution consumes the stored plan by promotionId; the client snapshot is
never the execution authority. Pipeline planning requires its referenced
Platform to exist on the target, so the supported dependency order is Platform,
then Pipeline, then Product Version. Other entity types remain review-only and
fail closed.
- Service:
user - Action:
importExecute - Request Data:
targetHostId(UUID) – The host to apply changes to.snapshot(Object) – Required only for global snapshots containingtables.promotionId(UUID) – Required for Platform, Pipeline, and Product Version selective execution; returned byimportDryRun.orphanAction(String) –"keep"(default),"delete"selectedorphanItemIds, or"sync"all target-only items.orphanItemIds(Array<UUID>) – Required selections fordelete; rejected if an ID is not aDELETEitem in the plan.
- Response: Global snapshots return their import counts. Selective execution returns
promotionStatus: "APPEND_ACCEPTED",transactionId,appended, andidempotentReplay. A no-op plan can returnCOMPLETEDimmediately.
Promotion Recovery (Command)
- Service:
user - Action:
promotionRecovery - Request:
promotionIdplusrecoveryAction(RECHECK,RECONCILE, orREPLAN). - RECHECK: Locks the promotion, correlates each event with any open
event_failure_*record, reads the target projection row, and compares its active state and monotonicaggregate_versionwith the stored expected version. - RECONCILE: Performs the same evidence check and returns actionable replay/corrective-event guidance. It never appends another transaction.
- REPLAN: Creates a new plan from the immutable stored source snapshot and links it through
supersedes_promotion_id. It never executes that plan automatically.
All three actions require portal.w, record the actor and outcome in promotion_recovery_t, and are safe under repeated or concurrent requests. Projection timeout defaults to five minutes and can be raised with PROMOTION_PROJECTION_TIMEOUT_SECONDS (minimum 30 seconds).
UI Pages
All pages are located under portal-view/src/pages/promotion/ and accessible via a top-level “Promotion” sidebar menu with children: Export, Import, History.
PromotionExport.tsx (/app/promotion/export)
A 3-step wizard guiding the user through the export process:
- Select Source & Type: User picks a source host from a dropdown and selects the entity type (starting with “Instance”).
- Select Entities: A
MaterialReactTableloads entities for the selected host with checkbox selection. Supports filtering, sorting, and pagination. - Preview & Export: Two options:
- Download JSON – Downloads the canonical snapshot as a
.jsonfile for cross-instance promotion. - Promote to Host – Select a target host and navigate to the Import page with the snapshot pre-loaded for dry run.
- Download JSON – Downloads the canonical snapshot as a
PromotionImport.tsx (/app/promotion/import)
Handles the import and execution workflow:
- Select Import Source: Upload a JSON file, or receive a snapshot from the Export page via navigation state.
- Dry Run Preview: After selecting a target host and clicking “Run Dry Run,” displays the diff plan:
- New items (green) – Will be created.
- Changed items (yellow) – Will be updated, with expandable field-level diffs.
- Same items (gray) – No action needed.
- Orphaned items (red) – Exist in target but not in source.
- Execute: Platform, Pipeline, and Product Version plans can be executed when
executable=true. The UI supports keeping all orphans, deleting selected orphans, or strict synchronization. Other selective entity plans remain disabled. Global migration snapshots bypass the selective dry-run plan and callglobalSnapshotImportdirectly.
PromotionHistory.tsx (/app/promotion/history)
A standard MaterialReactTable listing past promotions with append and projection status. Pending rows are rechecked every five seconds through the audited recovery command; an operator can also refresh explicitly.
PromotionDiffView.tsx (/app/promotion/diff)
Displays detailed promotion metadata, projection timestamps and failure diagnostics, expected versus observed item versions, and the recovery controls. Pending detail views follow the promotion to a terminal state every five seconds.
Selective Promotion Delivery Phases
- P0 – Contract and safety boundary: Platform, Pipeline, and Product Version are executable selective types; execution is plan-ID based, idempotent, and fail-closed. (Implemented.)
- P1 – Durable plan/history: Add promotion tables and real history/detail persistence. (Implemented.)
- P2 – Selective normalization: Diff Platform and Pipeline roots plus the Product Version root and five supported child collections, count child actions, validate natural identities and Platform/Pipeline dependencies, and record expected aggregate versions. (Implemented.)
- P3 – Event materialization and UI: Generate ordered Platform, Pipeline, and Product Version domain events, atomically append event store/outbox records with status updates, and enable guarded UI execution/orphan selection. (Implemented; deployment confirmation pending.)
- P4 – Projection completion: Track event-linked failure evidence and per-row projection convergence, persist terminal state/timestamps, and let the UI follow pending work. (Implemented.)
- P5 – Operational recovery: Persist append/stale-plan failures and provide audited recheck, non-appending reconcile guidance, and linked replan controls. Immutable events are corrected through the existing exact-replay or corrective-event workflows. (Implemented.)
- P6 – Qualification and expansion: PostgreSQL gates cover fresh/upgrade schema parity, completion, consumer failure, timeout, duplicate delivery, and concurrent rechecks. Broader entity types remain disabled until their contracts pass the inventory below. (Implemented for the enabled types.)
P6 Release Gates and Entity Inventory
Release qualification is fail-closed:
run-product-version-promotion-schema-gate.shmust pass against a disposable PostgreSQL database, including applying both patches twice and proving fresh/upgrade parity.PromotionPersistencePostgresTestmust pass completion, open consumer failure, timeout, repeated delivery, and concurrent recheck cases withPROMOTION_TEST_JDBC_URLconfigured.- The existing P0-P3 tests must prove all three orphan policies, stale target versions, single accepted transaction identity, and Platform -> Pipeline -> Product Version dependency order.
- A 10,000-item Product Version qualification fixture must keep dry-run and recheck below 10 seconds each and a 100-row history page below 2 seconds on the release PostgreSQL profile. Any regression above either limit blocks release.
- Alert when a promotion remains
PENDINGfor two minutes, reachesFAILED, or reaches the five-minute defaultTIMED_OUTdeadline. Roll back the service release when failures exceed 1% of executed promotions in 15 minutes or any append/status atomicity test fails. Database rollback is forward-only: retain immutable events and apply a corrective event or approved exact replay.
| Entity type | Create/update/delete events | Projection evidence key | Dependency contract | Selective execution |
|---|---|---|---|---|
| Platform | Complete | (host_id, platform_id) | none | Enabled |
| Pipeline | Complete | (host_id, pipeline_id) | Platform | Enabled |
| Product Version and five children | Complete | root/child natural keys plus aggregate_version | Pipeline, Config, Config Property | Enabled |
| Instance graph | Partial and graph-coupled | multi-table graph revision required | API/App/deployment closure | Disabled |
| Config and Config Property | Shared/global ownership unresolved | shared IDs | consumer/publication closure | Disabled |
| Remaining exported types | Not inventoried | not defined | not defined | Disabled |
Global Migration Export
Motivation
The entity-level promotion (ExportSnapshot) is designed for selective promotion — the user picks specific entities (e.g., 3 instances) and promotes them from a lower environment to a higher one. For that use case, the export produces a rich nested JSON with children and dependencies, which requires hand-crafted exportXxxSnapshot() methods per entity type.
However, a full database migration has fundamentally different requirements:
- Scope: ALL entities across ALL entity types — not a user-selected subset.
- Maintainability: When new tables are added to the system, the migration should work automatically without code changes.
- Simplicity: A flat per-table export is sufficient since all data is exported together (no missing dependency risk).
Design: Dynamic Table Discovery
Instead of maintaining a manual list of entity types and per-type export methods, the Global Migration Export uses PostgreSQL DatabaseMetaData to automatically discover and export all projection tables.
How It Works
- Discover all tables ending in
_tin thepublicschema viaDatabaseMetaData.getTables(). - Skip infrastructure tables that should never be exported:
event_store_t— immutable event log (events will be regenerated on import)outbox_message_t— transient consumer outboxconsumer_offsets— operational stateconsumer_lock— operational lockpromotion_t,promotion_item_t,promotion_recovery_t— promotion tracking (environment-specific)
- For each discovered table:
- Inspect column metadata to detect if the table has
host_idandactivecolumns. - If
activecolumn exists:SELECT * FROM table_t WHERE active = TRUE [AND host_id = ?]. - If no
activecolumn:SELECT * FROM table_t [WHERE host_id = ?]. - Convert each row to
Map<String, Object>with camelCase key names.
- Inspect column metadata to detect if the table has
- Record a consistency marker:
SELECT MAX(id) FROM event_store_tat the start of the export transaction to stamp the snapshot with thelastEventId. - Use
REPEATABLE READtransaction isolation for consistency across all tables (PostgreSQL MVCC ensures a frozen-in-time view even if events are being processed concurrently).
Data Consistency Strategy
Querying projection tables directly is safe because:
- PostgreSQL MVCC:
REPEATABLE READprovides a consistent snapshot at transaction start time. Concurrent event processing does not affect the exported data. - Atomic event application: Each event is applied via
handleEvent()within its own transaction, so partial aggregate states are never visible. lastEventIdmarker: The export records the maximum event ID at transaction start, providing an auditable consistency boundary without the cost of event replay.
Why not replay events from event_store_t?
- The projection tables are the replayed event result — re-replaying is redundant.
handleEvent()has 120+ event type cases — duplicating that logic in an in-memory replayer is impractical.- Event replay would not unlock any consistency benefit beyond what MVCC already provides.
Output Format
{
"exportVersion": "1.0",
"sourceHostId": "N2CMw0HGQXeLvC1wBfln2A",
"lastEventId": "abc123...",
"exportTs": "2026-04-09T20:00:00Z",
"tables": {
"config_t": {
"count": 5,
"rows": [
{ "configId": "...", "configName": "...", "configPhase": "...", ... },
...
]
},
"user_t": {
"count": 12,
"rows": [
{ "userId": "...", "email": "...", "firstName": "...", ... },
...
]
},
"role_t": { ... },
"instance_t": { ... },
...
}
}
Key differences from the per-entity promotion export:
| Aspect | Per-Entity Promotion (ExportSnapshot) | Global Migration (ExportGlobalSnapshot) |
|---|---|---|
| Scope | User-selected entities | All active entities |
| Structure | Nested (parent/children/dependencies) | Flat per-table |
| New table support | Requires code changes | Automatic via DatabaseMetaData |
| Use case | Lower env → Higher env | Full database migration |
| Output | Entity-centric JSON | Table-centric JSON |
| Import mechanism | Same-instance via promotion_t or Cross-instance via JSON | Cross-instance via JSON only |
Import: Event-Based Migration (Refined in Phase 2.5)
To ensure maximum compatibility and maintain the integrity of the event-sourced system, the global import process follows a 3-step pipeline:
Source DB → Export (Flat JSON) → Convert to Events (Ordered JSON) → Import (Target DB)
1. Snapshot-to-Events Conversion
An intermediate step (ConvertSnapshotToEvents) transforms the flat table-centric snapshot into an ordered JSON array of CloudEvents. This format is 100% compatible with the existing event-importer CLI tool (matching the 00-bootstrap.json structure).
2. Topological Sequencing (Dependency Awareness)
Since a full migration often involves complex relationships, the converter is “Relationship Aware.” It uses DatabaseMetaData.getImportedKeys() to dynamically discover parent→child dependencies.
- Topological Sort: It implements Kahn’s algorithm to order events such that parent entities (e.g.,
Org,Host,User,Role) are processed before their children (e.g.,UserHost,RoleUser,AuthProviderClient). - Dynamic: This approach handles new tables and FK constraints automatically without requiring code changes to a “hard-coded” dependency list.
3. Batch Replay & Reconciliation
The import handler performs a batch insertion of these generated events into event_store_t and outbox_message_t within a single transaction.
- Nonce Re-calculation: Nonces are re-calculated on the target system during import to ensure uniqueness.
- Automatic Projections: Inserting into the outbox triggers the
DbEventConsumerStartupHookto rebuild all materialized projection tables on the target system.
Service API Contract
-
Export:
- Handler:
GlobalSnapshotExport(user-query) - Service ID:
lightapi.net/user/exportGlobalSnapshot/0.1.0 - Request:
{ "sourceHostId": "...", "entityTypes": [...] } - Response: Canonical snapshot JSON (flat tables)
- Handler:
-
Convert (New):
- Handler:
ConvertSnapshotToEvents(user-query) - Service ID:
lightapi.net/user/convertSnapshotToEvents/0.1.0 - Request:
{ "snapshot": "...", "targetHostId": "...", "adminUserId": "..." } - Response: JSON array of ordered CloudEvents (event-importer compatible)
- Handler:
-
Import:
- Handler:
GlobalSnapshotImport(user-command) - Service ID:
lightapi.net/user/importGlobalSnapshot/0.1.0 - Request:
{ "targetHostId": "...", "snapshot": "...", "entityTypes": [...] } - Response:
{ "imported": 42, "total": 42 }
- Handler:
Implementation Phases (Updated)
- Phase 1 – UI Foundation: Create promotion pages, sidebar menu entry. (Completed)
- Phase 2 – Global Export: Implement dynamic table discovery via JDBC metadata. (Completed)
- Phase 2.5 – Global Migration Step: Implement Topological Sorting and Snapshot-to-Events conversion for CLI compatibility. (Completed)
- Phase 3 – Entity Promotion (Selective): Platform, Pipeline, and Product Version P0-P3 are implemented; other entity types remain dry-run only pending P6 contracts.
- Phase 4 – Same-Instance Tracking: Platform, Pipeline, and Product Version plans, items, history, append acceptance, projection completion, and audited recovery are persisted.