Skip to content

The reference graph — what points at this instance?

A Kind declares what it points at, as a thing of its own:

spec:
  relations:
    feature:
      to: Feature                 # this field names a Feature
      cardinality: one
      inverse_of: stories         # and Feature.stories is the other half

That declaration has been enforced on write for a while: save a Story whose spec.feature names nothing and the kernel says so. What it could not do was answer the question a product actually asks —

What breaks if I delete or change this instance?

The Kind screen could say Story.feature → Feature exists as a rule. It could not say that these forty-seven Stories point at this Feature. This page explains the mechanism that closes that gap, and — just as importantly — what it deliberately refuses to claim.

The edge costs no extra read

Validating a declared reference means loading the target instance. The write path was already doing that, checking is not None, and throwing away both the instance and — for a polymorphic reference — which of the declared Kinds actually matched.

So the producer is not a second mechanism that scans instances and derives relationships. It is the same pass, keeping its result:

resolve_relations(...) -> (edges, problems, discords, complete)
                             │        │         │         └── a read failed part-way
                             │        │         └── the inverse the target does not return
                             │        └── what the validator vetoes on
                             └── what the edge table stores

discords rides along for the same reason edges does: the target instance is already in hand, so asking "does it name me back through the declared inverse_of?" costs nothing. It is REPORTED and never enforced — imposing a reciprocal pair deadlocks (neither half can be written first) and deriving one means the kernel writing an instance the author never touched.

One pass, one set of reads, two consumers. That matters for a reason beyond cost: an edge derived separately from the check could disagree with the check. Here it cannot — the edge is the validator's own finding.

The rows are written by the storage adapter inside the same transaction as the instance, alongside the event outbox. Either the instance and its relations both land, or neither does.

What an edge records about its target: the OwnerReference quartet

An edge points at an instance with four facts, not one — the same four Kubernetes puts in an ownerReference, for the same reasons:

column what it answers why the name alone is not enough
to_name what the author wrote it is what keeps the .dna/ diff readable
to_id which instance it hit delete and recreate under the same name and it is a different object
to_api_version which Kind it hit a Kind name is unique only while the registry refuses collisions
to_scope where it resolved scope inheritance means "not always mine"

to_api_version is the newest of the four (i-110.3), and it repaid a borrowed invariant. The FROM side of the table has carried from_api_version in its primary key since the edge table was created; the TO side carried only a Kind NAME, and a name identifies a Kind only because dna.kernel.kinds.registry refuses to register two Kinds sharing one under different apiVersions. The graph's integrity therefore rested, silently, on a rule belonging to another module — one with an open exception list. Multi-hop traversal now chains on apiVersion as well as name, and a guard goes red the day two Kinds share a name.

NULL means "not known", and it is never filled by guessing. A dangling edge has no target and therefore no target apiVersion; a row written before the column existed keeps NULL wherever the backfill could not identify the target by id. Traversal treats an unknown apiVersion as "let the hop through", so the tightening can only remove hops it can genuinely tell apart.

This table existed once before, empty

dna_edges was created in 2026 by a migration whose own comment described a producer that was never written, and dropped fourteen months later with zero rows in it. Nothing distinguished "this instance has no relations" from "nobody ever filled the table". That is why the producer, the migration and the test that proves a write puts a row in it ship together, and why the acceptance test fails if the table exists without the producer.

Asking the graph

$ dna graph refs Feature f-poder-de-grafo --direction in --depth 2
[1] Story/s-canvas --feature[0]--> Feature/f-poder-de-grafo
[1] Story/s-blueprint --feature[0]--> Feature/f-poder-de-grafo
[2] Task/t-77 --story_ref[0]--> Story/s-canvas
(3 edge(s); stop: complete)
GET /v1/kinds/Feature/instances/f-poder-de-grafo/refs?direction=in&depth=2

The walk is one recursive CTE in standard SQL — identical on PostgreSQL and SQLite, no server extension, no second query language. (Apache AGE was considered and rejected: it is a server extension the hosting platform allowlists rather than we do, and it would strand the SQLite and filesystem adapters that the SDK carries as first-class citizens.)

Three things about that walk are refusals rather than features:

  • Depth is mandatory and capped. Spec.supersedes → Spec and Story.dependencies → Story are self-referential by design, so an unbounded walk is an incident waiting for the first cyclic board. Default 1, ceiling from DNA_GRAPH_MAX_DEPTH.
  • Cycles terminate, and are still reported. The edge that closes a cycle comes back flagged closes_cycle and is simply not expanded from. Dropping it would hide the cycle instead of surviving it.
  • scope and tenant are filtered in every branch, not only in the anchor — the classic cross-tenant leak of this query shape is one forgotten line in the recursive step.

What the answer refuses to pretend

The graph is only worth having if it is honest about its own edges, so four signals travel with every answer.

A dangling reference is a row, not a gap. With DNA_REF_VALIDATION=warn (the default) an instance with an unresolvable reference persists. Its edge is stored with a null target and returned with resolved: false. Hiding it would render a graph tidier than the data deserves — and those rows are precisely the list of what is broken. Delete a Feature that forty-seven Stories cite and the forty-seven edges remain, now dangling: the delete path validates no references at all, so this is the only trace that anything broke.

A store that keeps no edges answers unsupported, never []. An empty list reads as "nothing points at this instance" — a claim only a store that actually records edges is entitled to make. The filesystem adapter has neither a transaction to write edges in nor a table to write them to, so it says so (HTTP 501).

stop says why the walk endedcomplete, depth_reached or truncated. A caller that cannot tell "this is everything" from "this is where I stopped" will render the second as the first.

graph_producer says whether the producer is even on. With DNA_REF_VALIDATION=off the write path performs no lookups, so it produces no edges. That is a defensible operational choice; a screen rendering the resulting emptiness as "no relations" is not.

And one refusal on the write side: if a read fails part-way through resolving a instance's references, the stored edges are not replaced. A partial edge set stored as though it were whole is a graph that lies while looking finished — strictly worse than one that is honestly absent, because the absent one can be labelled.

Instances written before the producer existed

They have no edges, and there is exactly one honest way to give them some:

$ dna graph backfill --scope my-workspace --dry-run
9 declared (Kind, field) pair(s) · 214 instance(s) · would write 231 edge(s), 6 dangling

The backfill asks the same declaration the producer reads. For each declared (Kind, field) pair it queries the instances whose spec carries that field — on PostgreSQL a JSONB key-existence predicate the existing GIN index serves directly, so it is a handful of queries rather than a walk over every instance. It is idempotent (the same delete-then-insert per instance the producer uses) and runs cold: nothing in it needs the write path to have been warm.

It is emphatically not a scanner that guesses relations from name prefixes. That mechanism is what got the first edge table cancelled: guessing produces adivinhação, not a model, and it makes every reference that does not follow the convention invisible to the graph.

An instance whose references cannot be resolved completely is left alone and its scope is reported as pending, so a screen can say "still being filled" rather than showing a confident nothing.

What is deliberately out of scope

  • No inference engine. No rules, no subsumption, no derived facts, no materialized transitivity. One row corresponds to one field value somebody wrote.
  • Declared references only. The schema graph also carries composition edges (dep_filters, never checked against data) and name-convention guesses. Calling this "the relations" would claim a completeness the producer does not have — which is why every surface qualifies it as declared relations (spec.relations, addressed by instance name).
  • Top-level fields only. references_from_schema reads schema["properties"] and does not recurse into items, sub-objects, $ref or oneOf. A reference at spec.foo.bar is invisible, and stays so.
  • Current state, not history. An edge carries the instance version it was derived from (so drift is detectable), but the table holds the present.

Answering the other half: on_target_delete

The question at the top of this page has two halves, and the graph on its own answers only the first. "What points at this?" is a read. "And what should happen to them when I delete it?" is a policy, and it has to be declared — because there is no answer that is right for every relation.

So a relation may say:

spec:
  relations:
    feature:
      to: Feature
      cardinality: one
      on_target_delete: restrict     # refuse to delete a Feature Stories hold
value deleting the target…
restrict is refused, and the refusal lists what is holding it
delete_source takes the referring instances with it, transitively
allow succeeds; the reference is left pointing at nothing

The vocabulary is Gel's (on target delete restrict | delete source | allow), transliterated by the one rule this repo applies to every YAML key. Gel's fourth value, deferred restrict, is deliberately absent: it defers the check to a transaction COMMIT, and delete_instance is one instance per call with no such boundary — the word could only behave as restrict.

The default is allow, which is not Gel's. Gel defaults to restrict, which is right for a schema authored up front in a migration somebody reads whole. Here the relations already exist — 63 of them across 84 Kinds, 33 producing edges — and not one was written by an author who had this question in front of them. A restrict default would have converted 33 silent declarations into refusals in a single commit. So the default is what the runtime already did, and declaring nothing changes nothing.

An explicit allow is not redundant

on_target_delete: allow and saying nothing produce the same behavior and are not the same statement. An AuditLog entry about an instance that was deleted must keep pointing at it — that is what an audit log is for, not a defect. Declaring allow says so, and turns a dangling reference from an unmodelled exception into policy the graph can report.

Two things it refuses, both for the same reason — a policy needs a mechanism:

  • restrict and delete_source are refused at load on a relation the kernel does not resolve (a composite by, or a target spec field). Enforcement reads the edge graph, and only a resolved relation produces edges. allow stays legal there, and is the useful thing to say.
  • A store that keeps no edge graph cannot see what points at an instance, so if any policy is declared the delete is refused (501) rather than performed. Deleting anyway would silently assert that nothing referenced it.

The whole cascade is planned before anything is removed: there is no transaction spanning a multi-instance delete, so a refusal discovered halfway would already have destroyed data on its way to saying no.

This is integrity of application, per field — not integrity imposed by the database. The systems that get the latter do DDL per type in an RDBMS, which is incompatible with a type created at runtime; every system in our group (Kubernetes, Foundry, Backstage, DataHub) gave it up the same way.

See also