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¶
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 → SpecandStory.dependencies → Storyare self-referential by design, so an unbounded walk is an incident waiting for the first cyclic board. Default 1, ceiling fromDNA_GRAPH_MAX_DEPTH. - Cycles terminate, and are still reported. The edge that closes a cycle
comes back flagged
closes_cycleand is simply not expanded from. Dropping it would hide the cycle instead of surviving it. scopeandtenantare 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 ended — complete, 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_schemareadsschema["properties"]and does not recurse intoitems, sub-objects,$reforoneOf. A reference atspec.foo.baris 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:
restrictanddelete_sourceare refused at load on a relation the kernel does not resolve (a compositeby, or a target spec field). Enforcement reads the edge graph, and only a resolved relation produces edges.allowstays 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¶
- Kinds — identity and composition — where
spec.relationsis declared. - Tenancy and layers — why
to_scopecan differ from the scope you asked from. dna graph --help—backfillandrefs.