ADR-A79: Persistence compiler toolchain and template-based SPARQL generation

Status: Accepted Date: 2026-09-22 Supersedes: none Related: ADR-A78, ADR-A80, rdf-sparql-patterns-guide.md Chapter 25, persistence-profile-substrate sketch

Context

A dal:DataAccessProfile graph is configuration, not an executable. Something has to turn a resolved profile into the actual guarded DELETE/INSERT … WHERE templates, key-claim writes, tombstones, and audit queries that rdf-sparql-patterns-guide.md specifies. That something must not require an adopter to run any LATTICE Java process to get value from the ontology layer: an adopter who wants only the ontologies and the generated SPARQL, and intends to call it from their own application in their own language, must be able to do so.

Generating SPARQL from configuration by string interpolation is exactly the injection risk Chapter 28, QP1 forbids at the hand-written-query level, and a compiler that does it wholesale for every adopter’s configuration is a worse version of the same defect, since a hostile or malformed class IRI, shard count, or tenant string in an adopter’s own ontology now has one code path to corrupt every generated query rather than one query at a time.

The compiler’s output must also remain distinct from two runtime concerns this ADR explicitly does not resolve: binding real request-time values (an entity IRI, an expected version, a payload) into a compiled template, and choosing, at the moment a request executes, whether a particular store’s SPARQL dialect needs the template rewritten. Both require the store SPI (proposed A75) to exist first, and the SPI’s shape is not yet decided.

Decision

  1. The compiler is a design-time-only toolchain, tools/persistence, with two subcommands. compile consumes ontology/persistence individuals, the adopter’s own applied-ontology class declarations, and an optional dal:CapabilitySpec, and produces a canonical dal:CompiledProfile graph in Turtle: per target, the fully resolved configuration with per-dimension provenance (which scope supplied the winning value), a dal:GeneratedOperation per operation naming a stable dal:Template identifier and its dal:ParameterBindings, an unconditional dal:CapabilityRequirement, a dal:CapabilityCheck when a spec was supplied, and a structured diagnostics report. compile never produces SPARQL text, never reads a template body, and needs no live backend, so its output does not depend on knowing what backend, if any, will eventually run it. instantiate, entirely optional, reads a dal:CompiledProfile and the checked-in template library and mixes each generated operation’s parameters into its named template to produce literal, portable SPARQL text. Neither subcommand performs I/O against a live triple store or requires an SPI.

  2. The compiled profile, not a SPARQL file, is the canonical output of compile. It does not embed literal query text as a datatype property on any individual, a pattern ontology/mork’s mrk:QueryTemplate/mrk:queryText uses and this ontology deliberately does not repeat, because a query body sitting as an opaque string on an RDF node cannot be reasoned about, versioned independently, or shared across targets the way a named, referenced template can. dal:Template and dal:ParameterBinding are modelled on mrk:QueryTemplate and mrk:ParameterBinding’s reified, typed shape, diverging only on this point.

  3. Two substitution layers are kept strictly apart, one per subcommand. compile-time substitution resolves configuration constants (graph-IRI patterns, property IRIs, shard counts, zero-pad width, datatypes) into dal:ParameterBinding individuals. instantiate-time substitution mixes those bindings into a named template’s Mustache slots using a logic-less template engine (Mustache, via the chevron Python implementation). Request-time substitution proper — the genuine SPARQL variables a caller binds per call (?entity, ?expectedSeq, the payload) — is left as SPARQL variables in instantiate’s output and is never touched by either substitution layer. A dal:CompiledProfile’s parameter bindings name every constant a future caller needs, so a caller in any language can bind the remaining request-time variables correctly without guessing.

  4. No adopter-supplied value reaches a template as a raw string, in either subcommand. Every configuration value is passed through a single trusted RDF-term encoder (encode_iri, encode_literal, encode_var) that either produces a syntactically valid, escaped RDF term or refuses to compile. compile uses it to produce dal:ParameterBinding values. instantiate’s template renderer accepts only these wrapped term types, never a bare Python string, so a value that skipped the encoder is a type error, not a silent pass-through. Templates themselves are Lattice-authored and reviewed, and are treated as trusted structure. The security boundary is exactly, and only, the line between adopter-supplied configuration data and the encoder.

  5. An injection corpus is a release gate for instantiate, not an aspiration, since that is the only subcommand that ever produces SPARQL text. Adversarial IRIs and literals (unbalanced braces, embedded keywords, quote and backslash sequences, bidirectional-override Unicode) are fed through the encoder and the full render pipeline, and the rendered output is parsed by an independent SPARQL parser to assert it contains exactly one intended operation. This is the guide’s QP1 enforcement, applied to the compiler itself rather than to hand-written call sites.

  6. Out of scope for this ADR and for the plan it authorises: the store SPI (proposed A75), a runtime “Request Query Mapping” library that would wire live requests to compiled templates and an SPI implementation, and a “Query Execution” component that would let a specific SPI rewrite a compiled template’s syntax for its own dialect at request time. All three require the SPI’s shape to be decided first. They are recorded as open design questions in lattice-platform-agentic-development-v0.2.md, Part 13, and Part 6 of that plan is marked pending revision once this work lands, because its ingestion and query-plane slices currently assume ad hoc query construction that this ADR supersedes.

Consequences