:claude/graphitron-rewrite
+```
diff --git a/README.md b/README.md
index edb73226f4..50a40e4ab4 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,28 @@ Graphitron is used as a Maven plugin, offering two main functionalities:
- **Java Code Generation**: Creates Java source code from GraphQL schemas.
- **Schema Transformation**: Modifies GraphQL schemas based on various configurations.
-For more information, see:
-- [Vision and Goals](./VISION.md)
+## Documentation
+
+**New to Graphitron?** Start with the [documentation guide](./docs/README.adoc) for conceptual understanding:
+- [Vision and Goal](./docs/vision-and-goal.adoc) - What problem Graphitron solves and why
+- [Graphitron Principles](./docs/graphitron-principles.adoc) - Design philosophy for building systems that last decades
+- [Code Generation Triggers](./docs/architecture/reference/code-generation-triggers.adoc) - Schema patterns → what gets generated
+
+**Ready to use Graphitron?** See the technical reference documentation:
- [Online documentation](https://graphitron.sikt.no/)
-- [Maven Plugin README](./graphitron-maven-plugin/README.md) for plugin goals and configuration reference.
-- [Code-generator README](./graphitron-codegen-parent/graphitron-java-codegen/README.md) for detailed information on how to configure and use the Graphitron Java Code Generator.
-- [Schema Transform README](./graphitron-schema-transform/README.md) for information on how to transform GraphQL schemas.
-- [Common Module README](./graphitron-common/README.md) for exception handling framework and shared utilities.
-- [Example project README](./graphitron-example/README.md) for an example of how to use Graphitron.
+- [Tutorial](./docs/manual/tutorial/index.adoc) - A fresh checkout to a working GraphQL query against the Sakila example
+- [How-to guides](./docs/manual/how-to/index.adoc) - Day-to-day recipes: joins, conditions, mutations, services, pagination, errors, federation
+- [Reference](./docs/manual/reference/index.adoc) - Every directive, Maven plugin parameter, runtime API entry point, and diagnostic code
+- [Example project README](./graphitron-sakila-example/README.md) - A runnable Quarkus + JAX-RS reference application
+
+## Building
+
+The repo root is a single Maven reactor. Build it with the `local-db` profile
+(which switches jOOQ codegen to a native PostgreSQL; see `CLAUDE.md` for the
+catalog-jar footgun):
+
+```bash
+mvn install -Plocal-db
+```
+
+See the [Tutorial](./docs/manual/tutorial/index.adoc) for the end-to-end build and query flow.
diff --git a/VISION.md b/VISION.md
index 82f0c6d93e..a50c386fc1 100644
--- a/VISION.md
+++ b/VISION.md
@@ -42,6 +42,7 @@ Graphitron generates:
- All the code that fetches data from the database
- Efficient handling of nested data
- Proper batching to avoid redundant database calls
+- Resolver wiring that delegates to your own service classes for fields requiring custom logic
The generated code is:
diff --git a/docs/README.adoc b/docs/README.adoc
new file mode 100644
index 0000000000..17d56adcf6
--- /dev/null
+++ b/docs/README.adoc
@@ -0,0 +1,66 @@
+= Graphitron Documentation
+:toc: macro
+
+This folder is the source for the Graphitron documentation site at https://graphitron.sikt.no/.
+
+The site is built by Maven via the `graphitron-docs` module (this directory) and deployed to GitHub Pages by the `docs-build` / `docs-deploy` jobs in `.github/workflows/rewrite-build.yml` (trunk pushes only). PR preview builds run through `.github/workflows/preview-docs.yml` and upload `target/generated-docs/` as a workflow artifact.
+
+toc::[]
+
+== What's in this directory
+
+[cols="1,3"]
+|===
+| `index.adoc`
+| Site landing page.
+
+| `*.adoc`
+| Top-level product pages (vision, principles, security, dependencies, FAQ, ...).
+
+| `_theme/site.css`
+| Site-specific styles. Built on top of the Sikt Design System tokens fetched from `@sikt/sds-core` and `@sikt/sds-button` at build time.
+
+| `images/`
+| Logos, illustrations, favicon.
+
+| `pom.xml`
+| Maven module wiring. Plugins: `download-maven-plugin` (fetches SDS CSS), `maven-resources-plugin` (stages source + theme), `asciidoctor-maven-plugin` (renders HTML).
+
+| `target/`
+| Build output. Gitignored.
+|===
+
+The contributor-facing architecture docs live under `/docs/architecture/` (Diataxis-shaped) and the roadmap under `/roadmap/`. Both render into this site, but their authoring conventions stay in their own folders.
+
+== Building locally
+
+[source,bash]
+----
+mvn -pl :graphitron-docs -am package
+----
+
+Output lands in `docs/target/generated-docs/`. Open `index.html` in a browser to spot-check.
+
+To skip the AsciiDoctor render in a local Maven run (the JRuby startup adds ~10s):
+
+[source,bash]
+----
+mvn install -P!docs -Plocal-db
+----
+
+The resources copy and any roadmap-tool emit still run, so the staging tree is verified even when the HTML render is skipped.
+
+== Authoring conventions
+
+* File extension is `.adoc` (not `.asciidoc`, not `.asc`).
+* One H1 per file, set as the page title via `= Title`.
+* Cross-page links use `xref:` rather than raw URLs.
+* Code blocks use `[source,java]` (or `xml`, `yaml`, ...) with optional callouts.
+* Images live in `images/`; never hot-link to the deployed site.
+* `include::` does not resolve in GitHub's web preview; prefer self-contained pages and reserve `include::` for genuine reuse.
+
+GitHub renders `.adoc` natively in the web UI, so in-repo browsing works for plain prose. Treat in-repo rendering as a fallback; the deployed site is canonical.
+
+== Errors-vs-warnings
+
+The AsciiDoctor build is configured `failIf severity=WARN`: missing xrefs, missing includes, and unresolved attributes fail the build. We don't tolerate "warnings" in docs the way we sometimes do in compiler output; a missing xref is doc rot.
diff --git a/docs/_theme/docinfo-footer.html b/docs/_theme/docinfo-footer.html
new file mode 100644
index 0000000000..ac0d425640
--- /dev/null
+++ b/docs/_theme/docinfo-footer.html
@@ -0,0 +1,49 @@
+
+
diff --git a/docs/_theme/docinfo-header.html b/docs/_theme/docinfo-header.html
new file mode 100644
index 0000000000..467dc03601
--- /dev/null
+++ b/docs/_theme/docinfo-header.html
@@ -0,0 +1,17 @@
+
diff --git a/docs/_theme/site.css b/docs/_theme/site.css
new file mode 100644
index 0000000000..2f290b505d
--- /dev/null
+++ b/docs/_theme/site.css
@@ -0,0 +1,661 @@
+/*
+ * Graphitron documentation site theme.
+ *
+ * Built on the Sikt Design System tokens fetched into css/sds-core.css and
+ * css/sds-button.css at build time by download-maven-plugin. Those files
+ * expose the --sds-color-brand-*, --sds-color-text-*, --sds-color-layout-*,
+ * --sds-typography-*, and --sds-space-* custom properties consumed below.
+ *
+ * Selector targets: AsciiDoctor's defaults (#header, #content, .sect1,
+ * .admonitionblock, etc.), plus role-driven hooks (.hero, .feature,
+ * .nav-header, .site-footer) that the docinfo and index pages use.
+ */
+
+@import url("sds-core.css");
+@import url("sds-button.css");
+
+/* ---------- base ---------------------------------------------------------- */
+
+:root {
+ --site-content-max: 1180px;
+ /* Responsive horizontal page padding: 24px on mobile, 32px on tablet,
+ * 48px on desktop. Inherits the design system's 45rem / 64rem breakpoints
+ * from sds-core.css, so this var changes value without a media query. */
+ --site-content-pad: var(--sds-space-padding-large);
+ /* SDS declares `color-scheme: light dark` and resolves every token via
+ * light-dark(), so a dark-mode browser would auto-flip the page palette.
+ * Rouge's inlined syntax-highlight stylesheet (source-highlighter: rouge)
+ * is tuned for a white background — dark-blue strings, dark-red keywords,
+ * mid-gray comments — which become illegible on the darkened . The
+ * rest of the site (hero band, admonitions, footer, callouts) is also
+ * only designed for light. Pin the whole site to light until a real
+ * dark theme exists. */
+ color-scheme: light;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--sds-color-layout-page-default, #ffffff);
+ color: var(--sds-color-text-primary, #1a1a1a);
+ font-family: var(--sds-typography-font-family-default, "Haffer", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
+ font-size: 16px;
+ line-height: var(--sds-typography-body-lineheight-regular, 1.6);
+ /* Belt-and-suspenders: guarantee the page itself never exceeds the
+ * viewport width regardless of what wide content (tables, pre blocks,
+ * images, embedded SVGs) lands inside #content. Without this, a single
+ * unconstrained child can widen the body and drag the sticky
+ * .nav-header off-screen on horizontal scroll, since sticky only
+ * pins on the y-axis. `clip` rather than `hidden` because clip does
+ * not establish a scroll container, so position: sticky on .nav-header
+ * keeps tracking the viewport. */
+ overflow-x: clip;
+}
+
+a {
+ color: var(--sds-color-text-primary, #1a1a1a);
+ text-decoration: underline;
+ text-underline-position: under;
+}
+a:hover, a:focus-visible {
+ background-color: var(--sds-color-interaction-primary-transparent-highlight, transparent);
+ color: var(--sds-color-text-primary, #1a1a1a);
+}
+a:active {
+ background-color: var(--sds-color-interaction-primary-transparent-pressed, transparent);
+}
+
+/* ---------- top nav header (docinfo) -------------------------------------- */
+
+.nav-header {
+ background: var(--sds-color-layout-background-primary, #ffffff);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
+ padding: var(--sds-space-padding-small) var(--site-content-pad);
+ position: sticky;
+ top: 0;
+ z-index: 50;
+}
+.nav-header-inner {
+ max-width: var(--site-content-max);
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ gap: var(--sds-space-gap-medium);
+}
+.nav-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--sds-space-gap-small);
+ text-decoration: none;
+ font-weight: 700;
+ color: var(--sds-color-text-primary, #1a1a1a);
+}
+.nav-brand:hover {
+ background: transparent;
+ text-decoration: none;
+}
+.nav-brand img {
+ height: 28px;
+ width: auto;
+}
+.nav-links {
+ display: flex;
+ gap: var(--sds-space-gap-medium);
+ margin-left: var(--sds-space-gap-tiny);
+ flex-wrap: wrap;
+}
+.nav-links a {
+ text-decoration: none;
+ color: var(--sds-color-text-primary, #1a1a1a);
+ font-weight: 500;
+ padding: 6px 4px;
+}
+.nav-links a:hover {
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+ text-decoration: underline;
+ background: transparent;
+}
+.nav-spacer {
+ flex: 1;
+}
+.nav-github {
+ text-decoration: none;
+ font-weight: 500;
+}
+.nav-github:hover {
+ background: transparent;
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+}
+
+/* ---------- layout -------------------------------------------------------- */
+
+#header, #content, #footer {
+ max-width: var(--site-content-max);
+ margin: 0 auto;
+ padding: 0 var(--site-content-pad);
+}
+
+#header h1 {
+ margin: 0 0 6px 0;
+ padding: 28px 0 12px;
+ font-weight: 700;
+ font-size: 2.3rem;
+ letter-spacing: -0.01em;
+}
+#header .details {
+ color: var(--sds-color-text-secondary, #6b7280);
+ font-size: 0.9rem;
+}
+
+#content {
+ /* No top padding so a full-bleed .hero-band (homepage) butts directly
+ * against #header / .nav-header above. Content pages get their top
+ * spacing from #header h1's padding. */
+ padding-top: 0;
+ padding-bottom: var(--sds-space-padding-huge);
+}
+
+/* AsciiDoctor body class scope; `body.book` etc. shouldn't change layout. */
+body.article, body.book {
+ background: var(--sds-color-layout-page-default, #ffffff);
+}
+
+/* ---------- typography ---------------------------------------------------- */
+
+h1, h2, h3, h4, h5, h6 {
+ color: var(--sds-color-text-primary, #1a1a1a);
+ font-weight: 700;
+ line-height: 1.25;
+ margin: 1.6em 0 0.6em;
+ letter-spacing: -0.005em;
+}
+/* AsciiDoctor wraps each heading title in `` so the heading
+ * is itself a clickable anchor. The body `a` rule would otherwise tint every
+ * heading with the link color — inherit instead. */
+h1 .link, h2 .link, h3 .link, h4 .link, h5 .link, h6 .link {
+ color: inherit;
+ text-decoration: none;
+}
+h1 .link:hover, h2 .link:hover, h3 .link:hover, h4 .link:hover, h5 .link:hover, h6 .link:hover {
+ color: inherit;
+ background: transparent;
+ text-decoration: none;
+}
+h2 {
+ font-size: 1.7rem;
+ border-bottom: var(--sds-space-border-weight-thin) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ padding-bottom: 6px;
+}
+h3 { font-size: 1.3rem; }
+h4 { font-size: 1.1rem; }
+
+/* AsciiDoctor section anchor on h2/h3 hover */
+h2:hover .anchor::before, h3:hover .anchor::before {
+ visibility: visible;
+}
+
+p, li {
+ color: var(--sds-color-text-primary, #1a1a1a);
+}
+
+code, pre, kbd, tt {
+ font-family: var(--sds-typography-font-family-mono, "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace);
+}
+
+code {
+ background: var(--sds-color-layout-background-neutral, #f4f5f7);
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ border-radius: var(--sds-space-border-radius-minimal);
+ padding: 0.05em 0.35em;
+ font-size: 0.9em;
+}
+
+pre code {
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.listingblock pre, .literalblock pre {
+ background: var(--sds-color-layout-background-neutral, #fafbfc);
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ border-radius: var(--sds-space-border-radius-small);
+ padding: 14px 16px;
+ overflow-x: auto;
+ font-size: 0.9rem;
+}
+
+/* ---------- hero (index page only) --------------------------------------- */
+
+.hero-band {
+ background: var(--sds-color-brand-primary-subtle, #ece4ff);
+ margin: 0 calc(-1 * var(--site-content-pad)) var(--sds-space-gap-medium);
+ padding: var(--sds-space-padding-huge) var(--site-content-pad);
+ text-align: center;
+}
+.hero-band .hero-title {
+ font-size: 3.2rem;
+ font-weight: 700;
+ margin: 0 0 var(--sds-space-gap-small);
+ letter-spacing: -0.015em;
+ color: var(--sds-color-text-primary, #1a1a1a);
+}
+.hero-band .hero-tagline {
+ font-size: 1.25rem;
+ margin: 0 0 var(--sds-space-gap-medium);
+ color: var(--sds-color-text-primary, #1a1a1a);
+ opacity: 0.85;
+}
+.hero-band .cta-row {
+ display: flex;
+ justify-content: center;
+ gap: var(--sds-space-gap-small);
+ flex-wrap: wrap;
+}
+.cta-button {
+ display: inline-block;
+ background: var(--sds-color-interaction-primary-transparent-default, transparent);
+ color: var(--sds-color-text-primary, #1a1a1a);
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-brand-primary-strong, #5b3d99);
+ border-radius: var(--sds-space-border-radius-full);
+ padding: 10px 22px;
+ text-decoration: none;
+ font-weight: 600;
+ font-size: 1rem;
+ transition: background 120ms ease;
+}
+.cta-button:hover {
+ background: var(--sds-color-interaction-primary-transparent-highlight, rgba(91, 61, 153, 0.08));
+ color: var(--sds-color-text-primary, #1a1a1a);
+ text-decoration: none;
+}
+
+/* ---------- feature row (index page) ------------------------------------- */
+
+/* The 3-feature row uses an AsciiDoc table with `[cols="1a,1a,1a"]` so each
+ * cell is parsed as AsciiDoc and the [.feature] role + headings render. */
+table.featurerow,
+table.featurerow > colgroup,
+table.featurerow > * > tr > th,
+table.featurerow > * > tr > td {
+ border: none;
+ background: transparent;
+}
+table.featurerow {
+ width: 100%;
+ margin: var(--sds-space-gap-medium) 0 var(--sds-space-gap-large);
+ border-collapse: separate;
+ /* gap-large (48px) between cells. The vertical 0 is intentional: there's
+ * only one row. */
+ border-spacing: var(--sds-space-gap-large) 0;
+ table-layout: fixed;
+}
+table.featurerow > * > tr > td {
+ vertical-align: top;
+ padding: 0 var(--sds-space-padding-tiny);
+}
+/* Feature cards: image and heading centered (the "poster" feel), prose
+ * left-aligned (centered paragraphs of varied line length read as ragged
+ * across three columns; left-aligned reads as a clean card). */
+.feature img {
+ max-width: 220px;
+ width: 100%;
+ height: auto;
+ margin: 0 auto var(--sds-space-padding-medium);
+ display: block;
+}
+.feature h2,
+.feature h3 {
+ font-size: 1.2rem;
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-top: 0;
+ text-align: center;
+ font-weight: 600;
+}
+.feature p {
+ margin: var(--sds-space-gap-small) 0;
+ text-align: left;
+ color: var(--sds-color-text-primary, #1a1a1a);
+ line-height: 1.55;
+}
+
+/* ---------- TOC ----------------------------------------------------------- */
+
+/* AsciiDoctor's `:toc: left@` mode renders the TOC into #header with the
+ * `toc2` class. Its default stylesheet (which we don't load) would float
+ * this as a fixed left sidebar with a hard right border. We render it
+ * inline as a contained card instead: full border, rounded corners,
+ * internal padding so the title and the list don't hug the edges. */
+#toc.toc2 {
+ background: var(--sds-color-layout-background-neutral, #fafbfc);
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ border-radius: var(--sds-space-border-radius-small);
+ padding: var(--sds-space-padding-medium);
+ margin: 0 0 var(--sds-space-gap-medium);
+ font-size: 0.92rem;
+}
+#toc.toc2 a {
+ color: var(--sds-color-text-primary, #1a1a1a);
+ text-decoration: none;
+}
+#toc.toc2 a:hover {
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+ background: transparent;
+}
+#toc #toctitle {
+ font-weight: 700;
+ color: var(--sds-color-text-primary, #1a1a1a);
+ margin: 0 0 var(--sds-space-gap-small);
+}
+#toc.toc2 ul {
+ margin: 0;
+ padding-left: var(--sds-space-padding-large);
+}
+
+/* ---------- admonitions / advantage-box ----------------------------------- */
+
+.admonitionblock {
+ margin: 1.4em 0;
+}
+.admonitionblock > table {
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-brand-primary-subtle, #ece4ff);
+ border-left: var(--sds-space-border-weight-bold) solid var(--sds-color-brand-primary-strong, #5b3d99);
+ background: var(--sds-color-brand-accent-subtle, #f5f0ff);
+ border-radius: var(--sds-space-border-radius-small);
+ padding: var(--sds-space-padding-small) 14px;
+ width: 100%;
+}
+.admonitionblock td.icon {
+ width: 36px;
+ vertical-align: top;
+}
+.admonitionblock td.icon .title {
+ font-weight: 700;
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+ text-transform: uppercase;
+ font-size: 0.8rem;
+ letter-spacing: 0.05em;
+}
+.admonitionblock td.content {
+ padding-left: var(--sds-space-padding-small);
+}
+.advantage-box {
+ background: var(--sds-color-brand-primary-subtle, #ece4ff);
+ border-left: var(--sds-space-border-weight-bold) solid var(--sds-color-brand-primary-strong, #5b3d99);
+ border-radius: var(--sds-space-border-radius-medium);
+ padding: 16px 20px;
+ margin: 1.4em 0;
+}
+
+/* ---------- tables -------------------------------------------------------- */
+
+table.tableblock {
+ border-collapse: collapse;
+ margin: 1.2em 0;
+ width: 100%;
+}
+table.tableblock th, table.tableblock td {
+ border: var(--sds-space-border-weight-thin) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ padding: 10px 14px;
+ text-align: left;
+ vertical-align: top;
+}
+table.tableblock thead th {
+ background: var(--sds-color-layout-background-neutral, #fafbfc);
+ font-weight: 700;
+}
+
+/* ---------- footer (docinfo, dark band) ----------------------------------- */
+
+#footer {
+ /* AsciiDoctor's default per-page footer (timestamps). Hide it; the dark
+ * .site-footer below replaces it. */
+ display: none;
+}
+
+.site-footer {
+ background: var(--sds-color-brand-accent-strong, #1d1840);
+ color: var(--sds-color-text-on, #ffffff);
+ padding: 36px var(--site-content-pad) 28px;
+ margin-top: var(--sds-space-padding-huge);
+ font-size: 0.92rem;
+}
+.site-footer-inner {
+ max-width: var(--site-content-max);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--sds-space-gap-medium);
+}
+.site-footer h4 {
+ margin: 0 0 var(--sds-space-gap-small);
+ color: var(--sds-color-text-on, #ffffff);
+ font-size: 0.95rem;
+ font-weight: 700;
+ text-transform: none;
+ letter-spacing: 0.02em;
+}
+.site-footer ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.site-footer li {
+ margin: 6px 0;
+}
+.site-footer a {
+ color: var(--sds-color-text-on, #ffffff);
+ text-decoration: none;
+}
+.site-footer a:hover {
+ text-decoration: underline;
+ background: transparent;
+ color: var(--sds-color-text-on, #ffffff);
+}
+.site-footer-meta {
+ grid-column: 1 / -1;
+ margin-top: var(--sds-space-padding-medium);
+ padding-top: var(--sds-space-padding-medium);
+ border-top: var(--sds-space-border-weight-thin) solid rgba(255, 255, 255, 0.15);
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.85rem;
+}
+
+/* ---------- mobile (< 45rem, the design system's small breakpoint) ------- */
+
+/* Layout shapes that need overrides at narrow widths. The horizontal padding,
+ * hero band padding, and feature-image margin are already responsive via
+ * --sds-space-padding-* tokens, so they don't appear here. What remains:
+ * - the featurerow's 3 columns collapse to single-column block flow,
+ * - the manual landing's 2x2 quadrant grid collapses similarly,
+ * - the nav-header reflows so brand + GitHub share row 1 and links wrap
+ * to row 2,
+ * - the 3.2rem hero title scales down (typography tokens not used here
+ * because the design system's editorial-headline-* sizes are far larger
+ * than what fits on a phone). */
+/* ---------- manual quadrants (manual/index.adoc) ------------------------- */
+
+/* Diataxis four-quadrant landing. Same parsed-AsciiDoc-cell pattern as the
+ * homepage's featurerow: `[.quadrants,cols="1,1",...]` produces a 2x2 grid
+ * with a bold link as the cell title and a short paragraph beneath. */
+table.quadrants,
+table.quadrants > colgroup,
+table.quadrants > * > tr > th,
+table.quadrants > * > tr > td {
+ border: none;
+}
+table.quadrants {
+ width: 100%;
+ margin: var(--sds-space-gap-large) 0;
+ border-spacing: var(--sds-space-gap-medium);
+ table-layout: fixed;
+}
+table.quadrants > * > tr > td {
+ vertical-align: top;
+ padding: var(--sds-space-padding-medium);
+ background: var(--sds-color-layout-background-neutral, #fafbfc);
+ border-radius: var(--sds-space-border-radius-small);
+}
+.quadrant-title {
+ font-size: 1.25rem;
+ display: block;
+ margin-bottom: var(--sds-space-gap-small);
+}
+.quadrant-title a {
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+ text-decoration: none;
+}
+.quadrant-title a:hover {
+ text-decoration: underline;
+}
+
+/* ---------- page navigation (manual/tutorial/) -------------------------- */
+
+/* Three-column footer row carrying prev / up / next on sequential pages.
+ * AsciiDoc form: `[.pagenav,cols="<,^,>",frame=none,grid=none]` table
+ * with three single-cell xrefs. Empty cells (first page has no prev,
+ * last page has no next) preserve column alignment so the up-link
+ * stays centred regardless of which neighbours exist. */
+table.pagenav,
+table.pagenav > colgroup,
+table.pagenav > * > tr > th,
+table.pagenav > * > tr > td {
+ border: none;
+}
+table.pagenav {
+ width: 100%;
+ margin: var(--sds-space-gap-large) 0 0;
+ border-top: var(--sds-space-border-weight-thin, 1px) solid var(--sds-color-layout-divider-subtle, #e2e4e8);
+ border-spacing: 0;
+ table-layout: fixed;
+}
+table.pagenav > * > tr > td {
+ padding: var(--sds-space-padding-small) 0 0;
+ vertical-align: top;
+ font-size: 0.95rem;
+ color: var(--sds-color-text-secondary, #6b7280);
+}
+table.pagenav a {
+ color: var(--sds-color-brand-primary-strong, #5b3d99);
+ text-decoration: none;
+}
+table.pagenav a:hover {
+ text-decoration: underline;
+}
+
+@media (max-width: 45rem) {
+ table.featurerow {
+ display: block;
+ border-spacing: 0;
+ margin: var(--sds-space-gap-medium) 0 var(--sds-space-gap-medium);
+ }
+ /* Hide the colgroup that pins each cell to 33% width. */
+ table.featurerow > colgroup {
+ display: none;
+ }
+ table.featurerow > tbody,
+ table.featurerow > tbody > tr {
+ display: block;
+ }
+ table.featurerow > tbody > tr > td {
+ display: block;
+ width: 100%;
+ padding: 0;
+ margin: 0 0 var(--sds-space-gap-medium);
+ }
+ table.featurerow > tbody > tr > td:last-child {
+ margin-bottom: 0;
+ }
+ .feature img {
+ max-width: 160px;
+ }
+
+ /* The 2x2 quadrant grid uses the same collapse-to-single-column pattern
+ * as the homepage featurerow. */
+ table.quadrants {
+ display: block;
+ border-spacing: 0;
+ margin: var(--sds-space-gap-medium) 0;
+ }
+ table.quadrants > colgroup {
+ display: none;
+ }
+ table.quadrants > tbody,
+ table.quadrants > tbody > tr {
+ display: block;
+ }
+ table.quadrants > tbody > tr > td {
+ display: block;
+ width: 100%;
+ margin: 0 0 var(--sds-space-gap-medium);
+ }
+ table.quadrants > tbody > tr > td:last-child {
+ margin-bottom: 0;
+ }
+
+ /* Pagenav rows that fit comfortably as three columns on desktop
+ * cramp at phone widths once the link text exceeds ~30 characters.
+ * Stack vertically and keep alignment cues (← prefix on prev,
+ * → suffix on next) doing the navigational work in place of the
+ * row's left/center/right column geometry. */
+ table.pagenav {
+ display: block;
+ border-spacing: 0;
+ }
+ table.pagenav > colgroup {
+ display: none;
+ }
+ table.pagenav > tbody,
+ table.pagenav > tbody > tr {
+ display: block;
+ }
+ table.pagenav > tbody > tr > td {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: var(--sds-space-padding-small) 0 0;
+ }
+
+ .nav-header-inner {
+ flex-wrap: wrap;
+ gap: var(--sds-space-gap-tiny) var(--sds-space-gap-medium);
+ }
+ /* Brand + GitHub on row one; nav-links on row two. The desktop spacer
+ * (flex: 1) would fight that, so collapse it. */
+ .nav-spacer {
+ display: none;
+ }
+ .nav-brand {
+ flex: 1 1 auto;
+ }
+ .nav-links {
+ order: 3;
+ flex-basis: 100%;
+ margin-left: 0;
+ gap: var(--sds-space-gap-tiny) var(--sds-space-gap-medium);
+ }
+ .nav-links a {
+ padding: 4px 0;
+ }
+
+ .hero-band .hero-title {
+ font-size: 2.2rem;
+ }
+ .hero-band .hero-tagline {
+ font-size: 1.05rem;
+ }
+
+ /* Wide tables (the roadmap status board with long titles plus inline
+ * code spans is the worst offender) would otherwise push the body past
+ * the viewport, and because .nav-header is sticky only on the y-axis,
+ * horizontal page scroll drags the nav off-screen. Make each table its
+ * own horizontal-scroll container instead. display: block sacrifices
+ * outer table layout but the tbody/tr/td defaults keep the inner table
+ * intact. */
+ table.tableblock {
+ display: block;
+ overflow-x: auto;
+ max-width: 100%;
+ }
+}
diff --git a/docs/architecture/explanation/dispatch-axes.adoc b/docs/architecture/explanation/dispatch-axes.adoc
new file mode 100644
index 0000000000..d6b117d955
--- /dev/null
+++ b/docs/architecture/explanation/dispatch-axes.adoc
@@ -0,0 +1,118 @@
+= Dispatch axes: `SourceKey` and `LoaderRegistration`
+
+A DataLoader-backed source-side field carries five orthogonal pieces of dispatch information: the per-row key shape, the body-input contract for the rows-method, the per-source row count, the loader container kind, and the loader dispatch verb. Each is a separate type axis in the model. Consumers read off whichever axis they actually fork on; no consumer reconstructs an axis by `instanceof`-ing a conflated permit.
+
+This page is the chapter narrative for that contract. Reference detail (the per-arm record components, the per-axis enum/sealed values) lives on the source: javadoc on `SourceKey`, `SourceKey.Reader`, `SourceKey.Wrap`, and `LoaderRegistration`.
+
+== The axes
+
+Three model values carry the dispatch axes between them: a `SourceKey` per field (singular, classify-time), zero or more `SourceRow` instances per fetch (runtime, the data flowing through), and a `LoaderRegistration` per field (singular, classify-time, DataLoader identity).
+
+`SourceKey` carries four of the axes:
+
+[source,java]
+----
+record SourceKey(
+ TableRef target, // join target (or null for the parent-IS-source case)
+ List columns, // entry-point columns (parent-side or target-side per path)
+ List path, // empty = target-aligned; non-empty = FK chain to target
+ Wrap wrap, // Row | Record | TableRecord(ClassName)
+ Cardinality cardinality, // ONE | MANY
+ Reader reader // ColumnRead | AccessorCall | SourceRowsCall | …
+)
+----
+
+`LoaderRegistration` carries the remaining two:
+
+[source,java]
+----
+record LoaderRegistration(
+ boolean valueIsList, // load(K)→V vs load(K)→List
+ Container container, // POSITIONAL_LIST | MAPPED_SET
+ Dispatch dispatch // LOAD_ONE | LOAD_MANY
+)
+----
+
+The two values together describe one DataLoader-backed source side. Splitting them at this seam is what makes the rooted-DML data-field case representable: when the data fetcher reads `env.getSource()` directly instead of going through a loader, the field has a `SourceKey` and no `LoaderRegistration`. If the container and dispatch axes lived on `SourceKey`, the rooted case would need a vestigial `LoaderRegistration` slot or a fork in `SourceKey`.
+
+=== `Wrap`: per-row key shape
+
+`SourceKey.Wrap` is sealed: `Row` / `Record` / `TableRecord(ClassName)`. The arm names the jOOQ type the DataLoader's per-key value reads as.
+
+[source]
+----
+Wrap
+├─ Row() ← RowN<...> ; values only
+├─ Record() ← RecordN<...> ; values + value1()..valueN()
+└─ TableRecord(ClassName) ← typed jOOQ subclass ; e.g. FilmRecord
+----
+
+The `TableRecord` arm carries the developer-declared subtype as a payload because the column-tuple arms (`Row`, `Record`) have no use for it; an enum with a nullable `recordClass` field would be the conflated alternative. `SourceKey.keyElementType()` is total over the three arms without an extra nullable field on `SourceKey` itself.
+
+=== `Reader`: body input contract
+
+`SourceKey.Reader` is sealed with five arms; each one names what the rows-method body reads to produce its output. SQL-side bodies read parent-side data; service-side bodies read the service-return shape.
+
+[source]
+----
+Reader
+├─ ColumnRead() ← FK columns on the parent record (catalog FK)
+├─ AccessorCall(AccessorRef) ← typed zero-arg accessor on a record-backed parent
+├─ SourceRowsCall(LifterRef) ← @sourceRow static lifter on a record-backed parent
+├─ ServiceTableRecord(ClassName) ← @service returning a typed TableRecord subclass
+└─ ServiceUntypedRecord() ← @service returning Record<> / scalar
+----
+
+Reader is the *body's input contract*, not "where the data comes from"; the body emitter reads the contract to know what code to emit, not to recover the directive that produced the field. Adding a new arm (for example, walking a `Result` from upstream DML when the rooted-DML data-field path lands) is a one-arm addition to this enum; every consumer's exhaustive switch breaks at compile time until the new arm is handled.
+
+=== `Cardinality`: rows per key
+
+A two-arm enum: `ONE` (one source row per key — catalog FK, accessor-single, service target-aligned) or `MANY` (list-valued source, accessor-many, list-valued service). Drives the rows-method body's per-key iteration shape and the loader's `load` vs `loadMany` choice.
+
+`Cardinality` lives on `SourceKey` rather than `LoaderRegistration` because the per-source row count is a property of the source-side data shape, not of the DataLoader's identity. The same `SourceKey.MANY` can be loaded into either container (positional or mapped); the cardinality fixes the body's iteration, the container fixes the loader's framing.
+
+=== `Container` + `Dispatch`: DataLoader identity
+
+Two independent enums on `LoaderRegistration`:
+
+* `Container`: `POSITIONAL_LIST` (the loader is built with `newDataLoader`; keys arrive as `List`; returns `List` indexed 1:1 with keys) or `MAPPED_SET` (built with `newMappedDataLoader`; keys arrive as `Set`; returns `Map`).
+* `Dispatch`: `LOAD_ONE` (one call to `loader.load(key)` returning one value) or `LOAD_MANY` (one call to `loader.loadMany(keys)` returning a list).
+
+The two axes are independent. The `AccessorKeyedMany` projection lands at `POSITIONAL_LIST` + `LOAD_MANY`: the loader is positional but each fetcher call uses `loadMany` because the parent record carries a list of accessor-projected keys. Conflating container and dispatch into a single axis would force a fourth synthetic combination ("positional but per-key list-valued") that nothing in the model corresponds to.
+
+== Cross-axis invariants
+
+Three pairings are structurally illegal; `SourceKey`'s compact constructor rejects them. The pipeline-tier tests pin SDL → emitted-shape end-to-end, and the cross-module compile against `graphitron-sakila-example` is the structural backstop.
+
+[cols="1,3"]
+|===
+| Invariant | Why load-bearing
+
+| `SourceRowsCall ⇒ Wrap.Row` | The `@sourceRow` lifter contract pins output to entry-point columns shaped as `RowN<...>`. `GeneratorUtils.buildLifterRowKey` emits `$T key = Lifters.method((BackingClass) env.getSource())` where `$T` is `sourceKey.keyElementType()`; the local must be `RowN<...>` for the assignment to type-check. A `Wrap.Record` slipping through would emit a `RecordN<...>`-typed local fed by a `RowN<...>`-returning method ; the generated source wouldn't compile.
+
+| `AccessorCall ⇒ Wrap.Record` | The rows-method body's parent-input VALUES loop emits `DSL.val(k.value$L())` to extract the scalar payload off the per-key `RecordN<...>`. `value$L()` exists on `RecordN<...>` but not on `RowN<...>`. The invariant ensures `keyElementType()` is a `RecordN<...>` type and the `value$L()` invocation type-checks against the local key variable.
+
+| `ServiceTableRecord` target-aligned ⇒ empty `path` | When the service returns a typed `TableRecord` whose class matches the field's target table, walking past target via a `path` chain is structurally redundant ; the service already produced a target-aligned record. The `Wrap.TableRecord` arm in `GeneratorUtils.buildKeyExtraction` emits `parent.into(Tables.X)` directly without walking; the invariant guarantees a misaligned record never reaches this site.
+|===
+
+Each invariant is one paragraph at a single place (the compact constructor) but governs the emit shape at multiple consumers downstream. Relaxing one without auditing the consumer side surfaces as a pipeline-test failure or a compile error in the generated `graphitron-sakila-example` source, not as a runtime surprise.
+
+== Consumer-side dispatch
+
+Each emit site reads off whichever axis it actually forks on, without re-deriving the axis from a conflated identity:
+
+* `GeneratorUtils.buildRecordParentKeyExtraction` switches over `sourceKey.reader()` to choose the parent-side extraction emit shape, then within the `AccessorCall` arm reads `sourceKey.cardinality()` to choose single-vs-list emit. Two axes, two reads ; the dispatch is exhaustive over each axis independently.
+
+* `GeneratorUtils.buildKeyExtraction` (for table-bound parents on the split-query and service paths) switches over `sourceKey.wrap()` to choose between `DSL.row(...)`, `parent.into(table.col, ...)`, and `parent.into(Tables.X)` ; one axis, one read.
+
+* `DataLoaderFetcherEmitter.build` reads `registration.container()` to pick `DataLoaderFactory.newDataLoader` vs `newMappedDataLoader`, and `registration.dispatch()` to pick `loader.load(key)` vs `loader.loadMany(keys)`. Two axes, two reads; the same `SourceKey` can be paired with either container.
+
+* `RowsMethodCall.batchLoaderLambda` reads `registration.container()` to choose `List` vs `Set` for the lambda's keys parameter ; one axis, one read.
+
+* `RowsMethodSkeleton.build` dispatches the body framing on the `RowsMethodBody` permit, which the caller projected from `(sourceKey.reader(), registration.container())`. The skeleton's exhaustive switch over five `RowsMethodBody` permits is the seam between body construction (per-shape) and outer-method framing (uniform across permits).
+
+The pattern: each consumer's switch is exhaustive over exactly one axis, and the compiler enforces that adding a new arm to any axis breaks every consumer whose dispatch isn't yet aware of it.
+
+== Connection to the principle
+
+This is the live worked example for xref:rewrite-design-principles.adoc#sealed-hierarchies-over-enums-for-typed-information[Sealed hierarchies over enums for typed information]. The four-axis split is the principle in action: each axis is a sealed sub-hierarchy or enum carrying exactly the information its consumers need, and the compiler enforces exhaustive switches at every dispatch site. The smell the principle warns about ; a single shared accessor whose meaning depends on the variant, or a permit name that splices two axes together ; is the alternative this model rejects by construction.
diff --git a/docs/architecture/explanation/index.adoc b/docs/architecture/explanation/index.adoc
new file mode 100644
index 0000000000..c292b6989d
--- /dev/null
+++ b/docs/architecture/explanation/index.adoc
@@ -0,0 +1,12 @@
+= Architecture: Explanation
+:description: Design rationale for the Graphitron generator.
+:!toc:
+
+Why the generator is shaped the way it is.
+
+* xref:rewrite-design-principles.adoc[Rewrite Design Principles] — the architectural and technical principles that govern classifier and emitter.
+* xref:pipeline-overview.adoc[Pipeline overview] — how a `.graphqls` schema flows from parse to consumer compile.
+* xref:dispatch-axes.adoc[Dispatch axes] — the four-axis dispatch model behind DataLoader-backed source-side fields.
+* xref:typed-rejection.adoc[Typed rejection] — why rejection is a typed variant, the `Rejection` taxonomy, and the candidate-hint contract.
+
+xref:../index.adoc[← Architecture]
diff --git a/docs/architecture/explanation/pipeline-overview.adoc b/docs/architecture/explanation/pipeline-overview.adoc
new file mode 100644
index 0000000000..6e3f1f020b
--- /dev/null
+++ b/docs/architecture/explanation/pipeline-overview.adoc
@@ -0,0 +1,28 @@
+= Pipeline overview
+:description: How a .graphqls schema flows through the Graphitron generator from parse to consumer compile.
+:!toc:
+
+The generator turns `.graphqls` files into Java sources in a fixed pipeline: parse, classify, validate, emit, write, and (for the reference consumer) compile.
+
+[source,mermaid]
+----
+flowchart LR
+ A[".graphqls files"] --> B["RewriteSchemaLoader
(parse + auto-inject
directives.graphqls)"]
+ B --> C["GraphitronSchemaBuilder
(classify into
GraphitronSchema)"]
+ C --> D["GraphitronSchemaValidator
(reject Unclassified*,
surface diagnostics)"]
+ D --> E["Generators
(TypeFetcher / TypeClass /
TypeConditions /
QueryConditions / ...)"]
+ E --> F["JavaFile.writeToPath
(idempotent writes,
orphan sweep)"]
+ F --> G["consumer compile
(graphitron-sakila-example
verifies type + behaviour)"]
+----
+
+Three things to know about this pipeline that the diagram doesn't show:
+
+. *The loader auto-injects `directives.graphqls`* from the `graphitron` jar before parse, so consumer schemas never re-declare the canonical directives. This is what lets the classifier treat directive presence as ground truth one stage later: the schema loader has already fed every directive declaration into the parsed schema, regardless of whether the consumer's `.graphqls` files mentioned them.
+
+. *Classification is the only place directives are read.* `GraphitronSchemaBuilder` reads each directive once and resolves everything the generator needs into typed model values: table names, column references, method names, extraction strategies, batch keys. Generators downstream see the classified model and never touch directive syntax. The boundary keeps the model "what to emit," never "what to interpret."
+
+. *The writer's idempotency contract is unconditional.* On every run, `JavaFile.writeToPath` writes only files whose rendered content differs from disk (SHA-256 comparison) and deletes orphans in rewrite-owned sub-packages. Both halves run on every emit, not just full builds; this is what keeps the dev-loop's IDE-recompile times proportional and what stops a delete-a-type cycle from leaving stale files behind. Pinned by `IdempotentWriterTest` and `GeneratorDeterminismTest`.
+
+The xref:../reference/code-generation-triggers.adoc[Code Generation Triggers] page is a zoomed-in view of the middle three stages (schema → classified model → generators). This page names the loader, the validator, the writer's idempotency contract, and the consumer compile that closes the loop.
+
+xref:index.adoc[← Explanation index]
diff --git a/docs/architecture/explanation/rewrite-design-principles.adoc b/docs/architecture/explanation/rewrite-design-principles.adoc
new file mode 100644
index 0000000000..840a34459a
--- /dev/null
+++ b/docs/architecture/explanation/rewrite-design-principles.adoc
@@ -0,0 +1,270 @@
+= Rewrite Design Principles
+
+Technical and architectural principles that govern the rewrite pipeline. For Graphitron's strategic/philosophical principles, see xref:../../graphitron-principles.adoc[graphitron-principles.md].
+
+The typed-rejection narrative (the sealed `Resolved` shape across the resolver siblings, the `Rejection` taxonomy, the Levenshtein-ranked candidate hint contract) is consolidated at xref:typed-rejection.adoc[Typed rejection]; this doc retains the principle-list shape, with the rejection-related principle below collapsed to a forward pointer.
+
+'''
+
+== Generation-thinking
+
+*Before implementing a generator body, ensure the model carries what the generator needs ; pre-resolved, generation-ready.* `GraphitronSchemaBuilder` reads directives once and resolves everything: table names, column references, method names, extraction strategies. Generators receive a model in terms of "what to emit", not "what to interpret".
+
+Signs a model type needs more pre-resolution:
+- A generator switches on a raw string, or recomputes a derived name from a field name.
+- The same multi-arm type switch recurs across multiple generators.
+- Generation and calling are conflated in the same model type.
+- A generator branches on a predicate over pre-resolved data (e.g. which side of a join holds the FK). The decision was not resolved, only its inputs were ; lift the fork into the model as a sealed sub-variant. Rule of thumb: if two consumers (generators, validators, resolvers, dispatchers) evaluate the same predicate over a model field, the branch belongs in the model. The same predicate evaluated by multiple consumers is a sign the resolver is under-specified, and an opportunity for one site to drift from another.
+
+== Sealed hierarchies over enums for typed information
+
+When different variants of a concept carry different data, use a sealed interface ; not an enum with a shared field set. An enum forces every variant to have the same shape; a sealed record hierarchy gives each variant exactly the fields it needs.
+
+`SourceKey.Reader` illustrates the pattern at its current shape: `ColumnRead` carries no payload (catalog-FK columns live on the surrounding `SourceKey`, not the reader), `AccessorCall(AccessorRef)` carries the typed instance accessor on a record-backed parent's backing class, `SourceRowsCall(LifterRef)` carries the developer-supplied `@sourceRow` static lifter, `ServiceTableRecord(ClassName)` carries the developer-declared typed jOOQ `TableRecord` subclass the `@service` method returns, and `ServiceUntypedRecord` carries no payload (untyped `Record`/scalar service return). Each variant holds exactly the data its rows-method body needs to read parent-side input; none carry fields they don't use. `SourceKey.Wrap` is a sibling sealed: `Row` / `Record` / `TableRecord(ClassName)`, where the `TableRecord` arm carries the developer-declared subclass and the other two arms carry nothing. The compiler enforces exhaustive switches ; when a new variant is added, every switch that doesn't handle it becomes a compile error.
+
+When variants split on independent axes, use sealed sub-interfaces per axis (or separate records orthogonal to the variant axis) rather than inventing a god accessor whose meaning depends on the variant. The DataLoader-backed source side is the worked example: `SourceKey.Wrap` carries per-row key shape, `SourceKey.Reader` the body input contract, `SourceKey.Cardinality` (ONE / MANY) the per-source row count, and a sibling `LoaderRegistration` record carries `Container` (POSITIONAL_LIST / MAPPED_SET) and `Dispatch` (LOAD_ONE / LOAD_MANY). Each dispatch site reads off whichever axis it forks on instead of reconstructing it from a conflated permit. The xref:dispatch-axes.adoc[Dispatch axes] chapter narrates the four-axis split, the cross-axis invariants the compact constructor pins, and the consumer-side dispatch shapes. The smell to watch for: a single shared accessor whose meaning depends on the variant (e.g. "FK source columns" in one arm and "child target columns" in another), or a sealed permit name that splices two axes together. The split pushes the per-axis meaning into the type system.
+
+== Directives carry only what the SDL author needs to say
+
+Directive arguments should be flat scalars whenever the directive site already disambiguates the axis. An input-object wrapper is justified only when the directive carries several genuinely-orthogonal pieces of information at once; the default is a single typed scalar (`String!`, `Int!`, an enum), and reaching for an input wrapper is a deliberate decision rather than a default.
+
+`@field(name: String!)` is the worked example. The directive applies on four sites (`FIELD_DEFINITION`, `INPUT_FIELD_DEFINITION`, `ARGUMENT_DEFINITION`, `ENUM_VALUE`); the site itself tells the classifier which axis is being bound (column vs argument vs enum-value), so the directive only carries the underlying name. There is no `@field(target: { axis, name })` wrapper, because the axis is structural.
+
+The smell to watch for is an input wrapper that most callsites fill in two-of-four slots on. SDL authors end up typing a structured literal where a string would have served, and the directive's failure-mode surface widens from "the named thing didn't resolve" to a cross-product of "field A missing", "field B given but A wasn't", "A and B given but inconsistent". `ExternalCodeReference` (`name`, `className`, `method`, `argMapping`) is the existing case that new directive surfaces should not lean on; new directives default to `@field`'s shape.
+
+== Classification belongs at the parse boundary
+
+Reading the reflection `java.lang.reflect.Type` tree is permitted only at builder-side classifiers that convert reflection output into the typed model. Today five files cross that boundary: `ServiceCatalog` (for `@service` and `@tableMethod` parameter classification, including the post-R7 `classifySourcesType` for DataLoader source parameters); `ServiceDirectiveResolver` (for `@externalField` reference methods); `SourceRowDirectiveResolver` (R110, for the developer-supplied `@sourceRow` lifter signature); `ClassAccessorResolver` (R88, for resolving an SDL output field's accessor on a record-backed parent against the reflection-derived backing class's method set); and `FieldBuilder` (for `@sourceRow` argument-mapping and accessor reflection on record-backed parents whose backing class carries typed FK accessors). Each converts raw reflection output into `MethodRef.Param`, `SourceKey`, `LoaderRegistration`, `AccessorRef`, or `AccessorResolution` values (each carrying a `ParamSource` where applicable). Everything downstream ; validator, generator ; switches on the pre-classified values and never touches reflection types.
+
+The boundary lives at `JooqCatalog`: it is the canonical permitted holder of raw jOOQ types (`Table>`, `ForeignKey,?>`) and the path classifier code goes through. Two other classes also import `org.jooq` directly today: `BuildContext` (`ForeignKey` for `@reference` validation messages that need to enumerate keys for the candidate hint) and `catalog/CatalogBuilder` (`ForeignKey` + `Table` for the LSP completion-data snapshot, which marshals catalog metadata into a wire format the LSP server consumes). `TypeBuilder`, `FieldBuilder`, and `ServiceCatalog` consume the classified output via `JooqCatalog` rather than holding raw types directly. If a generator needs information not yet in a taxonomy record, the fix is to add a component and extract the value in the builder ; not to reach past the taxonomy boundary.
+
+`CallSiteExtraction` illustrates the principle for argument extraction: the builder decides once (at classify time) which extraction strategy applies to each argument ; one of five direct strategies (`Direct`, `EnumValueOf`, `TextMapLookup`, `ContextArg`, `JooqConvert`) or one of two sealed sub-groupers covering nested-input traversal (`NestedInputField`, for `@condition` on `INPUT_FIELD_DEFINITION`) and NodeId decode (`NodeIdDecodeKeys.{SkipMismatchedElement | ThrowOnMismatch}`) ; and stores that decision in `CallParam.extraction` or `ParamSource.Arg.extraction`. The generator switches on the pre-classified value and emits code directly.
+
+== Capability interfaces and sealed switches serve different roles
+
+When a generation pattern applies uniformly across multiple field variants, use an orthogonal capability interface rather than an N-way `instanceof` chain. Established interfaces: `SqlGeneratingField`, `MethodBackedField`, `BatchKeyField`.
+
+Capabilities express what is *uniformly true* across variants; sealed switches express what *varies by identity*. Use a capability when the generator treats variants identically (iterate `SqlGeneratingField.filters()` regardless of leaf type). Use a sealed switch when the generator forks on identity (which `$fields` arm to emit, which rows-method signature to synthesise). Capabilities don't eliminate exhaustiveness bookkeeping ; they relocate it.
+
+== Narrow component types over broad interfaces
+
+Field record components are declared with the narrowest type the classifier can guarantee rather than the broad sealed-interface root. A field whose return type is always table-bound declares `ReturnTypeRef.TableBoundReturnType` directly; a field whose return type is always polymorphic declares `ReturnTypeRef.PolymorphicReturnType` directly.
+
+This pushes classification certainty into the type system: code that receives a `ServiceTableField` knows its `returnType` is `TableBoundReturnType` without a runtime check.
+
+== Sub-taxonomies for resolution outcomes
+
+Complex resolution outcomes get their own sealed type rather than being stored as raw strings. `SourceKey.Wrap` (sealed: `Row` / `Record` / `TableRecord(ClassName)`) is the key-shape sub-taxonomy on `MethodRef.Param.Sourced` and `ParamSource.Sources`, `TableRef` of `GraphitronType.TableBackedType`, `ColumnRef` of `InputField.ColumnField`. The type of a field tells you exactly what states it can be in.
+
+Each new sub-taxonomy proposal comes with a one-line note on what distinct information it carries that a sibling cannot ; otherwise it's probably a field on an existing record. At milestone boundaries, audit which sub-taxonomies could collapse now that their forcing functions are visible.
+
+== Builder-internal sealed hierarchies for multi-target classification
+
+When a builder step classifies inputs into many variants that project into *different* generation-ready outputs, introduce a builder-internal sealed hierarchy. It captures the full classification, enables exhaustive projection into each target, and is discarded before reaching the model.
+
+`ArgumentRef` (see xref:../reference/argument-resolution.adoc[argument-resolution.md]) classifies every GraphQL argument once into a variant (`ColumnArg`, `OrderByArg`, `PaginationArgRef`, `TableInputArg`, etc.). Separate projection steps then switch on the classified values to produce `GeneratedConditionFilter`, `LookupMapping`, `OrderBySpec`, and `PaginationSpec` ; each projection is exhaustive and independent. The alternative ; multiple independent passes that implicitly coordinate by skipping each other's arguments (e.g., `buildFilters()` skipping pagination args using the same hardcoded names as `buildPaginationSpec()`) ; is fragile and makes adding new argument types error-prone.
+
+The key distinction from model-level sealed hierarchies: builder-internal hierarchies are ephemeral. They exist to structure a complex builder decision, not to carry information to generators. Generators never see `ArgumentRef` ; they see the projected results.
+
+== Builder-step results are sealed, not strings or out-params
+
+Every builder-step lift returns a sealed `Resolved`; rejection is a typed variant, never a string or out-param. The full narrative (sealed `Resolved` shape across the thirteen resolver siblings, the `Rejection` taxonomy, the `BuildContext.candidateHint` contract) lives at xref:typed-rejection.adoc[Typed rejection].
+
+== Model metadata over parallel type systems
+
+When the model already carries typed information, runtime data formats should derive from that metadata rather than inventing a parallel type system.
+
+`OrderByResult` pairs `List>` with `List>` ; each cursor column's `DataType` is already known. Cursor encode/decode should use `field.getDataType().convert()` for type-safe round-tripping, and `DSL.noField(field)` for the no-cursor seek case. This eliminates the need for a hand-rolled type-tag system (`i:`, `s:`, `l:`) in the cursor format ; the column metadata *is* the type information.
+
+The general principle: when the model has already classified and resolved type information at build time, that same information should drive any runtime format that needs types. A parallel type system in the runtime format is redundant and will diverge.
+
+== Wire-format encoding is a boundary concern, never a model concern
+
+Opaque wire formats (Relay NodeId base64 strings, Relay cursor strings, federation `_Any` representations) decode at the DataFetcher boundary into typed column tuples; everything downstream sees those tuples, not the wire shape. Conversely, the projection layer encodes column tuples back into wire format only at the same boundary. Variants representing the wire shape don't survive in the model.
+
+R50 is the worked example. The retired wire-shape carriers ; `InputField.NodeIdField` / `NodeIdReferenceField` / `NodeIdInFilterField` / `IdReferenceField`, `ChildField.NodeIdField` / `NodeIdReferenceField`, `BodyParam.NodeIdIn`, `LookupMapping.NodeIdMapping`, `ArgumentRef.ScalarArg.NodeIdArg` ; were each "the model says this is a NodeId" markers that forced downstream emitters to call `NodeIdEncoder.hasIds(...)` or similar wire-aware helpers across the boundary. The replacement: `CallSiteExtraction.NodeIdDecodeKeys.{SkipMismatchedElement | ThrowOnMismatch}` lives at the carrier slot where decode happens (input-fields, arg-level filters, lookup-key bindings); `CallSiteCompaction.NodeIdEncodeKeys(HelperRef.Encode)` lives at the projection slot where encode happens (column-shape carriers on output); `BodyParam.ColumnPredicate.{Eq | In | RowEq | RowIn}` carries the predicate shape over decoded column tuples without needing to know the predicate came from a wire-format input. Standard column predicates and lookup VALUES rows fall out for free.
+
+The pattern matches Connection-cursor encode/decode (which already lives at `ConnectionHelper.encodeCursor` / `decodeCursor` and never reached the model), and the federation `_Any` rep flow (which reads the rep at `EntityFetcherDispatch.resolveByReps` and walks alternatives over decoded values, never as opaque blobs).
+
+The general rule: for any opaque wire format, classify the failure mode (skip vs throw) and the direction (encode vs decode) at the boundary, never below it. A "this is a NodeId" or "this is a base64 cursor" marker spreading through the model is the same family of smell as a parallel runtime type system ; both are bypasses around classified information that the boundary already carries.
+
+== Wire boundaries are typed adapter / composer pairs
+
+Where the generator emits a method that crosses the wire-format boundary, it emits it in pair with a composer: the adapter takes `DataFetchingEnvironment` (or the wire-shape input) and produces typed values; the composer takes those typed values and does the actual work. The two share name and a table-anchor parameter, and the composer's signature is exactly the shape the adapter yields after decoding. The boundary is the pair, not the adapter alone.
+
+The worked example is the generated `QueryConditions.(Table, DataFetchingEnvironment)` plus the user-written `Conditions.(Table, ...)` it forwards to. The adapter decodes `NodeId` strings, walks input maps, and hands typed jOOQ values across the boundary; the composer takes those values (`Row`, `String`, `List`, ...) and composes a `Condition`. Same name, same table-first arg, two halves of one boundary.
+
+The smell to watch for is asymmetric typing across the pair: most often the adapter erasing type information the composer needs (e.g. an arity-erased `RowN` instead of the `Row` the decoder actually produced). When that happens, the composer's signature stops documenting the contract, and column-shape errors that should be compile failures become DSL-runtime surprises. The fix is to honour the type the decoder produces, not to widen the composer to absorb the loss. R79's switch from `RowN` to `Row<...>` is the application of this principle: the adapter side of the boundary already had `Record` from the typed `instanceof` pattern; the composer side just needed to type its argument the same way.
+
+The principle generalises beyond `QueryConditions`: any `(env, ...) -> typed-args` adapter that the generator emits in front of a user-written or emitter-written composer should land with the same symmetric typing. Drift between the two is a smell that the adapter is hiding the boundary rather than crossing it.
+
+== Validator mirrors classifier invariants
+
+Every classifier decision that implies a generator branch must fail at validate time if that branch is unimplemented. The validator reads the same dispatch sets the generator does, so an unsupported classification surfaces as a build-time error rather than a runtime `UnsupportedOperationException`. The dispatch state lives in `TypeFetcherGenerator` as a four-way disjoint partition over every `GraphitronField` sealed leaf: `IMPLEMENTED_LEAVES` (real fetcher arm), `PROJECTED_LEAVES` (emitted inline by `TypeClassGenerator.$fields`), `NOT_DISPATCHED_LEAVES` (cannot reach the fetcher switch), and `STUBBED_VARIANTS.keySet()` (stub-emitting variants). The partition is exhaustive and disjoint by construction; `GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus` enforces both properties. This closes the gap between "the schema classifies cleanly" and "the emitter has an arm for this leaf". `ValidateMojo` consumes the stubbed-variant set and fails the build by default.
+
+The rule extends beyond stubbed variants: when a classifier introduces a new invariant (e.g. "`@asConnection` not allowed on inline `TableField`"), the validator should reject it by the same mechanism the generator relies on ; no generator-side invariant goes unchecked at validate time. This keeps "problems caught at build time" honest and the generator's builder-invariant assumptions emitter-side safe.
+
+== Classifier guarantees shape emitter assumptions
+
+The rule above flows in one direction: a classifier rejection becomes a build-time error via the validator. The reverse direction also matters. A classifier acceptance can let an emitter assume narrower shapes, so the emitted code reads as tight as if it were hand-written: no defensive casts, no wildcard locals, no `instanceof` guards. The principle anchors on three layers:
+
+. *Type-system narrowing at the producer.* The narrowness the consumer needs lives in the producer's signature ; a record component, a return type, a sealed sub-variant. Once carried in the type, the contract is mechanically enforced and the consumer compiles unchanged.
+. *Pipeline-tier tests.* SDL → classified model → generated `TypeSpec` coverage pins the end-to-end shape; a regression that breaks the narrowed contract trips the pipeline test before the cross-module compile.
+. *The `graphitron-sakila-example` compile as cross-module backstop.* `mvn compile -pl :graphitron-sakila-example -Plocal-db` against a real jOOQ catalog catches any classifier/emitter mismatch during the build, before any code reaches a consumer.
+
+Compared with defensive runtime casts (which can throw `ClassCastException` on a real request, days after the build passed) or `var`-typed locals fed into parameterised entry points (which abandon the strict-shape guarantee entirely), the type-system-narrowed shape is the safest expression of the contract.
+
+Two worked examples illustrate the principle and the candidate type-system lifts that would carry each contract structurally:
+
+- *`@tableMethod` root fetcher.* `ServiceCatalog.reflectTableMethod` rejects developer methods whose return type is wider than the generated jOOQ table class. `TypeFetcherGenerator.buildQueryTableMethodFetcher` declares ` table = Method.x(...)` with no cast, and feeds the local directly into `Type.$fields(...)` which expects exactly that type. The candidate type-system lift is *multi-record type-token threading*: parameterise `MethodRef.StaticOnly` and `ReturnTypeRef.TableBoundReturnType` on a shared type token, then thread it through every site that constructs or reads either. The lift's blast radius is structural; jOOQ helper boundaries also accept type erasure per § "Selection-aware queries", capping how far the bound carries. Pre-lift, the pipeline tests plus the sakila-example compile pin the shape; post-lift, the signature carries the contract.
+
+- *`ColumnField` parent table.* The classifier produces a `ColumnField` only on a table-backed parent. The candidate type-system lift is a *single-record addition*: add a non-null `parentTable` record component to `ColumnField` at construction, populated by the classifier. The lift eliminates the parameter currently threaded into `TypeFetcherGenerator.generateTypeSpec` and the `IllegalStateException` reachability arm in the switch.
+
+When a contributor wants to record a producer-consumer linkage explicitly ; because the type-system lift isn't viable for the key and the principle's three anchors don't visibly tie the two sites together ; the recommended mechanism is a javadoc `{@link}` from the consumer to the producer (and optionally back). `{@link}` is IDE-refactor-tracked (renaming the producer auto-updates the link), carries no prose burden, and reuses the doc-tool the codebase already has. No custom annotation, no audit infrastructure to maintain.
+
+Rule: if you relax a producer's check body, audit every emitter site that consumes the corresponding shape, in the same commit. The pipeline tests and the cross-module compile are the safety net.
+
+== Pipeline tests are the primary behavioural tier
+
+Behaviour is asserted at the SDL → classified model → generated `TypeSpec` pipeline layer, not at the per-variant unit tier. Per-variant structural tests (method names, return types, which methods exist) are bookkeeping; the primary signal that a feature works is that a realistic SDL produces a realistic `TypeSpec` end-to-end through the classifier. New features earn a pipeline test first; unit tests cover structural invariants that pipeline coverage would make repetitive.
+
+Complementary tiers layered above: compilation of `graphitron-sakila-example` against real jOOQ classes (type correctness); execution of the generated code against real PostgreSQL (behaviour correctness). Code-string assertions on generated method bodies are banned at every tier; they test implementation, not behaviour, and break on every refactor.
+
+For canonical tier names, file locations, the decision rubric, and the `@UnitTier` / `@PipelineTier` / `@CompilationTier` / `@ExecutionTier` meta-annotations, see xref:../how-to/testing.adoc[Test-tier guide].
+
+== Documentation names only live tests/code
+
+Two failure modes share one principle: trusted documentation that the code does not mechanically pin.
+
+The narrow failure mode: javadoc, plan prose, and README references that name a test, method, or class must name one that exists today. A javadoc comment saying "enforced by `GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus`" when that method does not exist is worse than no comment ; it's a false invariant that readers trust. When a plan's wording anticipates a method, class, or test that the same plan will create, phrase it as "C3 adds `X`" rather than "as asserted by `X`".
+
+The broader failure mode: invariant claims that no live test, type, or assertion pins. A javadoc, annotation `description`, or doc paragraph saying "the producer rejects X so the emitter may assume Y" is exactly the same false-invariant family the narrow form names ; the symbols referenced may exist today, but if X gets silently relaxed (a `ClassName.equals` widened to `startsWith`, an `instanceof` arm broadened), the claim is silently false and readers still trust it. The fix is the same: pin the invariant in the type system or in a test that fails when the invariant fails, and let the documentation describe what's pinned rather than make a claim of its own.
+
+Reviewers check this explicitly during Draft → Approved and Pending Review → Done transitions; spot-check both forms.
+
+== Compilation against real jOOQ is a test tier
+
+`mvn compile -pl :graphitron-sakila-example -Plocal-db` against a real jOOQ catalog is the primary check that generated emission is type-correct. Unit tests assert structure; pipeline tests assert SDL → TypeSpec shape; compilation catches "the `Field>` parameter doesn't line up with the emitted DSL call" without a hand-written assertion. Every generator change must pass `-Plocal-db` compile before merging.
+
+The complementary tier above it, execution against a real PostgreSQL via the same fixture database, is the behaviour check. Together, compile + execute replace the body-content assertions that the "generation-thinking" principle bans.
+
+See xref:../how-to/testing.adoc[Test-tier guide] for build commands and guidance on choosing between tiers.
+
+== Generator Java version vs. generated output Java version
+
+Graphitron is a code generator. The Java version used to build the generator is independent of the Java version of the source it emits.
+
+- *Generator implementation* (almost everything in the reactor) may freely use Java 25 features ; sealed classes, pattern matching, records, switch expressions, switch patterns, text blocks, scoped values, and so on. The parent pom's `requireJavaVersion` enforcer pins the minimum to 25. The one exception is the hand-written runtime category below, which opts back down to 17 in its own pom.
+- *Generated source files* must target Java 17. Consumers compile Graphitron's output with their own toolchain, which may be Java 17. Generator authors are responsible for ensuring that any syntax emitted into generated files is valid Java 17 ; no switch patterns, no sequenced collections API, nothing that requires 21+. `graphitron-sakila-example` compiles with `17` to verify this.
+- *Hand-written runtime artifacts consumers depend on* must target Java 17, for the same reason as generated output: a runtime jar a consumer puts on the same classpath as Graphitron-generated sources must not require newer bytecode than that Java 17 floor. This is a distinct category from both of the above: not generated, but shipped to and run by consumers. `graphitron-jakarta-rest` (the reusable GraphQL-over-HTTP serving library) is the first instance; it overrides its main compile to `17` in its own pom while inheriting the parent's `-Xlint:all -Werror`. The generator itself stays Java 25.
+
+The practical implication: when adding code, distinguish between code *in* the generator (unrestricted Java 25), code *emitted by* the generator (Java 17), and hand-written runtime code *shipped to* consumers (Java 17).
+
+== Generated code is read and debugged
+
+*Emitted code is read, breakpointed, and stack-traced by the consumers who depend on it ; optimise it for their legibility, not for emitter-side brevity.* A generated method that throws on a real request must produce a stack frame, a line, and locals that a developer who has never seen the generator can reason about. This is the readability companion to "Classifier guarantees shape emitter assumptions": that principle keeps emitted code *tight* (no defensive casts, no wildcard locals); this one keeps it *legible*.
+
+Concrete rules for any code a generator emits:
+
+- *Explicit types, never `var`.* The reader of generated code does not have the generator's context; an explicit type at every local is documentation. (The same `var`-ban appears in "Classifier guarantees shape emitter assumptions" for a different reason ; there it protects the strict-shape guarantee, here it protects the reader.)
+- *Meaningful local names; short is good, throwaway is not.* `nodeId`, `key`, `sakKey` over `_s`, `_r`, `_nl`. Underscore-prefixed pattern-binding names carry no meaning and read as noise; a bare `_` is reserved in Java 21+ besides. Short names are welcome when they name the thing (`row`, `key`); cryptic ones are not.
+- *Statement form over expression tricks.* Prefer named locals and `if`/`else` blocks over deeply-nested ternaries, and never reach for an expression-only contortion (the `((Supplier) () -> { throw ...; }).get()` throw-inside-an-expression trick is the canonical offender) solely to keep emission inline. A developer cannot set a breakpoint inside a ternary arm or read a meaningful frame from a lambda-wrapped throw.
+- *When the output must be an expression* (it is consumed as a method-call argument, a field initialiser, a stream lambda), lift it into a named private helper method per the <> convention so the call site stays an expression while the *body* is readable statement form. The helper name documents intent (`decodeSakKey(...)`), and the helper body gets explicit types, named locals, and ordinary control flow.
+- *No `__`-prefixed Java identifiers in emitted code.* Emitted locals, lambda parameters, and method parameters use readable names (`row`, `byPk`, `fetched`, `violations`), never a `__`-prefixed default (`__r`, `__byPk`, `__fetched`). The generator emits every name in scope, including the method signature, so a collision is always knowable at generation time; the `__` prefix buys no safety that a readable name plus generation-time awareness does not already provide. Where an author-derived identifier (a GraphQL argument or input-component name) becomes a local, namespace it with a *readable, deterministic* prefix computed against the known-in-scope names (`arg_`, `c_`), never a blanket `__`. The `__`-prefix legitimately survives in two other shapes, both of which reach generated code as *string literals*, never as Java identifiers. First, *synthetic SQL column aliases* (`__sort__`, `__idx__`, `__rn__`, `__typename`, `__pkN__`), which live in the result-set column namespace alongside consumer-controlled table columns and wrap in `__` precisely to avoid colliding with a real column; by convention each is declared as a named constant carrying the collision rationale at its site (the meta-test does not pin the constant form, only the identifier-vs-literal boundary, so the constant discipline is a readability convention, not an enforced invariant). Second, *spec-defined external names we reference but do not own*: jOOQ's reflective `__NODE_TYPE_ID` / `__NODE_KEY_COLUMNS` metadata constants, the Apollo-Federation `federation__*` / `link__*` SDL scalars, and the GraphQL introspection `__typename` meta-field (read off a federation representation map, distinct from the synthetic `__typename` SQL column that happens to share its spelling). The discriminator, and the one the no-regression meta-test keys on, is *Java identifier vs string literal in the emitted output*: a lazy dunder surfaces as a bare identifier and fails the test; every legitimate `__` name above surfaces as a string literal (or, for the jOOQ metadata, a reflective field-name argument) and is masked before the scan. The only `__`-led identifier the test allowlists is jOOQ's `__NODE_*`, on the rare path where the generator references that reflective constant by name rather than by value.
+
+The smell to watch for: an emitter building a `CodeBlock` whose template spans several lines of nested `? :`, casts to `Object` to dodge a parameterised `instanceof`, and binds `_x`-style locals. That block is being written for the emitter author's convenience, not the consumer's; the fix is a named helper with a statement body. `ArgCallEmitter.buildNodeIdDecodeExtraction` is the current worked counter-example and the R260 cleanup target.
+
+== The reactor is self-contained
+
+The repo root `pom.xml` is a single self-contained Maven reactor. `mvn install` on a clean local repo builds every module (`graphitron-javapoet`, `graphitron`, `graphitron-fixtures-codegen`, `graphitron-sakila-db`, `graphitron-sakila-service`, `graphitron-mcp`, `graphitron-jakarta-rest`, `graphitron-maven-plugin`, `graphitron-sakila-example`, `graphitron-lsp`, `roadmap-tool`) with no dependency outside the pinned third-party set.
+
+The legacy `graphitron-parent` generator has been retired, so there is no second reactor and no cross-tree coordinate to guard against. The `graphitron-maven-plugin` module is the consumer entry point.
+
+'''
+
+== Emitter Conventions
+
+=== Return types
+
+DataFetchers return `Result` ; no DTOs, no TypeMappers. GraphQL-Java traverses records using the registered field DataFetchers. Exception: Connection fields return `ConnectionResult`, a generated carrier wrapping `Result` + pagination context.
+
+=== Selection-aware queries
+
+`DataFetchingFieldSelectionSet` and `SelectedField` are threaded through all table method signatures, structurally committing to selection-aware queries:
+
+- *Top-level queries*: call `Type.$fields(sel, table, env)` for the column list, then `dsl.select(fields).from(table)...`
+- *Inline nesting*: use jOOQ `multiset(select(columns).from(CHILD).where(...)).as("alias")` returning `Field>` (type-erased). Use type erasure at every helper method boundary ; jOOQ generic types compound badly with nesting depth, causing slow compile times.
+- *`@splitQuery`*: separate DataLoader; parent fetches FK/PK columns, child batches by those keys.
+
+Selection-driven queries produce different SQL per request, preventing cached query-plan reuse. This is an acceptable trade-off for wide tables with large optional columns; for narrow tables (≤ 10 columns) where most fields are always requested, `TABLE.*` is simpler and the dynamic-column overhead exceeds the benefit.
+
+=== Error quality
+
+`BuildContext.candidateHint(attempt, candidates)` sorts candidates by Levenshtein distance. The Levenshtein-suggestion contract has consolidated onto `BuildContext` and `Rejection` (the rejection-construction sites), with classifier-side callers thinning out as rejections are produced through the typed sealed-result path. Today: 17 occurrences across five files ; 7 in `BuildContext`, 3 in `TypeBuilder`, 3 in `Rejection`, 2 in `FieldBuilder`, 2 in `EnumMappingResolver`. When adding new jOOQ existence checks in the validator or builder, follow the same pattern ; pass the relevant candidate list from `JooqCatalog` to `candidateHint`, or produce the rejection through `Rejection.unknownName(...)` so the candidate list rides on the typed result.
+
+=== Column value binding: `DSL.val(rawValue, col.getDataType())`
+
+When emitting code that binds a raw GraphQL input value (from an input map or `env.getArgument(...)`)
+to a specific jOOQ column, always use the two-argument form:
+
+[source,java]
+----
+DSL.val(rawValue, table.COL.getDataType())
+----
+
+Do *not* use the one-argument form with a Java-side cast (`DSL.val((JavaType) rawValue)`):
+
+- GraphQL-Java delivers enum values as `String` ; a Java cast to the jOOQ enum class throws
+ `ClassCastException` at runtime.
+- GraphQL-Java delivers `ID` scalars as `String` ; a cast to `Long` (or any numeric PK type)
+ also throws.
+- The one-argument form ignores the column's registered jOOQ `Converter` entirely.
+
+The two-argument form hands `rawValue` to the column's `DataType` and its registered `Converter`
+at bind time. No SQL `CAST` is rendered; the coercion is purely Java-side, inside jOOQ.
+
+*`CallSiteExtraction` solves a different problem.* `Direct`, `EnumValueOf`, `TextMapLookup`,
+and `JooqConvert` exist to produce a typed Java value for a *condition/ordering method parameter*
+; code paths where a developer-written method expects the column's Java type, not a jOOQ `Field`.
+For inline jOOQ DSL expressions (INSERT `values(...)`, UPDATE `set(...)`, DELETE/UPDATE `where(...)`
+predicates), `DSL.val(rawValue, col.getDataType())` does the coercion inside jOOQ without any
+Java-side step, and no `CallSiteExtraction` switch is needed.
+
+Precedent: `LookupValuesJoinEmitter.addRowBuildingCore` (search for `DSL.val` with two arguments).
+
+For DTO-parent batching ; where the parent's backing class is a plain POJO or Java record without a
+jOOQ FK to the child's `@table` ; see <> below; the lifter
+contract is how the schema author hands the framework a typed key when the catalog can't supply one.
+
+[#dto-parent-batching]
+=== DTO-parent batching
+
+When a record-backed parent's backing class (reflected from its producer) is not a jOOQ `TableRecord`, the catalog cannot supply the
+FK columns the column-keyed DataLoader path needs to batch a child `@table` field. The `@sourceRow`
+directive closes that gap: the schema author supplies a static Java method that lifts a `RowN<...>`
+batch key out of the parent DTO, plus the `targetColumns` (column names on the child table) the
+key positions match. The classifier reflects on the lifter once at build time, validates the per-position
+column-class match, and produces a `SourceKey` whose `Reader` is `SourceRowsCall(lifter)` and whose
+`Wrap` is `Row`, carrying a `JoinStep.LiftedHop` (target table + slot list, single-hop by construction)
+in the `path` and a `LifterRef` (declaring class + method name) on the reader. The lifted slots are
+`JoinSlot.LifterSlot` permits, each folding source-side and target-side onto a single `ColumnRef` by
+construction (DataLoader key tuple IS target-column tuple as a type fact, not a prose precondition).
+The emitter feeds that into the existing `SplitRowsMethodEmitter.buildListMethod` path with no
+identity branching: target accessors come from `WithTarget.slots()`, key extraction from the lifter
+call. The lifter is the *single place* where the DTO → key mapping lives; if a schema author needs
+a different key, they author a new lifter method, not a new emitter arm.
+
+=== Helper-locality
+
+Emitted helper methods that bind column references to a specific aliased jOOQ `Table` instance always take the `Table` as a parameter ; never declare it locally. Callers from different paths (root fetcher, inline subquery, Split-rows method) need to pass distinct aliases for the same target table; a locally-declared `Table` forces the wrong alias on every caller but the one the helper was first written for.
+
+Pattern (canonical example, `OrderBy` emitted by `TypeFetcherGenerator.buildOrderByHelperMethod`):
+
+[source,java]
+----
+private static OrderByResult OrderBy(DataFetchingEnvironment env, table) { ... }
+----
+
+Each call site supplies the alias appropriate to its scope: the root fetcher passes its declared `Table`; a Split-rows method passes its FK-chain terminal alias; an inline subquery passes its correlated alias. The helper's column references resolve through the parameter.
+
+Compliant emitters (audit 2026-04-25): `TypeFetcherGenerator.buildOrderByHelperMethod`, `QueryConditionsGenerator`, `LookupValuesJoinEmitter` (root + child forms), `InlineLookupTableFieldEmitter`. `ConnectionHelperClassGenerator.pageRequest` / `encodeCursor` / `decodeCursor` are not Table-bound (operate on `Field>` / `SortField>`) and the rule does not apply.
diff --git a/docs/architecture/explanation/typed-rejection.adoc b/docs/architecture/explanation/typed-rejection.adoc
new file mode 100644
index 0000000000..bd6a7bd643
--- /dev/null
+++ b/docs/architecture/explanation/typed-rejection.adoc
@@ -0,0 +1,183 @@
+= Typed rejection
+
+Why a classifier failed to produce a model variant is a typed value, not a string. Every builder-step lift returns a sealed `Resolved` result; the rejection arm carries a `Rejection` instance whose variant tells the validator and downstream consumers (LSP fix-its, watch-mode formatters) what kind of failure happened and what data accompanies it. Switches across success and failure modes are exhaustive at compile time; relaxing a producer surfaces as a missing-arm error in every consumer, not a runtime surprise.
+
+This page is the chapter narrative for that contract. Reference detail (the per-leaf record components, the per-resolver `Resolved` arms) lives on the source: javadoc on `Rejection` and on each `*DirectiveResolver.Resolved` carries the per-permit data shape.
+
+== Sealed `Resolved` across the resolver siblings
+
+Thirteen directive resolvers share the same shape. Each entry point returns a sealed `Resolved` whose arms split success from failure; the caller's switch is exhaustive across both. The arm names vary by domain (the `@lookupKey` resolver has only `Ok` / `Rejected`; the `@service` resolver fans `Ok` into typed sub-arms because the downstream emitters need different model types per success kind), but the contract is uniform: rejection is a typed sibling of success, not a return-string-or-null.
+
+Where a rejection comes from is at the boundary of one builder step; how it surfaces to the user is at the boundary of the validator. In between, the typed value rides on the result without losing structure: a `Rejection.AuthorError.UnknownName` arm carries its `attempt`, its `candidates`, and a typed `AttemptKind` tag identifying the lookup space, all the way from the resolver that built it to the LSP fix-it that reads them off the wire.
+
+The validator does not parse prose. It switches on the `Rejection` variant, formats the human-readable line with `message()`, classifies it as `AUTHOR_ERROR` or `INVALID_SCHEMA` or `DEFERRED` for the diagnostic surface, and emits. The same typed value drives every consumer: the validator's log, the LSP fix-it, an editor's hover-card, a CI annotation. None of them re-parse text.
+
+== `Rejection` taxonomy
+
+The `Rejection` sealed hierarchy splits along *who can fix this*: the schema author, the runtime author who hasn't shipped support yet, or the schema as a whole.
+
+[source]
+----
+Rejection
+├─ AuthorError ← the schema author can correct this
+│ ├─ UnknownName ← name that didn't resolve against a closed set
+│ ├─ Structural ← rule violation carrying prose only
+│ ├─ AccessorMismatch ← record-backed parent's class doesn't expose the accessor
+│ ├─ RecordBindingMultiProducer ← multiple producers reach one SDL type with disagreeing classes
+│ ├─ TypeConflict ← cross-site contextArgument type-agreement disagreement
+│ └─ MultiProducerDomainTypeDisagreement ← producers reach one SDL Object type with disagreeing env.getSource() Java domain types
+├─ InvalidSchema ← the schema can't accept this combination at all
+│ ├─ DirectiveConflict ← two directives co-occur in a rejected combination
+│ ├─ CaseFoldCollision ← two type names are equal under case-folding
+│ └─ Structural ← rule violation carrying prose only
+└─ Deferred ← classifies cleanly but the generator hasn't emitted support yet
+----
+
+`AuthorError.UnknownName` is the data-rich arm: it carries the `attempt` the author wrote, the `candidates` the catalog had at this site (column names, table names, FK names, service-method names, ...), and an `AttemptKind` tag for downstream tooling. The kind is a typed tag rather than an arm split because every space carries the same `(attempt, candidates)` shape; an LSP fix-it that wants to offer "rename to one of these" reads `attempt` and `candidates` directly off the rejection without parsing the validator's prose.
+
+`AuthorError.Structural` and `InvalidSchema.Structural` are the prose-majority arms. They carry one `String reason` and exist because most rule violations are not name-against-closed-set lookups; they're "this combination cannot work, period" or "this rule was broken." Two arms instead of one because the diagnostic surface treats `AUTHOR_ERROR` and `INVALID_SCHEMA` differently: the first prompts the author to edit; the second prompts the author to drop or replace a directive entirely.
+
+`AuthorError.AccessorMismatch` is the third `AuthorError` arm because the record-backed-parent accessor-resolution surface (resolving an SDL output field's accessor against the parent's reflection-derived backing class) produces uniform diagnostics with a uniform fix shape (`@field(name: "...")`); a single arm with the hint baked into `message()` lets the resolver hand the validator the same typed value every site produces.
+
+`AuthorError.RecordBindingMultiProducer` is the fourth `AuthorError` arm, surfacing R96's producer-agreement check: when two or more producers (root producers or parent-accessor chains) reach the same SDL type with disagreeing reflected backing classes, the validator halts with a typed payload naming the SDL type and every disagreeing `ProducerBinding` site. The arm sits under `AuthorError` because the fix is author-correctable (align the producers on a single backing class via the same rename / retype / split toolbox the rest of `AuthorError` supports); the typed `List` payload follows the sub-data pattern that `UnknownName` established.
+
+`AuthorError.TypeConflict` is the fifth `AuthorError` arm, surfacing R190's cross-site `contextArgument` type-agreement check: when two or more directive sites (`@service`, `@tableMethod`, `@condition`) reference the same `contextArgument` name with mutually-incompatible Java types, the schema-driven `Graphitron.newExecutionInput(...)` factory cannot produce a single typed parameter slot. The arm carries the contextArgument name plus the typed `List` (each site's `MethodRef` coordinate and the `TypeName` that site declared); `message()` renders one indented line per site for the validator's prose surface, while LSP fix-its read the typed `sites` field directly. Like `RecordBindingMultiProducer`, the arm sits under `AuthorError` because the fix is author-correctable (align every site on a single Java type).
+
+`AuthorError.MultiProducerDomainTypeDisagreement` is the sixth `AuthorError` arm, surfacing R204's uniform-domain-return-type check: when two or more `OutputField` producers reach the same SDL Object return type with disagreeing `DomainReturnType` sealed arms (`Record(table)` vs `TableRecord(class)` vs `Plain(class)`), the producers put structurally different Java values at `env.getSource()` for the SDL type's child datafetchers. The generator commits to one source-Java-type per child-field coord at emit time and does not branch on runtime source type, so a runtime disagreement would feed a datafetcher generated against the other producer's record shape. The arm carries the SDL type name plus a typed `List` (each producer's `(parentTypeName, fieldName, DomainReturnType)`); `message()` renders one indented line per participant. The today-exercised case is the carrier-payload conflict (DML `@mutation` returning `Record(table)` vs `@service`-on-`Mutation` returning a typed `TableRecord` for the same payload SDL Object); the constraint is general across any current or future producer permit.
+
+`ServiceMethodCallError` is R238's sub-seal of `AuthorError`, scoped to the `@service`-binding failures of the `ServiceMethodCallWalker` / `ServiceCatalog.reflectServiceMethod` path that projects `@service` directive sites onto the `ServiceMethodCall` carrier on root sync service permits (`QueryServiceTableField`, `QueryServiceRecordField`, `MutationServiceTableField`, `MutationServiceRecordField`). Each typed arm carries the structural data its diagnostic message needs and a stable `lspCode()` under the `graphitron.service-method-call.` namespace; downstream tooling switches on the arm rather than parsing prose. R238 shipped only the two arms its translator-walker produced; R256 (`service-walker-substrate-absorption`) re-landed the service-binding failures that `ServiceCatalog` previously produced as `AuthorError.Structural` prose as typed arms, and partitioned the reflection-intrinsic failures (class-load, return-type, parameter-names, overload) into the sibling `ReflectionError` sub-seal (below) so a `@tableMethod` / `@externalField` failure of the same shape is not forced through a `@service`-named arm. Subsequent walker slices (condition, tableMethod, externalField) each add their own sibling sub-seal alongside this one, keeping the dimensional pivot one-row-per-walker rather than piling typed arms under a single flat `Structural`.
+
+`ServiceMethodCallError.MultipleDslContextSlots` fires when a single round (constructor or method) carries more than one `DSLContext` parameter slot; carries className and a `Round` enum identifying which round violated the invariant (R256 makes the constructor round reachable, since the holder may now bind a multi-parameter constructor). `ServiceMethodCallError.ParameterUnbindable` fires when a Java parameter slot does not match any GraphQL argument, declared context key, or DSLContext slot; carries paramName, the available argument names, and a Levenshtein-ranked suggestion. `ServiceMethodCallError.InstanceHolderUnconstructible` (R256) fires when an instance `@service` method's enclosing class cannot be used as a holder, either because it is abstract / an interface or because it exposes no public constructor whose parameters are each a `DSLContext` or a declared context argument; carries the class/method coordinate, the class's simple name (for the fix hint), and a `HolderProblem` discriminant. `ServiceMethodCallError.ArgumentParameterMismatch` (R256) fires when a Java parameter matches no GraphQL argument or context key; carries the parameter and method names, the available argument names and context keys, and a pre-rendered rename / argMapping / dot-path suggestion (subsumes the prose the legacy `Structural` arm produced at `ServiceCatalog`). `ServiceMethodCallError.DtoSourcesUnsupported` (R256) fires when a `@service` SOURCES parameter is a `List` / `Set` whose element is not backed by a jOOQ `TableRecord`; carries the parameter and method names plus the `@sourceRow` hint. `ServiceMethodCallError.UnrecognizedSourcesType` (R256) fires when a parameter looks like a SOURCES batch shape but its element type is none the classifier recognises; carries the parameter and method names plus the unrecognised Java type name.
+
+`ReflectionError` is R256's sub-seal of `AuthorError` for the reflection-intrinsic failures shared across `ServiceCatalog`'s three reflect helpers (`reflectServiceMethod`, `reflectTableMethod`, `reflectExternalField`). A class that cannot be loaded, a method whose return type does not match the field's declared type, a class compiled without `-parameters`, or an overloaded method name are properties of the reflected Java method regardless of which directive references it, so these arms live under the `graphitron.reflect.` namespace rather than being forced through `ServiceMethodCallError`. Like its siblings it carries one stable `lspCode()` per arm. `ReflectionError.ClassNotLoaded` fires when the referenced class cannot be loaded through the codegen classloader; carries the binary class name. `ReflectionError.ReturnTypeMismatch` fires when the reflected return type does not equal the type the field's declared return requires; carries the class/method coordinate, the expected vs. actual type in their message-surfaced simple form, and a `ReturnContext` discriminant selecting the `@service` vs. `@tableMethod` prose. `ReflectionError.ParameterNamesMissing` fires when the class was compiled without `-parameters` so a parameter that needs its name to bind has none; carries the class/method coordinate. `ReflectionError.AmbiguousMethod` fires when more than one declared method shares the referenced name (the reflect helpers previously took the first JVM-declaration-order match silently); carries the class/method coordinate and every same-name declaration's parameter arity.
+
+`UpdateRowsError` is R246's sub-seal of `AuthorError`, scoped to the `UpdateRowsWalker` that projects an `@mutation(typeName: UPDATE)` field's `@table` input onto the `UpdateRows` carrier on `MutationUpdateTableField`. Each typed arm carries the structural data its diagnostic message needs and a stable `lspCode()` under the `graphitron.update-rows.` namespace; downstream tooling switches on the arm rather than parsing prose. Like R238's `ServiceMethodCallError`, it is a sibling sub-seal of `AuthorError` rather than a set of arms under the flat `Structural`, keeping the dimensional pivot one-row-per-walker. The arms subsume the per-input-field and PK-coverage prose the legacy `MutationInputResolver` produced for the UPDATE path.
+
+`UpdateRowsError.NoUniqueKeyCoverage` fires when no primary key and no unique key has its column set covered by the input's columns; carries the table name, the input-covered columns, and every candidate key the walker considered (a table with no keys at all is the degenerate empty-candidate case). `UpdateRowsError.NoSetFields` fires when every input field contributes to the matched key, leaving an empty SET; carries the table name and the matched key. `UpdateRowsError.MixedCarrierKeyMembership` fires when a single *cross-table* FK composite-reference field's lifted columns straddle the matched key (some are key members, some are not); carries the field name and the in-key / outside-key column split. A cross-table FK reference partitions by key membership because its lifted column can legitimately be the row's own identity, so a straddle is unexpressible. A *self-FK* `@nodeId @reference` no longer reaches this arm (R354): its lifted columns are a pointer to a sibling row, never identity, so they route wholly to the SET partition (a shared key column then appears in both the WHERE and SET, reconciled by an emit-side cross-partition value-agreement check). `UpdateRowsError.UnsupportedInputFieldShape` fires for nesting fields, unbound fields without an override condition, or any non-admitted carrier; carries the field name, the classifier shape, and a reason. `UpdateRowsError.OverrideConditionNotSupported` fires when an input field carries `@condition(override: true)` (R215 admits the shape at classify time, but its emit-side never landed, so the filter would silently never run); carries the field name and the directive's source location. `UpdateRowsError.PlainColumnCollision` fires when two or more plain `@field` writers (no `@nodeId` decode among them) resolve to one SET column, which the single-row `Map.put` would silently last-write-wins and the bulk VALUES-join would crash on a duplicate derived column; carries the two field names and the column (R322, the UPDATE mirror of the INSERT-path and `@service` field-vs-field reject). An overlap involving a decode is admitted and reconciled by R322's runtime value-agreement check rather than rejected here.
+
+`DeleteRowsError` is R266's sub-seal of `AuthorError`, scoped to the `DeleteRowsWalker` that projects an `@mutation(typeName: DELETE)` field's `@table` input onto the `DeleteRows` carrier on `MutationDeleteTableField`, `MutationDeletePayloadField`, and `MutationBulkDeletePayloadField`. Each typed arm carries the structural data its diagnostic message needs and a stable `lspCode()` under the `graphitron.delete-rows.` namespace; downstream tooling switches on the arm rather than parsing prose. Like R246's `UpdateRowsError`, it is a sibling sub-seal of `AuthorError` rather than a set of arms under the flat `Structural`, keeping the dimensional pivot one-row-per-walker. The arm set is `UpdateRowsError`'s minus the two arms DELETE's shape makes meaningless: there is no `NoSetFields` (DELETE has no SET clause to be empty) and no `MixedCarrierKeyMembership` (DELETE has no SET boundary for a composite carrier to straddle, since every admitted column is a WHERE filter). Carving DELETE off `MutationInputResolver.resolveInput` retired the last live `@value` consumer and the directive itself (absorbing R188).
+
+`DeleteRowsError.NoUniqueKeyCoverage` fires when no primary key and no unique key has its column set covered by the input's columns and the mutation did not opt into `multiRow: true`; carries the table name, the input-covered columns, and every candidate key the walker considered (a table with no keys at all is the degenerate empty-candidate case, which the message points at `multiRow: true`). It subsumes R188's `table-has-no-pk` rejection. `DeleteRowsError.UnsupportedInputFieldShape` fires for nesting fields, unbound fields without an override condition, or any non-admitted carrier; carries the field name, the classifier shape, and a reason. `DeleteRowsError.OverrideConditionNotSupported` fires when an input field carries `@condition(override: true)` (R215 admits the shape at classify time, but its emit-side never landed, so the filter would silently never run); carries the field name and the directive's source location.
+
+`ErrorChannelWalkerError` is R244's sub-seal of `AuthorError`, scoped to the error-channel domain: the `ErrorChannelWalker` that resolves an outcome type's errors-field channel onto `ErrorChannel.Mapped`, plus the `OutcomeType` classification that produces the walker's input. Each typed arm carries the structural data its diagnostic message needs and a stable `lspCode()` under the `graphitron.error-channel.` namespace; downstream tooling switches on the arm rather than parsing prose. Like R238's `ServiceMethodCallError`, it is a sibling sub-seal of `AuthorError` rather than a set of arms under the flat `Structural`, keeping the dimensional pivot one-row-per-walker. Three arms are raised by the `OutcomeType` classification and the rest by `walk()`; they share one family because they share one SDL surface (the outcome type and its errors field) and one LSP namespace.
+
+`ErrorChannelWalkerError.MultipleErrorsFields` fires when a type carries more than one errors field; the binary `Outcome` witness has one error slot, so a type with two errors fields has no well-defined fork. Carries the outcome type name and the offending errors-field names. `ErrorChannelWalkerError.NonNullableSuccessProjectionField` fires when a success-projection (data) field is non-null; on the error arm that field resolves null and would raise `NonNullableFieldWasNullError`, bubbling the null up and dropping the sibling errors field, so success-projection fields must be nullable. Carries the outcome type name and the field name. `ErrorChannelWalkerError.NonNullableErrorsField` (R275) is the mirror: it fires when the errors field itself is non-null (`[X!]!`); on the success arm there are no errors and the field resolves null, which would raise `NonNullableFieldWasNullError` and drop the sibling data field, so errors fields must be nullable. Carries the outcome type name and the field name. `ErrorChannelWalkerError.ChannelRuleViolation` fires on a channel-level handler-rule violation (rule 7: no two VALIDATION handlers in one channel; rule 8: no duplicate match-criteria across the flattened handler list); carries the outcome type name, the errors-field name, the rule number, and a detail string, with `lspCode()` specialising per rule. `ErrorChannelWalkerError.HandlerSourceAccessorMissing` fires when an `@error` type's handler source class exposes no `PropertyDataFetcher`-visible accessor for one of the `@error` type's declared SDL fields (`path` and `message` exempt); carries the outcome type name, the `@error` type name, the handler class name, the missing field name, and the available accessors.
+
+`WireCoercionError` is R261's sub-seal of `AuthorError`, scoped to the wire-coercion failures a scalar/enum SDL leaf hits when its consumer-declared Java type does not match what graphql-java delivers on the wire. Before R261 every arg-classification site fell through to `CallSiteExtraction.Direct` and emitted a raw `(DeclaredType) wireValue` cast that compiled cleanly and `ClassCastException`d (or, for enums, `IllegalArgumentException`d) on the first request; graphql-java delivers `ID` and enum values as `String`, `Int` as `Integer`, `Float` as `Double`, input-objects as `Map`, so a declared cast target of a jOOQ record, a numeric PK type, a domain class, or a width-mismatched numeric is a guaranteed runtime crash invisible at build time. The classifier now confirms the coercion output is assignable to the declared type before emitting `Direct` — the `Direct` fall-through becomes the narrow arm the predicate confirms is wire-pass-through — and a mismatch surfaces here instead. Each typed arm carries the structural data its diagnostic message needs and a stable `lspCode()` under the `graphitron.wire-coercion.` namespace; downstream tooling switches on the arm rather than parsing prose. The judgment lives at the classifier (a new `WireCoercionResolver` predicate consuming `ScalarTypeResolver.coercionOutputType`), not on `ScalarTypeResolver`, which stays a pure name↔type mapping.
+
+`WireCoercionError.Assignability` fires when the graphql-java coercion output for a scalar SDL leaf does not equal the declared Java type (sites A-D of the audit: `@service` input-bean scalar fields, `@service` scalar args, and the non-service `@condition` / `@externalField` sites in a later slice); carries the SDL leaf type as written, the fully-qualified coercion-output class graphql-java delivers, the fully-qualified declared Java type the cast targets, and a `site` string. `WireCoercionError.EnumConstantDivergence` fires when the declared type is the enum and assignment succeeds but an SDL enum value name has no matching Java constant, so `Enum.valueOf((String) ...)` would throw (site E); a constant-name-set membership check on a distinct axis from `Assignability`, populated from the single `EnumMappingResolver` parity home shared with the column/arg enum path. Carries the Java enum class name, the divergent SDL value names, the full Java constant set, and a `site` string.
+
+`InvalidSchema.DirectiveConflict` carries the bare directive names (no leading `@`) plus the prose. The names ride as typed data so an LSP fix-it can offer "remove this directive" without scraping the prose for which one to remove.
+
+`InvalidSchema.CaseFoldCollision` is the case-fold-uniqueness arm: two or more type-name stems collapse to the same identifier on case-insensitive filesystems (APFS, NTFS), which would clobber the emitted Java files. The variant carries the full case-equivalent group as a typed `List` plus a `CaseFoldCollision.Origin` enum (`SDL`, `SYNTH_CONNECTION`, `SYNTH_EDGE`, `SYNTH_PAGE_INFO`) identifying which classifier arm each demoted member came from; `message()` switches on origin to specialise the actionable fix hint (synthesised arms point at `@asConnection(connectionName: ...)`; SDL arms suggest a rename). Carrying the group as structured data lets an LSP fix-it offer "rename one of these" with the candidate list ready, without scraping prose.
+
+`Deferred` classifies cleanly but the generator hasn't shipped emit support for the variant yet. The arm carries a `summary` plus a `planSlug` (the roadmap file basename, no extension) plus a `StubKey`. The stub key is itself a sealed sub-type: a `VariantClass` arm names the stubbed variant class (or carries `null` for inline-defer sites whose rejection names a feature shape); an `EmitBlock` arm names a typed enum value for "this shape can't emit yet" sites inside the emitters. The validator projects every `Deferred` through the same renderer; the typed key lets the LSP offer "open the roadmap item" instead of parsing the path back out.
+
+The xref:rewrite-design-principles.adoc#_classifier_guarantees_shape_emitter_assumptions[classifier-shaped emitter-assumption] principle and the xref:rewrite-design-principles.adoc#_validator_mirrors_classifier_invariants[validator-mirrors-classifier] rule both ride this contract: the validator switches on the same dispatch sets the generator does, so an unsupported classification surfaces as a typed `Deferred` at validate time, not as an `UnsupportedOperationException` at runtime. Validate is a typed-rejection projection of classify.
+
+== `BuildContext.candidateHint`: Levenshtein-ranked suggestions
+
+When a name doesn't resolve, the rejection carries the closed set of candidates the catalog had at that site; the user-visible hint sorts them by edit distance from the attempt, top five.
+
+[source,java]
+----
+String hint = candidateHint(attempt, candidates);
+// "; did you mean: candidate1, candidate2, candidate3"
+----
+
+The contract has consolidated onto two construction sites: `BuildContext.candidateHint(attempt, candidates)` for callers building rejection messages directly, and `Rejection.unknownName(...)` (and its kind-specific factories `unknownColumn`, `unknownTable`, `unknownForeignKey`, ...) for callers producing the rejection through the typed sealed-result path. Both compute the same hint; the typed-result path additionally rides the `attempt` and `candidates` as structured data on the rejection so downstream tooling can offer a fix-it without parsing prose.
+
+When adding a new existence check to the validator or builder, follow the same pattern: pass the relevant candidate list from `JooqCatalog` (or whatever closed set the lookup ranges over) to `candidateHint`, or produce the rejection through `Rejection.unknownName(...)` so the candidate list rides on the typed result.
+
+The diagnostic surface that consumers see, what each rejection class renders as, what severity it gets, what the build's log line looks like, is documented at xref:../../manual/reference/diagnostics-glossary.adoc[the diagnostics glossary]; that page is the user-facing entry point for "I saw this message, what does it mean."
+
+== Drift protection
+
+The chapter prose above enumerates `Rejection`'s permits: `AuthorError.UnknownName`, `AuthorError.Structural`, `AuthorError.AccessorMismatch`, `AuthorError.RecordBindingMultiProducer`, `AuthorError.TypeConflict`, `AuthorError.MultiProducerDomainTypeDisagreement`, `ServiceMethodCallError.MultipleDslContextSlots`, `ServiceMethodCallError.ParameterUnbindable`, `ServiceMethodCallError.InstanceHolderUnconstructible`, `ServiceMethodCallError.ArgumentParameterMismatch`, `ServiceMethodCallError.DtoSourcesUnsupported`, `ServiceMethodCallError.UnrecognizedSourcesType`, `ReflectionError.ClassNotLoaded`, `ReflectionError.ReturnTypeMismatch`, `ReflectionError.ParameterNamesMissing`, `ReflectionError.AmbiguousMethod`, `UpdateRowsError.NoUniqueKeyCoverage`, `UpdateRowsError.NoSetFields`, `UpdateRowsError.MixedCarrierKeyMembership`, `UpdateRowsError.UnsupportedInputFieldShape`, `UpdateRowsError.OverrideConditionNotSupported`, `UpdateRowsError.PlainColumnCollision`, `DeleteRowsError.NoUniqueKeyCoverage`, `DeleteRowsError.UnsupportedInputFieldShape`, `DeleteRowsError.OverrideConditionNotSupported`, `ErrorChannelWalkerError.MultipleErrorsFields`, `ErrorChannelWalkerError.NonNullableSuccessProjectionField`, `ErrorChannelWalkerError.NonNullableErrorsField`, `ErrorChannelWalkerError.ChannelRuleViolation`, `ErrorChannelWalkerError.HandlerSourceAccessorMissing`, `WireCoercionError.Assignability`, `WireCoercionError.EnumConstantDivergence`, `InvalidSchema.DirectiveConflict`, `InvalidSchema.CaseFoldCollision`, `InvalidSchema.Structural`, and `Deferred`. A new permit on the sealed hierarchy must land with a corresponding mention in this page; otherwise the prose silently goes stale. `SealedHierarchyDocCoverageTest` walks `Rejection.permits()` transitively and asserts each permit name appears in `typed-rejection.adoc`: bidirectional, tied to a closed set the compiler already exhaustivity-checks. A new permit added without a paragraph here fails the test; a permit removed without removing its mention fails too.
+
+The sealed-`Resolved` pattern across the thirteen sibling resolvers is described above shape-only; per-resolver arm enumerations (e.g. `LookupKeyDirectiveResolver.Resolved.{Ok, Rejected}`, `ServiceDirectiveResolver.Resolved.{Success.{TableBound | Result | Scalar} | ErrorsLifted | Rejected}`) live as javadoc on each `*DirectiveResolver.Resolved` declaration. There is no single `Resolved` parent class to walk, and the chapter does not pin per-resolver permits.
+
+[#diagram-d10]
+== Sealed hierarchy diagram
+
+[source,mermaid]
+----
+classDiagram
+ class Rejection {
+ <>
+ +String message()
+ +Rejection prefixedWith(String)
+ }
+ class AuthorError {
+ <>
+ }
+ class InvalidSchema {
+ <>
+ }
+ class UnknownName {
+ +String summary
+ +AttemptKind attemptKind
+ +String attempt
+ +List~String~ candidates
+ }
+ class Structural_AE["AuthorError.Structural"] {
+ +String reason
+ }
+ class AccessorMismatch {
+ +String reason
+ }
+ class RecordBindingMultiProducer {
+ +String sdlTypeName
+ +List~ProducerBinding~ bindings
+ }
+ class DirectiveConflict {
+ +List~String~ directives
+ +String reason
+ }
+ class CaseFoldCollision {
+ +List~String~ group
+ +Origin origin
+ }
+ class Structural_IS["InvalidSchema.Structural"] {
+ +String reason
+ }
+ class Deferred {
+ +String summary
+ +String planSlug
+ +StubKey stubKey
+ }
+
+ Rejection <|-- AuthorError
+ Rejection <|-- InvalidSchema
+ Rejection <|-- Deferred
+ AuthorError <|-- UnknownName
+ AuthorError <|-- Structural_AE
+ AuthorError <|-- AccessorMismatch
+ AuthorError <|-- RecordBindingMultiProducer
+ InvalidSchema <|-- DirectiveConflict
+ InvalidSchema <|-- CaseFoldCollision
+ InvalidSchema <|-- Structural_IS
+
+ class LookupKeyResolved["LookupKeyDirectiveResolver.Resolved"] {
+ <>
+ }
+ class LookupKeyOk["Resolved.Ok"] {
+ +ReturnTypeRef.TableBoundReturnType returnType
+ }
+ class LookupKeyRejected["Resolved.Rejected"] {
+ +Rejection rejection
+ }
+ LookupKeyResolved <|-- LookupKeyOk
+ LookupKeyResolved <|-- LookupKeyRejected
+ LookupKeyRejected --> Rejection : carries
+----
+
+The overlay shows `LookupKeyDirectiveResolver.Resolved` as one worked example of how a resolver's typed-result wraps a `Rejection`. The other twelve resolvers follow the same shape; check each `*DirectiveResolver.Resolved` for its specific arm set.
+
+'''
+
+*See also:*
+
+* {empty}xref:rewrite-design-principles.adoc#_builder_step_results_are_sealed_not_strings_or_out_params[Builder-step results are sealed]: the principle this page is the canonical source for.
+* {empty}xref:rewrite-design-principles.adoc#_validator_mirrors_classifier_invariants[Validator mirrors classifier invariants]: the validator-side rule that consumes typed rejections at the build's diagnostic surface.
+* {empty}xref:../../manual/reference/diagnostics-glossary.adoc[Diagnostics glossary]: the user-facing reference for what each rejection-derived diagnostic message means.
diff --git a/docs/architecture/how-to/dev-loop-internals.adoc b/docs/architecture/how-to/dev-loop-internals.adoc
new file mode 100644
index 0000000000..24ac9628d4
--- /dev/null
+++ b/docs/architecture/how-to/dev-loop-internals.adoc
@@ -0,0 +1,126 @@
+= Dev loop internals
+:description: How the graphitron:dev Mojo wires the LSP, MCP server, watchers, and generator dispatch into one JVM, plus the contributor-facing federation and native-runtime rationale.
+:!toc:
+
+Contributor-facing material for anyone extending the `dev`-loop surface, the federation wrap, or the native-runtime packaging. For the consumer-facing inner loop (how to run it, connect an editor, and connect an agent), see xref:../../manual/how-to/dev-loop.adoc[How-to: The dev loop] and xref:../../manual/how-to/mcp-agent-context.adoc[How-to: Agent context over MCP].
+
+[#dev-loop-detail]
+== Dev loop: how the goal is wired internally
+
+The `dev` goal runs five cooperating components in one JVM:
+
+* *LSP server* binds the TCP port (default `8487`) and speaks LSP to whatever editor or agent connects. It serves diagnostics, hover, completion, and go-to-definition off the most recent classified `GraphitronSchema` and the validator's report on it. State is in-memory only; no LSP cache lives on disk. Its `didSave` notification is the primary fast path into the generator dispatch when an editor is attached.
+* *MCP server* binds a second loopback port (`8488`) and speaks the Model Context Protocol over Streamable HTTP to an MCP-aware agent. It serves the handshake `instructions` string, an `about` prompt, a `directives` resource, and a set of read-only tools backed by the warm `Workspace`: catalog discovery (`catalog.tables`, `catalog.describe`, and the semantic `catalog.search`), `schema`, the `@service` / `@condition` / `@record` bindings, `diagnostics`, cross-reference `edges`, `status`, and `docs.search` over the bundled manual (R118, R385). It reads the warm `Workspace` but does not feed the generator dispatch; the connection is read-only. It lives in its own `graphitron-mcp` module so its heavy native (semantic-index) dependencies stay off the plugin's own compile surface. Hosted in embedded Jetty; the literal `8488` is pinned by a `DevMojoTest` assertion on `DevMojo.DEFAULT_MCP_PORT`.
+* *Schema watcher* is a `WatchService` over the consumer's `.graphqls` source roots; the headless fallback when no editor is attached. On a save, it debounces same-file events that arrive in clusters (some editors emit several `MODIFY` events per save) and signals the generator dispatch. The LSP `didSave` path feeds the same debounce, so the two routes coalesce on a single regen when both fire.
+* *Classpath watcher* is a `WatchService` over the consumer's compiled jOOQ output (`target/classes//`). When `mvn compile` in another terminal lands new `.class` files for jOOQ tables and columns, the watcher signals the generator dispatch the same way a schema save does. This is what lets a jOOQ schema regen pick up automatically without a `dev`-session restart.
+* *Generator dispatch* is the lifecycle thread that consumes wake-up events from either watcher, loads the (possibly-updated) classpath, runs the generator over the (possibly-updated) `.graphqls` sources, and feeds the resulting `GraphitronSchema` and emit results to the LSP server's in-memory state and to disk via the idempotent writer.
+
+One JVM, two loopback ports (LSP and MCP), one process tree. There's no daemon, no client/server split, no shared cache directory. The Mojo binds, watches, regenerates, and serves; on Ctrl+C the JVM shutdown hook closes the LSP socket, the MCP server, the `WatchService` instances, and the debounce executor cleanly.
+
+The idempotent-write coupling is what makes the loop usable as an editor backend. Because `JavaFile.writeToPath` writes only files whose rendered content actually changed (SHA-256 comparison) and deletes orphans in rewrite-owned sub-packages, a schema edit that touches one type rewrites that type's files and leaves every other generated file byte-identical on disk. The IDE's incremental compiler, Quarkus `quarkus:dev`, and Spring Boot DevTools all detect changes by mtime; unchanged files keep their mtimes, so only the actually-changed files trigger an IDE recompile. Two effects fall out: editor latency is proportional to the edit (not to the schema size), and `git diff` after a `dev` session shows only what a human-readable summary of the schema edit would predict.
+
+[#diagram-d7]
+[source,mermaid]
+----
+flowchart TD
+ subgraph JVM["dev JVM (one process)"]
+ Editor["editor / agent"] -. LSP/TCP :8487 .-> LSP["LSP server"]
+ Editor -. MCP/HTTP :8488 .-> MCP["MCP server
(read-only tools +
about prompt)"]
+ LSP -- didSave
(primary) --> Disp["generator dispatch"]
+ SW["schema watcher
(.graphqls,
headless fallback)"] -- debounced
save events --> Disp
+ CW["classpath watcher
(target/classes/<jooq>)"] -- .class change
events --> Disp
+ Disp -- run --> Gen["generator
(classify + emit)"]
+ Gen -- in-memory
GraphitronSchema --> LSP
+ Gen -- emit results --> Writer["JavaFile.writeToPath
(idempotent + orphan sweep)"]
+ Writer -- only-changed
files written --> Sources["target/generated-sources/graphitron"]
+ end
+ Sources -- mtime change --> IDE["IDE recompile
(IntelliJ / Quarkus / DevTools)"]
+----
+
+[#dev-multi-module]
+.Multi-module projects
+****
+Running `mvn graphitron:dev` from inside a sub-module of a multi-module
+build (for example `cd opptak-subgraph && mvn graphitron:dev`, for faster
+startup and scoped logs) picks up the `@service` / `@condition` /
+`@record` classes declared in *sibling* modules automatically: completion,
+hover, go-to-definition, and the unknown-class diagnostics all resolve
+against them. Graphitron reads the parent pom's `` list to find the
+siblings, so this works for the standard reactor layout without running from
+the aggregator.
+
+The one caveat: a sibling must have been compiled at least once, so its
+`target/classes` exists on disk. `graphitron:dev` does not build siblings for
+you; run `mvn install` (or `mvn -pl compile`) once after a checkout.
+If the dev goal reports that it resolved a single module and found no siblings,
+check that the parent pom's `` lists the module you started from.
+****
+
+[#federation-internals]
+== Federation: how the wrap is wired
+
+`@link` is the opt-in. `Graphitron.buildSchema(...)` checks the parsed SDL for an `@link` to a federation spec; if present, it routes through `federation-graphql-java-support` to wrap the schema with the `_Service.sdl` field, the `_Entity` union, and the `_entities` resolver. If absent, the build skips the wrap entirely and emits a vanilla schema. A consumer who wants federation just adds the `@link`; a consumer who doesn't gets no federation surface, no `_entities`, no `_Service`. The opt-in is the SDL declaration, nothing else.
+
+`` is the second federation entry point. It exists because `@tag(name: "...")` directives are only meaningful to a federation gateway, and a consumer setting tag values has implicitly committed to federation 2. The plugin synthesises an `@link` with `import: ["@tag"]` if none was declared, fails the build if `@link` is declared but `"@tag"` is missing from `import`, and stays out of the way if neither is set. The decision lives in the plugin so it's visible at build configuration; the runtime never sees the synthesis logic.
+
+`Graphitron.buildSchema` does the wrap, not the consumer. A second `Federation.transform(...)` call by the consumer would double-add `_Service` and `_Entity` and break composition; the contract is *the consumer never wraps*. The two-arg form takes a federation customizer (`fed -> fed.fetchEntities(...)`) for cases where a consumer needs to override `fetchEntities` for hand-rolled entity types.
+
+The `fetchEntities` seam lives where it does because the default fetcher only knows about Graphitron-classified types: `@node` types resolve via the NodeId path, types with a `@key` directive resolve via column-value lookup, and both share the same per-type batched `SELECT`. Anything outside that classification surface (hand-rolled objects, types from a non-Graphitron source) needs a custom fetcher; the customizer lets the consumer plug one in without touching `buildSchema`'s wiring.
+
+For the consumer-facing federation transport (the `@link` declaration, the two-arg `buildSchema` customizer, the don't-double-wrap rule), see xref:../../manual/how-to/apollo-federation.adoc[How-to: Apollo Federation transport].
+
+[#diagram-d9]
+[source,mermaid]
+----
+sequenceDiagram
+ participant SDL as .graphqls SDL
+ participant Build as Graphitron.buildSchema
+ participant Wrap as federation-graphql-java-support
+ participant Engine as graphql-java engine
+ participant Fetch as _entities resolver
+ participant DB as PostgreSQL
+
+ SDL->>Build: parse + classify
+ alt @link present
+ Build->>Wrap: wrap(schema, fetchEntities)
+ Wrap-->>Build: federation-wrapped schema
(adds _Service.sdl, _entities)
+ else no @link
+ Build-->>Engine: vanilla schema (no federation surface)
+ end
+ Build-->>Engine: ready
+ Engine->>Fetch: _entities([{__typename, key…}, …])
+ alt @node type
+ Fetch->>DB: SELECT by NodeId-decoded keys (batched per type)
+ else @key type
+ Fetch->>DB: SELECT by key-column tuples (batched per type)
+ else custom fetcher (fed.fetchEntities)
+ Fetch->>Fetch: consumer-supplied resolver
+ end
+ DB-->>Fetch: rows
+ Fetch-->>Engine: typed results
+----
+
+[#native-runtime-dependency]
+== Native runtime dependency
+
+There isn't one. `mvn graphitron:dev` runs an LSP server backed by a
+tree-sitter-based GraphQL parser whose two native pieces, the
+`tree_sitter_graphql` grammar and the `libtree-sitter` runtime, both ship in
+the `no.sikt:graphitron-tree-sitter-natives` jar and are extracted at startup.
+You do not install anything: no `brew install`, no `vcpkg install`, no
+from-source build on Debian/Ubuntu, and no `LD_LIBRARY_PATH` /
+`JAVA_TOOL_OPTIONS` wiring (NixOS included).
+
+Supported host architectures are `linux-x86_64`, `linux-aarch64`,
+`macos-aarch64`, and `windows-x86_64`. Intel-Mac (`macos-x86_64`) is not
+shipped; on an unsupported host the LSP fails fast at startup naming the
+`os.name` / `os.arch` it saw and the supported set.
+
+If the bundled runtime fails to load after extraction (a `noexec`
+`java.io.tmpdir`, a corrupt extract, or a missing system C runtime), the LSP
+surfaces a single startup error naming the extracted path rather than an opaque
+`UnsatisfiedLinkError`. See
+xref:../../manual/reference/lsp-requirements.adoc[LSP requirements] for the
+startup-diagnostics detail.
+
+xref:index.adoc[← How-to index]
diff --git a/docs/architecture/how-to/index.adoc b/docs/architecture/how-to/index.adoc
new file mode 100644
index 0000000000..9ef7876c22
--- /dev/null
+++ b/docs/architecture/how-to/index.adoc
@@ -0,0 +1,11 @@
+= Architecture: How-to
+:description: Contributor-facing recipes for working on the Graphitron generator.
+:!toc:
+
+Recipes for specific contributor tasks.
+
+* xref:testing.adoc[Test-tier guide] — choosing between the unit, pipeline, compilation, and execution tiers.
+* xref:dev-loop-internals.adoc[Dev loop internals] — how the `dev` goal wires the LSP, MCP server, watchers, and generator dispatch into one JVM.
+* xref:release-natives.adoc[Release the tree-sitter natives] — cutting a `graphitron-tree-sitter-natives` release.
+
+xref:../index.adoc[← Architecture]
diff --git a/docs/architecture/how-to/release-natives.adoc b/docs/architecture/how-to/release-natives.adoc
new file mode 100644
index 0000000000..d174be93d5
--- /dev/null
+++ b/docs/architecture/how-to/release-natives.adoc
@@ -0,0 +1,187 @@
+= tree-sitter natives release
+
+How to cut a new release of `no.sikt:graphitron-tree-sitter-natives`, when
+to bump which version digit, and what the release workflow verifies.
+
+This is internal-to-the-rewrite operational documentation, not a user-facing
+chapter. The published artifact is consumed only by graphitron-lsp; consumers
+of graphitron's generator surface never see it directly.
+
+== What the artifact is
+
+A single jar that ships two per-platform tree-sitter libraries under
+`lib/-/`: the GraphQL grammar and the tree-sitter runtime.
+
+[cols="1,2,2"]
+|===
+| Platform | Grammar | Runtime
+
+| `linux-x86_64` | `lib/linux-x86_64/libtree-sitter-graphql.so` | `lib/linux-x86_64/libtree-sitter.so`
+| `linux-aarch64` | `lib/linux-aarch64/libtree-sitter-graphql.so` | `lib/linux-aarch64/libtree-sitter.so`
+| `macos-aarch64` | `lib/macos-aarch64/libtree-sitter-graphql.dylib` | `lib/macos-aarch64/libtree-sitter.dylib`
+| `windows-x86_64` | `lib/windows-x86_64/tree-sitter-graphql.dll` | `lib/windows-x86_64/tree-sitter.dll`
+|===
+
+macOS is Apple-silicon-only. Sikt graphitron-lsp developers all run M1
+or newer, the macos-13 (Intel) GitHub-hosted runner is the slowest queue
+on the platform, and graphitron-lsp is internal-only per the spec's
+"Out of scope" list. Adding `macos-x86_64` back means re-enabling that
+matrix entry and bumping the jar-layout assertion expectations.
+
+The *grammar* binary is the vendored bkegley tree-sitter-graphql grammar
+(commit `5e66e96`), built with the upstream `tree-sitter build` CLI against
+the pinned parser ABI. It exports the grammar's `tree_sitter_graphql` entry
+point only, imports no `ts_*` symbols, and so loads independently of the
+runtime.
+
+The *runtime* binary is the upstream tree-sitter runtime
+(`libtree-sitter`, exporting the `ts_*` symbols) built from the pinned
+source tag, not the system package. graphitron-lsp's `BundledLibraryLookup`
+extracts both binaries from this jar and composes `grammar.or(runtime)` into
+jtreesitter's SPI lookup, so the LSP has no native system dependency at all.
+
+Bundling the runtime reverses the module's original grammar-only split. That
+split made the runtime a system dependency (`brew install tree-sitter`,
+`pacman -S`, `vcpkg install`, or a from-source build on Debian/Ubuntu, whose
+apt `libtree-sitter0` is 0.20.x and too old). The recurring cost of that
+install path, especially on Windows and Debian/Ubuntu, is what R401 retired
+by bundling the runtime; the tree-sitter project still does not publish a
+prebuilt `libtree-sitter` asset, so we build it ourselves on native runners
+from the pinned source (see "Cutting a release" below).
+
+The grammar build uses the upstream `tree-sitter build` CLI rather than a
+hand-rolled `cc` / `cl.exe` invocation. The CLI handles MSVC export
+annotations on Windows uniformly, which is what gives us a working Windows
+DLL with the grammar entry point correctly exported (the bonede route
+fails this on Windows, exporting `Java_org_treesitter_*` JNI wrappers
+only). The runtime build does not use the CLI: it builds `libtree-sitter`
+from the cloned source `lib/` (POSIX `make`; Windows MinGW-w64 `make` with
+static-linked gcc runtime), with a transitive-dep allowlist enforced as a
+release gate.
+
+== Versioning
+
+Format: `-`. First bundled-runtime release:
+`0.26.9-1`. The `` portion is the actual tree-sitter
+runtime version shipped in the jar: the upstream source tag both the grammar
+ABI and the bundled `libtree-sitter` are built from. It must match the
+`tree-sitter-cli-version` workflow input (tag `v0.26.9` for `0.26.9-1`).
+
+Before `0.26.9-1`, `` meant the parser ABI the grammar was
+built against and the runtime was a system dependency; from `0.26.9-1` it
+names the runtime we ship.
+
+[cols="1,2"]
+|===
+| Change | Version bump
+
+| Grammar update (new `parser.c`) | `` (e.g. `0.26.9-1` -> `0.26.9-2`)
+| Build-flag change | ``
+| Recompile / security rebuild with no source change | ``
+| Retarget to a newer upstream tree-sitter tag | `` resets `` to `1` (e.g. `0.26.9-3` -> `0.27.0-1`)
+|===
+
+Snapshots are not used. The natives module ships releases only, and a
+release that turns out to be broken on one platform after deploy stays on
+Central as a tombstone; graphitron-lsp pins to the working build that
+follows.
+
+== Cutting a release
+
+Three steps, the second being the GitHub Actions trigger.
+
+. *Bump the version* in
+ `graphitron-tree-sitter-natives/pom.xml`. If the
+ parser ABI moved, also update the matching pin in
+ `src/main/native/grammars/graphql/UPSTREAM.md` and the module-level
+ `UPSTREAM.md`. Commit on a normal feature branch and push.
+
+. *Trigger the workflow.* In the GitHub Actions UI for `sikt-no/graphitron`,
+ run `.github/workflows/tree-sitter-natives-release.yml` via
+ `workflow_dispatch`. The single input is the upstream `tree-sitter` tag
+ (e.g. `v0.26.9`); it pins both the CLI that builds the grammar and the
+ source checkout the bundled runtime is built from, and must match the
+ `` portion of the pom version.
+
+. *Watch the three stages.* The workflow only deploys if every stage
+ succeeds:
++
+.. *Build matrix* (four native runners). Each runner builds two binaries.
+ The grammar: download the pinned `tree-sitter` CLI, run
+ `tree-sitter build` against the vendored grammar, assert it exports
+ `tree_sitter_graphql`. The runtime: clone the pinned source tag and build
+ `libtree-sitter` from `lib/` (POSIX `make`; Windows MinGW-w64 `make` with
+ `-static -static-libgcc`), then run the release gates: it must export
+ `ts_language_abi_version` and the core `ts_parser_*` symbols, and its
+ transitive deps must fall within the per-platform allowlist (`ldd` /
+ `otool -L` / `dumpbin /dependents`; a stray `libgcc_s_seh-1.dll` or
+ third-party `.so` fails the release). Symbol checks use `nm -gU` on POSIX
+ and `dumpbin /exports` on Windows. Both binaries upload as
+ `native-`.
+.. *Package + deploy* (ubuntu-latest). Downloads all four artifacts, stages
+ grammar + runtime under `target/classes/lib/-/`, runs
+ `mvn package`, and asserts the produced jar contains exactly the eight
+ `lib/-/...` entries (four grammar + four runtime) and no others.
+ Then `mvn deploy` signs with GPG and publishes via
+ `central-publishing-maven-plugin` with `autoPublish=true`.
+.. *Post-deploy load+parse matrix* (four native runners). After deploy
+ succeeds, each runner resolves the freshly-published jar from a clean
+ local m2 (with retries against Central's propagation delay), then runs a
+ tiny FFM-based Java verifier with *no* system `libtree-sitter` installed.
+ The verifier extracts both the grammar and the runtime from the jar,
+ composes `grammar.or(runtime)`, calls
+ `Language.load(symbols, "tree_sitter_graphql")`, parses
+ `"type Query { hello: String }"`, and asserts the root is `source_file`
+ with no errors. A pass with no system runtime present is the proof that
+ the jar is self-sufficient on that platform.
+
+If stage 3 fails on any platform, bump `` and re-release. The
+failing version stays on Central; downstream `graphitron-lsp` pins to
+the next working ``.
+
+== Why the build infrastructure looks the way it does
+
+Three design choices worth knowing about for anyone maintaining this:
+
+. *Standalone pom, no parent inheritance.* The natives module lives in the
+ repo root alongside the reactor modules but its `pom.xml` does not declare
+ `graphitron-rewrite-parent`. Trunk's parent runs at
+ `10-SNAPSHOT` and declares no ``; inheriting would
+ publish a natives jar whose parent reference is unresolvable for any
+ Central consumer. The release plumbing (`maven-gpg-plugin`,
+ `central-publishing-maven-plugin`, `maven-source-plugin`,
+ `maven-javadoc-plugin`, `maven-deploy-plugin`) is inlined into the
+ module's pom for the same reason. The parent's `` list also
+ does not include this module, so the regular reactor build
+ (`mvn install -Plocal-db`) pays zero cost
+ for it.
+
+. *No CI build per PR.* The binaries don't change per PR (they're a
+ published artifact, not a per-build output), so expanding
+ `.github/workflows/rewrite-build.yml` to a four-platform matrix would
+ mostly re-verify a stable input. Instead, the verification matrix lives
+ in this workflow and runs against the actual published artifact at
+ release time. In normal CI, only the `linux-x86_64` arm of
+ `NativeLibraryBundleTest` executes per PR (the three others are gated
+ by `@EnabledOnOs` to their respective hosts and run on this workflow's
+ post-deploy matrix).
+
+. *Built with the upstream CLI, not hand-rolled compiler invocations.*
+ `tree-sitter build` knows how to do MSVC `__declspec(dllexport)` for
+ Windows, which the upstream tree-sitter C sources don't carry on their
+ own. The bonede `tree-sitter-graphql` jar we previously evaluated ships
+ a Windows DLL that exports `Java_org_treesitter_*` JNI wrappers but
+ not the bare `tree_sitter_graphql` entry point jtreesitter expects,
+ which is why the natives module exists at all.
+
+== Where the binaries live before deploy
+
+Not committed. The eight binaries (four grammar + four runtime) are produced
+fresh on four native runners per release and merged into the jar during the
+package+deploy stage. The vendored grammar sources under
+`graphitron-tree-sitter-natives/src/main/native/grammars/graphql/`
+are the grammar's only git-resident input; the runtime is built from the
+pinned upstream source tag cloned at release time, not vendored in git. Its
+provenance (source tag, per-platform build invocation and flags, the
+transitive-dep allowlist, and the update procedure) is recorded in the
+module-level `UPSTREAM.md` "Runtime provenance" section.
diff --git a/docs/architecture/how-to/testing.adoc b/docs/architecture/how-to/testing.adoc
new file mode 100644
index 0000000000..bcab88dba8
--- /dev/null
+++ b/docs/architecture/how-to/testing.adoc
@@ -0,0 +1,141 @@
+= Test-tier guide
+:toc: left
+:sectanchors:
+:sectlinks:
+
+This guide answers "which tier does my test belong to, where does the file go, and what should it assert?"
+Four tiers cover every test in the rewrite.
+For design principles (why pipeline is the primary behavioural tier, why code-string body matching is banned), see xref:../explanation/rewrite-design-principles.adoc[Rewrite Design Principles].
+For build commands and database setup, see `.claude/web-environment.md`.
+
+== Choosing a tier
+
+Read top-down; stop at the first match.
+
+. *The behaviour is "this generated source must compile against the real jOOQ catalog"* → **Compilation.**
+ No test class to write; the fixture-driven `mvn compile -pl :graphitron-sakila-example -Plocal-db` is the assertion.
+ Add a fixture instead of an assertion.
+. *The behaviour is "this generated request must round-trip against PostgreSQL and return the right rows / fire the right number of queries / honour DataLoader batching"* → **Execution.**
+ New `@Test` in `GraphQLQueryTest` (or one of the federation-/scatter-named companions).
+. *The behaviour is "this SDL pattern classifies into this variant" or "this SDL pattern produces a TypeSpec with this method shape"* → **Pipeline.**
+ New case in `GraphitronSchemaBuilderTest` (classification truth table) or a new `*PipelineTest` file.
+. *The behaviour is "this builder helper / classifier method / writer primitive / validator rule does X on input Y"* → **Unit.**
+ New case in a `*Test` next to the production class, or a `*ValidationTest` for validator rules.
+
+When two tiers could apply, prefer the one that captures the behaviour most directly.
+Pipeline beats unit: per-variant structural tests are bookkeeping; the primary signal is that a realistic SDL produces a realistic `TypeSpec` end-to-end.
+Pipeline also beats compilation and execution where the behaviour can be asserted on the classified model or `TypeSpec` shape, since pipeline runs without jOOQ codegen or Postgres.
+Execution beats compilation only when SQL behaviour or row content is the contract.
+
+_Tier is determined by what's asserted, not by what module the file lives in._
+`graphitron-sakila-example` hosts tests at every tier; its module dependency on post-generator artifacts is the reason those tests live there, not a tier signal.
+
+== Tier annotations
+
+Each tier has a JUnit 5 meta-annotation in `graphitron`'s test source root (`no.sikt.graphitron.rewrite.test.tier`), republished as a `tests` test-jar so other modules can consume them. Reachable from every test class in `graphitron` and `graphitron-sakila-example`:
+
+[source,java]
+----
+@UnitTier // @Tag("unit")
+@PipelineTier // @Tag("pipeline")
+@CompilationTier // @Tag("compilation")
+@ExecutionTier // @Tag("execution")
+----
+
+Place exactly one annotation at the class level.
+Tests that don't fit any of the four tiers (`GeneratorDeterminismTest` is the only current example) carry `@Tag("cross-cutting")` directly.
+
+With class-level tags in place, `mvn test -Dgroups=pipeline` runs only pipeline-tier classes; `-DexcludedGroups=execution` skips Postgres for fast inner loops.
+Both Surefire and Failsafe honour these flags without further config.
+
+An enforcement test in each in-scope module (`graphitron`, `graphitron-sakila-example`) walks that module's own test classpath and fails the build if any `@Test`-bearing class lacks a tier identity, or carries more than one.
+
+== Unit tier
+
+Structural invariants on individual classifiers, builders, emitters, and runtime helpers.
+
+*Where:* `graphitron/src/test/java/...` next to the production class.
+
+Three sub-families:
+
+*Generator unit tests* (`TypeFetcherGeneratorTest`, `TypeClassGeneratorTest`, `TypeConditionsGeneratorTest`, `GeneratorCoverageTest`; and the `generators/schema/` subdirectory: `EnumTypeGeneratorTest`, `GraphitronFacadeGeneratorTest`, `InputTypeGeneratorTest`, `ObjectTypeGeneratorTest`, etc.).
+Take pre-built model fixtures via `TestFixtures`; assert `TypeSpec` shape (method names, return types, parameter signatures).
+Banned: code-string body matching on the generated `MethodSpec` body; that is what compilation and execution cover.
+
+*Validator unit tests* (`*ValidationTest` family, e.g. `ColumnFieldValidationTest`, `QueryTableFieldValidationTest`).
+Build a `GraphitronSchema` with one parent type and one field at a known coordinate; assert `validate()` outcomes by `RejectionKind` and message substring.
+
+*Builder / catalog / writer unit tests* (`JooqCatalogFindColumnTest`, `IdempotentWriterTest`, `ArgBindingMapTest`, `ServiceCatalogTest`, etc.).
+Targeted constructor or single-method assertions; no full-pipeline plumbing.
+
+== Pipeline tier
+
+SDL → classified model → generated `TypeSpec`.
+
+*Where:* `graphitron/src/test/java/no/sikt/graphitron/rewrite/`.
+
+Two shapes:
+
+*Classification truth tables:* `GraphitronSchemaBuilderTest`.
+Each variant family is a `// ===== VariantName =====` section with an enum where each constant is one `(description, SDL, assertion)` triple; one parameterised test iterates the table.
+
+*Deeper SDL → TypeSpec / variant-shape tests:* `*PipelineTest` files: `NodeIdPipelineTest`, `SplitTableFieldPipelineTest`, `TableFieldPipelineTest`, `LookupTableFieldPipelineTest`, `NestingFieldPipelineTest`, `ServiceRootFetcherPipelineTest`, `TaggedInputsPipelineTest`, `StubbedVariantPipelineTest`; and in `generators/`: `FetcherPipelineTest`, `TablePipelineTest`.
+Build a schema with `TestSchemaHelper.buildSchema(sdl)`, assert structural shape on the resulting variant or generated `TypeSpec`.
+Banned: code-string body matching.
+
+== Compilation tier
+
+Generated source must compile against the test catalog.
+
+*Where:* `graphitron-sakila-example`, run with `mvn compile -pl :graphitron-sakila-example -Plocal-db`.
+The compiler is the assertion; no hand-written assertions are needed for type correctness.
+
+Two test classes layer structural checks on top:
+
+`GeneratedSourcesSmokeTest`: every expected class is present in the emitter's output package (catches a generator that silently drops a class).
+
+`GeneratedSourcesLintTest`: generator-hygiene rules over emitted source text (e.g. no `var` in emitted code).
+
+== Execution tier
+
+Full GraphQL request → SQL → row round-trip.
+
+*Where:* `graphitron-sakila-example`, run with `mvn test -pl :graphitron-sakila-example -Plocal-db`.
+
+Canonical classes: `GraphQLQueryTest` on the shared fixture; `FederationEntitiesDispatchTest` on the federated fixture.
+
+Patterns:
+
+* JDBC round-trip count via the `QUERY_COUNT` listener (`AtomicInteger` reset per test to assert DataLoader batching or lazy-on-selection).
+* Returned-row-id sets and field-value assertions against the Sakila fixture catalog.
+* Structural SQL-shape assertions via the `SQL_LOG` `ExecuteListener` (e.g. that no `select count` ran when `totalCount` was not selected).
+
+== Module location vs. tier (`graphitron-sakila-example`)
+
+Several tests live in `graphitron-sakila-example` for module-dependency reasons but classify by assertion, not module.
+Only `GeneratorDeterminismTest` is `@Tag("cross-cutting")`; the rest carry one of the four tier annotations:
+
+* `GeneratedSourcesSmokeTest`, `GeneratedSourcesLintTest`: `@CompilationTier` (consume the compile output).
+* `FederationBuildSmokeTest`, `NoFederationRegressionTest`: `@PipelineTier` (schema-construction assertions on the fixture-derived generated facade; no SQL).
+* `ScatterSingleByIdxTest`: `@UnitTier` (direct unit coverage, fully in-memory; lives in `graphitron-sakila-example` because it reflects against a generated `*Fetchers` class).
+* `GraphQLQueryTest`, `FederationEntitiesDispatchTest`: `@ExecutionTier`.
+* `GeneratorDeterminismTest`: `@Tag("cross-cutting")`, system-level ratchet for the three-clause writer contract (determinism + minimal-change writes + clean orphan removal). Does not fit pipeline (no classifier-to-TypeSpec assertion), compilation (no compile happens), or execution (no SQL).
+
+== Build commands
+
+[source,bash]
+----
+# Unit + pipeline (no database needed)
+mvn test -pl :graphitron -Plocal-db
+
+# Compilation (generated source compiles against real jOOQ catalog)
+mvn compile -pl :graphitron-sakila-example -Plocal-db
+
+# All tiers including execution (requires local PostgreSQL via -Plocal-db)
+mvn test -Plocal-db
+
+# Skip execution tier for fast inner loops
+mvn test -pl :graphitron -Plocal-db -DexcludedGroups=execution
+----
+
+See `.claude/web-environment.md` for database setup prerequisites and the fixtures-jar footgun recovery.
diff --git a/docs/architecture/index.adoc b/docs/architecture/index.adoc
new file mode 100644
index 0000000000..4c3e68f5ec
--- /dev/null
+++ b/docs/architecture/index.adoc
@@ -0,0 +1,59 @@
+= Architecture
+:description: Contributor-facing documentation for the Graphitron generator, shaped as a Diataxis tree.
+:!toc:
+
+Contributor-facing documentation for the Graphitron generator: how the pipeline classifies a GraphQL schema, what code it emits, and the design principles that govern both. For end-user documentation (writing schemas, wiring up Maven, running the generator), see xref:../manual/index.adoc[the user manual].
+
+These pages are Diataxis-shaped; pick the quadrant that matches what you are trying to do right now.
+
+[.quadrants,cols="1,1",frame=none,grid=none]
+|===
+a|
+[.quadrant-title]*xref:explanation/index.adoc[Explanation]*
+
+I want to understand why the generator is shaped the way it is.
+
+The design principles that govern classifier and emitter, the dispatch-axis model, the typed-rejection contract, and how the pipeline fits together end to end.
+
+a|
+[.quadrant-title]*xref:reference/index.adoc[Reference]*
+
+I know what I am looking for and want the facts.
+
+The classification taxonomy and what each generator emits, the unified argument-resolution lift, the runtime extension points, and the module map.
+
+a|
+[.quadrant-title]*xref:how-to/index.adoc[How-to guides]*
+
+I have a specific contributor task and want a recipe.
+
+Choosing a test tier, wiring an editor or agent into the `dev` loop internals, and cutting a tree-sitter natives release.
+
+a|
+[.quadrant-title]*Ongoing work*
+
+I want to see what is planned or in flight.
+
+The forward-looking view lives at xref:../roadmap/index.adoc[the Rewrite Roadmap].
+
+|===
+
+== You came here because…
+
+You want to *extend the runtime*, wire per-request values into `Graphitron.newExecutionInput(...)`, register custom scalars, hook in jOOQ listeners. → xref:reference/runtime-extension-points.adoc[Runtime Extension Points].
+
+You're *integrating with Apollo Federation*, the `@link` opt-in, the `` flag, providing a custom entity fetcher. → xref:../manual/how-to/apollo-federation.adoc[How-to: Apollo Federation transport]; the contributor-facing wiring rationale lives in xref:how-to/dev-loop-internals.adoc#federation-internals[Dev loop internals → Federation].
+
+You want to *understand rejections*, what `AUTHOR_ERROR` / `INVALID_SCHEMA` / `DEFERRED` actually mean in the builder, why rejection is a typed variant rather than a string, how Levenshtein candidate hints get attached. → xref:explanation/typed-rejection.adoc[Typed rejection].
+
+You're *integrating an editor or agent with the dev loop*, what the `dev` Mojo wires up, what the LSP / schema watcher / classpath watcher each watch, why idempotent writes matter. → xref:how-to/dev-loop-internals.adoc#dev-loop-detail[Dev loop internals].
+
+You want to *read the classification taxonomy*, every variant the schema builder produces, every generator's input. → xref:reference/code-generation-triggers.adoc[Code Generation Triggers].
+
+You're looking for a *deeper reference*, the architectural principles that govern both classifier and emitter, the test-tier rubric, the unified argument-resolution lift, the dispatch-axis model behind DataLoader-backed source-side fields. → see xref:explanation/rewrite-design-principles.adoc[Rewrite Design Principles], xref:how-to/testing.adoc[Test-tier guide], xref:reference/argument-resolution.adoc[Argument Resolution], xref:explanation/dispatch-axes.adoc[Dispatch axes]. The module map is at xref:reference/modules.adoc[Modules]; the pipeline overview at xref:explanation/pipeline-overview.adoc[Pipeline overview].
+
+== Publishing
+
+Trunk runs at `10-SNAPSHOT`; releases are cut from tag-driven GitHub Releases. The `.github/workflows/maven-publish.yml` workflow accepts `v..` and `v..-RC`, sets the version across the reactor, signs (sources + javadoc + GPG), and pushes to Maven Central via the `central-publishing-maven-plugin` with `autoPublish=true`. Maven version ordering treats `10.0.0-RC1` as strictly less than `10.0.0`, so consumers asking for `[10.0.0,)` won't pick up RCs by accident. Snapshots aren't published; the parent declares no ``, so an accidental `mvn deploy` on `10-SNAPSHOT` fails fast.
+
+Publishable surface: `graphitron-javapoet`, `graphitron`, `graphitron-lsp`, `graphitron-mcp`, `graphitron-maven-plugin`, `graphitron-jakarta-rest`. `graphitron-jakarta-rest` is a real artifact consumers pull onto their runtime classpath, so it joins the deploy set (no `maven.deploy.skip`), unlike `graphitron-sakila-example`, which only consumes the library and stays deploy-skipped. `graphitron-mcp` is published like `graphitron-lsp` because the plugin declares a compile-scope dependency on it and a Maven plugin resolves its declared dependencies from the consumer's repositories at execution time; it must not join the `maven.deploy.skip` list. Test fixtures and example consumers (`graphitron-fixtures-codegen`, `graphitron-sakila-db`, `graphitron-sakila-service`, `graphitron-sakila-example`, `roadmap-tool`, `docs`) carry `true`.
diff --git a/docs/architecture/reference/argument-resolution.adoc b/docs/architecture/reference/argument-resolution.adoc
new file mode 100644
index 0000000000..a4a758b3ce
--- /dev/null
+++ b/docs/architecture/reference/argument-resolution.adoc
@@ -0,0 +1,430 @@
+= Argument Resolution
+
+Reference document for how the rewrite classifies GraphQL arguments and
+projects them into filters, lookup mappings, order-by specs, and pagination
+specs. Covers `@condition` at all three legal positions
+(`FIELD_DEFINITION`, `ARGUMENT_DEFINITION`, `INPUT_FIELD_DEFINITION`) and the
+override-propagation semantics that tie them together.
+
+== Pipeline shape
+
+- `FieldBuilder.classifyArguments` returns `List` (sealed,
+ three top-level arms plus two intermediate sealed sub-groupers):
+ `ScalarArg.{ColumnArg | CompositeColumnArg | ColumnReferenceArg |
+ CompositeColumnReferenceArg | UnboundArg}`,
+ `InputTypeArg.{TableInputArg | PlainInputArg}`,
+ plus the top-level `OrderByArg`, `PaginationArgRef`, `UnclassifiedArg`.
+ The composite/reference scalar arms (R50) carry multi-column or FK-resolved
+ bindings; the sealed sub-groupers let projections pattern-switch on shape
+ axis without enumerating leaves.
+- Projection helpers consume that list: `projectFilters`,
+ `projectOrderBySpec`, `projectPaginationSpec`, `projectForLookup`.
+- `contextArguments` flow through `ServiceCatalog.reflectTableMethod` into
+ trailing `ParamSource.Context` parameters on the generated method calls.
+- `ArgConditionRef(ConditionFilter filter, boolean override)` carries the
+ reusable "condition + override flag" pair at every level (field, arg,
+ input-field).
+- `TableInputArg.fieldBindings: List` carries the
+ `@lookupKey`-only bindings; composite-key lookups are wired end-to-end
+ via `LookupValuesJoinEmitter`.
+- `LookupField` capability with non-`Optional` `LookupMapping lookupMapping()`
+ pairs with `@lookupKey`.
+- `TypeBuilder.isUsedWithOverrideCondition` skips table-column validation on
+ inputs whose outer field / argument / own fields declare
+ `@condition(override: true)`.
+
+== Scope
+
+`@condition` is legal at three positions per `directives.graphqls`:
+`FIELD_DEFINITION`, `ARGUMENT_DEFINITION`, `INPUT_FIELD_DEFINITION`. Each
+`@condition`-carrying field *inside* an input type contributes its own
+predicate when the input is used at a call site. Nested input-field
+conditions compose. Outer-level overrides propagate downward.
+
+The input-field position covers both `@table`-annotated input types
+(primary case) and plain input types used under the legacy
+"implicit-table" heuristic, where the input's fields resolve against the
+enclosing query field's target table. A divergence-scan of alf's
+production schema
+(`alf/graphitron-rewrite:graphitron-rewrite/generator-schema.graphql`,
+not committed to trunk) counted 62 plain inputs carrying inner
+`@condition`, 3 of them under an outer field-level
+`@condition(override: true)` (`Query.emner`, `Query.emnerV2`,
+`Query.studenter`). Zero `@table` inputs carry inner `@condition`
+because `@table` inputs on alf rely on *implicit column conditions*
+instead (63 distinct call sites). Implicit column conditions ship
+alongside this Phase 4 work (commit `96e39df`); an un-annotated
+`ColumnField` / `ColumnReferenceField` on a `@table` input contributes
+a `BodyParam` with `NestedInputField` extraction to the same
+`GeneratedConditionFilter` this phase emits.
+
+== Design
+
+=== Data model
+
+Three `InputField` variants
+(`graphitron/.../model/InputField.java`) carry
+`Optional condition`:
+
+- `InputField.ColumnField`
+- `InputField.ColumnReferenceField`
+- `InputField.NestingField`
+
+The same variants cover both resolution sources: `@table`-input fields
+(classified at type-build time against the input's own declared table) and
+plain-input fields (classified at argument-classify time against the
+enclosing query field's target table). The variant doesn't need to know
+which source produced it; the carrying argument record (`TableInputArg` or
+`PlainInputArg`) remembers that.
+
+`NodeIdField` is intentionally excluded; see Out of Scope. `ArgConditionRef`
+is reused verbatim; its `override` flag is the input-field-level override
+(matching legacy semantics: `override: true` on an input field replaces that
+field's implicit condition with the explicit method).
+
+=== Classification: reading the directive at type-build and call-site time
+
+`@table` inputs and plain inputs classify at different times because their
+resolution tables differ:
+
+- *`@table` inputs.* Classified once at type-build time by
+ `TypeBuilder.buildInputField` against the input's own `@table(name:)`.
+- *Plain inputs.* Classified per call site by
+ `FieldBuilder.classifyPlainInputFields` against the enclosing query
+ field's target table (`rt`). Same plain input used at N call sites
+ classifies N times, one per resolved table. Classification is cheap;
+ reclassification is simpler than caching, and a per-site cache would
+ complicate invalidation without a measured need.
+
+*Shared per-field classifier.* `BuildContext.classifyInputField(field,
+parentTypeName, tableRef, expandingTypes, errors) -> InputFieldResolution`
+hosts the column / `@reference` / nesting decision tree, accessible from
+both `TypeBuilder` and `FieldBuilder`. `TypeBuilder` calls it with the
+input type's declared table; `FieldBuilder` calls it with the call site's
+`rt` when it encounters a plain-input arg. The shared classifier means
+`NestingField` semantics stay identical across both resolution paths: a
+plain input nested inside a `@table` input still resolves against the
+parent `@table`'s table via the existing recursive call, and a plain input
+used directly as a field argument resolves against `rt`.
+
+*Condition helper.* `BuildContext.buildInputFieldCondition(GraphQLInputObjectField
+field, String inputFieldName, List errors) -> Optional`
+mirrors `FieldBuilder.buildArgCondition`:
+
+- Directive parsing is delegated to `BuildContext.readConditionDirective`,
+ which is `GraphQLDirectiveContainer`-generic so `GraphQLInputObjectField`
+ works without modification.
+- Reflection via `ServiceCatalog.reflectTableMethod(className, method,
+ Set.of(inputFieldName), Set.copyOf(contextArguments))`. The method's
+ primary argument is the single input-field value, named after the
+ SDL field name (matches legacy; see `withListedInputConditions`
+ fixture: `customerString(table, input.getId())`).
+- On reflection failure, the error is appended and `Optional.empty()` is
+ returned, mirroring the `buildArgCondition` error contract.
+
+The helper is agnostic to `@table` vs. plain source; both paths call it
+with the same shape. `classifyInputField`, `buildInputFieldCondition`,
+and `readConditionDirective` all live in `BuildContext`; callers in
+`TypeBuilder` and `FieldBuilder` reach them via `ctx`.
+
+=== Projection: threading conditions to the call site
+
+`FieldBuilder.projectFilters` handles outer-arg-level `@condition` on both
+`TableInputArg` and `PlainInputArg` and then walks each input's classified
+`InputField` records via `walkInputFieldConditions`, appending every
+present condition. The walking logic is identical across both carriers;
+differences live only in the carrying record.
+
+*Both variants carry a classified field list.* `TableInputArg` and
+`PlainInputArg` each carry `List fields` populated at
+classify time. `TableInputArg.fieldBindings` is `@lookupKey`-only and
+insufficient on its own, since condition-carrying fields aren't
+necessarily lookup keys.
+
+The alternative was to read the field list out of a registry at projection
+time. Rejected: re-couples projection to builder context, breaks the
+invariant that projection is a pure function of `List` (no
+builder state, no registry lookups). For `PlainInputArg` there is no
+registry entry to read from at all, so the carry-on-the-record shape is
+the only coherent option there anyway.
+
+=== Override propagation
+
+Three directive levels can co-exist at one call site:
+
+- *Field*: `fieldDef @condition`
+- *Argument*: `arg @condition`
+- *Input field*: `inputField @condition`
+
+Nesting adds a fourth tier: an input type contains an input field whose type is
+itself another input type, which has its own fields. Each nested level can
+carry its own `@condition`.
+
+*Propagation rule (downward inheritance).* `override: true` at any enclosing
+level (parent-field ⊇ arg ⊇ nesting-field) suppresses every nested *implicit
+condition* (jOOQ `table.COLUMN.eq(input.getField())`). Explicit `@condition`
+methods are never suppressed by ancestor overrides; they're independent
+declarations by the schema author, and a level's own `override` flag affects
+only that level's implicit condition.
+
+==== Legacy behavior reference (and intentional divergence)
+
+The rule above (downward inheritance, explicit methods survive) is the rule
+the *rewrite* will enforce. It is NOT the rule the legacy generator
+implements. Reviewers and implementers should know the delta before signing
+off on §Override propagation.
+
+*Legacy schema, `withListedInputConditions`*
+(link:../../../graphitron-codegen-parent/graphitron-java-codegen/src/test/resources/queries/fetch/records/withListedInputConditions/schema.graphqls[schema.graphqls]):
+
+[source,graphql]
+----
+type Query {
+ customer(in: [CustomerInput]): CustomerTable @condition(..., method: "customerJOOQRecordList")
+ customerOverride(in: [CustomerInput]): CustomerTable @condition(..., method: "customerJOOQRecordList", override: true)
+}
+input CustomerInput @table(name: "CUSTOMER") {
+ id: ID! @condition(..., method: "customerString")
+ first: String! @field(name: "FIRST_NAME")
+ @condition(..., method: "customerString", override: true)
+}
+----
+
+*Legacy output, same fixture's*
+link:../../../graphitron-codegen-parent/graphitron-java-codegen/src/test/resources/queries/fetch/records/withListedInputConditions/expected/QueryDBQueries.java[`expected/QueryDBQueries.java`]:
+
+- `customerForQuery` (no outer override, :17-37) emits the full stack:
+ row-IN containing `hasId(id)` + `customerString(table, id)` +
+ `customerString(table, firstName)`, AND-ed with `customerJOOQRecordList`.
+ Inner `id` (no override) contributes both implicit condition AND explicit
+ method; inner `first` (`override: true`) contributes only the explicit
+ method (its own implicit condition suppressed at the input-field level).
+ No explicit method is dropped by the outer level.
+- `customerOverrideForQuery` (outer override, :40-48) emits *only*
+ `customerJOOQRecordList`. Every inner contribution is dropped: `id`'s
+ implicit condition, `id`'s explicit `customerString`, and `first`'s
+ explicit `customerString`. There is no row-IN construct at all.
+
+**The legacy rule is total-replace: an outer `override: true` substitutes its
+own explicit method for everything below it, regardless of whether inner
+fields carry their own explicit `@condition` methods.** The rewrite's
+proposed rule preserves inner explicit methods across the boundary. That is
+a *deliberate divergence* from legacy.
+
+*Rationale for diverging.* The legacy behavior couples implicit conditions
+and explicit methods into a single "outer owns everything" toggle, which means
+a schema author can't declaratively compose an outer replacement condition
+with inner explicit side-conditions. The rewrite treats each level's
+`override` flag as affecting only that level's implicit condition, which lets
+`@condition(override: true)` replace the implicit condition without also
+silencing explicit input-field conditions written by the schema author.
+
+*Divergence-pinning tests.* Two execution tests pin the rewrite against
+legacy's total-replace rule:
+`inputFieldCondition_tableInput_outerOverride_preservesInnerExplicitMethod`
+(outer `@condition(override: true)` over a `@table` input whose field
+carries its own `@condition`) and
+`inputFieldCondition_plainInput_outerOverride_preservesInnerExplicitMethod`
+(same shape against a plain input; alf production shape). Both generate a
+predicate conjunction that evaluates to an empty result set; a regression
+to the legacy "outer owns everything" rule would drop the inner predicate
+and return rows, breaking the tests by name.
+
+*If a downstream consumer relies on the legacy coupling*, handle it with
+an author-side schema edit (drop the inner `@condition` methods that
+should not run under outer override) or promote to its own backlog item.
+The rewrite does not reproduce total-replace.
+
+*Audit of legacy override fixtures.* Three additional fixtures under
+`queries/fetch/records/` were inspected to confirm the 6-row truth table
+is complete:
+
+- `multiLevelInputJavaRecordOverrideCondition`: three-level nesting
+ `Input3 → Input2 → Input1`, with `@condition(override: true)` at the
+ `Input2.input1` nesting field (not at outer arg, not at field). Confirms
+ nesting-field-level override is a real production shape; already covered
+ by the "any enclosing override (field ⊇ arg ⊇ nesting-field)" propagation
+ rule. No new row.
+- `nestedListInputJavaRecordOverrideCondition`: arg-level
+ `@condition(override: true)` over `[Input1]` whose fields carry no
+ `@condition`. Row 4 of the truth table.
+- `listInputJavaRecordAndFieldOverrideCondition`: parent-field-level
+ `@condition(override: true)` composed with arg-level `@condition` (no
+ override) on a sibling scalar-list arg. Parent-field-level override
+ propagates to the arg's implicit conditions; explicit arg method fires.
+ Row 2 and row 5 combined across two args; no new row for input-field
+ semantics (no input type is involved).
+
+All three fall within the 6-row table.
+
+=== Truth table (per input-field, per call site)
+
+"Any enclosing override" = parent-field-level OR arg-level OR any
+intermediate nesting-field's `override: true`.
+
+[cols="1,1,1,1", options="header"]
+|===
+| Any enclosing override | Input field `@condition` | Implicit condition | Explicit method
+
+| No | Absent | Emitted | n/a
+| No | Present (no override) | Emitted | Emitted
+| No | Present (override:true) | Suppressed | Emitted
+| Yes | Absent | Suppressed | n/a
+| Yes | Present (no override) | Suppressed | Emitted
+| Yes | Present (override:true) | Suppressed | Emitted
+|===
+
+Enforced by the symmetric-implicit-predicate-emission pipeline test added in
+R205 (`plainInput_resolvedColumnWithoutCondition_emitsImplicitBodyParam`),
+which pins identical implicit-condition emission for `@table` and plain inputs.
+
+"Emitted" in the explicit-method column means the method call lands in the
+`List` returned by `projectFilters`; downstream emitters AND
+all present filters together (see §Emission). The earlier column label
+"Replaces" was inherited from column-arg vocabulary and is misleading here,
+since rows 5-6 have no implicit condition left to replace.
+
+Six rows, not nine: the previous draft's "outer `override: false`" row is
+indistinguishable from "outer absent" since `false` is the directive default.
+Confirmed against `BuildContext.argBoolean` (which defaults `ARG_OVERRIDE` to
+`false`) and the SDL declaration in `directives.graphqls` (`override: Boolean
+= false`).
+
+=== Emission: no new emitters
+
+`projectFilters` output is `List`; each `ConditionFilter` is
+already a callable reference carrying `Table>` + arg-value parameters. The
+downstream emitters (`LookupTableFieldEmitter`, `InlineLookupTableFieldEmitter`,
+`SplitRowsMethodEmitter`) already AND-in each `ConditionFilter` without
+knowing its provenance. Input-field conditions land alongside field-level
+and arg-level conditions in the same filter list.
+
+*List-typed inputs (composite-key lookups).* `LookupValuesJoinEmitter` emits
+VALUES+JOIN rows; per-row condition evaluation already reads fields via
+`input.get(i).get()`. Input-field conditions piggyback on the same
+loop; projection just hands them as additional filters. Verify round-trip
+count with an execution test (see §Test strategy).
+
+*Nested non-`@table` input types.* `InputField.NestingField` resolves its
+own fields against the parent's table. A condition on the nesting field is
+reflected with the nesting field's SDL name as the sole arg (same shape as a
+scalar input field's condition); projection walks `NestingField.fields`
+recursively to pick up inner conditions, threading a `boolean enclosingOverride`
+accumulator: any level's `override: true` flips it to `true` for all
+descendants. No new emitter shape.
+
+=== Validator
+
+1. *`TypeBuilder.isUsedWithOverrideCondition`.* Returns true when any
+ consuming field or argument declares `@condition(override: true)`
+ against the input type, *or* when the input type itself has any
+ field with `@condition(override: true)`. This preserves the
+ "skip table-column validation when overridden" escape hatch across
+ outer-level and per-field overrides. Plain inputs do not pass through
+ `isUsedWithOverrideCondition` (it gates table-column validation for
+ `@table` inputs); the per-call-site classifier handles plain-input
+ column resolution directly against the outer field's table with the
+ existing `catalog.findColumn` + `@field(name:)` path.
+
+2. *`GraphitronSchemaValidator`.* No new structural validation:
+ graphql-java enforces `on INPUT_FIELD_DEFINITION` placement at
+ schema-parse time. Reflection errors surface through the existing
+ `errors` list in `TypeBuilder.buildInputField` → `UnclassifiedType`
+ fallback for `@table` inputs, and through the per-call-site
+ classifier's `errors` list for plain inputs (same `UnclassifiedArg`
+ fallback already used for other classify-time failures).
+
+== Runtime: nested input-field arg extraction
+
+When a `@condition` method sits on an input field, the runtime values
+passed to it are not reachable as top-level arguments. The
+`CallSiteExtraction.NestedInputField(String outerArgName, List path)`
+variant records the path from the outer argument down to the leaf value.
+`FieldBuilder.walkInputFieldConditions` threads `(outerArgName, pathPrefix)`
+through the recursion; when a condition is found, `rewrapForNested`
+replaces each `ParamSource.Arg` param's extraction with
+`NestedInputField(outerArgName, prefix + [fieldName])`.
+
+At code-gen time, `ArgCallEmitter.buildArgExtraction` turns that into a
+null-safe nested `instanceof Map, ?>` ternary chain that traverses from
+the top-level argument Map down to the leaf value. The chain short-circuits
+to `null` at any level whose value is absent or is not a Map, so a
+`@condition` method always receives either the concrete leaf value or
+`null`; reflecting it with a Map or a wrong-shaped value is not possible.
+
+== Test assertions
+
+Follows `docs/rewrite-design-principles.md`: no body-string assertions on
+emitted method bodies. Execution tests assert, for each case:
+
+- JDBC round-trip count matches expectation (catches spurious extra queries).
+- Returned row IDs match the hand-authored expected set.
+- WHERE-clause shape via a jOOQ `ExecuteListener` capturing the generated
+ SQL: compare structural tokens (column references, operator positions,
+ AND/OR tree shape), not literal strings.
+
+Pipeline tests (`GraphitronSchemaBuilderTest`) assert on the classifier
+output directly (`List`, `List`), not on emitted
+code.
+
+== Design decisions & rationale
+
+- *`readConditionDirective` home: `BuildContext`.* Rejected
+ alternatives: a new `ConditionDirectives` utility; keeping it in
+ `FieldBuilder` and duplicating a minimal copy in `TypeBuilder`.
+ `BuildContext` already houses `DIR_CONDITION`, `ARG_OVERRIDE`,
+ `argBoolean`, and `argStringList`; co-locating directive-parsing helpers
+ there is consistent and every caller already has a `ctx` handle.
+
+- **Projection access to `InputField` list: carried on the argument
+ record, not looked up from a registry.** `TableInputArg` and
+ `PlainInputArg` each hold `List fields`, populated at
+ classify time. Registry lookup at projection time was rejected: it
+ re-couples projection to builder context and breaks the invariant that
+ projection is a pure function of `List`. For
+ `PlainInputArg` there is no registry entry anyway (plain-input fields
+ classify per call site against the outer field's `rt`), so the
+ carry-on-record shape is the only coherent one there.
+
+- **Condition-method signature for `NestingField` conditions: single arg
+ named after the SDL field.** Matches the reflection shape
+ `ServiceCatalog.reflectTableMethod(className, method, Set.of(fieldName),
+ ...)` already used by scalar input-field conditions. Per-leaf
+ parameterization was rejected as speculative: no legacy fixture or alf
+ call site requires it, and it would change the reflection key from a
+ single field name to an ordered tuple that does not round-trip through
+ `ArgConditionRef` without schema changes. If a method needs inner
+ values, it traverses the passed object.
+
+- *Reflection-failure behaviour: per-arg, not per-type.* Input-field
+ condition reflection mirrors `buildArgCondition`: append the error,
+ return `Optional.empty()`, leave the rest of the field classifying
+ cleanly. Promoting the whole `TableInputType` to `UnclassifiedType` was
+ rejected on blast-radius grounds: a reflection failure is a
+ caller-fixable error, not a schema-structural one, so it should not
+ invalidate the input type's other fields.
+
+- **`ArgCallEmitter` shape for nested input-field extraction: new sealed
+ variant `CallSiteExtraction.NestedInputField(outerArgName, path)`.**
+ Rejected alternatives: (B) an optional `outerArgPath` field on
+ `CallParam` that every extraction variant checks (couples every variant
+ to the nested case); (C) projection pre-lifts a
+ `Object = env.getArgument(outerArg) instanceof Map m ?
+ m.get(field) : null;` local at the top of the fetcher body and
+ references it (complicates projection with a new emission slot and
+ doesn't compose with `NestingField` chains). The sealed hierarchy is
+ already the right place for extraction-shape variations (`Direct`,
+ `EnumValueOf`, `TextMapLookup`, `ContextArg`, `JooqConvert`);
+ `NestedInputField` fits the same pattern, makes the nested case explicit
+ at every emitter switch, and composes cleanly with `NestingField`
+ recursion in projection.
+
+== Out of Scope
+
+- *Mutations.* Input-type arguments for DML use a different mapping.
+ Mutations get their own plan.
+- *`NodeIdField` with `@condition`.* Node-id input fields decode through
+ `NodeIdStrategy` rather than direct column binding, so input-field-level
+ `@condition` would compose with the encoded-id path differently than with
+ plain column fields. Promote to its own backlog item if a real schema
+ surfaces this.
diff --git a/docs/architecture/reference/code-generation-triggers.adoc b/docs/architecture/reference/code-generation-triggers.adoc
new file mode 100644
index 0000000000..ab52afcb82
--- /dev/null
+++ b/docs/architecture/reference/code-generation-triggers.adoc
@@ -0,0 +1,668 @@
+= Code Generation Triggers
+
+A guide to how GraphQL schema patterns drive Graphitron's code generation. This document introduces the classification pipeline and the vocabulary needed to read the source code. For variant details and record components, read the Javadoc on each source file listed in the <> below.
+
+'''
+
+== How Classification Works
+
+`GraphitronSchemaBuilder` reads the schema once and classifies every type and field into a sealed
+hierarchy. The generators then operate on these classified models ; they never re-read directives.
+
+----
+GraphQL Schema
+ ↓
+GraphitronSchemaBuilder (the only place directives are read)
+ ↓
+GraphitronSchema
+ ├── Map types (one per GraphQL type)
+ ├── Map fields (one per field)
+ ├── Map> fieldsByType (derived index)
+ ├── Map entitiesByType (federation entity mappings)
+ └── List warnings (non-fatal advisories)
+ ↓
+Generators
+ ├── TypeFetcherGenerator → fetchers.*Fetchers
+ ├── TypeClassGenerator → types.*
+ └── TypeConditionsGenerator → conditions.*Conditions
+----
+
+Each sealed variant maps to specific generator output. The sections below show the full
+directive-pattern → variant → generator output chain.
+
+'''
+
+== Classification Vocabulary
+
+[NOTE]
+====
+*Original framing.* This section describes the field axes (source context, target type, scope) in their original form. They fold into the three-axis `(source, operation, target)` model in <>, which the field now carries directly through its `source()` / `operation()` / `target()` accessors. Treat <> as canonical for how a field classifies; the terms below survive as the historical framing and as properties derived from the three axes, not as independent axes.
+====
+
+Two independent classifications describe every field: the *source context* it is defined on (parent type), and the *target type* it returns. Both matter because scope transitions are determined by the pair, not by either alone.
+
+=== Source context
+
+The type on which a field is defined.
+
+|===
+| Source context | Trigger | What Graphitron generates
+
+| *Unmapped* | *(none ; Query, Mutation)* | Entry point. No SQL yet.
+| *Table-mapped* | `@table` | Full SQL generation ; queries, joins, projections.
+| *Result-mapped* | producer's reflected return is a Java/jOOQ class | Runtime wiring only. The type's backing class is derived by reflection from its producing field (a `@service` return, a `@tableMethod` return, or a parent-accessor chain). Graphitron validates types and wires data fetchers, but generates no SQL until a new scope starts.
+|===
+
+=== Target type
+
+The classification of the field's return type (the element type ; looked through `List` and `Connection` wrappers). Encoded as `ReturnTypeRef`.
+
+|===
+| Target type | `ReturnTypeRef` variant | When it appears
+
+| *Target table* | `TableBoundReturnType` | Return type has `@table` (or is a `@table` + `@discriminate` interface), or a `NestingField` inherits the parent's table context. Carries a fully resolved `TableRef`.
+| *Target record* | `ResultReturnType` | Return type is class-backed: its backing Java/jOOQ class is reflected from the producing field's return type, not from a directive.
+| *Target scalar* | `ScalarReturnType` | Scalar, enum, or an unclassified type name (e.g. `@nodeId(typeName:)` argument types).
+| *Target polymorphic* | `PolymorphicReturnType` | Interfaces/unions spanning multiple tables, and the Relay/Federation built-ins `node` / `_entities`.
+|===
+
+"Target table" is the pivot concept for scope transitions: every new scope is a query rooted in some target table, driven either by the root entering a table-mapped type or by a *record handoff* from a result-mapped source into a target-table return.
+
+=== Scope
+
+A Graphitron scope corresponds to one SQL statement. Fields within a scope contribute to the same query. Scope is determined by the *(source context, target type)* pair ; *independently* of `@lookupKey`, which is orthogonal.
+
+[#diagram-d3]
+[source,mermaid]
+----
+stateDiagram-v2
+ direction LR
+ [*] --> NoScope
+ NoScope --> InScope : enter
(unmapped root field
reaches target-table type)
+ InScope --> InScope : split
(@splitQuery on table-mapped source
→ new scope via DataLoader, parent PK key)
+ InScope --> InScope : record handoff
(target-table field on result-mapped source
or @service/@tableMethod returning target-table type
→ new scope via DataLoader, parent PK or custom batch key)
+ InScope --> Private : exit
(@service field creates private scope
independent of any Graphitron-managed scope)
+ Private --> InScope : (private scope ends, caller continues)
+ InScope --> [*]
+----
+
+|===
+| Boundary | Trigger
+
+| *Enter* | An unmapped root field reaches a target-table type ; the first scope starts
+| *Split* | `@splitQuery` on a table-mapped source ; a new scope via DataLoader, keyed by the parent's PK
+| *Record handoff* | A target-table field on a result-mapped source, or any user-provided return (`@service`, `@tableMethod`) reaching a target-table type ; new scope via DataLoader, keyed by the parent's PK or a custom batch key
+| *Exit* | `@service` fields create a *private scope* ; their SQL statement is independent of any Graphitron-managed scope
+|===
+
+`@lookupKey` does not appear in this table on purpose. It shapes the batch (adds the derived target table and the N × M invariant) but does not by itself open or close a scope ; that is always decided by the source/target pair above.
+
+=== Derived tables
+
+Two kinds of `VALUES(…)` derived tables built by Graphitron when batching:
+
+- *Derived source table* ; built from parent source records. Contains the FK-relevant columns from the parent: the parent's PK/unique-key columns when the FK is on the child side, or the FK columns themselves when the FK is on the parent side. Used for `@splitQuery` table fields, user-provided returns (`@service`, `@tableMethod`), and mutation read-backs.
+- *Derived target table* ; built from `@lookupKey` argument values (from `SelectedField.getArguments()`). Each argument value (or list element) is one row. *Identical for every source in a batch* ; all N parents in a batch share the same request arguments, so M (the number of lookup rows) is constant for the entire batch. Base result count is exactly N × M.
+
+*`@condition` on lookup fields is allowed.* The condition method, however, must preserve the N × M positional contract: each (source, target) pair produced by the derived-table cross join is either kept in full or dropped in full, and no additional rows may be introduced. In practice this means the condition should be a predicate over the pair of rows, not a filter that can change the per-parent result cardinality non-uniformly. Violating the contract desynchronises batch dispatch ; the client receives rows that cannot be reattached to their source. The contract is a developer responsibility, not a build-time check.
+
+=== Conditions
+
+|===
+| Kind | Purpose | Source
+
+| *Reference condition* | How two tables are joined within a scope | `@reference` directive: FK key → `FkJoin`; condition method → `ConditionJoin`
+| *Filter condition* | Narrows the result set of the current scope | `@condition` directive, arguments, cursor
+| *Lookup condition* | Filters the (source × target) row pairs produced by a lookup's derived target table. Must preserve the N × M positional contract ; see <> above. | `@condition` directive on a field with `@lookupKey`
+|===
+
+=== Structural properties
+
+|===
+| Property | Effect
+
+| *`@splitQuery`* | On a table-mapped source, forces a new scope via DataLoader: `TableField` (no `@lookupKey`) → `SplitTableField`; field with `@lookupKey` → `SplitLookupTableField`. On a result-mapped source it is redundant ; the record handoff already opens a new scope ; and should produce a build *warning*, not an error.
+| *`@lookupKey`* | Argument values become the derived target table (see <>). Blocks pagination (preserves the N × M result invariant). Without `@splitQuery` → `LookupTableField`; with `@splitQuery` → `SplitLookupTableField`. Orthogonal to scope ; see <>.
+|===
+
+'''
+
+== Type Classification
+
+The worked examples in this section render directly from the classification test corpus
+(`ClassifiedCorpus` in `graphitron`'s test sources): the SDL shown is the live fixture the classifier
+runs against, projected through a documented query by the query-as-view renderer, with the test-only
+`@classified` / `@classifiedType` assertion directives stripped. The `ClassifiedDocTest` build guard
+fails if this page ever drifts from what the corpus renders, so a displayed example is always one the
+classifier actually produces the stated verdict for. The reference table that follows enumerates the
+verdicts not yet migrated to a worked example.
+
+=== `@table` type → `TableType`
+
+A type carrying `@table(name:)` (without `@node` or `@discriminate`) classifies as `TableType`, the
+pivot for SQL generation. A root field returning it enters a new query scope rooted in that table, and
+a scalar child whose name matches a column projects inline.
+
+[source,graphql]
+----
+type Query {
+ "A single film, fetched by primary key."
+ film: Film
+}
+
+type Film @table(name: "film") {
+ "The film's display title."
+ title: String
+}
+----
+
+`Film` classifies as `TableType` (full SQL generation: queries, joins, projections); `Query.film`
+enters the scope; `Film.title` projects an inline column. Asserted by the `catalog` corpus example via
+`@classifiedType(as: TableType)`. The field descriptions come from `# ...` comments authored on the
+example's projection query, rendered as SDL descriptions by `QueryViewRenderer`.
+
+=== Reference table
+
+|===
+| Classification Trigger on Type | `GraphitronType` Variant | Generator Output
+
+| `@table` + `@node` | `NodeType` | `*Fetchers` class + `*` class (with Relay ID handling)
+| Producer's reflected return is a Java/jOOQ class (a `@service` return, a `@tableMethod` return, or a parent-accessor chain) | `ResultType`* | `*Fetchers` class only (no SQL scope of its own). The backing class is reflection-derived, not declared.
+| `Query` or `Mutation` root type | `RootType` | `*Fetchers` class only
+| Interface with `@table` + `@discriminate` | `TableInterfaceType` | `*Fetchers` class
+| Interface without `@table` (multi-table) | `InterfaceType` | `*Fetchers` class
+| Union type | `UnionType` | `*Fetchers` class
+| `@error` | `ErrorType` | No generation (error mapping config)
+| Input type with `@table` (deprecated on input; see `docs/manual/reference/directives/table.adoc`) | `TableInputType` | Used in mutation generation. A build warning fires per `@table`-on-input usage, except encoded-ID / scalar-return INSERT/UPSERT.
+| Input type without `@table` (the `@service` / `@condition` / `@tableMethod` parameter the input flows into is reflected as a developer class) | `InputType`* | No generation (developer-provided class). The input's backing class is reflected from the parameter type it flows into.
+| Input type with `@table` used on fields with conflicting return tables | `PojoInputType` (unbound) | No generation ; column binding resolved per field-usage
+| Object type with no `@table` and no producer binding | `NestingType` | No generation; nested under a parent table-bound or class-backed scope via `NestingField`
+| GraphQL `enum` type | `EnumType` | No generation; enum values map to DB strings/ints at column-bind time
+| GraphQL `scalar` type with `@scalarType(scalar:)` | `ScalarType` | No generation; the consumer's `public static final GraphQLScalarType` constant is registered on the synthesized schema via `.additionalType(...)`. Graphitron reflects on the constant's `Coercing` to recover the Java type used for input-record components, service params, and `Field` projections.
+| GraphQL `scalar` type matching the extended-scalars convention table (no directive, `graphql-java-extended-scalars` on the classpath) | `ScalarType` | Same as above, but the constant FQN is read from `ScalarTypeResolver`'s 30-entry convention table (`scalar BigDecimal` → `ExtendedScalars.GraphQLBigDecimal`, etc.).
+| Spec built-in scalar (`Int` / `Float` / `String` / `Boolean` / `ID`) | `ScalarType` | Same registration shape; Java type comes from a closed table (the spec binds these names). `@scalarType` on a spec built-in is a hard validation error (directive conflict).
+| GraphQL `scalar` type with no directive and no convention-table entry | `UnclassifiedType` | Validation error pointing at `@scalarType(scalar:)` or extended-scalars as the fix; no silent fallback to `Object`.
+| Generated `*Connection` type (from `@asConnection` transform) | `ConnectionType` | Pagination wrapper; pairs with `EdgeType` and `PageInfoType` below
+| Generated `*Edge` type | `EdgeType` | Edge wrapper carrying `node` + `cursor` for the connection
+| Generated `PageInfo` type | `PageInfoType` | Connection page-info wrapper
+| Conflicting or unresolvable directives | `UnclassifiedType` | Validation error ; build fails
+|===
+
+*Intermediate sealed interfaces* (not shown in the table ; grouping nodes in the hierarchy):
+- `TableBackedType` ; groups `TableType`, `NodeType`, `TableInterfaceType`. Builders switch on this to detect table-mapped types.
+- `ResultType` is itself a sealed sub-interface with four concrete variants: `JavaRecordType`, `Backed` (the sole concrete leaf of the intermediate `PojoResultType`), `JooqRecordType`, `JooqTableRecordType` ; reflecting how the result class is represented in Java.
+- `InputType` is itself a sealed sub-interface with four concrete variants: `JavaRecordInputType`, `PojoInputType`, `JooqRecordInputType`, `JooqTableRecordInputType` ; same split by Java representation. `PojoInputType` is also used when an input type appears as an argument on fields with different return tables ; it is classified as unbound rather than failing.
+
+'''
+
+[#field-classification]
+== Field Classification
+
+A field's classification factors into three asserted axes plus a derived layer. The worked examples below
+render the dimensional form from the corpus; the leaf-name tables that follow are a curated reference,
+enumerating each sealed variant and the generator output it drives. The corpus, not these tables, is the
+coverage source of truth: `VariantCoverageTest` guarantees every output-field leaf is demonstrated by a
+corpus fixture, so a variant absent from a table below is still tested, just not featured here in prose.
+
+A field is an edge: it *arrives into* a `source`, *performs* an `operation`, and *projects* a `target`.
+The three asserted axes are:
+
+* *`source`*, the field's arrival endpoint, a wrapper around a shape. The wrapper arm *is* position and
+ the legality gate: `Root` (permitting `Query` / `Mutation`) is a root field, `OnlyChild` / `Child` is a
+ nested field (one or many source objects arriving). Write operations are legal only on `Root.Mutation`,
+ `NodeResolve` only on `Root.Query`, `Nest` only on a nested source. The nested arms wrap a
+ `SourceShape` (`Table` / `Record`), the catalog-vs-Java polarity of what arrives at `env.getSource()`.
+* *`operation`*, the verb the field performs: reads (`Fetch`, `Paginate`, `Lookup`, `NodeResolve`,
+ `Nest`, and the modeled-but-unpopulated `EntityResolve` / `Count` / `Facet`), writes (`Insert`,
+ `Upsert`, `Update`, `Delete`, and the unpopulated `UpdateMatching` / `DeleteMatching`), and the
+ developer `ServiceCall` (the read-vs-write split it once carried is now read off the `source` root).
+* *`target`*, the field's projection endpoint, a wrapper (`Single` / `List`) around a `TargetShape`.
+ The shape carries *build-vs-consume*: `Table` / `Column` are catalog shapes graphitron *builds* the SQL
+ for; `Record` / `Field` are domain shapes graphitron *consumes* without having built; `Connection`,
+ `Interface`, `Union` are the container and polymorphic shapes. `Table:Column :: Record:Field`
+ (mirror : reflect). A Relay connection is `Single(Connection(...))`, its windowed-read verb the
+ `Paginate` operation.
+
+The *derived layer* is computed from those three plus the field's slots and schema position, never
+asserted: `FetchRelated` (a `Fetch` reaching a related entity over a join-path), *re-fetch* (a
+service/DML producer yielding a `Table` shape, forcing a re-projection), *new-query* (`@splitQuery` /
+polymorphic / record-handoff opening a fresh keyed query), and *polarity* (mutating-or-not, from the
+`source` root and the write operations). The governing principle is *assert what nothing else carries;
+derive what another axis or slot already forces*. The subsections below sound out the derived layer and
+each axis in turn.
+
+=== The derived layer: same verdict, different mechanism
+
+Two fields can share an identical `(source, operation, target)` verdict yet emit different SQL, because
+the fetcher/loader mechanism is *derived*, not asserted. The cleanest illustration is the new-query
+derivation: a `@table` child reachable by a foreign key from a `@table` parent inlines as a correlated
+subquery folded into the parent's SELECT, while adding `@splitQuery` opens a new keyed query dispatched
+through a DataLoader. Both classify identically (`source = Child(Table)`, `operation = Fetch`,
+`target = Single(Table)`); only the derived new-query layer differs, forced by the `@splitQuery` slot.
+
+[source,graphql]
+----
+type Query {
+ city: City
+}
+
+type City @table(name: "city") {
+ country: Country
+ countrySplit: Country @splitQuery
+}
+
+type Country @table(name: "country") {
+ name: String @field(name: "country")
+}
+----
+
+`City.country` and `City.countrySplit` return the same `Country` over the same `city -> country` foreign
+key and carry the same `Child` / `Fetch` / `Table` verdict; `@splitQuery` flips only the derived
+new-query layer, not an asserted axis. Asserted by the `child-table` corpus example.
+
+==== The record-handoff boundary
+
+`@splitQuery` is not the only trigger for the new-query derivation. A `@table` child reached by a foreign
+key inlines under a `@table` parent, but the *same* FK-reached child re-queries under a record-backed
+parent, because the record handoff has already opened a new DataLoader-backed scope that the correlated
+subquery cannot fold back into. The parent's table-ness, not a directive, forces the derived re-query
+here; the asserted verdict is unchanged.
+
+[source,graphql]
+----
+type Query {
+ film: Film
+}
+
+type Film @table(name: "film") {
+ language: Language @reference(path: [{key : "film_language_id_fkey"}])
+ details: FilmDetails
+}
+
+type Language @table(name: "language") {
+ name: String
+}
+
+type FilmDetails {
+ language: Language @reference(path: [{key : "film_language_id_fkey"}])
+}
+----
+
+`Film.language` (`TableField`) and `FilmDetails.language` (`RecordTableField`) return the same `Language`
+over the same `film_language_id_fkey` foreign key and carry the same `Fetch` / `Table` operation and
+target; they differ only on the source shape (`Child(Table)` vs `Child(Record)`), and the record handoff
+forces a derived keyed re-query under `FilmDetails`, not a different asserted axis.
+Asserted by the `record-table` corpus example.
+
+=== `target` shape: build-vs-consume (Column vs. Field)
+
+The `target` shape records what domain object the value is, and with it whether graphitron *builds* the
+SQL (catalog: `Table` / `Column`) or *consumes* a value it did not build (domain: `Record` / `Field`).
+Two scalar flavors hinge on the parent's table-ness: a scalar under a `@table` parent projects a `Column`
+(a real database column graphitron projects), while under a record-backed parent (a plain object with no
+`@table`, here produced as a service method's return type) a scalar projects a `Field` (a POJO property
+read off a record graphitron only reflects). A nested non-table object under a record parent is the object
+flavor of the same `Field` shape. All three are `Fetch`; the parent's table-ness moves the source shape
+(`Table` vs `Record`) and with it the target shape across the build-vs-consume line.
+
+[source,graphql]
+----
+type Query {
+ film: Film
+}
+
+type Film @table(name: "film") {
+ title: String
+ details: FilmDetails
+}
+
+type FilmDetails {
+ stats: FilmStats
+}
+
+type FilmStats {
+ count: Int
+}
+----
+
+`Film.title` classifies with target shape `Column` (`title` is a column of the `film` table graphitron
+builds the projection for). `FilmStats.count`, a scalar under the record-backed `FilmStats`, classifies
+with target shape `Field` (a property graphitron reflects). `FilmDetails.stats`, a nested non-table object
+under the record-backed `FilmDetails`, also classifies with target shape `Field` (its object flavor). All
+hold `operation = Fetch`; the two record-backed cases also carry `source = Child(Record)`. Asserted by
+the `mapping` corpus example.
+
+=== Polymorphic fields: interfaces, unions, and Relay nodes
+
+A field returning an interface, a union, or a Relay `Node` is *catalog-bound* over the participant
+types: classification resolves the polymorphic type to its participant `@table` rows and projects each
+branch from the catalog. The target shape is therefore `Interface` / `Union` (the projection lands on
+participant table rows), with the participant set carried as a *derived slot* rather than as a distinct
+shape value. The operation is `Fetch` for interface and union fields (root or child) and `NodeResolve`
+for the Relay `node` / `nodes` roots; the new keyed query a plain-interface or union field opens
+(`InterfaceField` / `UnionField`, and the polymorphic roots `QueryInterfaceField` / `QueryUnionField` /
+`QueryNodeField` / `QueryNodesField`) is a *derived* new-query, not an asserted axis. The one structural
+difference is a `@table`+`@discriminate` interface child (`TableInterfaceField`): it is
+foreign-key-correlatable from the parent, so it inlines rather than opening a new query, but its asserted
+`Child` / `Fetch` / `Table` verdict is the same.
+
+[source,graphql]
+----
+type Query {
+ customer: Customer
+}
+
+type Customer @table(name: "customer") {
+ address: Named
+}
+
+interface Named {
+ name: String
+}
+----
+
+`Customer.address` returns the plain interface `Named` (implemented by the `@table` type `Address`),
+so it classifies with `source = Child`, `operation = Fetch`, target shape `Interface` (`InterfaceField`):
+a derived new keyed query projects the participant table, with `Address` recorded as the participant slot.
+Asserted by the `interface` corpus example.
+
+A union child follows the same dimensional rule. The selection descends into each participant through an
+inline fragment (`... on Film`, `... on Actor`), and classification resolves the union to its participant
+`@table` rows:
+
+[source,graphql]
+----
+type Query {
+ filmActor: FilmActor
+}
+
+type FilmActor @table(name: "film_actor") {
+ related: FilmOrActor
+}
+
+type Film @table(name: "film") {
+ title: String
+}
+
+type Actor @table(name: "actor") {
+ firstName: String @field(name: "FIRST_NAME")
+}
+
+union FilmOrActor = Film | Actor
+----
+
+`FilmActor.related` returns the union `FilmOrActor`, so it classifies with `source = Child`,
+`operation = Fetch`, target shape `Union` (`UnionField`): a derived new keyed query projects whichever
+participant table a row resolves to, with `Film` and `Actor` recorded as the participant slots. Asserted
+by the `union` corpus example.
+
+The `@table`+`@discriminate` interface child (`TableInterfaceField`, inline, target shape `Table`) and
+the Relay `Node` root (`QueryNodeField`, `operation = NodeResolve`, target shape `Interface`) are further
+leaves on this same polymorphic rule; they are asserted corpus-only (`table-interface`, `relay-node`).
+
+A discriminated interface may also be a *joined-table* (class-table) inheritance: each concrete type
+declares its own detail `@table` distinct from the discriminated base, with its inherited (base-resident)
+fields carrying a `@reference` back to the base and its own columns living on the detail table. Such a
+participant classifies as a `ParticipantRef.JoinedTableBound` carrying the resolved child-to-parent hop
+(rather than a single-table `TableBound`); its inherited field is a `ColumnReferenceField` resolved on
+the base and its own field a plain `ColumnField` on the detail table. The interface fetcher selects from
+the base and emits a discriminator-gated `LEFT JOIN` to each participant's detail table, projecting the
+shared fields off the base and each participant's detail-exclusive columns off its detail alias; the same
+concrete type is independently queryable on its own, resolving its inherited fields through the parent
+reference. The child-to-parent join must be PK=FK (the detail table's foreign-key columns to the base are
+its own primary key, single-column or composite), which keeps the base-to-detail join single-valued.
+Asserted by the `joined-table-interface` corpus example.
+
+=== Reading the generator-output column
+
+The "`*Fetchers` Generates" column in the tables below names which of four emission paths a variant
+drives. The four are an exhaustive, disjoint partition of every field leaf, enforced by
+`GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus`, so a new leaf cannot be added
+without declaring its path:
+
+* *Fetcher method* ; a real method on the generated `*Fetchers` class: a synchronous field fetcher, or
+ an asynchronous DataLoader fetcher paired with a `rows*()` batch method (called out as DataLoader-backed
+ where it matters). These are the `IMPLEMENTED_LEAVES`.
+* *Inline projection* ; no per-field method ; the value is projected straight into the parent's SELECT by
+ `TypeClassGenerator.$fields`. These are the `PROJECTED_LEAVES`.
+* *Wiring value* ; no generated method either ; the field is registered as a `DataFetcher` / `ColumnFetcher`
+ value through `FetcherEmitter` / `FetcherRegistrationsEmitter`. These are `IMPLEMENTED_LEAVES` leaves with
+ an empty dispatch arm.
+* *Deferred stub* ; a throwing stub method standing in for generation that is not built yet, paired with a
+ build-time validator rejection. Only `CompositeColumnReferenceField` is deferred today (`STUBBED_VARIANTS`).
+
+=== Query Fields
+
+|===
+| Schema Pattern | `QueryField` Variant | `*Fetchers` Generates
+
+| Any argument has `@lookupKey` | `QueryLookupTableField` | Synchronous fetcher + `lookup*()` rows method (+ VALUES input-rows helper)
+| `@tableMethod` | `QueryTableMethodTableField` | Fetcher method
+| Field named `node` (Relay) | `QueryNodeField` | Fetcher method
+| Field named `nodes` (Relay; auto-emitted, batched-by-id) | `QueryNodesField` | Async DataLoader fetcher dispatching by NodeId
+| Return: `@table`+`@discriminate` interface | `QueryTableInterfaceField` | Fetcher method
+| Return: multi-table interface | `QueryInterfaceField` | Fetcher method(s) ; per-participant polymorphic projection
+| Return: union | `QueryUnionField` | Fetcher method(s) ; per-participant polymorphic projection
+| `@service`, return `@table` type | `QueryServiceTableField` | Async DataLoader fetcher + `rows*()` method
+| `@service`, return non-table type | `QueryServiceRecordField` | Fetcher method
+| Return: `@table` type (default) | `QueryTableField` | Full fetcher ; condition call + orderBy build + inline DSL chain (`dsl.select(Type.$fields(...)).from(table)...`)
+| Anything else | `UnclassifiedField`** | Validation error ; build fails
+|===
+
+The federation `_entities` field is *not* modelled as a `QueryField` permit; it is resolved by `federation-graphql-java-support` directly and dispatched through the generated `EntityFetcherDispatch` runtime helper. The corresponding entity-resolution metadata is carried in `GraphitronSchema.entitiesByType`.
+
+NOTE: A `@service` field's input *parameter* may itself be a generated jOOQ `TableRecord` (singular or `List<…>`), distinct from the field's return type. When the parameter's SDL input type classifies as `JooqTableRecordInputType`, the call site binds it on the *column axis* rather than instantiating a Java bean: each plain input field names a column through `@field(name:)`, and an optional `@nodeId` field decodes the record's scalar key. The parameter is materialised by a generated `create` (singular) / `createList` (list) helper on the `*Fetchers` class (the `CallSiteExtraction.JooqRecord` binding), which loads the columns through `record.fromArray(…, Tables..…)` and decodes the identity through `NodeIdEncoder.decodeValues`. The binding is coordinate-agnostic: it applies identically whether the `@service` field is a root field or a `@table`-parent child field, sharing one helper.
+
+=== Mutation Fields
+
+A mutation whose `@mutation(typeName:)` writes the catalog and returns a `@table` type classifies with
+`source = Mutation`, the write verb as its `operation` (`Insert` / `Update` / `Delete`), and
+target shape `Table`. The write produces the affected row, then a follow-up `SELECT` re-projects it
+through the catalog; that read-back is the *derived re-fetch* (a write producer yielding a `Table` shape),
+not a separate asserted axis. The mutation's input object is part of the rendered closure, so the excerpt
+shows the `@table`-bound input the write consumes:
+
+[source,graphql]
+----
+type Mutation {
+ createFilm(in: FilmInput!): Film @mutation(typeName: INSERT)
+}
+
+type Film @table(name: "film") {
+ title: String
+}
+
+input FilmInput @table(name: "film") {
+ title: String
+}
+----
+
+`Mutation.createFilm` inserts a `film` row from `FilmInput` and projects the inserted row as the `@table`
+type `Film`: `source = Mutation`, `operation = Insert`, target shape `Table` (`MutationInsertTableField`),
+the read-back being the derived re-fetch. Asserted by the `dml` corpus example.
+
+|===
+| Schema Pattern | `MutationField` Variant | `*Fetchers` Generates
+
+| `@mutation(typeName: INSERT)`, returning `ID` or a `@table` type | `MutationInsertTableField` | Fetcher method ; write + read-back SELECT (the derived re-fetch)
+| `@mutation(typeName: UPDATE)`, returning `ID` or a `@table` type | `MutationUpdateTableField` | Fetcher method ; write + read-back SELECT (the derived re-fetch)
+| `@mutation(typeName: DELETE)`, returning `ID` | `MutationDeleteTableField` | Fetcher method ; write + encoded-PK off `RETURNING` (no read-back ; the row is gone, target shape `Column`)
+| `@mutation(typeName: UPSERT)`, returning `ID` or a `@table` type | `MutationUpsertTableField` | Rejected at classification ; UPSERT generation gated pending R145
+| `@mutation(typeName: INSERT\|UPDATE\|DELETE\|UPSERT)`, returning a single-record class-backed carrier (one recognized data field, optional errors-shaped field; the carrier's backing class is reflected from the producing field, not declared) | `MutationDmlRecordField` | Two-step fetcher: per-kind DML chain inside `transactionResult` with PK-only `RETURNING`, plus a follow-up data-field SELECT outside the transaction
+| `@mutation(typeName: INSERT\|UPDATE)` with bulk `@table` input, returning a single-record carrier with a list-shaped `@table`-element data field | `MutationBulkDmlRecordField` | Per-row DML inside one `transactionResult`, accumulating a typed `Result>` in input order
+| `@service`, return `@table` type | `MutationServiceTableField` | Async DataLoader fetcher + `rows*()` method
+| `@service`, return non-table type | `MutationServiceRecordField` | Fetcher method
+| Neither `@service` nor `@mutation` | `UnclassifiedField`** | Validation error ; build fails
+| Both `@service` and `@mutation` | `UnclassifiedField`** | Validation error ; build fails
+|===
+
+The four `Mutation*TableField` permits are guaranteed never to carry a class-backed (record-shaped) return: every DML mutation whose return type reflects to a record carrier routes through the DML-carrier permits (`MutationDmlRecordField` / `MutationBulkDmlRecordField`) via `BuildContext.scanStructuralDmlPayload`. The narrowness is enforced structurally by the routing in `FieldBuilder.classifyMutationField`, not by an Invariant the validator restates.
+
+=== Child Fields (on `@table` parent)
+
+==== Scalar / Enum return type
+
+|===
+| Schema Pattern | `ChildField` Variant | `*Fetchers` Generates
+
+| `@field(name:)` or matching column name | `ColumnField` (`compaction = Direct`) | Wiring value (`ColumnFetcher`)
+| `@reference` on scalar | `ColumnReferenceField` (`compaction = Direct`) | Inline projection in `$fields` + `ColumnFetcher` wiring value
+| `@reference` on scalar declared on a `TableInterfaceType` participant | `ParticipantColumnReferenceField` | Wiring value ; materialised by the enclosing `TableInterfaceField` fetcher's conditional LEFT JOIN and read back via `FetcherEmitter` (participant-side FK, not the interface-side resolver)
+| `@nodeId` (single-column PK), no typeName | `ColumnField` (`compaction = NodeIdEncodeKeys`) | Wiring value (`ColumnFetcher`); the projected column is wrapped in the per-Node `encode` helper
+| `@nodeId(typeName:)`, single-column PK target | `ColumnReferenceField` (`compaction = NodeIdEncodeKeys`) | Inline `$fields` projection against the FK-resolved target column, wrapped in `encode`
+| `@nodeId` (composite PK), no typeName | `CompositeColumnField` | Inline `$fields` projection of `RowN<...>` of the parent's PK columns, wrapped in `encode`
+| `@nodeId(typeName:)`, composite PK target | `CompositeColumnReferenceField` | Deferred stub ; rooted-at-parent composite NodeId reference, pending `nodeidreferencefield-join-projection-form`
+|===
+
+==== Object return type
+
+|===
+| Schema Pattern | `ChildField` Variant | `*Fetchers` Generates
+
+| `@externalField(reference: {className, method})` | `ComputedField` | Inlined call in `$fields()` (`.(table).as("