Disciplines · Runbooks

Runbook: V1 Reconciliation and Replay

table is the mistake this document exists to prevent.** It holds one row per

12sections17 minread

On this page

Owner: production operations duty officer (rota:prod-ops). Last reviewed: 2026-08-14. Version: 1.

A restore brings back a copy of the estate as of some instant. This document is about everything that happened after that instant, and about the work that was in flight when the estate stopped: which of it can be re-driven, which of it must never be re-driven, and how an operator establishes which is which.

It is the step infra/hetzner/backup/box-loss-restore-drill.sh names in its report as nextRequiredControl, and the step every other runbook hands off to. The nine state classes below are the ones §S10.12.c names.

0. Where the state actually is, because it is not where the table names say#

admin_store_snapshot is the V1 durable state store, and reading it as one table is the mistake this document exists to prevent. It holds one row per (store_key, scope_id) carrying a whole document JSONB, written by createSnapshotSink (libs/oshun/persistence/src/durable-snapshot-store.ts). On the development box on 2026-08-14 it held 415 rows under 52 store keys. A second job store, thirty-eight crypto invoices carrying on-chain transaction ids, and the editorial release streams are all in there. None of them is visible to a search for a table name.

Two conclusions recorded against this estate were reached that way, and both are corrected below. The §S10.12.a groundwork recorded "no CREATE TABLE matching release anywhere", which is true of the table names and false about the estate — v1_metis_publication_package carries release_id and release_history as columns (§9). And the first draft of §§3–4 of this document said V1 has no outbox and no inbox, on the same evidence, while V1 has both: an append-only operation log that commits a notification intent with the state change that produced it, a delivered-set the drain subtracts, and a reconcile that runs at boot. That error was made inside the document that exists to warn about it, which is the strongest argument for the rule that this page could have produced. It is corrected in place rather than rewritten away.

So every procedure below names its stores as store keys as well as tables, and scripts/operations/v1-replay-inventory.mjs enumerates both. A search that can only find one of the two shapes is the reason a state class goes unnoticed until a restore drops it.

Two more consequences of the snapshot store's design, both of which decide procedures further down:

  1. The document is written whole. A durable write serializes the entire collection, so the recovery unit is a store key and not a record: there is no partial restore of one job inside generation-jobs.

  2. The version column orders two readings of one store key. admin_store_snapshot.version is (existing ?? 0) + 1 on every save (durable-snapshot-store.ts), so it strictly increases per (store_key, scope_id) and a source-side inventory can be ordered against a restored-side one without trusting either host's clock.

    It is not the recovery-point marker §S10.11 found missing, and reading it as one would be a mistake. That finding was about which committed transactions are inside a dump — a question this counter cannot answer, because it says nothing about where the pg_dump boundary fell or how the relational capture is ordered against the Redis checkpoint. What it gives is narrower and still useful: a per-store-key sequence, so "this store key came back at an older revision than the source held" is a checkable sentence.

1. jobs#

Stores. admin_store_snapshot store key generation-jobs (apps/oshun/bff/src/generation/jobs-route.ts) and store key metis-ingest-jobs (apps/oshun/bff/src/metis/ingest-job-store.ts). Both rest on dep:primary-store; dep:job-queue on this estate is the in-process map in front of the first of them.

What accrues. Accepted work that has not reached a terminal state. Measured on 2026-08-14: 8 generation jobs (7 failed, 1 queued) and 55 Metis ingest jobs (32 completed, 23 awaiting_source_content) — 24 records in flight across the two stores.

The procedure. For generation-jobs it is already automatic and it runs at boot: wireDurableGenerationJobs loads the snapshot and converts every job still in running to needs_review with the error generation_interrupted_after_durable_claim, then persists that recovery before accepting traffic. This is correct and it is the reason the job path is re-drivable at all — a durable running claim is written before the provider is called (persistJobs() at the external-effect barrier), so a running job in a restored snapshot proves that dispatch may already have happened and must not be repeated.

For metis-ingest-jobs there is no boot recovery, and none is needed: MetisIngestJobStatus is completed | awaiting_source_content, and the second is a job waiting for a person to supply content rather than a machine claim in flight. A restore leaves it exactly where it was.

The gap, stated. The boot recovery reconciles the JOB and not the two durable ledgers the job wrote to before dispatch. activeQuotaGate.admit() and activeBudgetGate.reserve() both run before the provider call and both write to Redis (RedisGenerationBudgetStore, RedisGenerationQuotaStore); nothing in wireDurableGenerationJobs releases either. A job held for review after a restore therefore keeps its quota slot and its estimated spend. That is the conservative direction for money and the wrong direction for the actor, and no procedure compensates it.

Verification. The count of needs_review jobs carrying generation_interrupted_after_durable_claim after boot equals the count of running jobs in the source-side inventory, and the totals agree.

External visibility. True. A running job may have reached dep:provider-gateway, and the estate has no provider-side receipt to compare against — the outcome exists only as a field on our own job record, which the restore rebuilt. That is a declared gap, not an omission; see §10.

2. leases#

Store. live_media_pipeline_job_attempts (libs/shared/live-media/src/sql-media-pipeline-job-store.ts, migration in libs/aphrodite/database/prisma/migrations/00009_live_media_pipeline_jobs/).

This is the estate's only real fence, and it works. lease_token is carried in the UPDATE ... WHERE of every heartbeat, expiry and completion (AND lease_token = $4 AND status = 'leased'), so a worker holding a superseded lease updates zero rows. recoverExpiredLeases selects leased attempts whose lease_expires_at has passed using the database clock, moves the attempt to lease_expired under that same fenced predicate, and returns the job to retry_wait, failed or cancelled with exponential backoff, all under FOR UPDATE OF j, a SKIP LOCKED.

Why the window is the recovery point and not a clock. The reclaim set is "every attempt whose expiry has passed", and a restore can only move the clock forward relative to the captured expiries — so after a restore the set is every restored leased attempt, bounded by what came back rather than by an interval somebody chose. The procedure needs no window parameter for that reason.

The gap, stated, and it is the whole procedure. Nothing on this estate runs it. recoverExpiredLeases is called only by MediaPipelineWorker.runOnce (libs/shared/live-media/src/media-pipeline-worker.ts), constructed only by createLiveMediaPipelineRuntime, used only by apps/aphrodite/streaming — and apps/aphrodite/streaming appears in no compose file, no infrastructure manifest and no workflow in this repository. docker/docker-compose.yml declares no such service. Measured on 2026-08-14, the relation live_media_pipeline_job_attempts does not exist in any database on the box: the compose's POSTGRES_MULTIPLE_DATABASES list names aphrodite, and the running server has no aphrodite database. The fence is real, the reclaim loop is correct, and neither is deployed where the V1 recovery path can reach them.

The inventory producer reports this subject unmeasurable rather than zero, for the reason §0 gives: an absent store and an empty one are the same number and opposite facts.

3. outbox#

The first draft of this section said "there is no outbox in V1", on the strength of no table matching outbox in the 146 CREATE TABLE statements under libs/oshun/persistence/prisma/migrations/. That is §0's error committed in the document that exists to warn about it, and it is corrected here rather than quietly rewritten, because it is the strongest evidence for the rule.

V1 has an outbox and it is complete. The academic-records store (apps/oshun/bff/src/metis/academic-integrity-appeal-store.ts, snapshot store key metis-academic-records) keeps operations — an append-only log carrying a revision — and derives notificationIntents from it by replay. A verdict or appeal transition and the notification intent it produces are one durable operation, which is the property transactional publication exists to give: an intent cannot exist without the state change that made it, and a state change cannot commit without its intent.

The drain and its reconcile. reconcilePendingAcademicIntegrityNotifications() (apps/oshun/bff/src/metis/academic-integrity-notification-worker.ts) is called from apps/oshun/bff/src/server.ts at boot, deliberately positioned after both durable stores hydrate, and dispatches every pending intent. The idempotency key is the intent's stable messageId — the module says so itself: "a crash after message commit but before outbox acknowledgement is retried as a harmless duplicate on restart". That is the whole procedure, and it already runs.

Verification. The reconcile returns { attempted, pending }, and pending is recomputed AFTER the drain rather than assumed — so a run that dispatched nothing and a run that drained everything are distinguishable in its return value. Requiring pending === 0 is the check.

Externally visible: no, and the reason is specific rather than assumed. The dispatch calls sendMessageWithReceiptsDurably with channel: 'in-app', so the message lands in this estate's own customer message centre. A duplicate is visible to the recipient and not to anything outside; the repeat is bounded by the delivered set below.

Measured on 2026-08-14: the store exists and holds 0 operations at revision 0 — measured, not absent, which is the distinction §10's producer keeps.

4. inbox#

Also present, in the same store, and it is the acknowledgement half. deliveredNotificationIds is the set of intents already dispatched, and listPendingNotifications() is literally the intents minus that set. That is the inbox pattern: a consumer-side record of what has been processed, consulted before processing again, so a redelivery is dropped rather than repeated.

Why it is stronger than a marker, and where it is weaker. A last-processed sequence marker assumes the consumer processes in order and never skips; a delivered SET makes no such assumption and survives out-of-order dispatch. What it does not carry is study.domain_event_ack's attempts, last_error and dead_letter — so an intent that fails to dispatch every time is retried at every boot forever, with nothing counting the failures and nothing parking it. A poison intent wedges nothing and is never noticed, which is the failure mode to watch for after a restore that brings back a recipient the notifier cannot resolve.

The window is the delivered set, not the recovery point, and that is the honest description: the drain re-derives what is pending from what came back, so it is bounded by the restored state on both sides.

A trap worth naming, because it was nearly written into this document. admin_store_snapshot also carries a store key called operator-inbox-decisions. It is an operator decision log (decisionId, verdict, assignee, auditEventId), not an inbox-pattern dedup record, and claiming it as this subject's store would be an answer about the wrong subject in the right shape. The real one is not called an inbox at all.

The pattern elsewhere, for contrast. study.domain_event_outbox and study.domain_event_ack in another application (apps/yemaya/svc-study-workspace/migrations/0022_study_domain_event_outbox.sql) do this relationally: outbox_seq bigint GENERATED ALWAYS AS IDENTITY as the total order, event_id uuid UNIQUE as the consumer idempotency key, and an ack table with attempts and dead_letter per consumer. That is what V1's would look like if it needed multiple consumers or a dead-letter path; today it has one consumer and no parking.

5. events#

Store. workbench_event (apps/oshun/bff/src/workbench/intent-store.ts), seq BIGSERIAL primary key, append-only, with entity_kind/entity_id/actor constrained at the table.

This is the estate's one genuine replay, and it is verifiable by comparison rather than by reading code. Every write appends the event and applies the same reducer the replay path uses, in one transaction, so the projection is reduce(ledger) by construction. replayLedger recomputes the rows from scratch with no wall clock inside the reducer, and apps/oshun/bff/scripts/crosscheck-intent-plane.ts runs the comparison against the real database: it rebuilds from the ledger and asserts replay(ledger) equals the rows. That is the verification, and it catches the specific damage a partial restore does — a projection row lost while its events survive, which replay rebuilds, and a row deleted straight out of the projection, which replay also rebuilds and thereby exposes.

The window is the restored ledger itself, because the replay is total: it starts from the lowest sequence present. Measured on 2026-08-14 the ledger held 0 rows on this box, which is a measurement and not an absence — the relation exists.

The gap, stated. crosscheck-intent-plane.ts is not wired into any package.json script or workflow; it is run by hand with a database URL. It is named in §10's statement so that a restore procedure points at it.

6. provider-outcomes#

Store. admin_store_snapshot store key crypto-invoices (apps/oshun/bff/src/payments/invoice-store.ts).

Why this is the subject's store and there is no other. V1 has no generic provider-effect ledger — the reconciler's providerEffects collection has no table behind it anywhere in the schema. What it does have is the crypto invoice store, and that store is the estate's single most consequential record of an outcome produced outside it: CryptoInvoiceStatus is pending | confirmed | expired | cancelled, a confirmed invoice carries txId, blockHash and blockHeight, and the entitlement it grants is reconciled through a durable completion marker. Measured on 2026-08-14: 38 invoices — 20 expired, 10 pending, 8 confirmed, and all 8 confirmed rows carry a txId.

The procedure. Compare each restored invoice against the chain, keyed by invoiceId, and specifically:

  1. Every restored confirmed invoice must still carry txId, confirmedAtUnixSeconds and its signed receipt; the store's own validation already refuses a confirmed row with any confirmation field null, so a restored row that fails it is a corrupted restore rather than a lost one.
  2. Every restored pending invoice must be re-checked against the chain before it is expired. A payment that confirmed after the capture instant is invisible in the restored copy and visible on the chain forever; expiring it on our record alone takes money for nothing.
  3. An invoice present on the chain and absent from the restore is data loss and must block resumption, not be re-minted.

External authority. The chain transaction named by txId. This is the one subject in the estate whose external side genuinely outlives any restore of ours and can be queried independently, which is what makes its reconciliation meaningful rather than self-referential.

7. pending-uploads — ABSENT#

V1 has no durable upload session. libs/oshun/studio-authoring/src/asset-metadata/bulk-upload.ts plans resumable chunks and persists nothing; it has no route in the BFF. There is no upload table in the V1 schema and no store key holding upload sessions. An interrupted upload is therefore lost rather than left half-applied, and the actor repeats it.

Where the pattern exists. study.upload_session (apps/yemaya/svc-study-workspace/migrations/0016_study_upload_session.sql) in another application: declared_sha256 and received_bytes as the resumption ledger, with a table invariant that received can never exceed declared. Note for whoever builds V1's: that table has no expiry column, so a session interrupted by a restore stays open indefinitely. dep:blob-store holds the bytes either way.

8. gates#

Store. v1_tara_workbench_review_gate_result (libs/oshun/persistence/prisma/migrations/20260720120000_tara_content_workbench/), carrying gate_id, verdict, evidence, source_record_id, payload_hash, contract_schema and contract_version.

The procedure is a revalidation, and the schema is what makes it possible. A gate result is a function of a source record and a contract version, and the row records both: source_record_id says what was judged and payload_hash + contract_version say what it was judged as, under which rules. So after a restore the operator does not ask whether the gate rows came back — the rows are derived and can be recomputed — but whether recomputing them produces the same verdicts. A verdict that changes on recompute means the source record or the contract moved across the restore boundary, and that is the finding.

Recompute rather than trust, for one specific reason. The generation release gate is fail-closed: evaluateGenerationRelease (apps/oshun/bff/src/generation/release-gate.ts) blocks on governance_measurement_absent when the evidence is missing rather than releasing. A restored gate row that says pass while its evidence did not come back is the one input that turns a fail-closed gate into an open one, and only a recompute distinguishes it from a genuine pass.

Measured on 2026-08-14: 0 gate results on this box — measured, relation present.

9. releases#

Stores. v1_metis_publication_packagerelease_id TEXT NOT NULL and release_history JSONB NOT NULL, alongside distribution and governance — and admin_store_snapshot store key admin-editorial-release-streams.

This subject was previously recorded as absent from V1, and that was wrong. The search behind it was for a table whose NAME matches release, and V1's release state is carried in COLUMNS on the publication package and in a snapshot store key. It is the same error §0 describes and the third time this estate has produced it.

The procedure. Reconcile by release_id: every restored publication package must have its own release_id present in its release_history, and every release the history records as published must be compared against dep:external-publish-target. A publication is externally visible by definition — it is the act of putting something where other people can see it — so a restored package that believes it published something it did not, or does not know about something it did, is the failure this reconciliation exists to catch.

The gap, stated. There is no readback from an external publish target in this repository. libs/oshun/workbench-kit/src/target-verification.ts models one — a externalRef read back from the target after a write — and nothing calls it against a real target. So this procedure's external side is unavailable, and that is declared in §10 rather than papered over with a verification that reads our own rows.

Measured on 2026-08-14: 0 publication packages and 1 editorial release stream bucket on this box.

10. The first action, which did not exist#

scripts/operations/v1-restore-reconcile.mjs is named as the required next control by the restore drill and by every disaster-recovery runbook, and nothing in this repository produces its input. It takes --input FILE describing seven collections on a restored side, the same seven on an external side, and a per-collection expected count carrying its source and the instant it was recorded. A search for the input schema string oshun.v1-restore-reconciliation-input.v1 returns three occurrences: the validator that consumes it, that validator's unit test, and the kit scan that builds a synthetic one. The operator following the runbook reaches the required control and the step has no first action.

And it has no first action for a reason worth stating, because it decides what to build rather than who to blame. The reconciler's seven collections are publications, providerEffects, queues, approvals, invoices, artifacts and placements, matched on externalRef or idempotencyKey. The V1 schema has 146 tables and no column named external_ref or idempotency_key in any of themexternal_refs, plural and JSONB, exists on two tables and is a different thing. Four of the seven collections have no table at all, and artifact_detection_report, the nearest name match, is about visual artifacts in generated media rather than build artifacts: an answer about the wrong subject in the right shape. The input has no producer because it describes an estate this is not, and writing one would mean inventing the columns to fill it. What this estate has is the nine state classes above, and what they need is an inventory of themselves.

scripts/operations/v1-replay-inventory.mjs is that first action. It reads the nine state classes above out of the V1 database and writes them down:

bash
# On the source, BEFORE the loss — or from the most recent restore of a
# capture taken before it. This is the expectation.
OSHUN_V1_DATABASE_URL=postgresql://oshun:...@host:5432/oshun_dev \
  node scripts/operations/v1-replay-inventory.mjs --output source.json --side source

# On the recovery host, AFTER the restore.
OSHUN_V1_DATABASE_URL=postgresql://oshun:...@recovery:5432/oshun_dev \
  node scripts/operations/v1-replay-inventory.mjs --output restored.json --side restored

# The comparison. Exits 2 unless every subject was compared and nothing is missing.
node scripts/operations/v1-replay-inventory.mjs \
  --compare source.json restored.json --output reconciliation.json

What it refuses to do is the point:

  • It never reports a count for a store it could not read. A subject whose store is absent is unmeasurable with the reason. 0 and "there was nothing to look in" are the same number and opposite facts, and a reconciliation that cannot tell them apart is the one §S10.11 found passing over seven empty inventories.
  • It refuses a comparison whose two sides came from the same host, and one where both sides carry the same label. Two readings of one machine are two readings of one machine whatever they are called.
  • A clean verdict requires coverage. reconciled is false while any subject is unusable, so "nothing was lost" cannot be reported by an inventory that looked at nothing. On this box it is false today, because leases is unmeasurable — which is the honest answer.

What it does not do, said plainly. It does not produce v1-restore-reconcile.mjs's input, and it is not a drop-in for it — for the reason two paragraphs up. It reads OUR side. Two of the three externally-visible subjects have no external side to read: there is no provider receipt store for generation jobs, and no readback from dep:external-publish-target for releases. Those are declared as gaps in the statement below and are two of the findings this cell hands to §S10.12.g. The third, provider outcomes, has a real external authority in the chain, and reconciling against it is a procedure an operator performs with the chain explorer rather than a script this repository can run offline.

11. Machine-checkable statement#

The nine procedures in the shape replayFaults (libs/oshun/workbench-kit/src/game-day.ts) grades, plus the estate binding libs/oshun/workbench-kit/tools/replay-scan.mjs checks against the repository: every store named here must exist, every entry point must exist and have a caller, and every subject declared absent must still be absent.

json
{
  "id": "replay:v1",
  "version": "1",
  "producer": "scripts/operations/v1-replay-inventory.mjs",
  "procedures": [
    {
      "subject": "jobs",
      "kind": "revalidate",
      "idempotencyKey": "jobId",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "after boot, the count of needs_review jobs carrying generation_interrupted_after_durable_claim equals the count of running jobs in the source-side inventory",
      "externallyVisible": true,
      "externalAuthority": null
    },
    {
      "subject": "leases",
      "kind": "revalidate",
      "idempotencyKey": "attemptId",
      "windowStart": "recovery-point",
      "fencingToken": "lease_token",
      "verification": "recoverExpiredLeases returns the reclaimed attempts and a second call returns none",
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "outbox",
      "kind": "replay",
      "idempotencyKey": "messageId",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "reconcilePendingAcademicIntegrityNotifications returns pending 0, recomputed after the drain rather than assumed",
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "inbox",
      "kind": "revalidate",
      "idempotencyKey": "notificationId",
      "windowStart": "last-processed-marker",
      "fencingToken": null,
      "verification": "listPendingNotifications returns the intents minus deliveredNotificationIds, and is empty after the drain",
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "events",
      "kind": "recompute",
      "idempotencyKey": "workbench_event.seq",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "crosscheck-intent-plane.ts rebuilds the projection with replayLedger and reports it equal to the rows",
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "provider-outcomes",
      "kind": "reconcile-counts",
      "idempotencyKey": "invoiceId",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "every restored confirmed invoice carries its txId and receipt, and every restored pending invoice is re-checked against the chain before it is expired",
      "externallyVisible": true,
      "externalAuthority": "the chain transaction named by invoice.txId, queried by blockHash and blockHeight"
    },
    {
      "subject": "pending-uploads",
      "kind": "none",
      "idempotencyKey": null,
      "windowStart": "none",
      "fencingToken": null,
      "verification": null,
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "gates",
      "kind": "revalidate",
      "idempotencyKey": "payload_hash with contract_version",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "recomputing each gate over its restored source_record_id under its recorded contract_version reproduces the stored verdict",
      "externallyVisible": false,
      "externalAuthority": null
    },
    {
      "subject": "releases",
      "kind": "reconcile-counts",
      "idempotencyKey": "release_id",
      "windowStart": "recovery-point",
      "fencingToken": null,
      "verification": "every restored publication package carries its own release_id in its release_history",
      "externallyVisible": true,
      "externalAuthority": null
    }
  ],
  "binding": [
    {
      "subject": "jobs",
      "dependencyIds": [
        "dep:primary-store",
        "dep:job-queue",
        "dep:provider-gateway"
      ],
      "stores": [
        { "kind": "snapshot-key", "name": "generation-jobs" },
        { "kind": "snapshot-key", "name": "metis-ingest-jobs" }
      ],
      "entryPoints": [
        "apps/oshun/bff/src/generation/jobs-route.ts",
        "apps/oshun/bff/src/metis/ingest-job-store.ts"
      ],
      "absent": false
    },
    {
      "subject": "leases",
      "dependencyIds": ["dep:primary-store"],
      "stores": [
        {
          "kind": "table",
          "name": "live_media_pipeline_job_attempts",
          "migrations": "libs/aphrodite/database/prisma/migrations",
          "database": "aphrodite"
        }
      ],
      "entryPoints": [
        "libs/shared/live-media/src/sql-media-pipeline-job-store.ts",
        "libs/shared/live-media/src/media-pipeline-worker.ts"
      ],
      "absent": false
    },
    {
      "subject": "outbox",
      "dependencyIds": ["dep:primary-store"],
      "stores": [{ "kind": "snapshot-key", "name": "metis-academic-records" }],
      "entryPoints": [
        "apps/oshun/bff/src/metis/academic-integrity-appeal-store.ts",
        "apps/oshun/bff/src/metis/academic-integrity-notification-worker.ts"
      ],
      "absent": false
    },
    {
      "subject": "inbox",
      "dependencyIds": ["dep:primary-store"],
      "stores": [{ "kind": "snapshot-key", "name": "metis-academic-records" }],
      "entryPoints": [
        "apps/oshun/bff/src/metis/academic-integrity-appeal-store.ts"
      ],
      "absent": false
    },
    {
      "subject": "events",
      "dependencyIds": ["dep:primary-store"],
      "stores": [
        {
          "kind": "table",
          "name": "workbench_event",
          "migrations": "libs/oshun/persistence/prisma/migrations",
          "database": "oshun_dev"
        }
      ],
      "entryPoints": [
        "apps/oshun/bff/src/workbench/intent-store.ts",
        "apps/oshun/bff/scripts/crosscheck-intent-plane.ts"
      ],
      "absent": false
    },
    {
      "subject": "provider-outcomes",
      "dependencyIds": ["dep:primary-store", "dep:provider-gateway"],
      "stores": [{ "kind": "snapshot-key", "name": "crypto-invoices" }],
      "entryPoints": ["apps/oshun/bff/src/payments/invoice-store.ts"],
      "absent": false
    },
    {
      "subject": "pending-uploads",
      "dependencyIds": ["dep:blob-store"],
      "stores": [],
      "entryPoints": [],
      "absent": true,
      "absenceEvidence": {
        "noTableMatching": "upload",
        "patternElsewhere": "apps/yemaya/svc-study-workspace/migrations/0016_study_upload_session.sql"
      }
    },
    {
      "subject": "gates",
      "dependencyIds": ["dep:primary-store"],
      "stores": [
        {
          "kind": "table",
          "name": "v1_tara_workbench_review_gate_result",
          "migrations": "libs/oshun/persistence/prisma/migrations",
          "database": "oshun_dev"
        }
      ],
      "entryPoints": ["apps/oshun/bff/src/generation/release-gate.ts"],
      "absent": false
    },
    {
      "subject": "releases",
      "dependencyIds": ["dep:primary-store", "dep:external-publish-target"],
      "stores": [
        {
          "kind": "table",
          "name": "v1_metis_publication_package",
          "migrations": "libs/oshun/persistence/prisma/migrations",
          "database": "oshun_dev"
        },
        { "kind": "snapshot-key", "name": "admin-editorial-release-streams" }
      ],
      "entryPoints": ["libs/oshun/workbench-kit/src/target-verification.ts"],
      "absent": false
    }
  ]
}