ID |
|
|---|---|
Status |
Backlog |
Bucket |
architecture |
Priority |
3 |
Theme |
model-cleanup |
Created |
2026-09-02 |
Updated |
2026-09-02 |
Only the model owns gatherers: consumers trigger a refresh, they never report a fact
Goal
A gatherer needs intimate knowledge of the model, because it decides what a row means: which
coordinate a directive application attaches to, which corpus a fact belongs to, which arm of a
closed taxonomy a verdict is. So gatherers belong to graphitron-model and nowhere else, and the
model publishes no interface through which a consumer can report a fact. What a consumer may say is
that something changed; the model then decides what must be refreshed and refreshes it. When this
lands, the only calls across the boundary are a refresh trigger and a diagnostics payload, and
neither names a model concept.
roadmap/capture-without-the-materialization-refresh.md breaks the generator’s dependency on the
fact tier and stops at graphitron. It cannot reach further: graphitron-maven-plugin sits above
both tiers and legitimately depends on both, so the write-direction guard that item ships is a
ratchet on the generator and says nothing about the plugin. This item is the plugin’s half.
Words used here
Gatherer is the fact tier’s own word, registered in meta_gatherer with a corpus junction that
is the store’s definition of a crawler. Report means a consumer hands the model a value and the
model writes it as a row. Trigger means a consumer says a source moved and the model decides
what that implies.
The rule, stated so it can be applied
The model exposes no write API that carries model vocabulary. "No reporting at all" is the right instinct but slightly too broad, and the over-breadth is what makes a generic diagnostics relation look like a concession when it is not. A gatherer must be model-owned because it decides meaning. A located message decides nothing: position, severity, text, and which producer emitted it carry no model concept, so there is nothing for a caller to get wrong. The danger is proportional to the vocabulary the fact carries, and the rule should say so.
The corollary that settles the harder half: a corpus fact can always be gathered; a conclusion can
only be reported. The model can go read any corpus, the emitted tree included. It cannot go read a
decision that existed only in the memory of the tier that reached it.
roadmap/run-record-families-for-commands-and-emitted-units.md carries that corollary in full,
because it is where it decides something.
The four writers the dev session drives today
DevMojo constructs all four on its session store handle. None is reachable from
GraphQLRewriteGenerator; each records what the plugin observed rather than the generator’s
account of itself.
-
JavaSourceFactsis already a gatherer in the wrong hands. Both halves are model-owned code today:SourceWalkerandJavaSourceFactsare both in the fact tier’s move set, andcompileSourceRootsis a component ofRewriteContext, which also moves. So the plugin owns neither the walk nor the write nor the knowledge of where to walk; it owns the sequencing. This one collapses into a model-side entry point and takes a parameter away rather than adding one. It is also the one write that is not downstream of a generator pass at all: it seeds at startup and refreshes off a.javawatcher, its corpus being the consumer’s own sources. -
CompileFactsmust stay a report, and that is not a compromise. Its corpus is the emitted tree plus javac’s verdict on it, and the model cannot go get either: the compile engine cannot come down,AbiSignaturenaming javapoet seven times andTypeSpecReferenceWalkfour, so moving it would break the fact tier’s no-javapoet rule outright. The one family that structurally cannot be gathered is also the one carrying no vocabulary, which is the rule confirming itself rather than bending. -
BuildWarningFactsis the residue arm and carries no rule by definition, so it is a payload on the same terms. -
RejectionFactsis the case that fails hardest and needs no work here.rejection_validation_errorcarrieskindunder a CHECK,variant,lsp_code,attempt_kindandstub_key: the thickest vocabulary in the store, reported downward from the transitional classification walk. The fact model already calls the family scaffolding with no stratum, existing so a derivation can be diffed against that walk and retiring with it. So this item should not fold it into anything; it should let it die.
The trigger
The model already decides what to refresh from stamps: StoreRefresh.prepare does exactly this at
warm capture, reconciling only what it cannot prove unchanged. This generalises that from
capture-time to on-demand, and the API needs no vocabulary: a path is not a model concept, and the
model knows which corpus a path belongs to because it recorded the source_root`s and the
`store_source rows itself.
The emit side already computes the precise trigger for free. JavaFile.writeToPathReporting is
content-idempotent, hashing rendered content against the file on disk and writing only on a
difference, and it returns changed() per file. So "these files moved" is a list of paths the
incremental compiler had to compute anyway. A regeneration that changes nothing changes no file on
disk, leaves every stamp valid, and costs the refresh nothing.
One inconsistency to resolve while here: SourceWalker’s own per-instance cache keys on mtime,
while `java_file.stamp is a content hash, and the store’s column comment argues why the hash wins
("a checkout, a rebase or a container layer defeats" a timestamp). Two signals for one question. The
skip-if-identical writer means they agree in practice on generated output, which is not the same as
being right.
A generic diagnostics relation
javac_diagnostic and build_warning_no_rule are one relation with a producer tag: same grain, same
payload, different producer. Collapsing them is what makes the payload rule expressible as a single
write surface rather than one per producer. lint_ stays out, carrying a rule id and a fix tree
(lint_finding_fix, lint_finding_fix_edit) that is model vocabulary and whose own charter names
store-native predicates as its destination. rejection_ stays out for the reason above.
The fact model’s provenance guidance looks like it forbids this and does not, but the reading is
deliberate and a reviewer should be asked to confirm it rather than assume it. That guidance says
independent walks producing accounts of the same subject want separate relations coalesced by a
view, which is what the prefix-less diagnostic view is today. javac’s stream and the build’s
advisory residue are not two accounts of one subject; they are the same payload shape about
different subjects. "This run reported this message at this position with this severity from this
producer" survives the say-what-one-row-asserts test with no consumer, pass, or class named in it.
Sequencing
After roadmap/capture-without-the-materialization-refresh.md, which is what puts SourceWalker,
JavaSourceFacts, CompileFacts and the diagnostics writers in graphitron-model in the first
place. Nothing here is foreclosed by that item: every input type those writers take is in its move
set, so they compile wherever they land, and their caller sits above both tiers either way.
Independent of roadmap/run-record-families-for-commands-and-emitted-units.md, which reintroduces
what a run concluded on top of these boundaries. This item decides what may cross the boundary;
that one uses the crossing.