Skip to content

fix(store)!: fenced composition attachment, one sequenced write plan, complete owned units - #667

Merged
pdlug merged 11 commits into
integration/ontology-semanticsfrom
feat/review2-rw-store
Sep 10, 2026
Merged

pdlug merged 11 commits into
integration/ontology-semanticsfrom
feat/review2-rw-store

Conversation

@pdlug

@pdlug pdlug commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Second whole-branch review of #637, the store and query findings: getOrCreate({ partOf }) could silently reparent during a race, an attachment could commit even though getOrCreate rejected, scoped tx.measure receipts omitted cascaded parts, and a complete owned-unit export still truncated silently at 1,000 hops.

One fenced owner for an attachment against an existing row

applyCompositionAttachmentUnderFence re-reads the incumbent under the per-graph write lock, asks decideCompositionIncumbent what that incumbent means, and writes at most one retire and one attach. The only dimension callers differ on is the explicit disposition the write plan carries: nodes.<Kind>.reparent(...) states onIncumbent: "replace", every get-or-create path states "refuse". Before this, the get-or-create partOf postcondition decided from a lock-free read and then called the reparent machinery, whose fenced re-read always replaced, so two callers that each asked for a different whole could both observe "no whole yet" and the second one silently moved the part instead of refusing. The lock-free read survives only as a pre-check that lets an already-satisfied call stay read-only, stated props included (incumbentSatisfiesRequestedAttachment, built on the same props verdict the refusal reads); every verdict and every write comes from the fenced read. resolveCompositionAttachmentRequest is the one resolver every attachment surface reaches. A new two-connection test in the concurrency suite proves the race outcome (one attachment, one refusal) on real overlapping writers rather than an in-process interleaving.

The get-or-create update leg is one write plan, sequenced reads then writes

The existing-row ifExists: "update" and resurrection legs run the attachment inside the same transaction as the property update (executeNodeUpsertUpdate's compositionAttachment). Because that leg runs on a caller's enclosing store.transaction(...) when there is one, with no nested frame to roll back, the frame is ordered so every read the attachment owes precedes the update's first statement: the fenced incumbent decision and the whole's liveness (decideCompositionAttachmentUnderFence, which now carries lock: GraphWriteLock as compile-time evidence like assertCompositionExistencePreserved), then the realizing edge's own preparation (prepareCompositionAttachmentDecision: the part's liveness on the update leg, cardinality, acyclicity, the required-existence rule). attachCompositionCreateEdge is split into that read half and insertPreparedCompositionEdge, and the leg issues only the insert after the update. A refusal from any read leaves nothing written, and a refused property update (a unique conflict, a validation error) never reaches the insert, so ownership and history stay untouched in every case. The resurrection leg skips only the part endpoint read, since its row is a tombstone until the update restores it; the whole is still refused at decide time. assertEndpointRowLive is the one row-liveness predicate both assertLiveEdgeEndpoints and the decide half call. Load-bearing tests cover a conflicting incumbent, a dead whole, and a whole-side cardinality refusal, each caught inside an enclosing transaction with the property left unapplied; each fails with the corresponding read moved back below the update.

Cascaded parts reach every measured receipt

A whole deleted through a tx.measure(...) scope counted as one write in that scope's sub-receipt but contributed nothing to its cascadedParts: cascade refs travel through the node operation context, and a scope was built by wrapping the outer context's collections a second time. A transaction's write surface (collections, identity facade, and the node operation context they bind to) is now built by one parameterized builder, and a measure scope rebuilds it against the whole recorder chain; recordCascadedParts fans out along that chain, so a measured delete populates the scope's receipt and every receipt enclosing it. The scope also overlays the runtime's internal delete port with one built from its own surface, so a delete issued through that port inside a scope is attributed to the scope. Attribution stays structural, by the context the write ran through, never by timing.

The complete owned unit, with no hop ceiling

subgraph({ composition: true }) walked its part closure with a hop-bounded recursive CTE capped at 1,000 levels, so a deeper part chain came back truncated, which is not an export unit. The closure is now buildExhaustiveDirectedReachableCte: UNION over a frontier of exactly (id, kind), the same device that makes the composition-union acyclicity probe exhaustive, so it reaches a fixpoint on any finite graph with no depth ceiling, no path column, and no cycle policy. maxDepth and cyclePolicy continue to bound the edges traversal a caller lists alongside the closure. An engine that cuts the closure statement short (a statement_timeout, a cancelled query) is refused with ConfigurationError (COMPOSITION_UNIT_INDETERMINATE) through the same isStatementCutShortError predicate the probe uses, instead of surfacing an opaque driver error. The hop-bounded directed builder had no other caller and is removed. The ontology and limitations pages state completeness as delivered.

Both surfaces are new in this release, so the incumbent disposition and the exhaustive closure are stated in their changesets as delivered behavior rather than as breaking changes.

…rite plan

An attachment against a row that already exists is now one owner:
`applyCompositionAttachmentUnderFence` re-reads the incumbent under the
per-graph write lock, asks `decideCompositionIncumbent` what that incumbent
means, and writes at most one retire and one attach. The only dimension
callers differ on is the explicit disposition the write plan carries —
`nodes.<Kind>.reparent(...)` states `onIncumbent: "replace"`, every
get-or-create path states `"refuse"`.

Before this, the get-or-create `partOf` postcondition decided from a LOCK-FREE
read and then called the reparent machinery, whose fenced re-read always
replaced. Two callers that each asked for a different whole could therefore
both observe "no whole yet" and the second one silently MOVED the part instead
of refusing. The lock-free read survives only as a cheap pre-check that lets an
already-satisfied call stay read-only; every verdict and every write now comes
from the fenced read.

The get-or-create existing-row leg also runs ONE write plan: the attachment is
resolved read-free, then applied inside the same transaction as the property
update (`executeNodeUpsertUpdate`'s `compositionAttachment`). A refused update —
a unique conflict, a validation error — leaves ownership and history untouched,
where it previously committed the attachment in a separate transaction first.
The resurrection leg joins the same path, so a resurrection that asks for the
whole it already holds is idempotent rather than a lost-claim error.
A whole deleted through a `tx.measure(...)` scope counted as one write in that
scope's sub-receipt but contributed nothing to its `cascadedParts`: the cascade
refs travel through the node OPERATION CONTEXT, and a scope was built by
wrapping the outer context's collections a second time — which catches write
intents at the collection surface and nothing that happens inside an operation.

A transaction's write surface (collections, identity facade, and the node
operation context they are bound to) is now built by one parameterized builder,
and a measure scope rebuilds it against the whole recorder CHAIN rather than
re-wrapping the outer collections. `recordCascadedParts` fans out along that
chain, so a measured delete populates the scope's receipt and every receipt
enclosing it. Attribution stays structural — by the context the write ran
through, never by timing — so a delete issued through the outer transaction
while a scope is open still stays out of that scope, and concurrent or
overlapping measures remain safe by construction.
`subgraph({ composition: true })` walked its part closure with a hop-bounded
recursive CTE capped at MAX_EXPLICIT_RECURSIVE_DEPTH, so a part chain longer
than 1000 levels came back TRUNCATED — a whole plus a prefix of its parts,
which is not an export unit: reloading it drops the tail of every deep subtree,
and a required part cut off from its whole cannot be recreated at all.

The closure is now `buildExhaustiveDirectedReachableCte`: `UNION` (not
`UNION ALL`) over a frontier of exactly `(id, kind)`, which is the device that
already makes the composition-union acyclicity probe exhaustive. Set semantics
on the frontier's whole column list reaches a fixpoint on any finite graph, so
there is no depth ceiling to exceed, no path column to carry, and no cycle
policy to choose — a cyclic relation written outside the write fence terminates
too, because a revisited node adds no new row. `maxDepth` and `cyclePolicy`
continue to bound the `edges` traversal a caller lists alongside the closure.

The hop-bounded directed builder this replaces had no other caller and is gone;
the `WITH RECURSIVE` emission inventory is updated in both directions. The
ontology and limitations pages now state completeness as delivered, with no
truncation caveat to read past.
`executeNodeUpsertUpdate` applied the get-or-create leg's property update and
only then asked what the stated `partOf` meant, so a refused attachment left
the update applied whenever the caller caught the refusal inside an enclosing
`store.transaction(...)` — that leg runs ON the caller's transaction, with no
nested frame to roll back. The base branch refused first (the postcondition ran
in its own transaction ahead of the update), so this was a regression introduced
by folding the two halves into one write plan.

The fenced attachment is now two owners: `decideCompositionAttachmentUnderFence`
re-reads the incumbent on the frame's target and returns the DECISION itself,
and `applyCompositionAttachmentDecision` writes it. The update leg decides,
updates, then applies, so every refusal the decision owns — a different
incumbent whole, the same whole through a different realizing edge, a `props`
value the live edge disagrees with — precedes the update's first statement. The
refusals the attach write raises (a dead or missing whole, cardinality,
acyclicity, a lost claim) still follow it and still abort the frame's own
transaction: on the resurrection leg the part is a tombstone until the update
restores it, so an endpoint read taken earlier would refuse every resurrection.

The postcondition's lock-free pre-check now skips on the SAME conjunction the
fenced satisfied arm reads, stated `props` included
(`incumbentSatisfiesRequestedAttachment`, built on the props verdict the
refusal also reads). It previously escalated to the per-graph write fence
whenever `partOf.props` was stated at all, even when the live edge already held
exactly those props — so an idempotent ingest that restates them took
`BEGIN IMMEDIATE` / a session advisory lock on every call to be told it had
nothing to write. Only the skip is ever taken lock-free; any disagreement still
escalates to the fence, which owns the refusal.

`executeNodeReparent` keeps the combined decide-then-apply call, and the write
plan both attachment-only surfaces share is extracted so the get-or-create
postcondition passes the request it already holds instead of handing back a raw
attachment for `executeNodeReparent` to resolve a second time.
`resolveCompositionAttachmentRequest` is now the one resolver every attachment
surface reaches, with `onIncumbent` the only dimension they differ on.
…tions

The in-process simulation forces its interleaving by running the competing
write inside the loser's own read, on one connection, and says so: it cannot
make two writers overlap. "Two get-or-create calls naming different wholes end
with one attachment and one refusal" is a check-then-act decided by an
application read, which is the class this suite exists for, so it gets the same
two-pool, outcome-only treatment as the other four constraints here.

`typegraph_edge_claims` joins the per-test TRUNCATE: the composition case and
the existing cardinality-`one` case both reserve a claim row, and a row left
behind would refuse the same write on the next run against this suite's
persistent database.
…t unit

Two holes in the completeness claims the lane's own commits made.

`tx.measure(...)` rebuilt the write surface against the recorder chain but left
`TRANSACTION_RUNTIME`'s delete port pointing at the OUTER transaction's node
operation context, so a delete issued through that port inside a scope counted
as a write in the scope while its composition `cascadedParts` reached only the
enclosing receipts. `TransactionWriteSurface.nodeOperationContext` exists to
prevent exactly that and was never read. The scope now overlays a port built
from its own surface, non-enumerable like the outer one.

`subgraph({ composition: true })` promised "delivered or refused, never
partial" by citing the acyclicity probe, but unlike that probe it had no
refusal for an engine that cut the statement short: a `statement_timeout` or a
cancelled query surfaced as an opaque driver error. The composition closure's
fetch now classifies that case through the same `isStatementCutShortError`
predicate the probe uses, refusing with `ConfigurationError`
(`COMPOSITION_UNIT_INDETERMINATE`) naming the root. The caller's own `edges`
traversal is left alone: it is depth- and cycle-bounded and promises nothing a
cut-short statement would break.
…avels with

decideCompositionAttachmentUnderFence now reads the WHOLE endpoint's
liveness itself, under the caller's held write lock, when its verdict
is going to attach a new edge ("attach" or "replace"). Previously
that read only happened inside attachCompositionCreateEdge's ordinary
edge-create pipeline, which on the get-or-create ifExists: "update"
leg runs AFTER the property update — so a caller catching the refusal
inside an enclosing store.transaction(...) (no nested frame to roll
back) ended up with the property update committed and the part left
unattached, exactly the atomicity gap the decide/apply split was
supposed to close.

The PART endpoint stays owned by assertLiveEdgeEndpoints at write
time, since the resurrection leg's part row is still a tombstone
until the update restores it. The liveness predicate itself
(assertEndpointRowLive) moves to composition-create.ts, the module
edge-operations.ts already depends on for this exact class of
decision, so both call sites share one spelling instead of a second
copy drifting.

decideCompositionAttachmentUnderFence also gains a lock: GraphWriteLock
parameter, compile-time evidence (the same device
assertCompositionExistencePreserved uses) that its reads — the
incumbent re-read and now the whole's liveness read — cannot precede
the per-graph write lock.

Corrects the option docblock and changeset to state plainly which
three refusals (a lost composition claim, cardinality, acyclicity)
still follow the update and therefore still rely on this frame's own
transaction aborting, rather than claiming every refusal is now safe
under a nested store.transaction(...).

Adds a load-bearing test: an unattached optional part, a dead whole,
ifExists: "update", caught inside store.transaction(...) — the
property update stays unapplied and the part stays unattached.
Reverting the new liveness read (mutation check in the test) makes it
fail with the mutated property committed, confirming the test guards
the fix.
…sibility claim

The #attachMeasure comment justified rebinding the internal delete
port by claiming a scope that inherited the outer port would still
report the delete's write via the collection wrappers. It would not:
transactionDeleteNodeWithPolicy calls executeNodeDelete against a
node operation context directly and never touches surface.nodes, so
this delete never increments any receipt's writes counters, rebound
or not. The only thing rebinding decides is where cascadedParts is
attributed. Corrected the comment to state that plainly, including
the resulting receipt shape: cascadedParts populated with writes left
at zero for that same delete.
… it travels with

The get-or-create update leg decided the incumbent and the whole's liveness before the property update, but the realizing edge's own reads (part liveness, cardinality, acyclicity, required existence) still ran inside the insert after it. A caller catching one of those refusals inside an enclosing transaction kept the update with the part unattached. The attach is now split into a read half and the insert; the leg prepares before the update and issues only the insert after it. Load-bearing test: a whole-side cardinality refusal caught inside a transaction leaves the property unapplied (fails with the prepare moved below the update).
@pdlug
pdlug merged commit c7f3637 into integration/ontology-semantics Sep 10, 2026
21 checks passed
@pdlug
pdlug deleted the feat/review2-rw-store branch September 10, 2026 09:54
pdlug added a commit that referenced this pull request Sep 23, 2026
… complete owned units (#667)

Second whole-branch review of #637, the store and query findings:
`getOrCreate({ partOf })` could silently reparent during a race, an
attachment could commit even though `getOrCreate` rejected, scoped
`tx.measure` receipts omitted cascaded parts, and a complete owned-unit
export still truncated silently at 1,000 hops.

`applyCompositionAttachmentUnderFence` re-reads the incumbent under the
per-graph write lock, asks `decideCompositionIncumbent` what that
incumbent means, and writes at most one retire and one attach. The only
dimension callers differ on is the explicit disposition the write plan
carries: `nodes.<Kind>.reparent(...)` states `onIncumbent: "replace"`,
every get-or-create path states `"refuse"`. Before this, the
get-or-create `partOf` postcondition decided from a lock-free read and
then called the reparent machinery, whose fenced re-read always
replaced, so two callers that each asked for a different whole could
both observe "no whole yet" and the second one silently moved the part
instead of refusing. The lock-free read survives only as a pre-check
that lets an already-satisfied call stay read-only, stated `props`
included (`incumbentSatisfiesRequestedAttachment`, built on the same
props verdict the refusal reads); every verdict and every write comes
from the fenced read. `resolveCompositionAttachmentRequest` is the one
resolver every attachment surface reaches. A new two-connection test in
the concurrency suite proves the race outcome (one attachment, one
refusal) on real overlapping writers rather than an in-process
interleaving.

writes

The existing-row `ifExists: "update"` and resurrection legs run the
attachment inside the same transaction as the property update
(`executeNodeUpsertUpdate`'s `compositionAttachment`). Because that leg
runs on a caller's enclosing `store.transaction(...)` when there is one,
with no nested frame to roll back, the frame is ordered so every read
the attachment owes precedes the update's first statement: the fenced
incumbent decision and the whole's liveness
(`decideCompositionAttachmentUnderFence`, which now carries `lock:
GraphWriteLock` as compile-time evidence like
`assertCompositionExistencePreserved`), then the realizing edge's own
preparation (`prepareCompositionAttachmentDecision`: the part's liveness
on the update leg, cardinality, acyclicity, the required-existence
rule). `attachCompositionCreateEdge` is split into that read half and
`insertPreparedCompositionEdge`, and the leg issues only the insert
after the update. A refusal from any read leaves nothing written, and a
refused property update (a unique conflict, a validation error) never
reaches the insert, so ownership and history stay untouched in every
case. The resurrection leg skips only the part endpoint read, since its
row is a tombstone until the update restores it; the whole is still
refused at decide time. `assertEndpointRowLive` is the one row-liveness
predicate both `assertLiveEdgeEndpoints` and the decide half call.
Load-bearing tests cover a conflicting incumbent, a dead whole, and a
whole-side cardinality refusal, each caught inside an enclosing
transaction with the property left unapplied; each fails with the
corresponding read moved back below the update.

A whole deleted through a `tx.measure(...)` scope counted as one write
in that scope's sub-receipt but contributed nothing to its
`cascadedParts`: cascade refs travel through the node operation context,
and a scope was built by wrapping the outer context's collections a
second time. A transaction's write surface (collections, identity
facade, and the node operation context they bind to) is now built by one
parameterized builder, and a measure scope rebuilds it against the whole
recorder chain; `recordCascadedParts` fans out along that chain, so a
measured delete populates the scope's receipt and every receipt
enclosing it. The scope also overlays the runtime's internal delete port
with one built from its own surface, so a delete issued through that
port inside a scope is attributed to the scope. Attribution stays
structural, by the context the write ran through, never by timing.

`subgraph({ composition: true })` walked its part closure with a
hop-bounded recursive CTE capped at 1,000 levels, so a deeper part chain
came back truncated, which is not an export unit. The closure is now
`buildExhaustiveDirectedReachableCte`: `UNION` over a frontier of
exactly `(id, kind)`, the same device that makes the composition-union
acyclicity probe exhaustive, so it reaches a fixpoint on any finite
graph with no depth ceiling, no path column, and no cycle policy.
`maxDepth` and `cyclePolicy` continue to bound the `edges` traversal a
caller lists alongside the closure. An engine that cuts the closure
statement short (a `statement_timeout`, a cancelled query) is refused
with `ConfigurationError` (`COMPOSITION_UNIT_INDETERMINATE`) through the
same `isStatementCutShortError` predicate the probe uses, instead of
surfacing an opaque driver error. The hop-bounded directed builder had
no other caller and is removed. The ontology and limitations pages state
completeness as delivered.

Both surfaces are new in this release, so the incumbent disposition and
the exhaustive closure are stated in their changesets as delivered
behavior rather than as breaking changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant