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
excludedlink, with the reason innote, 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 bytx_changesfiltered 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 anentity_status_changestable 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
kindset 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.kindstays as an unchecked label. inputsJSONB, 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 atstage_runs, entities and links attransactions). 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_effectstable 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_changeslog holding every row body as JSON andDELETEon 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. Thelive_*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¶
- Reword D12 and PRD R3: corrections are new transactions, the log records both; acceptance criterion unchanged. O3: undo rate becomes correction rate.
- Close #10 with a pointer here,
since there is no
statuscolumn left to generate. - Decide whether the two advocate briefs are filed under
docs/research/as the record of the comparison, or stay outside the repo. - Write the retention rule for
stage_runs.outputandjobs.payloadin the ingestion tables or the pipeline's design doc. - Retitle the "wrong merge undone" scenario to match the mechanism.
- Carry the word: the superseding entry for D12 and the PRD's R2, R3, and O3 say transaction where they say action.