Skip to content

Commit 406c723

Browse files
alfclaude
andcommitted
The nodehood merge had two implementations, and one was dead
R952 was filed to refile intent_node_metadata_defect and split it per grain. Absorbed here because the capture stages read it. Looking at it turned up what the item had not seen: the merge it belongs to exists twice. graphitron_node and graphitron_node_keycolumn are stored relations written by the graphitron gatherer, merging what the author wrote with what the generated class published and recording which tier answered. intent_resolved_node_type_id and intent_resolved_node_key_column restated the same tiers, the same pick and the same well-formedness gate as views, with intent_inferred_node_type as the published arm both stood on. The defect rule looked like it had four readers; it had two, counted once per implementation. The live side had already moved. intent_resolved_node_key_shape, seven readers, already stood on the stored relation; the key-column view had no production reader at all; the type-id view had one, StoreNodeTables, taking two columns graphitron_node carries. All three views go. Swapping the reads onto the stored relations is what proved the two agreed, and three cases did not. Each was the view admitting what the stored relation's foreign keys forbid: a pinned column the table lacks, forwarded as resolved; key columns for a @node with no @table, which is not a node; a metadata entry returned in the author's spelling rather than the catalog's. Two of those cases said in their own javadoc that they existed to record a difference rather than an outcome, and the difference is settled. One behaviour changed rather than being confirmed. An ambiguous binding now silences the pinned tier as well as the two table-reaching ones: a key column is a column of a table, so a name pinned against two candidate tables resolves against neither. What it buys is R952's stated goal without its split. DetectionReadReachGateTest loses three relations from ResolvedKeyProjections' reach and nothing replaces them, node identity being read off a table now rather than a tiered union re-expanded per driving row. The schema holds 125 views where it held 128. The fact model page's provenance rule is rewritten in the same change. It said the resolved value is always a view and never a stored merge, which graphitron_node had already stopped being true of. It now says that two sources of one value are at least three facts, that the derived fact owes an answer where its sources disagree, that how it is realised is the storage rule's question, and that the origin belongs on the derived row because it is the way back to the rest of what that source knows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDMJx75yxRTJACZ9fW2D5S
1 parent 72be35e commit 406c723

18 files changed

Lines changed: 238 additions & 235 deletions

File tree

docs/architecture/explanation/fact-model.adoc

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,17 @@ Where the predecessor's answer lives is a second question, and a smaller one tha
4242

4343
*Not mechanically enforced:* the check is a sentence said out loud before the DDL exists. The gates catch downstream shapes (an unregistered relation, a derivation with no anchor, a stored value that should be a view), never the naming that produced them.
4444

45-
== Provenance: pick the shape per fact
45+
== Provenance: every source is a fact, and the resolved value is another
4646

47-
Do not assume a fact's origin is a column on the fact. Authored and inferred values often come from independent walks (the directive decode versus the catalog or classpath), and when they do the natural form is separate relations coalesced by a view, not one relation with a provenance tag. When one value fills one slot, a sparse authored column plus a default rule suffices. Either way the resolved value is always a view over the populations, never a stored merge.
47+
Do not assume a fact's origin is a column on the fact, and do not assume that two sources of "the same" value are one fact at all. They are at least three. What the author wrote is one fact, stated where the author wrote it. What another corpus publishes is a second, stated where that corpus was read. What the generator should use is a third, derived from the others, and it is the only one a consumer of the resolved answer reads. A third source is a fourth fact and changes none of this. Collapsing them into one relation with an origin column loses every source but the winner, because a row can then hold only the value that won. Keeping the sources and calling their union the answer loses the derived fact, because a union is not a decision.
4848

49-
The claim stratum is the shipped separate-relations case: `intent_authored_field_claim` and `intent_authored_type_claim` union one arm per claiming `graphitron_` relation, `intent_column_match_claim` carries the structural reading with its join witnesses as payload, `intent_resolved_field_claim` is the reduction, and `intent_authored_claim_conflict` detects coordinates carrying contradictory claims. That relation is total over the authored claims and carries no population filter: an authored contradiction is a contradiction wherever it sits, so each consumer joins the population its own question needs (the build-error surface joins the classification domain, because only the emitted surface can fail a build; the editor's diagnostic arm reads the rows ungated). The conflict reduction lives in the view's SQL; `AuthoredClaimConflicts` mints the typed rejection from the closed verdict vocabulary and the coordinate's own claim rows, and applies the build-error population. The message is minted rather than derived because its naming order is the claim enum's declaration order, which is not a captured fact of any graph and so is no view's to express; a capture-cadence writer stores the same rejection in `intent_authored_claim_rejection` for the diagnostics surface to read as a plain column, which is why that surface's claim-conflict arm looks like its captured siblings instead of assembling a sentence in SQL. The one-slot case is `graphitron_node_entry`: `type_id` holds what the author wrote and stays null where they wrote nothing, so a surface reading the relation sees authored data only, by design, and the resolved value falls back to the type-name rule the relation's own comment names as a derivation.
49+
The derived fact owes an answer where its sources disagree, and that answer is the substance of the derivation rather than a detail of it. Precedence is one answer, and node identity takes it: an author writing `@node(typeId:)` beats the same table's published constant, because the author is saying something about this graph where the catalog is saying something about a database that many graphs may read. Refusal is another answer, and so is a row naming the contradiction so somebody can be told. A rule that picks silently where its sources conflict has taken the decision without recording that there was one, and the next reader cannot tell a resolved disagreement from an absence of one.
50+
51+
Record which source answered, and record it on the derived fact. It reads like a label and is not one: it is the way back. A reader holding `type_id_origin = JOOQ_METADATA` knows to join `sql_node_metadata` for that table, where the rest of what the generated class published is waiting, including the constants this rule did not need; a reader holding `SDL_DECLARED` knows to reach the entry instead, where the written position is, and can point at the author's own text. Without the column both are re-derivations of a decision the rule already took, and each reader takes it again and may take it differently. This is the answer to the objection that a tag no consumer branches on is inventory: consumers do not branch on it, they follow it, and a fact that opens a door is worth its column even where nothing behind that door is wanted yet.
52+
53+
How the derivation is realised is a separate question, and the storage rule below answers it rather than this section. A view states the rule where nothing forces otherwise. A stored relation is right where the derivation establishes a grain something else keys into, or where the cost of re-deriving it was measured rather than assumed. Nodehood is the shipped case of the stored form: `graphitron_node` and `graphitron_node_keycolumn` merge the authored and the published populations into the relations that are authoritative for nodehood, each carrying which tier answered, because that merge is what every consumer reads and because a tiered union re-expanded once per probe is the cost a stored derivation exists to remove. The sources survive it: `graphitron_node_entry` still holds what the author wrote, null where they wrote nothing, so a surface reporting on the author's own text reads that relation and not the merge.
54+
55+
The claim stratum is the shipped case of the other shape, where the derivation stays a view: `intent_authored_field_claim` and `intent_authored_type_claim` union one arm per claiming `graphitron_` relation, `intent_column_match_claim` carries the structural reading with its join witnesses as payload, `intent_resolved_field_claim` is the reduction, and `intent_authored_claim_conflict` detects coordinates carrying contradictory claims. That relation is total over the authored claims and carries no population filter: an authored contradiction is a contradiction wherever it sits, so each consumer joins the population its own question needs (the build-error surface joins the classification domain, because only the emitted surface can fail a build; the editor's diagnostic arm reads the rows ungated). The conflict reduction lives in the view's SQL; `AuthoredClaimConflicts` mints the typed rejection from the closed verdict vocabulary and the coordinate's own claim rows, and applies the build-error population. The message is minted rather than derived because its naming order is the claim enum's declaration order, which is not a captured fact of any graph and so is no view's to express; a capture-cadence writer stores the same rejection in `intent_authored_claim_rejection` for the diagnostics surface to read as a plain column, which is why that surface's claim-conflict arm looks like its captured siblings instead of assembling a sentence in SQL. The one-slot case is `graphitron_node_entry`: `type_id` holds what the author wrote and stays null where they wrote nothing, so a surface reading the relation sees authored data only, by design, and the resolved value falls back to the type-name rule the relation's own comment names as a derivation.
5056

5157
The consumer split is which population each reads: code generation wants the resolved answer, the editor gives feedback only on what the author wrote, the knowledge surface cites the authored source and reports the resolved value.
5258

graphitron-model/src/main/java/no/sikt/graphitron/model/capture/document/GraphitronAnchor.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ private static void nodeKeyColumns(DSLContext dsl, String graph, LocalDateTime t
661661
private static Table<?> routineApplications(DSLContext dsl, String graph) {
662662
var fd = GRAPHQL_AST_FIELD_DIRECTIVE_ENTRY;
663663
var ix = GRAPHQL_AST_ENTRY;
664+
var ee = GRAPHQL_AST_ELEMENT_ENTRY;
664665
var ef = GRAPHQL_ELEMENT_FIELD;
665666
return dsl
666667
.select(ef.TYPE_NAME.as(TYPE_NAME), ef.FIELD_NAME.as(FIELD_NAME),
@@ -672,8 +673,12 @@ private static Table<?> routineApplications(DSLContext dsl, String graph) {
672673
.from(fd)
673674
.join(ix).on(ix.GRAPH_NAME.eq(fd.GRAPH_NAME), ix.SOURCE_NAME.eq(fd.SOURCE_NAME),
674675
ix.SOURCE_LINE.eq(fd.SOURCE_LINE), ix.SOURCE_COLUMN.eq(fd.SOURCE_COLUMN))
675-
.join(ef).on(ef.GRAPH_NAME.eq(ix.GRAPH_NAME),
676-
ef.COORDINATE.eq(ix.ELEMENT_COORDINATE))
676+
// The coordinate comes off the enclosing element's own entry rather than off a
677+
// column on the index, which the supertype split moved: the index states the position
678+
// and the element entry beside it states what is written there.
679+
.join(ee).on(ee.GRAPH_NAME.eq(ix.GRAPH_NAME), ee.SOURCE_NAME.eq(ix.SOURCE_NAME),
680+
ee.SOURCE_LINE.eq(ix.PARENT_LINE), ee.SOURCE_COLUMN.eq(ix.PARENT_COLUMN))
681+
.join(ef).on(ef.GRAPH_NAME.eq(ix.GRAPH_NAME), ef.COORDINATE.eq(ee.COORDINATE))
677682
.where(fd.GRAPH_NAME.eq(graph))
678683
.and(fd.NAME.eq("routine"))
679684
.and(ef.ARGUMENT_NAME.isNull())

graphitron-model/src/main/java/no/sikt/graphitron/model/derive/ResolvedKeyProjections.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ private ResolvedKeyProjections() {}
4848
/**
4949
* Every relation this component's read names, its own and the ones {@link StoreNodeTables}
5050
* names on its behalf. The helper's reads are this component's reads: {@link #read} calls it
51-
* unconditionally, so a pass that reaches here evaluates {@code intent_resolved_node_type_id}
51+
* unconditionally, so a pass that reaches here evaluates {@code graphitron_node}
5252
* whatever the projection rows say.
5353
*/
5454
public static final Set<Table<?>> READS = StoreNodeTables.readsWith(

graphitron-model/src/main/java/no/sikt/graphitron/model/derive/StoreNodeTables.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
import java.util.stream.Stream;
1515

1616
import static java.util.stream.Collectors.toUnmodifiableSet;
17+
import static no.sikt.graphitron.model.Tables.GRAPHITRON_NODE;
1718
import static no.sikt.graphitron.model.Tables.GRAPHITRON_NODE_KEYCOLUMN;
18-
import static no.sikt.graphitron.model.Tables.INTENT_RESOLVED_NODE_TYPE_ID;
1919
import static no.sikt.graphitron.model.Tables.INTENT_RESOLVED_TYPE_BINDING;
2020
import static no.sikt.graphitron.model.Tables.SQL_COLUMN;
2121
import static no.sikt.graphitron.model.Tables.SQL_CONSTRAINT_COLUMN;
@@ -34,8 +34,8 @@
3434
* further in. Every component below is a captured fact and none is inferred: the table's own names
3535
* from {@code sql_table}, its columns from {@code sql_column}, its primary key through
3636
* {@code sql_primary_key}, the per-schema {@code Tables} constants class from
37-
* {@code sql_schema.tables_class_fqn}, the key list from {@code intent_resolved_node_key_column} and
38-
* the wire id from {@code intent_resolved_node_type_id}.
37+
* {@code sql_schema.tables_class_fqn}, the key list from {@code graphitron_node_keycolumn} and
38+
* the wire id from {@code graphitron_node}.
3939
*
4040
* <p>The constants class is the component worth naming, because its absence is what kept table
4141
* references walk-side. It is per schema and reachable only by loading it off the codegen classpath,
@@ -60,7 +60,7 @@ private StoreNodeTables() {}
6060
* caller, so repointing it edits one set.
6161
*/
6262
public static final Set<Table<?>> READS = Set.of(GRAPHITRON_NODE_KEYCOLUMN,
63-
INTENT_RESOLVED_NODE_TYPE_ID, INTENT_RESOLVED_TYPE_BINDING, SQL_COLUMN,
63+
GRAPHITRON_NODE, INTENT_RESOLVED_TYPE_BINDING, SQL_COLUMN,
6464
SQL_CONSTRAINT_COLUMN, SQL_PRIMARY_KEY, SQL_SCHEMA, SQL_TABLE);
6565

6666
/**
@@ -136,7 +136,7 @@ private record Binding(String nodeTypeName, String typeId, String sourceName,
136136

137137
private static List<Binding> bindings(DSLContext dsl, String graphName) {
138138
var b = INTENT_RESOLVED_TYPE_BINDING;
139-
var i = INTENT_RESOLVED_NODE_TYPE_ID;
139+
var i = GRAPHITRON_NODE;
140140
return dsl.select(i.TYPE_NAME, i.TYPE_ID, b.TABLE_SOURCE_NAME, b.TABLE_SCHEMA, b.TABLE_NAME)
141141
.from(i)
142142
.join(b).on(b.GRAPH_NAME.eq(i.GRAPH_NAME), b.TYPE_NAME.eq(i.TYPE_NAME),

0 commit comments

Comments
 (0)