Skip to content

Transactions

From the graph data model: the transaction every mutation of the graph passes through, and the two pointers on every graph row that record what it changed.

Status Proposed, in review with Nacho. Settled 2026-09-07: the two-pointer shape over a transaction log as source of truth ("for now"); a transaction stores no effect and no inputs, kind is a label for people; there is no undo primitive, a correction is a new transaction. seq added 2026-09-08. Pending: the D12 and R3 rewording at ratification
Created 2026-09-04
Updated 2026-09-08
Context domain.md: invariants 1, 6, 7. D1 (merging is linking, unmerging is superseding), D12 (action log and undo scope), D7 (sequential applier). PRD R2, R3, O3. Scenarios under review in PR #5: wrong merge undone, concurrent unknown person, bootstrap fold. Issue #21. Precedent: Datomic's reified transactions

Summary

Every change to the graph is one transaction: who did it, when, and for a person, why. The transaction is a row in transactions and holds nothing else. The word is Datomic's, not Postgres's: a graph transaction is a persisted row that runs inside a database transaction, and a bootstrap batch may commit several of them in one. Renamed from actions on 2026-09-08 (Nacho) because the word carries what the table means: everything stamped with one id became true together. Every graph row carries two pointers: created_by_tx_id, the transaction that made it true, and superseded_by_tx_id, the transaction that made it false, null while the row is live. That is the whole history model. What a transaction did is the rows that point at it. Why the system did it is the stage run or job the transaction names as its actor, whose stored output is the decision. The live graph is the rows whose second pointer is null; the graph as of transaction T is the rows whose creating transaction has seq at or below T's and whose superseding transaction, if any, has seq above it. Nothing is updated or deleted: to change your mind you fill the second pointer on the old row and insert a new row, both under the same transaction. A correction is that and nothing more: a wrongly linked partial is relinked by a new transaction, and the pointers record the mistake and the fix. There is no undo.

erDiagram
  transactions ||--o{ entity_links : "created_by_tx_id"
  transactions |o--o{ entity_links : "superseded_by_tx_id"
  transactions ||--o{ entities : "created_by / status_tx_id"

  transactions {
    uuid id PK "caller-derived"
    bigint seq "identity; the order, per org"
    text kind "unchecked label for people"
    text actor_kind "system | human"
    uuid actor_id "no FK: stage run or job id, or Platform user id"
    text note "nullable; a person's reason"
  }
  entity_links {
    uuid id PK
    uuid created_by_tx_id FK "made it true"
    uuid superseded_by_tx_id FK "nullable; made it false"
  }
  entities {
    uuid id PK
    text status "canonical | unresolved"
    uuid created_by_tx_id FK
    uuid status_tx_id FK "last set status"
  }

organization_id and created_at come from OrgScopedBase and are omitted. Only entity_links is drawn; partial_entities and claims carry the same two pointers. entities is the exception explained below.

The two pointers

Column On Meaning
created_by_tx_id entities, partial_entities, claims, entity_links Required. Composite foreign key to transactions. The transaction that inserted the row
superseded_by_tx_id partial_entities, claims, entity_links Nullable. The transaction that retired the row. Filled once, from null, and never changed again; the database rejects any other update (below)
status_tx_id entities Required. The transaction that last wrote status. Equal to created_by_tx_id until the first promotion

Everything else one might store as a column or a table is a read on these:

Wanted Read
Is the row live superseded_by_tx_id IS NULL
When was it retired the superseding transaction's created_at; its place in the order is that transaction's seq
What replaced it the row the same transaction inserted for the same partial (and entity, for links)
What did transaction A do the rows with A in either pointer; the view tx_changes(action_id, table_name, row_id, change) unions the four tables and is zero schema
What kind of operation was A the shape of those rows (below)
Why did A do it actor_kind = 'system': the run or job actor_id names, whose stored output is the decision; actor_kind = 'human': note
An entity's status history the transactions that have stamped status_tx_id, recoverable because each names the entity in its own rows
Which links did a bad matching run create created_by_tx_id IN (transactions WHERE actor_id = run); the fix is one new transaction retiring them

The rows name the operation. One member link created and nothing retired is a merge. One evidence link created is evidence attached. An unresolved entity and its member link created together is a no-match that minted an entity. An evidence link retired, a member link created, and the sibling evidence links retired is a promotion. Claims retired and created on the same partial is a reconciliation of an event revision. A member link retired with nothing created is an unmerge; retired with a member link to another entity created is a relink or a fold. status_tx_id stamped is a promotion or demotion of the entity. Where two intents produce the same shape (a wrong merge and a partial a judge found unrelated are both one member link retired), actor_kind and note tell them apart. kind restates this shape as a word so a person reading the table does not have to derive it; no query depends on it.

Entities are never retired. An entity has no second pointer. It stops mattering when it has no live member link (invariant 3): after an unmerge, or as the loser of a bootstrap fold, it stays as a row with zero live links and is invisible to search and the entity view. Its one mutable column, status, is written in place by the transaction that promotes or demotes it, which stamps status_tx_id.

The database holds the line. A BEFORE DELETE trigger on the four graph tables rejects every delete. A BEFORE UPDATE trigger rejects every update except: filling a null superseded_by_tx_id; writing entities.status together with status_tx_id; and the rebuild-only normalized_value and normalizer_version on claims (claims). So invariant 1 is a constraint, and the model is honestly "append-only for decisions, mutable for two derived caches".

Indexes on every graph table: (organization_id, created_by_tx_id) and a partial (organization_id, superseded_by_tx_id) WHERE superseded_by_tx_id IS NOT NULL. The live-row partial indexes on links and claims take superseded_by_tx_id IS NULL as their predicate. Views live_entity_links, live_partial_entities, and live_claims carry the predicate for hand queries, so a raw read looks like a plain current-state table.

transactions

One transaction per domain operation: applying one resolution item, one review verdict, one bootstrap directive, one reconciliation of an event revision, one partial stored with its claims. An operation usually writes several rows, all stamped with the one transaction and applied in one savepoint by the functions layer. One transaction per row write was rejected because a half-applied promotion leaves the graph inconsistent and the explanation of any row should land on the decision, not a fragment of it; one per stage run because the applier consumes items one at a time (D7).

Column Meaning
id Caller-derived. System transactions: uuid5 over (organization_id, stage run id, item ordinal) or the job's equivalent stable key; human transactions: over the API request id. A replay after a crash finds its own transaction already recorded and stops, which is what makes the applier idempotent
seq bigint GENERATED ALWAYS AS IDENTITY, unique. The order of transactions. Timestamps are not adequate for ordering (Nacho, 2026-09-08): two transactions can share a millisecond and clocks drift between workers. A sequence value is assigned at insert, not at commit, so it is not a global commit order; but every write transaction in an organization runs under the per-org advisory lock (invariant 7), so within an organization insert order is commit order and seq is a total order. Gaps are normal. Cross-organization order is never asked for
kind A label written by the command class that performed the transaction (link, promote, reconcile, import, review, whatever the functions layer names them). Text, no check constraint, nullable. For people reading the table and for the O3 counters; the rows are the authority on what happened, and nothing switches on this column
actor_kind system or human, checked. Both write the same log (D12)
actor_id For the system, the stage run or job that decided: the matching run for a resolution, the extraction run for a reconciliation, the bootstrap job for an import. For a person, the Platform user id. Reference only, no foreign key
note A person's reason, free text, nullable. The verdict on a review, the reason for a relink. The one piece of information in this table that exists nowhere else

Constraints: UNIQUE (organization_id, id); UNIQUE (seq); a check on actor_kind. Index (organization_id, seq), the order an audit read walks.

No effect, no inputs. An inputs JSONB carrying the request verbatim was considered and dropped on 2026-09-07 (Nacho): the effect part ("link pe-1 to E1") is what the rows say, with foreign keys instead of JSON; the justification part (matching's candidates and scores, the extraction output, the bootstrap directive) is the stored output of the run or job the transaction names, stage_runs.output and jobs.payload in the repo today. Copying it into every transaction stored it twice. The applier's re-validation disagreement in the concurrent-person scenario is visible without storage: the run's item says unresolved, the rows say evidence.

Retention rule this creates. stage_runs.output and jobs.payload are part of the audit record and are kept as long as the graph rows that name them. They are append-only today; the rule that they are never pruned belongs to the ingestion tables and the pipeline docs, and is flagged there at ratification.

Corrections, not undo

D12 and PRD R3 ask for one-step undo of resolution actions. Nacho withdrew undo as a primitive on 2026-09-07: the point of partial entities is that a wrongly placed one is simply placed again. Every correction anyone would want is a forward transaction that retires and creates rows like any other:

Mistake Correction, one new transaction
Wrong merge retire the member link and insert an excluded link to the same entity (entities and links), so matching cannot redo it; optionally create a member link to the right entity
Wrong promotion of evidence to member retire the member link; re-create the evidence link if it still holds
Wrong promotion of an entity to canonical write status back, stamping status_tx_id
A bad matching run linked many partials retire every link created under that run's transactions, found through created_by_tx_id
Transitive damage: entities promoted or partials linked because of a wrong partial's claims the claims that matched, read from the matching run's stored output, then relinks; undo never covered this either

What a generic undo would have added is knowing the prior state without looking. The history is one query away, so the person looks. The acceptance criterion of R3, "restores prior query results, appends a transaction, deletes nothing", is exactly what a relink does, so the requirement holds and only its wording changes at ratification: corrections are new transactions, the log records both the mistake and the fix. O3's undo rate becomes a correction rate: transactions by a person that retire system-created links, one query on the two pointers.

Removed with it: undo_of_tx_id, the undo legality rules, and the split of transactions into re-runnable and undoable kinds that those rules needed. A row is still live once (a second pointer is filled, never cleared), but nobody has to think about it: no code path wants to make a retired row live again.

Code and data

The command pattern splits in two, and only one half is stored (D12: "command-pattern style"). In code, one command class per operation under indra/graph/ performs the operation from the request it was given, inside one transaction and one savepoint. In the database, the transactions row holds who and when, and the graph rows the command touched carry its id in one of the two pointers. The request models live with the command classes, not in this schema.

What the scenarios get from this

  • Wrong merge undone. The scenario's unmerge is one transaction by a person that fills one link's second pointer and inserts an excluded link, with the reason in note, in one savepoint. The search projection needs no write: search reads live member links (claims, Search), so the alias leaves the index the moment the pointer is filled. The state of the entity is explained by tx_changes filtered on the entity's links, which is R2. The scenario's title says "undone"; the mechanism is a correction, and the scenario is reworded at ratification.
  • Concurrent unknown person. Every transaction runs under the per-org advisory lock, so the applier can re-run search() inside the transaction and trust the answer. The store makes re-validation possible; the rule that a resolution item is a proposal to re-check belongs in B3, as the scenario says.
  • Bootstrap fold. One system transaction, actor the import job, that retires the old member link and creates the new one. The policy of when to fold belongs in B5. The importer's rule "do not recreate a link a person removed" is one join: a retired link whose superseding transaction has actor_kind = 'human'.

Alternatives considered

  • Five supersession columns (status, superseded_at, superseded_by_id, superseded_reason, superseded_by_tx_id) plus an entity_status_changes table and a closed reason vocabulary. Removed on 2026-09-07. Each was a read on the two pointers restated as a column, and together they were the model Nacho could not explain to the other engineers. Any of them can return as a column if its read proves hot.
  • A closed kind set with a check constraint and one documented payload per kind. Dropped 2026-09-07: the rows already name the operation, and describing every kind in the schema doc duplicated the functions layer's list. kind stays as an unchecked label.
  • inputs JSONB, the request verbatim. Dropped 2026-09-07, above. Would return only if the pipeline stops retaining run outputs, which the retention rule forbids.
  • An undo primitive (undo_of_tx_id, legality rules, inverses by new rows), and the observation/decision split of transactions it required (partials and claims pointing at stage_runs, entities and links at transactions). Dropped 2026-09-07, above. The split had a merit of its own, that storing a partial is not a decision, and can be revisited in the functions layer without touching this schema: a reconciliation is still one transaction whose actor is the run.
  • A separate action_effects table listing what each transaction changed. Rejected: it restated what the rows already say.
  • The transaction log as source of truth, graph tables as mutable projections (event sourcing, with an tx_changes log holding every row body as JSON and DELETE on unlink). Weighed on 2026-09-07 with one advocate brief per side, held outside the repo until this document is ratified. Its real advantage: the tables read as plain current state, with no live predicate to teach. Not chosen for now because it stores every change twice (JSON in the log, rows in the tables) with nothing in the database keeping them equal; it needs a replay engine, a rebuild command, and upcasters for the JSON bodies (Overeem et al., 2021 list event evolution and rebuilding projections among the top practitioner challenges); state at time T becomes a fold instead of a filter; and it supersedes D1 and restates invariant 1, which is a team decision. The live_* views give this model most of that readability at no cost.
  • Row-level audit by trigger, or system-versioned history tables. Capture every change generically and know nothing about why. Fine for a compliance log beside this design, wrong as the design.

At ratification

  1. Reword D12 and PRD R3: corrections are new transactions, the log records both; acceptance criterion unchanged. O3: undo rate becomes correction rate.
  2. Close #10 with a pointer here, since there is no status column left to generate.
  3. Decide whether the two advocate briefs are filed under docs/research/ as the record of the comparison, or stay outside the repo.
  4. Write the retention rule for stage_runs.output and jobs.payload in the ingestion tables or the pipeline's design doc.
  5. Retitle the "wrong merge undone" scenario to match the mechanism.
  6. Carry the word: the superseding entry for D12 and the PRD's R2, R3, and O3 say transaction where they say action.