[DD-018] Affinity transitivity: declared-rule graph closure, channel sharing consent, and derived-pair materialisation

DD Identifier

DD-018

Target version

v0.6.0

Context

DD-004 (#4 (closed)) defines Affinity transitivity in terms of shared attribute values, but DD-005 (v0.5.0, #5 (closed)) computes only direct pairs -- the source and target explicitly named by a single AffinityRule. DD-005's own text noted the gap as deferred, not resolved. Graph exploration during v0.6.0 preparation (Department -> Project -> Site, all sharing channel="geo", value="west") showed DD-004's wording admits two different readings, which must be disambiguated before any storage or invalidation design makes sense.

Options considered

Option Description Risk
1 -- Declared-rule graph closure Transitivity follows the chain of explicitly declared AffinityRules (D->P declared, P->S declared => D~S derived) None structural -- bridging only exists where a developer wired a path
2 -- (channel, value) equivalence class Any two instances sharing the same (channel, value) anywhere in the app registry are affine, rule or no rule Defeats DD-005's target_field safety net one level up -- unrelated models could become affine purely by channel-name coincidence

Decision

Option 1 is retained. Option 2 is rejected for the risk above.

Following declared-rule chains introduces its own new risk: two AffinityRules declared independently on different source models can reuse the same channel name for unrelated purposes and, if they share a common model as an endpoint, silently form an unintended chain. This is caught at declaration time by a new system check.

clade.E003 -- channel collision at a shared node

For a given channel name, consider the graph of every declared AffinityRule carrying it, across every source model in the app registry. If any model has degree > 1 in this graph under that channel name -- regardless of whether it is the declaring source or the named target on either edge -- the chain through that model is rejected by default.

shared flag on AffinityRule, default False

AffinityRule("region", to="app.Project", target_field="region", channel="geo", shared=True)

Consent is symmetric, per node, per channel: a model's participation in a given channel is unlocked for chaining only once every AffinityRule touching it under that channel name carries shared=True. Setting it on one side while the other stays False (the default) still fails E003. Channel-name reuse with no shared model between the two rules is always valid regardless of the flag -- E003 only fires on a genuine junction.

E003 cannot be evaluated per-model in isolation (unlike E001/E002) -- it inspects the full registry of declared AffinityRules for a given channel name across every installed app, at AppConfig.ready() / manage.py check time. Schema/declaration-time only -- no visibility into runtime data, does not evaluate value.

Materialisation of derived pairs

A new boolean field, Affinity.is_derived (default False), distinguishes derived rows from direct ones. No separate model, no change to the default read surface (affinities_of()/affinities_of_grouped() return direct and derived rows together).

Derived pairs are computed by self-join over already-materialised rows, not by re-walking AffinityRules or comparing field names on the pivot: D~S is derived whenever Affinity(D, P, channel, value) and Affinity(P, S, channel, value) both already exist with the same channel and the same value, for a channel where E003 has granted shared=True consent at P. This is correct by construction with respect to DD-004's "share attribute X" requirement -- if the values genuinely differ end-to-end, no two rows with a matching (channel, value) exist at the pivot in the first place, so nothing is derived.

This is a standard fixed-point closure (repeated join of direct + already-derived rows until no new row appears), the same shape as transitive-closure algorithms such as Floyd-Warshall over a finite, already-bounded set of rows. It converges naturally and needs no explicit depth limit -- bounded by the size of the connected shared=True component. A ring of shared=True edges simply closes into one clique, as explicitly consented to at every edge.

Invalidation

Both triggers extend the existing on_affinity_save/on_affinity_delete handlers, not new signal wiring:

  • Deletion of a bridging node. on_affinity_delete already purges every row (direct or derived) referencing the deleted instance, unconditionally. After that purge, the closure computation is re-run for every shared=True channel the deleted instance participated in, scoped to its former neighbourhood, so a derived pair still justified through an alternate pivot is recreated, and one that isn't stays absent.
  • Value change breaking a link. The existing delete-then-recreate in _sync_source_instance/_sync_target_instance already regenerates the changed instance's direct rows. The same closure recomputation runs after that regeneration completes -- a value change is, from the closure's point of view, equivalent to delete-then-reinsert of that instance's direct rows, so one code path covers both triggers.

Constraints to honour when this decision is implemented

  • E003 is a schema/declaration-time check only -- it must never consult instance data or evaluate value.
  • Consent for a channel at a node must be verified as symmetric across every AffinityRule touching that node under that channel, not just the one being added or edited.
  • Derived-row computation must not re-derive field-name matching on the pivot -- it operates purely on existing (channel, value) row equality.
  • No artificial depth or component-size limit is introduced pre-emptively -- recomputation cost on large shared=True components is an accepted, documented cost for v0.6.0 (same posture as DD-005's own write-time cost), revisited at v0.8.0 if it proves too expensive in practice.
  • clade.E003 schema consent is not a semantic guarantee: the narrative guide (#78 (closed), DD-017, concepts/affinity.md) must document, as known behaviour not a bug, that a shared=True chain can silently extend further than the developer who set one edge's flag can see.

Affected scopes

  • api -- public interface (AffinityRule.shared, Affinity.is_derived, clade.E003, closure computation)
  • tests -- pytest, coverage
  • docs -- concepts/affinity.md narrative guide (#78 (closed), DD-017)

References

Edited by biface