ADR-0015: Explicit directed association metadata¶
Date: 2026-10-11. Status: initial native-association runtime and audited acceptance gates verified; chronological evidence below records earlier checkpoints.
Mapping contract¶
The generic and non-generic EF entity builders expose:
join.ToGraphRelationship("ACTED_IN",
sourceForeignKeyProperties: [nameof(Credit.ActorId)],
targetForeignKeyProperties: [nameof(Credit.FilmId)]);
The builder is the actual EF join entity builder, configured with EF foreign keys and a composite primary key. Each array selects one complete FK by its declared property order. The direction is explicitly source-principal to target-principal; no alphabetical or generated-name inference occurs. Arrays are copied into read-only annotations and validated again from finalized metadata.
GraphBindingCompiler now creates immutable node and association projections from the same finalized IModel, cached by model identity. AssociationEdgeBinding holds the relationship type, selected endpoint bindings, association key and payload bindings. Each endpoint links its finalized FK, compiled node and ordered dependent/principal property bindings. This adds no second model authority or tracker.
Validation boundary¶
- Node and edge storage annotations are mutually exclusive. Property bags are allowed only for explicitly mapped association storage.
- An association has exactly two non-owning, non-unique, required FKs to explicitly mapped node primary keys. FK slot sets are disjoint and their union is exactly the association primary key.
- Alternative principal keys, extra association keys, independently generated FK values, identified edge keys and edges used as relationship endpoints are rejected in this increment.
- Endpoint CLR/wire types must agree. Resolved converter instances must be identical (or both absent). Ordinary built-in mappings and a principal's inherited/shared explicit converter passed tests; separately constructed equivalent converters are conservatively rejected. No expression-string or CLR-type-only equivalence assumption is used.
- Two EF associations cannot own the same directed source-node/type/target-node storage tuple. Unique endpoint FKs are rejected because pair identity does not enforce one-to-one multiplicity across contexts.
- Only node inverse and skip navigations associated with these explicit joins are accepted. Their presence does not implement navigation query loading. Existing identifier, scalar, key and unsupported-model safeguards remain active.
Evidence and remaining work¶
Twenty focused tests passed centrally on the first compiled run; all 96 unit tests then passed. They cover immutable caching, explicit direction/self-associations, composite slot order, dictionary joins, reference/inverse navigation metadata, payload conversion and negative model cases. A separate read-only review checked the final guards and found no remaining concrete issue in the inspected metadata subset.
GetNode explicitly rejects association bindings. The current query and save paths therefore cannot silently persist a join as a node; tests verify rejection before transport. No native edge commands, database schema changes, relationship reads or database concurrency tests ran in this increment. Those remain GB-010/M4 work after the remaining live gates permit it.
Pure mutation planning follow-up¶
The next increment adds an unregistered GraphMutationPlanner, composing the existing node planner with association planning. Fifteen real-entry planner tests and fourteen renderer tests passed; the full unit suite reached 127. Neo4jDatabase still uses only the node planner, so this increment does not enable edge persistence.
Operations are ordered as node create, node update, association delete, association update, association add, then node delete. Input order is retained within each phase. This puts new endpoints before relationships and tracked relationship deletions before restrictive node deletions. Each operation represents one changed EF entry, regardless of its preceding lock commands. Required node bindings include unchanged association endpoints for future uniqueness-schema verification.
Added associations use current endpoint keys; modified/deleted associations use original keys. Principal store property names and validated dependent conversions determine endpoint parameters. Changed association keys, temporary keys and shared-identity replacements reject. Modified payloads include only modified properties plus any generated version; concurrency predicates use originals. Generated long versions remain pending plan values, with no state acceptance or publication during planning.
The renderer binds existing directed endpoints and uses MERGE with payload assignment only on creation. Existing associations are accepted only when mapped payload values agree. All mutation commands retain at most two physical candidates before checking payload or tokens, so a duplicate pair cannot be accepted merely because one candidate matches. Update/delete lock the singleton relationship through a dependent token self-assignment before evaluating original tokens, then mutate only the eligible singleton.
A future executor must verify endpoint uniqueness constraints, run every operation and its endpoint locks in one effective transaction, require exactly one result from each lock and mutation, and roll back on any mismatch. Source-then-target locking is not a global deadlock-prevention guarantee. Parameterization and rendered command structure are unit verified; pinned-server syntax, empty-result aggregation, duplicate detection, nullable token locking, writer races and rollback behavior still need live acceptance before execution wiring is accepted.
Association read-plan follow-up¶
GraphReadBinding now projects every finalized EF property into its existing property index. Source/target FK values come from the corresponding principal key store properties, decoded through the dependent property's validated converter. Payloads come from relationship properties. Typed owners and directed roots extend the pure Cypher plan without representing relationships as nodes. Plan collections are copied into immutable views, including record replacements.
Twenty-three new read-plan cases passed and all 157 unit tests passed. The runtime visitor still requires GetNode; association query roots are not activated. Association aggregates are explicitly rejected until they preserve the same corruption diagnostic as entity reads.
Before activation, queries must establish endpoint application-key uniqueness and reject multiple physical relationships for a directed endpoint/type pair before passing an entity row to EF. Payload filtering or pagination must not disguise a duplicate pair. Neither DISTINCT, a physical elementId substituted for the EF key, nor EF identity resolution establishes this invariant.
Pinned EF source shows that the existing injected materializers can retain the join's actual IEntityType, including shared dictionary types, and perform tracking fix-up from the materialized FKs. Tracking queries may connect to existing tracked principals; no-tracking queries do not fix the context, and a standalone identity-resolution manager cannot connect to principals tracked only by the context. A filtered association query must not mark an entire navigation loaded.
The separate materializer probe passed nineteen cases using real EF query services, materializer injection, state managers and projected value conversion. It verifies all six endpoint/join row orders, explicit/reference/inverse/skip fix-up, dictionary joins, originals and modified flags, repeated identity behavior, no-tracking isolation and malformed rows before partial tracking. Scripted rows bypass Cypher execution and production query-root translation; they are not live graph evidence. All 176 unit tests and the solution build pass, while runtime integration and live fresh-context acceptance remain required.
Guarded association rows and endpoint schema requirements¶
The prepared renderer now emits a correlated count for the exact bound source/type/target tuple, retaining at most two candidates before aggregation. This diagnostic is computed independently of payload predicates and pagination. GraphReadBinding.ReadRow requires a driver long count of exactly one before conversion or EF shaping. Scalar projections also carry every endpoint key, so missing or malformed keys cannot pass merely because the caller selected a payload. All selected values are decoded before the row reaches EF.
Each association read binding records its distinct endpoint node bindings. The shared GraphKeyConstraintValidator requires an observed node uniqueness or node-key constraint with exactly the mapped label and key-property set; duplicate property names in malformed observations reject. Existing node saves use this extracted check with their existing schema query. Future association query execution must verify these requirements against its actual selected database and must propagate schema-read failures. It must not infer property existence from uniqueness or create schema automatically.
The diagnostic covers returned endpoint pairs only. Completely filtered or skipped pairs do not receive a client-side check, and this is not a graph-wide integrity scan. Concurrent changes may affect a read; no snapshot or race-free uniqueness guarantee is established. A rejected row never reaches EF, but entities from earlier valid rows may already be tracked when a later row fails. Association aggregates remain unsupported until their diagnostic contract is implemented and verified.
The combined checkpoint passes 214 unit tests: 12 renderer guards, 12 row-decoder guards, 11 schema checks, 24 read-plan cases, 21 materializer cases and the existing suites. The full solution builds with zero warnings/errors. Eight direct-driver read-guard integration cases compile, covering full/scalar records, filtered duplicates with LIMIT, missing keys, direction/type, self-loops and empty results. They have not executed against Neo4j because the shared container allowance remains occupied. Runtime association queries and saves remain gated; compilation and scripted row tests do not establish live storage semantics.
Live command acceptance and external-writer deadlocks¶
The user approved a session-only resource exception, allowing one bounded GraphBridge fixture alongside foreign containers while preserving the 5 GiB reserve. Fresh startup succeeded on Neo4j 2026.09.0 Community with 1536 MiB and one CPU. All eight read guards passed against actual driver records. The first combined live run passed 55 of 56 tests; the external-writer race exposed a node/relationship lock cycle at commit.
The test had incorrectly treated the mutation count as implying a successful writer and required every loser to return count zero. An external writer that acquires relationship locks before commit-time node locks can deadlock against endpoint-first locking. Neo4j documents that the exact Neo.TransientError.Transaction.DeadlockDetected error aborts the transaction. Concurrent data access, error definition.
The corrected test records committed, stale-original and deadlock-aborted outcomes separately. It catches only that precise error in the external-writer scenario, finishes the victim immediately, and never retries. Exactly one writer must commit successfully; the resulting graph must contain one outgoing edge with version two and that winner's payload, plus the original two endpoints. Other exceptions fail. The two-provider race retains its strict one-success/one-stale-original contract. The focused 11-command suite and subsequent full 56-case integration suite passed; both actually exercised the deadlock-abort branch. No renderer lock change was needed, and no universal deadlock-free guarantee is made.
Command acceptance now permits connecting the unified mutation plan and guarded read binding to EF runtime services. Actual EF edge persistence, entry accounting, transaction failure handling and fresh-context tracking/fix-up still require the next integration gate; direct-driver command tests alone do not establish them.
Runtime association gate passed¶
The unified planner is now used by Neo4jDatabase; each operation's endpoint locks and mutation require cardinality one within the effective transaction. The executor verifies all node requirements, including unchanged endpoints of edge-only batches. It publishes pending generated values only after a successful owned commit or explicit batch and returns changed EF entries, not command counts. Ten real EF save cases passed, covering new/existing endpoints, selective payloads, versions, deletion ordering, missing schema/endpoints, physical duplicates, stale tokens, atomic rollback and two EF writers.
The runtime query expression now uses GraphReadBinding for node and association roots. Predicates and ordering reference the correct source, target or relationship property; scalar projections retain hidden endpoint keys. Enumeration verifies endpoint schema through the same selected database/transaction runner and decodes a complete guarded row before EF materialization. There is no additional state owner. Inherited custom FK converters also reject order/range operations when their principal conversion does not establish equivalent store ordering.
Eight live runtime query cases passed: explicit and dictionary associations, reference/inverse/skip fix-up, originals and identity, no-tracking modes, composite endpoint filters and paging, corrupt keys/duplicates, missing schema and borrowed transaction/cancellation/disposal. An actual fresh-context query → update → SaveChanges → fresh query → delete sequence passes. The first query run exposed a test constraint-name collision with the write suite; isolated query fixture names resolved it without changing production guards.
Final verification is a clean solution build, 216 passing unit tests and 74 passing live integration tests. This establishes the initial M3 association subset. It does not add Include, navigation predicates/loading, association aggregates, identified/parallel edges, globally consistent reads or automatic retries. Named dictionary read/fix-up is live verified; dictionary persistence needs its own acceptance before being advertised. Previously noted returned-row and partial-stream tracking limits still apply.
The follow-up earlier-phase closure added and passed dictionary persistence acceptance: both new/existing endpoint skip-navigation additions, dictionary payload updates, fresh-context queries and skip-navigation removal now have live evidence. The combined suite reaches 76 passing live cases with the same 216 unit cases. No production change was required; the remaining query, identity and isolation limitations above are unchanged.
Earlier-phase acceptance closure¶
The subsequent 80-case local live run includes matching/conflicting simultaneous Added associations and endpoint replacement/rollback. Matching additions accept both EF entries but leave one physical relationship at version 1; conflicting payloads yield one accepted winner and one pending concurrency loser without overwrite. Delete-old/add-new returns two saved EF entries; a missing new endpoint rolls back the old deletion and preserves pending states. Concurrency tests synchronize starts but do not guarantee lock-wait timing. These complete the audited M3 association gates; identified edges and broader query shapes remain later work.