Repository navigation
Enforce cardinality-one on merge_nodes_in_tx's edge repoint - #2937
Conversation
SqliteStore::merge_nodes_in_tx re-points every relationship edge touching a merge's loser onto the survivor via a raw endpoint UPDATE, with no notion of a declared relationship's cardinality/ reverse_cardinality — the checks create_relationship_in_tx enforces (core#2804, replace semantics per #2902). A repoint could therefore leave the survivor with two live edges where a cardinality: One or reverse_cardinality: One relationship allows at most one, e.g. when survivor and loser each held their own compliant edge toward different targets before the merge. merge_nodes_in_tx now returns the raw repointed edges (relationship type, new source, new target) instead of just a count, since the store layer has no schema-resolution access to check cardinality itself. NodeService::merge_nodes, which already resolves declared relationships for create_relationship_in_tx, closes the gap in the same transaction via a new enforce_cardinality_after_merge_in_tx: for each repointed edge on a cardinality-one end, it evicts the edge via the existing remove_relationship_in_tx primitive, keeping the survivor's pre-existing edge and dropping the loser's repointed one. Built-in relationship types (has_child, mentions, member_of, has_role) carry no declared cardinality and are skipped. Adds two merge_nodes_test.rs cases covering the forward (cardinality: One) and reverse (reverse_cardinality: One) directions; the existing suite is unchanged and still passes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Code Review SummaryNet positive, closes #2908 correctly. I hand-traced the eviction direction in both the forward ( FindingsCritical IssuesNone found after real scrutiny of the two areas most likely to hide a bug (eviction direction, transaction/rollback correctness). Suggested Improvements
Nitpicks
|
mstomar125
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: APPROVE — net positive, no blockers (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
enforce_cardinality_after_merge_in_tx's reverse branch can evict an edge belonging to a node other than the merge survivor (the repointed edge's own source). If that relationship is also required: true on the source's schema and this was its only edge, remove_relationship_in_tx's last-edge guard rejects the eviction, and the Err aborts the entire merge transaction rather than just that one edge's repoint. This is a real, reachable, and correct (fail-closed) outcome for what is a genuinely irreconcilable conflict, but was previously undocumented and untested. - Expand enforce_cardinality_after_merge_in_tx's doc comment to explain the reverse branch's failure mode explicitly, why it's safe (atomic rollback, verified against SqliteStore::with_transaction), and note the has_child two-parent gap (filed separately as #2945) is unaffected by this method rather than implying it's currently guaranteed. - Add merge_rolls_back_entirely_when_reverse_cardinality_eviction_would_violate_a_required_relationship, exercising the trap end-to-end and asserting the rollback leaves zero partial state (loser stays active, both edges land back where they started). - Swap the reverse branch's source lookup from a raw get_node_in_tx to get_node_in_tx_or_virtual_date, matching create_relationship_in_tx's own resolver for a relationship's source instead of silently skipping enforcement for a not-yet-persisted virtual node. Addresses feedback from two independent review passes on PR #2937. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address-review summaryTwo independent review passes came back on this PR (the embedded pragmatic-code-reviewer above, plus a separate high-effort ✅ Addressed
⏭️ Filed as follow-up, not addressed here (correctly out of scope)
Skipped
Verification
Re-Review DecisionDecision: NO RE-REVIEW NEEDED Rationale: Per this repo's own The PR is ready for merge, pending the user's own review — not merging per the standing "coordinator does not merge" constraint on this task. |
…ionship's new return shape PR #2937 (merged to main after this branch's base) added enforce_cardinality_after_merge_in_tx, which calls resolve_declared_relationship expecting its old SchemaRelationship-only return. Rebasing surfaces the resulting compile break from this PR's own signature change (now returns the owners map alongside the match). Its reverse branch had the identical redundant-resolve pattern already fixed elsewhere in this file: it discarded resolve_declared_relationship's owners map, then immediately re-derived the same map for the same source.node_type via a second full-chain resolve_relationships call. Reuse the owners map from the tuple instead, for the same reason as the create_relationship_in_tx fix this mirrors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ionship's new return shape PR #2937 (merged to main after this branch's base) added enforce_cardinality_after_merge_in_tx, which calls resolve_declared_relationship expecting its old SchemaRelationship-only return. Rebasing surfaces the resulting compile break from this PR's own signature change (now returns the owners map alongside the match). Its reverse branch had the identical redundant-resolve pattern already fixed elsewhere in this file: it discarded resolve_declared_relationship's owners map, then immediately re-derived the same map for the same source.node_type via a second full-chain resolve_relationships call. Reuse the owners map from the tuple instead, for the same reason as the create_relationship_in_tx fix this mirrors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…2933) * Consolidate extends-chain parent-map resolution onto one edge source load_parent_map (schema/mod.rs) rebuilt the schema-extends parent map by loading and hydrating every schema node via get_all_schemas() and reading each one's declaration back off declared_parent()'s JSON-parsed SchemaRelationship, independently of NodeService::resolve_type_chain's single get_extends_parent_map() query over the same edges read directly off indexed relationship columns. Two independently-derived views of the same extends edges, with nothing guarding that they stayed in agreement. Point load_parent_map at get_extends_parent_map() directly. resolve_declared_relationship (relationship.rs) hand-walked the extends chain with its own per-scope get_schema_node + SchemaRelationship:: get_relationship lookups instead of calling the existing resolve_relationships helper, which already performs the identical nearest-first chain walk via the shared flatten_chain_by_name generalization. Delegate to it instead. This also closes a gap the old hand-walk carried: resolve_relationships excludes the extends/extended_by type-system bookkeeping row, which SchemaNode::get_relationship did not — naming "extends" as a relationship_name previously resolved a schema's own extends declaration as if it were a real relationship a data-level edge could be created under. Adds a regression test pinning that resolve_effective_fields (schema layer) and NodeService::resolve_field_owners agree on the effective field set for the same extends chain, and a test pinning that create_relationship now rejects "extends" as a declared relationship name the way any other undeclared name is rejected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address high-effort review: delete orphaned method, strengthen field test, document tradeoffs Review on PR #2933 surfaced three findings: - SchemaNode::get_relationship (models/schema_node.rs) lost its only production caller when resolve_declared_relationship was consolidated onto resolve_relationships, leaving it reachable only from tests. Deleted, and its 5 test call sites in schema_test.rs inlined to the equivalent relationships.iter().find(...). - The new single-source-of-truth regression test only compared sorted field names between resolve_effective_fields and resolve_field_owners, which can't catch the two resolvers agreeing on names while disagreeing on a field's type/protection/required/core_values/etc. Strengthened to also compare every field's full serialized form (SchemaField doesn't derive PartialEq — it's a nodespace-types wire type used far more broadly than this test, so reusing Serialize avoids widening that type just for this assertion) and gave Root's test field real attributes (required, an enum vocabulary) for the comparison to have something to diverge on. - resolve_declared_relationship's doc comment now spells out the two behavioral consequences of delegating to resolve_relationships instead of hand-walking with an early exit: every chain scope is now read before the nearest match is picked (no more stopping at the first declaring scope), and a real DB error from a farther, uninvolved ancestor now propagates where it previously wouldn't have been reached. Both are accepted rather than incidental — extends chains are shallow by construction, and every other single-name lookup through resolve_relationships/resolve_field_owners already pays this same cost (check_node_completeness, find_duplicate_for) — so this brings relationship creation in line with the rest of the codebase rather than leaving it as the one outlier with a bespoke early exit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address second review round: delete orphaned mutator, dedupe test lookups, cut redundant owners resolve get_relationship_mut had zero callers after the prior round deleted its sibling get_relationship — delete it too rather than leaving a structurally identical piece of dead code three lines below. schema_test.rs: replace 5 inline .relationships.iter().find(|r| r.name == "...") call sites with one test-local find_relationship helper, now that get_relationship's deletion left them duplicating the same lookup idiom. relationship.rs: resolve_declared_relationship built resolve_relationships's owners map and discarded it. create_relationship_in_tx's reverse_cardinality: One branch then re-derived the same owners map for the same schema_id with a second, redundant full-chain resolve_relationships call, inside the same open write transaction. Return the owners map from resolve_declared_relationship and reuse it instead, halving that transaction's chain resolutions without reintroducing a short-circuit hand-walk. The broader tradeoff (always resolving every scope rather than stopping at the nearest match) stays as documented: extends chains are shallow by construction, a short-circuit-then-fallback variant would reintroduce the duplicated per-scope walk this PR exists to remove, and the only real production extends declaration (the built-in linear methodology) is a single level deep. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Drop issue-number reference from find_relationship's doc comment The pre-push gate's check-code-references ratchet caught this: a core#NNNN citation in a code comment, against CLAUDE.md's "describe the behavior/constraint directly" rule. Describe the deletion/fold-in directly instead of citing the issue. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Adapt enforce_cardinality_after_merge_in_tx to resolve_declared_relationship's new return shape PR #2937 (merged to main after this branch's base) added enforce_cardinality_after_merge_in_tx, which calls resolve_declared_relationship expecting its old SchemaRelationship-only return. Rebasing surfaces the resulting compile break from this PR's own signature change (now returns the owners map alongside the match). Its reverse branch had the identical redundant-resolve pattern already fixed elsewhere in this file: it discarded resolve_declared_relationship's owners map, then immediately re-derived the same map for the same source.node_type via a second full-chain resolve_relationships call. Reuse the owners map from the tuple instead, for the same reason as the create_relationship_in_tx fix this mirrors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
SqliteStore::merge_nodes_in_tx(the node-ID-conflict/dedup merge path) re-points every relationship edge touching a merge's loser onto the survivor via a raw endpointUPDATE, with no notion of a declared relationship'scardinality/reverse_cardinality— the checkscreate_relationship_in_txenforces (core#2804, replace semantics per #2902). A repoint could therefore leave the survivor with two live edges where acardinality: Oneorreverse_cardinality: Onerelationship allows at most one — e.g. when survivor and loser each held their own compliant edge toward different targets before the merge.Closes #2908.
Approach
The store layer (
merge_nodes_in_tx) has no schema-resolution access, so it can't check cardinality itself — that logic (resolve_declared_relationship,resolve_relationships,type_satisfies) lives entirely inNodeService. Rather than plumb schema resolution down into the store (invasive), or duplicate the cardinality-check logic, I:merge_nodes_in_tx's return shape to hand back the raw repointed edges ((relationship_type, new_source_id, new_target_id)) instead of just a count (packages/core/src/db/sqlite_store/conflicts.rs).NodeService::enforce_cardinality_after_merge_in_tx(packages/core/src/services/node_service/relationship.rs), called byNodeService::merge_nodesimmediately after the store-level repoint, in the same transaction. For each repointed edge on a cardinality-one end, it evicts that edge via the existingremove_relationship_in_txprimitive — the same eviction callcreate_relationship_in_tx's replace semantics use — keeping the survivor's pre-existing edge and dropping the loser's freshly-repointed one. Built-in relationship types (has_child,mentions,member_of,has_role) carry no declared cardinality and are skipped;has_childsingle-parent is a separate, structural guarantee unaffected by this.NodeService::merge_nodes(packages/core/src/services/node_service/conflicts.rs) folds evicted edges into the merge's existingedges_droppedcount and subtracts them fromedges_repointed, soMergeOutcome/Resolution::Merge's wire shape is unchanged — an evicted repoint is reported the same way any other dropped edge already is.Test plan
cardinality: Oneedge toward different targets before a merge; after merging, exactly one edge remains and it's the survivor's original (merge_evicts_a_repointed_edge_that_violates_forward_cardinality_one)reverse_cardinality: Onedirection specifically, using the core-seededperson.tasks→taskdeclaration, merged on the target (task) side (merge_evicts_a_repointed_edge_that_violates_reverse_cardinality_one)cargo test -p nodespace-core --test merge_nodes_test— 9/9 pass (7 pre-existing + 2 new), zero regressionscargo test -p nodespace-core --lib db::sqlite_store::— 97/97 passcargo test -p nodespace-core --lib(full crate) — 1727/1727 passbun run quality:fix— clean (clippy-D warnings -D clippy::unused-async, fmt, eslint, tsc)test:all+cargo build --bin nodespaced+test:e2e) — passed🤖 Generated with Claude Code