Skip to content

Enforce cardinality-one on merge_nodes_in_tx's edge repoint - #2937

Merged
mstomar125 merged 2 commits into
mainfrom
issue-2908-merge-cardinality
Sep 25, 2026
Merged

mstomar125 merged 2 commits into
mainfrom
issue-2908-merge-cardinality

Conversation

@mstomar125

Copy link
Copy Markdown
Contributor

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 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.

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 in NodeService. Rather than plumb schema resolution down into the store (invasive), or duplicate the cardinality-check logic, I:

  • Changed 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).
  • Added NodeService::enforce_cardinality_after_merge_in_tx (packages/core/src/services/node_service/relationship.rs), called by NodeService::merge_nodes immediately after the store-level repoint, in the same transaction. For each repointed edge on a cardinality-one end, it evicts that edge via the existing remove_relationship_in_tx primitive — the same eviction call create_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_child single-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 existing edges_dropped count and subtracts them from edges_repointed, so MergeOutcome/Resolution::Merge's wire shape is unchanged — an evicted repoint is reported the same way any other dropped edge already is.

Test plan

  • New test: two nodes each hold their own compliant cardinality: One edge 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)
  • New test: the reverse_cardinality: One direction specifically, using the core-seeded person.tasks → task declaration, 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 regressions
  • cargo test -p nodespace-core --lib db::sqlite_store:: — 97/97 pass
  • cargo test -p nodespace-core --lib (full crate) — 1727/1727 pass
  • bun run quality:fix — clean (clippy -D warnings -D clippy::unused-async, fmt, eslint, tsc)
  • Pre-push gate (test:all + cargo build --bin nodespaced + test:e2e) — passed

🤖 Generated with Claude Code

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>
@mstomar125

Copy link
Copy Markdown
Contributor Author

Code Review Summary

Net positive, closes #2908 correctly. I hand-traced the eviction direction in both the forward (cardinality: One) and reverse (reverse_cardinality: One) branches of enforce_cardinality_after_merge_in_tx against actual DB semantics — confirmed create_generic_relationship_in_tx(tx, source_id, target_id, ...) maps source_id → in_node, target_id → out_node (packages/core/src/db/sqlite_store/relationships.rs:690-729), and that merge_nodes_in_tx's repoint pushes (relationship_type, new_in, new_out) — so both branches keep the survivor's pre-existing edge and evict only the just-repointed one, in both directions. Verified live: cargo test -p nodespace-core --test merge_nodes_test → 9/9 pass (7 pre-existing + 2 new, real output below); cargo test -p nodespace-core --lib db::sqlite_store:: → 97/97 pass; cargo clippy -p nodespace-core --lib --tests -- -D warnings -D clippy::unused-async → clean. Confirmed the reused primitives (resolve_declared_relationship, resolve_relationships, type_satisfies, get_relationship_edges_from_source_in_tx, get_relationship_edges_into_target_in_tx, remove_relationship_in_tx) are untouched by this diff (no fn signature lines appear in the relationship.rs diff for any of them) — genuine reuse, not duplicated logic.

running 9 tests
test get_conflict_returns_none_for_an_unknown_id ... ok
test merge_archives_the_loser_not_hard_deletes_it ... ok
test get_conflict_returns_the_record_by_its_own_id ... ok
test merge_drops_a_repoint_that_would_collide_with_an_existing_survivor_edge ... ok
test merge_closes_the_conflict_record_with_a_merge_resolution ... ok
test merge_unions_properties_survivor_wins_ties_and_captures_superseded ... ok
test merge_repoints_has_child_and_preserves_order ... ok
test merge_evicts_a_repointed_edge_that_violates_forward_cardinality_one ... ok
test merge_evicts_a_repointed_edge_that_violates_reverse_cardinality_one ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Findings

Critical Issues

None found after real scrutiny of the two areas most likely to hide a bug (eviction direction, transaction/rollback correctness).

Suggested Improvements

  • packages/core/src/services/node_service/relationship.rs, enforce_cardinality_after_merge_in_tx's reverse branch (if target_id == survivor_id): the eviction call remove_relationship_in_tx(tx, source_id, relationship_type, target_id) removes the edge from source_id's (not the survivor's) edge set. If that relationship is also required: true on source_id's node type (direction Out) and this was source_id's only edge of that type, remove_relationship_in_tx's last-edge guard rejects with Err, which propagates out of enforce_cardinality_after_merge_in_tx → out of the with_transaction closure in merge_nodes → the whole merge rolls back. I verified this rollback is clean: SqliteStore::with_transaction (packages/core/src/db/sqlite_store/tx.rs:86-107) commits on Ok, explicitly rolls back on Err (with Drop = ROLLBACK as a backstop), and NodeService::with_transaction (node_service/mod.rs:673-706) discards the buffered event envelopes on that Err path rather than flushing them — so there's no partial state and no spurious event. This is a real, reachable scenario for any custom schema declaring reverse_cardinality: One + required: true forward (not exercised by the new tests — person.tasks is required: None, so it doesn't trip this). It is also not a new gap: create_relationship_in_tx's own reverse-cardinality eviction (relationship.rs ~1008-1056) has the identical trap by explicit design — its comment says a real "last required edge" there "correctly stays rejected either way." So failing the whole merge here is a consistent, fail-closed trade-off, not a regression. What's missing is that enforce_cardinality_after_merge_in_tx's doc comment only explains why the forward branch's insert-then-evict ordering is safe from this trap ("the repoint has already landed...") — it says nothing about the reverse branch still being able to fail outright and abort the entire merge. A caller hitting this would see an opaque "relationship is required and this is its last edge" error out of a merge_nodes call with no hint that an unrelated node's required-relationship count caused it. Recommend either an explicit doc-comment callout of this failure mode, or (better) a test exercising it with a required: true + reverse_cardinality: One custom declaration, so the fail-closed behavior is pinned down rather than incidental.

  • packages/core/src/db/sqlite_store/conflicts.rs, merge_nodes_in_tx's has_child repoint (unchanged by this PR): it can still leave a survivor with two has_child parents post-merge. Scenario: survivor already has parent A (A -[has_child]-> survivor), loser has parent B (B -[has_child]-> loser); after the merge's repoint, B's edge becomes B -[has_child]-> survivor, alongside A's pre-existing edge. The repoint's only collision guard is the (in_node, out_node, relationship_type) unique-index check (idx_rel_unique, schema.rs:87), which doesn't catch "two different in_nodes into the same out_node." I confirmed there's no DB-level single-parent constraint either — the relationship table's triggers only cover collection-is-root (schema.rs:307-325), nothing enforces has_child single-parent structurally. merge_repoints_has_child_and_preserves_order in merge_nodes_test.rs doesn't cover this case (it only gives the loser a child, never a parent, before merging). This is pre-existing — the has_child repoint logic itself is untouched by this diff, only the return type changed — and is legitimately out of scope for issue merge_nodes_in_tx bypasses cardinality/reverse_cardinality enforcement #2908, which is explicitly scoped to declared cardinality/reverse_cardinality, not built-in structural constraints. Worth filing as a separate follow-up issue: the new doc comment's claim that "has_child single-parent is a separate, structural guarantee unaffected by this" is true in the narrow sense that this PR doesn't change it, but could read as implying that guarantee is actually held elsewhere post-merge — right now it isn't.

Nitpicks

  • Nit: enforce_cardinality_after_merge_in_tx re-resolves resolve_declared_relationship/resolve_relationships fresh on every loop iteration, even for repeated relationship types touching the same schema. Fine at merge's typical edge-count scale — not worth adding a per-call cache for.

@mstomar125 mstomar125 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@mstomar125

Copy link
Copy Markdown
Contributor Author

Address-review summary

Two independent review passes came back on this PR (the embedded pragmatic-code-reviewer above, plus a separate high-effort /code-review) — both converged on the same substantive finding, and the second added one smaller consistency note. Verdict from both was net-positive / no blockers; addressed everything raised anyway, per CLAUDE.md's unconditional address-review step.

✅ Addressed

  1. [Improvement] Reverse-branch rollback trap, undocumented + untested (both reviews) — enforce_cardinality_after_merge_in_tx's reverse (reverse_cardinality: One) branch evicts an edge belonging to the repointed edge's source node, not the survivor. 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 that Err aborts the entire merge transaction, not just that one edge. Traced this is a real, reachable, and correct outcome (the conflict is genuinely irreconcilable: keeping the edge violates reverse_cardinality: One, evicting it violates required: true on an unrelated node — failing closed is the only safe choice, exactly matching create_relationship_in_tx's identical pre-existing trade-off in its own reverse-cardinality eviction).

    • Expanded the method's doc comment (packages/core/src/services/node_service/relationship.rs) to explain this failure mode explicitly instead of only covering the safe forward-branch case.
    • Added merge_rolls_back_entirely_when_reverse_cardinality_eviction_would_violate_a_required_relationship (packages/core/tests/merge_nodes_test.rs) — exercises the trap end-to-end and asserts the transaction rollback leaves zero partial state: loser stays active (not archived), both edges land back exactly where they started, nothing repointed durably.
  2. [Nit-adjacent, second review] get_node_in_tx vs get_node_in_tx_or_virtual_date asymmetry — the reverse branch's source-node lookup used the raw resolver instead of the virtual-date-aware wrapper create_relationship_in_tx uses for exactly this purpose, so a not-yet-persisted virtual node as an edge's source would silently skip enforcement instead of being resolved. Confirmed currently unreachable (no schema is registered under node_type: "date"), but swapped it for consistency and to close the latent trap defensively — costs nothing, matches the established pattern.

⏭️ Filed as follow-up, not addressed here (correctly out of scope)

  1. [Improvement] Pre-existing has_child two-parent gap (both reviews) — merge_nodes_in_tx's raw repoint can still leave a survivor with two has_child parents (different in_nodes into the same out_node isn't caught by the unique-index collision check). Confirmed this predates this PR (the has_child repoint logic itself is unchanged — only the return type changed) and is out of scope for merge_nodes_in_tx bypasses cardinality/reverse_cardinality enforcement #2908, which is specifically scoped to declared cardinality/reverse_cardinality, not built-in structural constraints. Also tightened the doc comment's wording so "unaffected by this" can't be misread as "currently guaranteed." Filed as merge_nodes_in_tx can leave a survivor with two has_child parents #2945 to track separately.

Skipped

  • Second review's framing that the required-vs-cardinality conflict "should" let the merge succeed — traced by hand and confirmed there's no resolution that satisfies both invariants simultaneously (see point 1 above); failing the merge is correct, not a bug. No code change warranted beyond documenting + testing it (done above).

Verification

  • cargo test -p nodespace-core --test merge_nodes_test → 10/10 pass (8 pre-existing + 2 new: the original PR's forward/reverse cardinality tests, plus this round's rollback test)
  • cargo test -p nodespace-core --lib db::sqlite_store:: → 97/97 pass
  • bun run quality:fix → clean, 0 warnings/errors
  • Pre-push gate (test:all + cargo build --bin nodespaced + test:e2e) → passed, pushed as e20c458a

Re-Review Decision

Decision: NO RE-REVIEW NEEDED

Rationale: Per this repo's own /address-review criteria — no critical issues were found by either pass; the changes made are a doc-comment expansion (documentation), a new test exercising already-existing, unchanged production logic (no new functionality, no architectural change), and a one-line resolver swap that's a drop-in equivalent already established elsewhere in this exact file for the identical purpose, with both reviews independently confirming it's currently unreachable either way. The underlying cardinality-enforcement algorithm itself (both branches, the eviction primitives, the transaction wrapper) is byte-for-byte unchanged from what both reviews already scrutinized at high effort and traced by hand. All changes are straightforward, low-risk, and test-verified.

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.

@mstomar125
mstomar125 merged commit ce57a85 into main Sep 25, 2026
@mstomar125
mstomar125 deleted the issue-2908-merge-cardinality branch September 25, 2026 11:39
mstomar125 added a commit that referenced this pull request Sep 25, 2026
…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>
mstomar125 added a commit that referenced this pull request Sep 28, 2026
…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>
mstomar125 added a commit that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

merge_nodes_in_tx bypasses cardinality/reverse_cardinality enforcement

1 participant