Repository navigation
Conversation
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.
This was referenced Sep 11, 2026
pdlug
force-pushed
the
integration/ontology-semantics
branch
from
September 23, 2026 00:06
1f25e42 to
4938644
Compare
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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### Breakingsection; 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 answersincomparablerather than guessing when a construct cannot be judged. A field the projection cannot express, such asembedding()orz.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 changesafe,warningorbreaking, and awarningis a tightening probed against live data inside the schema-commit transaction: addingdisjointWith,subClassOf,equivalentToorpartOf/hasPart, narrowing an edge kind's endpoints, or declaring a stricter cardinality oracyclic: truerefuses the commit withMigrationError(ontology-tightening-violated) when existing rows violate it.details.violationshas exactly the shapestore.verifyConstraintFences()returns, so the offending rows can be listed, fixed, and the migration retried.Store.evolve(),withEvolvedTransaction()androllbackSchema()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 asubClassOf/equivalentTorelation or a kind so that akindWithSubClassesconstraint's subclass component loses a member sorting before a populated kind, which is where that kind's claims may sit. Both refuse withMigrationError(unique-constraint-requires-empty-kind) from an existence probe inside the schema-commit transaction, and a backend that cannot run the probe atomically refuses withConfigurationError(UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND).One same-class owner, and typed subsumption.
subClassOfandequivalentTocarry a structural contract checked at compile time and again at registry build.equivalentTobetween 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", replacesincludeSubClassesonfrom()/to()/fromDynamic()/toDynamic()and onsearch(). A kind list infrom([...])takes the same option and store default as a single kind. When an extension adds a subclass at runtime (throughevolve()or a latercreateStoreWithSchemareload) under a kind the compile-time graph types exactly, an alias over that kind with no statedexpansionis refused withConfigurationError(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 itskindandNodeIdtostringwhile 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 (andbulkFindEdgesFrom/Totype the reference each bucket echoes as the same widened input), andgetNodeByRef({ kind, id })on the store, transactions and views resolves a reference to its typed node. A search hit statedexpansion: "subclasses"widens the same way, andstore.searchaccepts an options value typed by its own exported option types.Edge-kind integrity.
targetCardinalitybounds the edges pointing at one node independently ofcardinality, enforced atomically through the same claim machinery as the source side on every write path.acyclic: truedeclares an edge kind a DAG: every write that can add an edge to the relation is refused withEdgeAcyclicityErrorwhen the new edge'stoalready reaches itsfrom, a batch is probed whole before its first write, and graph merge reports a cycle a plan would close as a plan-timeAcyclicityMergeConflictError.defineGraphrefuses 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 anacyclicor composition relation, a delete of a composition whole, a match-key convergence) advances the graph's row intypegraph_fencesbehind the advisory lock, so aREPEATABLE READorSERIALIZABLEsession whose snapshot predates another such writer's commit fails on that row with SQLSTATE40001instead 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 surfacesTransactionConflictErrorto 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.sqlis 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 atREAD COMMITTEDunder the advisory schema fence, so its tightening preflight reads every writer it waited on; a caller-ownedREPEATABLE READorSERIALIZABLEtransaction handed to a schema write (adoptSchemaWriteTransaction, or a transaction-scoped PostgreSQL backend) is refused withSCHEMA_WRITE_REQUIRES_READ_COMMITTEDbefore its first statement.Composition.
partOf(Part, Whole, { via })andhasPartdeclare a whole-part relation realized by an existing edge kind, with orientation inferred from the edge's endpoints andpartSidewhere 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 aspartOf: { whole: { kind, id }, via?, props?, validFrom?, validTo? }oncreate,bulkCreateand 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.bulkGetOrCreateByConstraintdecides 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 enclosingstore.transaction(...).nodes.<Kind>.reparent(id, { whole, via?, props?, at? })andbulkReparentmove a part to another whole at one instant, retiring the old attachment by the incumbent's population (onedeletes it,oneActiveends its window) so no coordinate shows zero wholes or two. A composition edge write refuses avalidFromorvalidTolater than now on every path, andreparentrefuses a laterat(CompositionError, one code per situation); a move with noatafter the clock steps back moves at the incumbent'svalidFrominstead of inverting its window, and a statedatearlier 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 aoneActiverealizing edgeone, 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 ascompositionFutureWindow. On a graph with anyoneActivecardinality,exportGraphalways carriesvalidFrom/validTo, as identity graphs already do, and refusesincludeTemporal: 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, ascascadedPartson theonOperationEndcontext and on receipts.nodes.<Kind>.planDelete(id)returns what that delete would remove, computed by the delete's own planner, anddelete(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 withNodeDeletePlanMismatchErrorbefore 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.hardDeleteandbulkDeleterefuse 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()andwholes()navigate the closure, by default exhaustively with no hop ceiling so a chain of any depth returns whole, while a statedmaxHopsbounds the walk exactly as given, andmaxHops: 1with nodepthorpathis 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)andreplay(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 throughlimit,cursorandnextCursor, report acompletenessresult 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 asstore.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 (format3.0) carries transitions and the retention watermark, andpruneIdentityTransitionsadvances 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 adifferentassertion holds apart, counting thesameassertions the merge stages, and reports it onMergeReport.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 withcause: "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 inbranchId, instead of dropping the attachment as an edge to a deleted endpoint. A merge optionMergeOptionsdoes not declare is refused withInvalidMergeOptionsError. 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_V2andMergePlanArtifactV1are removed, and earlier artifacts are refused as unsupported rather than malformed. On a graph with target-side cardinality, anacyclicedge 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.planCandidateWriteSetreturns the scope it chose and the reason for a fallback beside the plan,planningScope: "candidate"refuses instead of falling back (CandidatePlanningScopeError), and an explicitly requestedreviewScope: "candidate"is refused withMergeReviewError(candidate-scope-ineligible) carrying the same reason.Removed surface. Custom
metaEdge(), the publicInferenceTypeunion and the deprecatedsameAs/differentFromfactories are gone, since none drove behavior beyond serialized metadata. Free-form vocabulary moves todefineGraph({ annotations }), with one migration guide. A stored schema that still holds a custom-meta-edge relation the code no longer declares diffs asbreaking, with a migration action naming each relation, andevolve(),planEvolution()andremoveKinds()refuse to drop it, so the stored copy is discarded only by an explicitmigrateSchema().Behavior fixes that ride along.
paginate(),page()andstream()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(), andparts()/wholes()beyond a single step) is refused withConfigurationError(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, andStoreViewreads 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 caughtIdentityContradictionErrorno longer leaves the row behind. Merge apply counts the plan's own edge deletions when it judges a node delete'srestrictpolicy, so a branch that deletes an edge and then itsrestrictendpoint 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, edgebulkUpsertByIdand bulkgetOrCreateByEndpointsdecide 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 looserbroader/narrowertaxonomy and from instance-level edges that merely happen to describe containment.Identity merge carries truth rather than arbitrating it. A
sameassertion 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
partOfcreates 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
tscand 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.
cardinality: "one"acyclic: truecardinality: "one"acyclic: trueThe 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.
cardinality: "one"acyclic: trueEight-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 oneREPEATABLE READsnapshot 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_transitionsandtypegraph_identity_transition_retention, as base-schema release 6. The bump is one-way: a privileged open (createStoreWithSchema,createAdapterStoreWithSchema) orbackend.adoptBaseSchema()catches a database up in place, DML-only runtimes refuse withBaseSchemaMigrationErroruntil 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 explicitmigrateSchema()after the relations move toannotations. A schema version an earlier release committed keeps its document andschema_hashcolumn, 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 anacyclicor composition relation, deletes a composition whole, or converges on an edge match key needsINSERTandUPDATEontypegraph_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