Skip to content

Ontology and semantics program (integration) - #637

Open
pdlug wants to merge 301 commits into
mainfrom
integration/ontology-semantics
Open

pdlug wants to merge 301 commits into
mainfrom
integration/ontology-semantics

Conversation

@pdlug

@pdlug pdlug commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

The ontology and semantics release: ontology change classification, typed subsumption, edge-kind integrity (target-side cardinality and acyclic relations), a real composition relation, a replayable identity history with identity-aware graph merge, and the removal of dead ontology surface. The changesets under .changeset/ are the user-facing changelog and carry every ### Breaking section; this description records what ships and why.

What changed

Ontology changes are classified by their data consequences. isStructuralSubtype(child, parent) decides, over TypeGraph's projected JSON Schema, whether every value of one node schema satisfies another, and answers incomparable rather than guessing when a construct cannot be judged. A field the projection cannot express, such as embedding() or z.date(), is left unjudged on its own while every other field is still compared (Zod 4.5 or later; older Zod skips the pair), and the schema-diff preflight (getSchemaChanges, requiresMigration) runs the same check as the commit. The ontology change classifier names each relation change safe, warning or breaking, and a warning is a tightening probed against live data inside the schema-commit transaction: adding disjointWith, subClassOf, equivalentTo or partOf/hasPart, narrowing an edge kind's endpoints, or declaring a stricter cardinality or acyclic: true refuses the commit with MigrationError (ontology-tightening-violated) when existing rows violate it. details.violations has exactly the shape store.verifyConstraintFences() returns, so the offending rows can be listed, fixed, and the migration retried. Store.evolve(), withEvolvedTransaction() and rollbackSchema() run the same preflight, in one fixed order with the structural gates. The same preflight refuses two commits that used to succeed and silently leave a unique constraint unenforced, because a schema commit validates no existing row against a unique constraint and rebuilds no claim: adding or reshaping a unique constraint on a kind that holds rows (a kind re-added over rows a discarded drop left behind included), and removing a subClassOf/equivalentTo relation or a kind so that a kindWithSubClasses constraint's subclass component loses a member sorting before a populated kind, which is where that kind's claims may sit. Both refuse with MigrationError (unique-constraint-requires-empty-kind) from an existence probe inside the schema-commit transaction, and a backend that cannot run the probe atomically refuses with ConfigurationError (UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND).

One same-class owner, and typed subsumption. subClassOf and equivalentTo carry a structural contract checked at compile time and again at registry build. equivalentTo between registered kinds is mutual subsumption, and assignability, subclass expansion, endpoint admission, uniqueness scope, merge reconciliation and search all read the same answer; external IRIs stay inert references. Supertype queries are polymorphic by default, and one option, expansion: "exact" | "subclasses" | "narrower", replaces includeSubClasses on from()/to()/fromDynamic()/toDynamic() and on search(). A kind list in from([...]) takes the same option and store default as a single kind. When an extension adds a subclass at runtime (through evolve() or a later createStoreWithSchema reload) under a kind the compile-time graph types exactly, an alias over that kind with no stated expansion is refused with ConfigurationError (QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES) instead of returning rows its type does not admit. queryDefaults.expansion: "exact" restores the previous behavior in one place. Every meta-edge factory returns a relation typed with its name and endpoint kinds, so an alias the ontology can affect widens its kind and NodeId to string while one it cannot keeps its literal type. Typed edge collections type their endpoints through the same rule, accept subclass nodes, subclass references and polymorphic query rows as endpoints (and bulkFindEdgesFrom/To type the reference each bucket echoes as the same widened input), and getNodeByRef({ kind, id }) on the store, transactions and views resolves a reference to its typed node. A search hit stated expansion: "subclasses" widens the same way, and store.search accepts an options value typed by its own exported option types.

Edge-kind integrity. targetCardinality bounds the edges pointing at one node independently of cardinality, enforced atomically through the same claim machinery as the source side on every write path. acyclic: true declares an edge kind a DAG: every write that can add an edge to the relation is refused with EdgeAcyclicityError when the new edge's to already reaches its from, a batch is probed whole before its first write, and graph merge reports a cycle a plan would close as a plan-time AcyclicityMergeConflictError. defineGraph refuses an integrity option stated outside its domain. On PostgreSQL the acyclicity and composition fences hold at every supported isolation level and between writers at different levels: a write that makes a decision only the per-graph write fence protects (a write into an acyclic or composition relation, a delete of a composition whole, a match-key convergence) advances the graph's row in typegraph_fences behind the advisory lock, so a REPEATABLE READ or SERIALIZABLE session whose snapshot predates another such writer's commit fails on that row with SQLSTATE 40001 instead of checking a relation it can no longer see. TypeGraph replays the transactions it opened (a constrained collection write, an import chunk, a merge commit) and surfaces TransactionConflictError to the owner of a caller-owned one. Every other holder of the fence takes the lock alone and issues the statements it issued before. tx.sql is the only raw SQL handle a transaction context carries, so caller SQL cannot roll a savepoint back over the fence a session keeps reusing. A schema write runs at READ COMMITTED under the advisory schema fence, so its tightening preflight reads every writer it waited on; a caller-owned REPEATABLE READ or SERIALIZABLE transaction handed to a schema write (adoptSchemaWriteTransaction, or a transaction-scoped PostgreSQL backend) is refused with SCHEMA_WRITE_REQUIRES_READ_COMMITTED before its first statement.

Composition. partOf(Part, Whole, { via }) and hasPart declare a whole-part relation realized by an existing edge kind, with orientation inferred from the edge's endpoints and partSide where both are legal. One whole per part is fenced by a reserved claim axis across every realizing edge kind and both orientations, with a pre-write probe for batches and backends without claim relations. A part states its attachment as partOf: { whole: { kind, id }, via?, props?, validFrom?, validTo? } on create, bulkCreate and both get-or-create entries, written in the same transaction as the node; on get-or-create it is a postcondition, so two racing calls naming different wholes end with one attachment and one refusal. bulkGetOrCreateByConstraint decides its composition attachments as a set before its first write: every verdict a read can reach for a matched item, counting the attachments of the rows the batch is about to create, precedes the first row, so a refused batch attaches, updates and resurrects nothing even when the refusal is caught inside an enclosing store.transaction(...). nodes.<Kind>.reparent(id, { whole, via?, props?, at? }) and bulkReparent move a part to another whole at one instant, retiring the old attachment by the incumbent's population (one deletes it, oneActive ends its window) so no coordinate shows zero wholes or two. A composition edge write refuses a validFrom or validTo later than now on every path, and reparent refuses a later at (CompositionError, one code per situation); a move with no at after the clock steps back moves at the incumbent's validFrom instead of inverting its window, and a stated at earlier than that start is refused (COMPOSITION_MOVE_BEFORE_INCUMBENT), so an open-ended composition row is a member and an ended one is history for good: one predicate decides membership for the claims, the audit, the cascade, the acyclicity relation and every row a write proposes, resurrects or reopens. A schema commit that makes ended rows count, such as redeclaring a oneActive realizing edge one, probes the whole composition relation for a cycle and audits each part for a second whole, and a commit that adds a pair over an edge kind whose stored rows start or end later than now is refused; store.verifyConstraintFences() reports such rows as compositionFutureWindow. On a graph with any oneActive cardinality, exportGraph always carries validFrom/validTo, as identity graphs already do, and refuses includeTemporal: false, because an ended row exported without its end would import as current. existence: "required" refuses a bare create and a detach of a live part. Deleting a whole cascades leaf-first through its parts in a deterministic order, with every verdict reached before the first row is deleted, and reports the parts, each with its immediate whole and realizing edge kind, as cascadedParts on the onOperationEnd context and on receipts. nodes.<Kind>.planDelete(id) returns what that delete would remove, computed by the delete's own planner, and delete(id, { expect: plan }) turns that preview into a guard: the delete compares its own plan, computed in its transaction (under the per-graph lock for a composition whole), and refuses with NodeDeletePlanMismatchError before any write if the parts or removed edges differ or the node is gone, so a "delete this and these parts" confirmation can span two requests. hardDelete and bulkDelete refuse an options argument. Restoring a whole does not restore its parts; the composition docs give a tested recipe for undoing a cascade from the delete's receipt. parts() and wholes() navigate the closure, by default exhaustively with no hop ceiling so a chain of any depth returns whole, while a stated maxHops bounds the walk exactly as given, and maxHops: 1 with no depth or path is a single step that pages and streams like any traversal; subgraph({ composition: true }) returns the complete owned unit at the read's coordinate, and provenance treats a required part as support-dependent on its whole, so a belief-status close of a fact whole closes its required parts and a reopen brings back the ones still supported. Import, the constraint-fence audit and graph merge all see composition: merge plans report and refuse composition orphans, and a branch's composition writes apply as a unit.

Identity history and identity-aware merge. An identity-enabled graph with history capture keeps an append-only transition log: store.identity.transitionsOf(ref) and replay(ref) return every change to a node's identity class with its cause, both temporal coordinates and, for a merge, the branch, plan and review that produced it. Both page through limit, cursor and nextCursor, report a completeness result naming each reason a page is not the whole answer (more pages, retention, restored transitions, members captured before history began, pending notes inside a transaction), discover lineage past the requested window, and reconstruct membership through the same historical reader as store.asOfRecorded, so a replay can never disagree with a read. A transition is reported only while the reference was a member of the class it names, so a class the reference left keeps its later merges and splits to itself. Archival interchange (format 3.0) carries transitions and the retention watermark, and pruneIdentityTransitions advances the watermark monotonically. Graph merge carries identity truth with fixed, reported rules: a three-way classification of every staged assertion and retraction, duplicate reduction to one survivor, a hard conflict when branches assert opposing relations for one pair, and a plan-time separation veto that drops a scored match between entities a different assertion holds apart, counting the same assertions the merge stages, and reports it on MergeReport.identityConflicts. A branch that only retracts a pair another branch retracts and re-asserts is refused as a race rather than silently overruled. A plan that deletes a part the target has meanwhile moved under another live whole reports it as a composition orphan with cause: "moved" and refuses at apply. When one branch attaches a part, by a move, a new edge or a reopened row, and another branch deletes that part or its whole, planning refuses with the same finding, naming the attaching branch in branchId, instead of dropping the attachment as an edge to a deleted endpoint. A merge option MergeOptions does not declare is refused with InvalidMergeOptionsError. Merge plans move to format 2 and merge reviews to formats 3 (whole target) and 4 (candidate-scoped); MERGE_REVIEW_FORMAT_VERSION_V1, MERGE_REVIEW_FORMAT_VERSION_V2 and MergePlanArtifactV1 are removed, and earlier artifacts are refused as unsupported rather than malformed. On a graph with target-side cardinality, an acyclic edge or a composition pair, the sparse base a candidate-scoped read uses never sees the rows those constraints depend on, so candidate write-set planning falls back to planning against the whole target. planCandidateWriteSet returns the scope it chose and the reason for a fallback beside the plan, planningScope: "candidate" refuses instead of falling back (CandidatePlanningScopeError), and an explicitly requested reviewScope: "candidate" is refused with MergeReviewError (candidate-scope-ineligible) carrying the same reason.

Removed surface. Custom metaEdge(), the public InferenceType union and the deprecated sameAs/differentFrom factories are gone, since none drove behavior beyond serialized metadata. Free-form vocabulary moves to defineGraph({ annotations }), with one migration guide. A stored schema that still holds a custom-meta-edge relation the code no longer declares diffs as breaking, with a migration action naming each relation, and evolve(), planEvolution() and removeKinds() refuse to drop it, so the stored copy is discarded only by an explicit migrateSchema().

Behavior fixes that ride along. paginate(), page() and stream() no longer skip or repeat rows of a traversal query, and a multi-hop traversal returns each matched path once. Cursor pagination over a recursive traversal (recursive(), and parts() / wholes() beyond a single step) is refused with ConfigurationError (RECURSIVE_PAGINATION_UNSUPPORTED) before any statement runs, because a recursive row has nothing to extend the start-node keyset with and pages could silently drop rows. store.subgraph() fires the query hooks it documented. subgraph() and the graph algorithms refuse an unregistered kind, and StoreView reads refuse a coordinate their caller states. store.clear({ preserveContributionMaterializations: true }) is refused on a backend that cannot honor it instead of deleting the markers. A stored schema no longer diffs as breaking because Zod's projection respelled a nullable union or a closed tuple. A node create, a tombstone restore (upsertById, get-or-create resurrection, their bulk forms) and a provenance revive on an identity graph decide the identity fold before writing the row, so a caught IdentityContradictionError no longer leaves the row behind. Merge apply counts the plan's own edge deletions when it judges a node delete's restrict policy, so a branch that deletes an edge and then its restrict endpoint merges. search() refuses a kind its index cannot cover, exact or expanded, exactly where $fulltext.matches() refuses the same alias, instead of returning fewer rows.

Design rationale

One owner per decision, per the contract discipline in AGENTS.md: the composition claim key, the pair a live row realizes, the edge-write fence reason, the in-frame node delete that every delete path and the cascade run, the acyclicity predicate, the opposing-relation detector, the post-retraction aftermath and the schema-commit preflight each have a single spelling that every caller reaches. Reads precede writes on every fused frame that can run inside a caller's enclosing transaction, because the operations layer has no savepoints and sequencing is its only atomicity, so a refusal caught inside store.transaction(...) leaves nothing of the refused write. Bulk writes hold to the same rule: bulkGetOrCreateByConstraint, edge bulkUpsertById and bulk getOrCreateByEndpoints decide every item, judged against the counts the earlier items leave, before the first row is written, and a convergence retry re-runs only an attempt that wrote nothing.

The composition relation is a real relation, not a new traversal flavor: it fixes the cardinality of the realizing edge, derives orientation from the declared pair, and is judged as one oriented union for acyclicity. That is what lets one claim axis hold "one whole per part" across realizing edges, lets the cascade and subgraph({ composition: true }) know exactly what an owned unit is, and keeps composition distinct from the looser broader/narrower taxonomy and from instance-level edges that merely happen to describe containment.

Identity merge carries truth rather than arbitrating it. A same assertion is ledger truth the merge carries across and never fuses the rows it names, and the separation veto only ever removes a candidate match, so the merge never invents or overrides an identity decision. Every transition a merge causes records the branch, plan and review that caused it.

The atomic edge-claim statements are driven by one VALUES relation grouped by claim shape, with composition scope riding the group key. A batch of partOf creates reads each whole once per kind, never re-reads a part row it just inserted, and probes acyclicity once over every composition edge the batch will write, which also makes a cycle running through two of the batch's own items visible. The cascade carries the rows it already read into the delete instead of reading them again. Merge's required-existence orphan candidates derive from one normalized write view, and the identity retraction aftermath has one implementation reached from every caller.

The build emits declarations once with tsc and rolls every entrypoint from that emit. The previous per-entrypoint rollup rooted at source held per-module emit state for all 449 modules and peaked at 9.9 GiB wherever it was rooted, so no public type family drove it; a whole-program emit costs 1.53 GiB and 7 seconds and the rollup from that emit 0.46 GiB and 1 second. The complete build peaks at about 1.5 GiB, the CI heap ceiling is back at 8 GiB, and 40 of 40 emitted chunks and 37 of 38 entry declarations are byte-identical to the old output apart from a cosmetic chunk-name suffix.

Measurements

Single-writer insert latency on a relation of 10^5 edges, median / p95, with fresh planner statistics, on an 8-core laptop under concurrent load, so the ratios matter more than the absolute numbers.

shape SQLite cardinality: "one" SQLite acyclic: true PostgreSQL cardinality: "one" PostgreSQL acyclic: true
forest 0.2 / 0.3 ms 0.2 / 0.2 ms 4.1 / 6.0 ms 3.8 / 8.6 ms
chain-append 0.4 / 1.0 ms 0.1 / 0.3 ms 2.6 / 2.8 ms 7.3 / 14.3 ms
chain-prepend 0.3 / 0.5 ms 220.5 / 229.1 ms 2.8 / 3.1 ms 467.9 / 475.4 ms

The forest p95 is 0.7 times the cardinality-one baseline on SQLite and 1.4 times on PostgreSQL, inside the 3 times criterion fixed before measuring. Chain-prepend is the documented worst case: the new edge points at the head of the chain, so the exhaustive walk visits every reachable edge at roughly 2 µs (SQLite) to 5 µs (PostgreSQL) per hop, linear in the reachable set rather than in the relation. The PostgreSQL chain-append p95 was measured while autovacuum processed the freshly seeded rows; its 10^4-edge counterpart is 2.2 ms against a 2.6 ms baseline.

PostgreSQL contention, W connections appending to one shared 10^5-edge relation for 30 seconds each, throughput and p99.

writers cardinality: "one" acyclic: true
2 390/s, 13.5 ms 529/s, 13.1 ms
4 391/s, 59.3 ms 361/s, 40.1 ms
8 412/s, 119.7 ms 487/s, 70.3 ms
16 692/s, 153.3 ms 654/s, 136.1 ms

Eight-writer acyclic throughput is 118 percent of the cardinality-one run against a 70 percent criterion fixed before measuring. Both fences serialize on the same per-graph advisory lock, so the probe adds only its own sub-millisecond index seek to the contended path; both engines seek the edge table's endpoint index per hop.

Both tables were measured before the acyclic write also advanced the graph's fence row. That cost was measured separately on PostgreSQL 18 with one client, running the fence statement alone inside BEGIN/COMMIT: 0.11 ms for the lock alone against 0.17 ms for the lock plus the row. The update is HOT while no older snapshot is open. With one REPEATABLE READ snapshot held open, the lock-plus-row statement rose to 0.24 ms after 20,000 deciding transactions and 0.63 ms after 80,000 as dead row versions accumulated, while the lock-only statement stayed at 0.11 ms.

Deployment and upgrade shape

The identity transition log adds two relations to every database, typegraph_identity_transitions and typegraph_identity_transition_retention, as base-schema release 6. The bump is one-way: a privileged open (createStoreWithSchema, createAdapterStoreWithSchema) or backend.adoptBaseSchema() catches a database up in place, DML-only runtimes refuse with BaseSchemaMigrationError until it has run, and a release that knows only version 5 refuses a database stamped 6. A custom engine profile's two DDL members for the new relations are optional, and adoption refuses with a typed error naming whichever is missing. Supertype queries, kind lists included, return subtype rows by default, deleting a composition whole deletes its parts, and stored merge plans and reviews must be planned again. A database whose stored schema holds a custom-meta-edge relation needs one explicit migrateSchema() after the relations move to annotations. A schema version an earlier release committed keeps its document and schema_hash column, and every comparison against it hashes the stored document in this release's serialization, so an unchanged graph with ontology relations reopens, registers templates, re-instantiates template targets and plans merges without a rewrite. A durable PostgreSQL working copy whose allocation was sealed at base-schema version 5 is adopted in place on reopen. On PostgreSQL, a role that writes into an acyclic or composition relation, deletes a composition whole, or converges on an edge match key needs INSERT and UPDATE on typegraph_fences, a relation the base schema already holds.

Deferred

Identity-driven pairing and conflict-arbitration policies for graph merge are not part of this PR. Pairing candidates by identity assertions, and policy options that choose how a merge resolves assertion, edge, uniqueness or provenance conflicts, were built and then removed for lack of a consumer scenario; what ships is the fixed, reported rule set above, the separation veto, the three-way ledger reconciliation and decision provenance on every transition. The request for the removed policies is tracked in #657, which is rescoped to wait for a concrete scenario and acceptance criteria and is not closed by this PR.

Closes #610
Closes #653
Closes #673
Closes #674
Closes #675

@pdlug
pdlug marked this pull request as ready for review September 9, 2026 16:31
pdlug added a commit that referenced this pull request Sep 10, 2026
…666)

Second whole-branch review of #637, finding: an ontology endpoint
declared as a finite union of node types (`parent: typeof ParentA |
typeof ParentB`) still produced exact alias types, because
`SubsumptionAffected` decided "does this relation name kind K" by
`Extract`ing the whole relation against a single-kind endpoint shape. A
union endpoint matches no such shape, so the decision answered `false`
for every kind the endpoint can hold — an unsound under-widening, the
opposite of the conservative over-widening the erased arms take on
purpose.

The decision now reduces each relation's `MatchedEndpoints` to the kind
literals its endpoints can hold and asks whether `K` is among them. That
covers both spellings of a union endpoint: a union of endpoint types
(from a helper that declares a subclass of either of two roots) and a
union inside one endpoint's `kind` (`NodeType<"Audio" | "Video">`).
`MatchedEndpoints` is the sole owner of the name-to-endpoint-position
mapping; the decided arm no longer re-spells it as `Extract` arms beside
the erased arm's use of it, and both erased arms are unchanged. A union
endpoint therefore widens exactly the kinds it can hold and nothing
else, which the typed-subsumption changeset and the queries guide now
state.

Forgotten-export debt moves +1 on the seven entrypoints that reach
`SubsumptionAffected`, for exactly one new declaration
(`SubsumptionElementNamesKind`); removing that name from each new symbol
set reproduces the previously recorded digest.
pdlug added a commit that referenced this pull request Sep 10, 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.

## 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.
pdlug added a commit that referenced this pull request Sep 10, 2026
…retired (#669)

Third whole-branch review of #637, the one remaining P1:
`nodes.<Kind>.reparent(...)`'s replace arm retired the incumbent
attachment and only then prepared the replacement edge. Preparation can
still refuse (cardinality at the new whole, acyclicity over the
composition union, the realizing edge's props), and because the write
path has no savepoint, a caller that catches that refusal inside an
enclosing `store.transaction(...)` let the retirement commit without the
replacement, leaving the part detached, a required-existence part
included.

The replacement is now prepared before the incumbent is retired, and the
frame issues only the retire and the insert after every read has passed,
the same reads-then-writes order the get-or-create update leg already
follows. Simply moving the preparation earlier would have refused every
move whose incumbent realizes the same edge kind, because the incumbent
still counts against the part's own cardinality until it is retired. So
the preparation reads through `createRetiringEdgeValidationBackend`, a
read overlay beside the existing in-batch cardinality overlay in
`edge-batch-validation.ts`, which subtracts the retiring row from any
endpoint count that includes it. That yields exactly the count the
insert will see: a `population: "one"` incumbent is soft-deleted and
leaves a `live` count, a `population: "oneActive"` incumbent has its
window ended and leaves a `liveAndActive` count, and a pair's population
is the very axis whose count it leaves. `edgeExistsBetween` is not
overlaid because an incumbent sharing the replacement's `(kind, from,
to)` is the same attachment, which the fenced decision reports as
satisfied and never replaces. The row-level rendering of the count
predicate sits beside the cache key that already mirrors the same tuple,
so the two renderings of one read live in one place.

The load-bearing test moves a part to a whole that already holds its one
part, catches the `CardinalityError` inside a transaction, and asserts
the incumbent attachment is still open. With the preparation moved back
below the retirement it fails with the part holding no attachment at
all, which is the reviewed scenario.

Also from that review: the composition-attachment changeset states the
reparent order, and the internal comments around the get-or-create leg
are shortened to each caller's obligation, with the sequencing invariant
kept beside `prepareCompositionAttachmentDecision` and the reason the
replacement needs its own count beside the overlay.
pdlug added a commit that referenced this pull request Sep 11, 2026
Fourth whole-branch review of #637: the lint failure on the integration
head and the documentation findings.

## Lint

The reparent refusal test bound a filter directly to an awaited read,
which the lint rule against await-expression members refuses; the read
is bound first now. That was the only CI failure; the Build job was the
aggregate gate reporting it.

## Documentation

The equivalence-is-subsumption changeset told readers to drop
`includeSubClasses` to stop expansion. Omission now defaults to subclass
expansion, so it names `expansion: "exact"` and points at the
typed-subsumption changeset for the migration.

`cascadedParts` is documented where receipts and hooks are introduced,
not only on the limitations page: the receipt guide gains a worked
example of a whole's delete reporting its parts on the `onOperationEnd`
context, on the `transactionWithReceipt` receipt, and on a `tx.measure`
scope's receipt, and states that cascaded parts are refs beside the
write-intent counters and never folded into them. The displayed
`OperationHookContext` definition carries the field.

The identity guide's first `transitionsOf` example read as complete
while discarding `nextFrom`; it now says a page and shows the paging
loop first, with a single call described as the first page. The source
guide annotated the widened alias kind as a literal union; the comment
separates the runtime values from the static type.

The get-or-create `partOf` postcondition moves out of the
required-existence refusals into its own subsection with an outcome
table, since it applies to optional parts too and most of its outcomes
are successes; the required-existence list is now two refusals with a
pointer to it.

Two runnable examples cover the workflows the review found untaught.
`28-composition-lifecycle.ts` declares a required pair whose realizing
edge carries a required property, refuses a bare create, attaches
through `getOrCreateByConstraint`'s postcondition (created, found,
refused for a different whole), reparents between wholes and to the
current whole as a no-op, exports the complete owned unit with
`subgraph({ composition: true })`, and reads the cascade from an
`onOperationEnd` hook and a receipt. `29-identity-history.ts` pages
`transitionsOf` and `replay` on `nextFrom` with a limit small enough to
produce several pages, prunes with `pruneIdentityTransitions` and shows
the no-op below the watermark, and labels restored transitions after a
real archival export and import into a second history-enabled store.
Both run under `pnpm test:examples`.

Writing the examples surfaced five facts the guides did not state, now
added: `CompositionExistenceError.code` is constant across situations so
callers branch on `details.situation`; `reparent` writes a new realizing
edge and so restates required edge props; one retraction on a class of
three or more members can record more than one `retract` transition; an
archival import that carries a live assertion records one native
transition on the target beside the restored rows, on an empty target
too; and restored rows keep the source graph's revision, so "oldest
first" can list them before or after native rows regardless of when they
happened.
@pdlug
pdlug force-pushed the integration/ontology-semantics branch from 1f25e42 to 4938644 Compare September 23, 2026 00:06
pdlug added a commit that referenced this pull request Sep 23, 2026
…666)

Second whole-branch review of #637, finding: an ontology endpoint
declared as a finite union of node types (`parent: typeof ParentA |
typeof ParentB`) still produced exact alias types, because
`SubsumptionAffected` decided "does this relation name kind K" by
`Extract`ing the whole relation against a single-kind endpoint shape. A
union endpoint matches no such shape, so the decision answered `false`
for every kind the endpoint can hold — an unsound under-widening, the
opposite of the conservative over-widening the erased arms take on
purpose.

The decision now reduces each relation's `MatchedEndpoints` to the kind
literals its endpoints can hold and asks whether `K` is among them. That
covers both spellings of a union endpoint: a union of endpoint types
(from a helper that declares a subclass of either of two roots) and a
union inside one endpoint's `kind` (`NodeType<"Audio" | "Video">`).
`MatchedEndpoints` is the sole owner of the name-to-endpoint-position
mapping; the decided arm no longer re-spells it as `Extract` arms beside
the erased arm's use of it, and both erased arms are unchanged. A union
endpoint therefore widens exactly the kinds it can hold and nothing
else, which the typed-subsumption changeset and the queries guide now
state.

Forgotten-export debt moves +1 on the seven entrypoints that reach
`SubsumptionAffected`, for exactly one new declaration
(`SubsumptionElementNamesKind`); removing that name from each new symbol
set reproduces the previously recorded digest.
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.
pdlug added a commit that referenced this pull request Sep 23, 2026
…retired (#669)

Third whole-branch review of #637, the one remaining P1:
`nodes.<Kind>.reparent(...)`'s replace arm retired the incumbent
attachment and only then prepared the replacement edge. Preparation can
still refuse (cardinality at the new whole, acyclicity over the
composition union, the realizing edge's props), and because the write
path has no savepoint, a caller that catches that refusal inside an
enclosing `store.transaction(...)` let the retirement commit without the
replacement, leaving the part detached, a required-existence part
included.

The replacement is now prepared before the incumbent is retired, and the
frame issues only the retire and the insert after every read has passed,
the same reads-then-writes order the get-or-create update leg already
follows. Simply moving the preparation earlier would have refused every
move whose incumbent realizes the same edge kind, because the incumbent
still counts against the part's own cardinality until it is retired. So
the preparation reads through `createRetiringEdgeValidationBackend`, a
read overlay beside the existing in-batch cardinality overlay in
`edge-batch-validation.ts`, which subtracts the retiring row from any
endpoint count that includes it. That yields exactly the count the
insert will see: a `population: "one"` incumbent is soft-deleted and
leaves a `live` count, a `population: "oneActive"` incumbent has its
window ended and leaves a `liveAndActive` count, and a pair's population
is the very axis whose count it leaves. `edgeExistsBetween` is not
overlaid because an incumbent sharing the replacement's `(kind, from,
to)` is the same attachment, which the fenced decision reports as
satisfied and never replaces. The row-level rendering of the count
predicate sits beside the cache key that already mirrors the same tuple,
so the two renderings of one read live in one place.

The load-bearing test moves a part to a whole that already holds its one
part, catches the `CardinalityError` inside a transaction, and asserts
the incumbent attachment is still open. With the preparation moved back
below the retirement it fails with the part holding no attachment at
all, which is the reviewed scenario.

Also from that review: the composition-attachment changeset states the
reparent order, and the internal comments around the get-or-create leg
are shortened to each caller's obligation, with the sequencing invariant
kept beside `prepareCompositionAttachmentDecision` and the reason the
replacement needs its own count beside the overlay.
pdlug added a commit that referenced this pull request Sep 23, 2026
Fourth whole-branch review of #637: the lint failure on the integration
head and the documentation findings.

## Lint

The reparent refusal test bound a filter directly to an awaited read,
which the lint rule against await-expression members refuses; the read
is bound first now. That was the only CI failure; the Build job was the
aggregate gate reporting it.

## Documentation

The equivalence-is-subsumption changeset told readers to drop
`includeSubClasses` to stop expansion. Omission now defaults to subclass
expansion, so it names `expansion: "exact"` and points at the
typed-subsumption changeset for the migration.

`cascadedParts` is documented where receipts and hooks are introduced,
not only on the limitations page: the receipt guide gains a worked
example of a whole's delete reporting its parts on the `onOperationEnd`
context, on the `transactionWithReceipt` receipt, and on a `tx.measure`
scope's receipt, and states that cascaded parts are refs beside the
write-intent counters and never folded into them. The displayed
`OperationHookContext` definition carries the field.

The identity guide's first `transitionsOf` example read as complete
while discarding `nextFrom`; it now says a page and shows the paging
loop first, with a single call described as the first page. The source
guide annotated the widened alias kind as a literal union; the comment
separates the runtime values from the static type.

The get-or-create `partOf` postcondition moves out of the
required-existence refusals into its own subsection with an outcome
table, since it applies to optional parts too and most of its outcomes
are successes; the required-existence list is now two refusals with a
pointer to it.

Two runnable examples cover the workflows the review found untaught.
`28-composition-lifecycle.ts` declares a required pair whose realizing
edge carries a required property, refuses a bare create, attaches
through `getOrCreateByConstraint`'s postcondition (created, found,
refused for a different whole), reparents between wholes and to the
current whole as a no-op, exports the complete owned unit with
`subgraph({ composition: true })`, and reads the cascade from an
`onOperationEnd` hook and a receipt. `29-identity-history.ts` pages
`transitionsOf` and `replay` on `nextFrom` with a limit small enough to
produce several pages, prunes with `pruneIdentityTransitions` and shows
the no-op below the watermark, and labels restored transitions after a
real archival export and import into a second history-enabled store.
Both run under `pnpm test:examples`.

Writing the examples surfaced five facts the guides did not state, now
added: `CompositionExistenceError.code` is constant across situations so
callers branch on `details.situation`; `reparent` writes a new realizing
edge and so restates required edge props; one retraction on a class of
three or more members can record more than one `retract` transition; an
archival import that carries a live assertion records one native
transition on the target beside the restored rows, on an empty target
too; and restored rows keep the source graph's revision, so "oldest
first" can list them before or after native rows regardless of when they
happened.
parts() and wholes() refused after any earlier traversal, citing a one-recursive-traversal-per-query limit that multi-stage recursive compilation already removed. A recursing composition step, including a mixed-orientation one with its normalizing directed-edges relation, compiles as a later stage, so the guard and the resurrected docs limitation bullet are gone.
The rebase left buildReachableCte as a pasted copy of main's body beside the branch's prepareReachableCte, which went unused, and grafted main's edgeWindows computation into prepareReachableFilters where nothing read it. buildReachableCte now composes prepareReachableCte with main's windowed-edges step, every frontier branch goes through recursiveFrontierBranch with an explicit edge relation, and the exhaustive walk's options no longer admit edgeWindows or sourceIds. The emitted SQL is unchanged.
The typed-subsumption changeset claimed updateWhere was unaffected by polymorphic sources, but its candidates query follows the default and must resolve to one kind. The changeset and store docs now state that rule and its refusal code, list subgraph({ includeKinds }) and explicit kind lists among the exact-kind surfaces, add upgrade notes for refused candidate queries and for saved cursors of supertype queries, and document that tx.subgraph() honors composition while batchOnce's read.subgraph() refuses it.
…ss is recorded

replaceAffectedClosure handed its root-keyed distinct components to diffClosureTransitions, which looks each affected member's new class up by member key; every non-root member missed and its transition record was silently dropped from the append-only log. Index the distinct components by member through one shared helper (indexComponentsByMember, now also behind buildComponents and UnionFind.components) so the diff sees every member without reintroducing per-member component copies.
…nal seam

types.ts and identity-three-way.ts imported IdentityAssertionPolicyLabel straight from identity/transition-log, which the transition-log module ratchet refuses outside its allowlist. typegraph-internal already re-exports the type, so both now import it from there.
…play

Under engine-native recorded time a store opened with history: true keeps no TypeGraph transition log, yet replay, transitionsOf, pruning and archival-transitions restores refused with IDENTITY_REPLAY_REQUIRES_HISTORY and advised opening with history: true. The identity context now carries the store's recorded-time ownership and one factory picks IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED for that state. The upfront archival-restore guard in import now gates on TypeGraph capture like the store's backstop, so an engine-native target refuses before any node is written instead of after the stream's entity sections.
…e delete and revive owners

closeFactCurrency and reopenFactCurrency called applyNodeSoftDelete and applyNodeResurrect directly, skipping the identity detach and restore fold every other node delete and revival performs, so a closed fact kept its open identity assertions and stayed in its class while tombstoned. The close now goes through the transaction's delete-with-policy port (no delete-behavior enforcement, no composition cascade) and the reopen through a new transaction revive port backed by executeNodeRevive, whose in-frame owner reviveNodeRowInFrame pairs session.reviveNode with the identity restore fold. Both run under the transaction's buffered hooks, one fact at a time. The transaction runtime's now-unused node hook runner member is removed.
…eopened

Narrowing a class member's validTo noted a window-end transition, but clearValidTo, which moves the same membership boundary back, noted nothing, so replay missed the reopen. Every update path that can carry validTo or clearValidTo now runs one in-frame owner, applyIdentityWindowEnd, whose identity hook treats a cleared end as an open window: nothing to refuse, and a window-end note whenever the stored end was finite. The upsert-update batch path, which never called the hook, now does too, so bulkUpsertById can no longer narrow a node's window past an open identity assertion unrefused.
…y stores

Store#identitySchemaPreflight, which store.evolve() and withEvolvedTransaction() run inside the schema-commit transaction, never passed a capture binding, so on a history store every ledger touch and transition note the preflight's closure rebuild took was silently dropped: the raw commit transaction has no capture session bound to it. It now binds one whenever the store captures history, as Store#removeIdentityKindsInSchemaPreflight already does. A lagging closure repaired by an evolve now leaves its schema-transition record.
pdlug added 30 commits October 23, 2026 20:07
The live structural-subsumption projection now converts a kind with Zod's unrepresentable handler, marking each node z.toJSONSchema cannot express (embedding(), z.date(), z.set(), a transform, any z.custom) instead of falling back to the whole-kind placeholder. isStructuralSubtype accepts a marked node unjudged after the identity rule, so every other field of the pair is still compared: a subclass that adds embedding() opens, and one that also loosens a parent's min(5) is refused. The persisted projection and schema hash are unchanged; a deserialized document keeps the whole-pair skip, and the live projection keeps the placeholder when the conversion fails for another reason or Zod predates the handler.

Removing a persisted relation whose meta-edge is not built in is now classified breaking instead of safe, since no code path can declare it any more and the stored document holds its last copy. The change names the relation in the new OntologyChange.removedCustomRelation, and getMigrationActions lists every such relation in one action, so ensureSchema stops instead of auto-migrating and an explicit migrateSchema() commits the removal.
Every transitionsOf and replay page now carries completeness, computed by one
owner in replay.ts: complete, or partial with each reason that holds (more
pages, retention cut, unattributed restored transitions, restored transitions
replay left out of steps, and identity changes a transaction has yet to flush).
transitionsOf now reports the retention cut too. The truncatedBefore and
incompleteDiscovery fields it replaces are removed.
…dedParts

Expose the read half of the node-delete planner as nodes.<Kind>.planDelete(id): the parts the composition cascade would delete and every edge the delete would remove, computed by the planner the delete runs with every write skipped. On a transaction's collections it reads under the per-graph write lock the delete takes; on the store's collections it is an unlocked best-effort read. A refused delete is refused with the same error.

cascadedParts on delete receipts and operation hooks now carries each part's immediate whole and the composition edge kind that bound it.

Pin parts()/wholes() with maxHops: 1 (no depth or path) as the pageable direct-children recipe with a cross-backend test, and correct the docs that said every parts() query refuses pagination. Document subgraph({ composition: true }) as an export rather than a delete preview, and state the cascade's lifecycle right after the first composition example.
…int-deleted

A required part whose inherited composition edge canonicalization drops
because another branch deleted the whole is reported at plan time and
refused inside the apply transaction by the deleted-whole check, so the
documented residual gap does not exist. Pin the shape across every
onDeleteModifyConflict policy and state the actual guarantee in the docs.
planCandidateWriteSet chose between staging the candidate's own rows and
cloning the whole target through a bare boolean, and fell back silently.
candidatePlanningScope is now the one owner of that decision, refined by
the match-identity owner read, and returns the scope with a closed reason.
Both candidate planners return { plan, planningScope }; the scope stays
outside the digested artifact. planningScope: "candidate" refuses with
CandidatePlanningScopeError instead of falling back, before any staging
backend is created, and a refused candidate-scoped review carries the
decision in details.planningScope.
…le-time alias type cannot hold

List sources, from([...], alias, options?), now take the same expansion
option and store default as from(kind): each listed kind expands through
the shared expansion owner, the union is deduplicated, and the alias is
typed kind by kind. The schema introspector resolves shared fields per
root kind, so an expanded list reads a parent-optional field a subclass
omits as the listed kind declares it.

When an alias is typed from the compile-time graph, that graph keeps the
kind exact, and the live registry expands it further (a subclass added by
store.evolve() or reloaded by createStoreWithSchema), from(), list
sources, to() and store.search() with expansion: "subclasses" refuse
with QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES. The decision lives in
expandKindsForAxis, which every surface calls with the typing it gave
the alias.

Docs gain a scope-at-a-glance table and the corrected restart behavior.
…yRef

Typed edge collections now read each declared endpoint through
SubsumedNodeType, the same gate query aliases use, so an endpoint kind
with subClassOf descendants or equivalentTo kinds reads back with a
string kind and a widened NodeId brand on every edge read surface.
Endpoint arguments accept a node or reference of any kind the runtime
admits in its place (AssignableNodeKinds, transitive over subClassOf and
equivalentTo, with the conservative arms for erased ontology literals)
and a polymorphic query row, so a subclass row connects without a cast.

store.getNodeByRef, tx.getNodeByRef and view.getNodeByRef resolve a
{ kind, id } reference through the node collection lookup, typed as the
kind's node for a literal kind and as the kind-discriminated union plus
the base Node for a string kind.
# Conflicts:
#	packages/typegraph/scripts/api-report.ts
# Conflicts:
#	packages/typegraph/scripts/api-report.ts
# Conflicts:
#	packages/typegraph/scripts/api-report.ts
#	packages/typegraph/tests/typed-subsumption-types.test.ts
The transactional planDelete takes the delete's lock, which for a
composition whole on PostgreSQL advances the graph's fence row. Inside a
read-only transaction that ended in a raw driver error on PostgreSQL while
SQLite answered a plan. It now refuses up front, before any statement and on
every backend, with the read-only UnsupportedBackendCapabilityError that
tx.requestRecordedRevision() already raises, naming the store-form preview
as the alternative. Both refusals share one helper.

The planDelete docs, changeset and limitations now say what the preview
actually writes (no graph, sidecar, history or revision row; the
transactional form takes the delete's lock), that it fails the way the
delete's fence fails under REPEATABLE READ / SERIALIZABLE, and that
re-attaching a cascaded tree needs the deleted root restored first.
…pe decision

A candidate edge whose durable owner key cannot be derived is no longer reported as a whole-target planning scope: the owner read returns a discriminated malformed result, the candidate is staged in its own scope, and import reports the candidate's own validation error with or without planningScope: "candidate". A target that cannot carry a public merge plan is refused with MergePlanCapabilityError before staging. The candidate scope object is frozen, and planCandidateWriteSetForEvolution no longer declares planningScope.
…sition completeness

A lineage whose current class holds members that arrived through writes
capture did not see (identity changes or node creation before history: true
was enabled, or writes that bypassed capture) reported complete with an
empty or short page. Pages now carry an uncaptured-members reason counting
those members, derived from the current class and the recorded evidence the
lineage walk already reads plus one recorded-node lookup over the class.

Also derives replay's restored-not-reconstructed count from the step loop,
corrects the below-watermark transitionsOf wording, replaces stale
truncatedBefore comments, and pins the per-graph pending-notes answer.
…urces only for uncovered extension subclasses

store.search with expansion: "subclasses" now types its hit as PolymorphicNodeType whatever the compile-time graph declares, matching from(kind, alias, { expansion: "subclasses" }), so a subclass a persisted extension added is searched instead of refused. A list source is refused only for an extra kind no listed kind's type admits, decided in alias-expansion.ts, and the refusal names the surface it advises. A set-update candidate refusal advises from the kinds the source named rather than the resolved axis. Docs and the changeset state that the refusal reads runtime ontology values, so it also fires for a store typed over a widened graph type or an erased ontology.
An endpoint read off a widened edge, `{ kind: edge.toKind, id: edge.toId }`,
and a polymorphic query row now pass into every endpoint input without a
cast. SubsumedNodeReference owns the widened `{ kind: string, id }` arm with
the widened NodeId brand, for affected kinds only. EdgeEndpointRef uses it
in place of the polymorphic-node arm, and the new GraphNodeReferenceInput
(GraphNodeReference plus that arm) types the bulkFindEdgesFrom/To sources
and targets and the neighbors/countNeighbors source on the store, on
transactions and in batchOnce. An unregistered kind is still refused at
runtime with KindNotFoundError.

getNodeByRef and getNodeCollectionOrThrow now refuse an unregistered kind
through assertRegisteredNodeKinds, the shared registration check, instead
of their own inline copies.

The docs and changeset now describe the structural admission correctly:
any node or reference whose properties include the declared kind's
compiles at an affected endpoint. They also say that an OntologyRelation
annotation widens every typed edge endpoint, and they list neighbors()
among the widened read surfaces.
…m relation drops outside migrateSchema

getSchemaChanges and requiresMigration now build the live kind registry over the merged graph, the same check the commit path and createStore run, so a pair refused beside a field JSON Schema cannot express is reported before the upgrade.

store.evolve(), store.planEvolution() and store.removeKinds() refuse with MigrationError (reason custom-relation-removal) before writing when the stored schema holds a custom-meta-edge relation the commit would drop; a relation dropped alongside a kind it names is now named too. A node Zod converts only in part (a dynamic .catch()) is compared as one carrying a Zod-only constraint rather than accepted unjudged.
# Conflicts:
#	packages/typegraph/scripts/api-report.ts
…ove the delete preview docs into the composition section
…recipe

nodes.<Kind>.delete(id, { expect: plan }) deletes only if the plan the delete computes for itself, inside its transaction and under its own per-graph write lock, still matches the plan planDelete(id) returned. This covers the cross-request flow where a user confirms a preview in a later request and no lock can span the gap. A difference refuses with the new NodeDeletePlanMismatchError (NODE_DELETE_PLAN_MISMATCH, situation "changed" naming parts and edge ids added and dropped, or "notLive" for a node deleted since the preview) before any write.

The comparison has one owner (node-delete-expectation.ts) and is order-insensitive: parts compare by (kind, id, whole, viaEdgeKind), edges by id. planDelete and the guarded delete share one plan projection (nodeDeletePlanOf), and a guarded delete judges its root so the root's own removed edges are compared. nodeDeleteRequiresPortablePath now owns every request-shaped routing dimension, so a guarded delete never reaches the fused single-statement delete. Options of the wrong shape, { expect: undefined } and duplicated entries are refused with ValidationError; expect is required once options are passed, so planDelete's undefined cannot be passed as an expectation. hardDelete and bulkDelete take no expect: a hard delete also purges tombstoned nodes and soft-deleted edge rows the plan does not name.

The composition docs gain an "Undoing a cascading delete" recipe (capture the whole and the planned parts with their props, then recreate whole-first with the same ids and partOf), backed by a test that runs the documented blocks.
…nd bulkDelete, and pin the guard's lock with a concurrent test

The guarded delete docs claimed a lock-free write committing between the delete's plan and its statements becomes a serialization failure at REPEATABLE READ or SERIALIZABLE. It does not: an ordinary edge write takes no lock the delete holds, and PostgreSQL arbitrates SERIALIZABLE only among SERIALIZABLE transactions. The TSDoc, ontology and API reference pages and the changeset now state what each isolation level does: at READ COMMITTED such an edge is removed with the node (or refuses a restrict delete), and at REPEATABLE READ or SERIALIZABLE it stays live on the deleted node, as it does for every delete. The comparison covers what the delete planned, and only a composition whole's delete plans under the per-graph lock; for any other kind every concurrent write can change the answer up to the comparison.

hardDelete and bulkDelete take no options; an options argument from an untyped caller is now refused with ValidationError instead of running an unguarded delete. NodeDeletePlanMismatchError moves to category "constraint", like RestrictedDeleteError.

New tests: a PostgreSQL case where the guarded delete waits on the fence while a second connection attaches a part (fails if the comparison moves to an unlocked pre-read), a dropped-edge-only mismatch, and the hardDelete/bulkDelete options refusal.
…semantics

# Conflicts:
#	packages/typegraph/src/store/operations/node-operations.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant