Grounded templates
Bind logical source names to real data — files, commands, loaders, or in-memory values — so a contract checks claims against current truth, not just shape.
A mediva contract can do more than check shape. A grounded rule checks a claim in the document against real data: the issue number in a PR body must be an open issue, the change list must cover every file the diff touched, a quoted diagnostic code must actually exist.
<!-- mdv: fragment close = Fixes|Closes|Resolves -->
<!-- mdv: section required pattern.mask="<close> <issue|in: openIssues?>" -->
## Related Issue
Fixes #482
<!-- mdv: endsection -->
<!-- mdv: section required -->
## Scope
<!-- mdv: list required minItems=1 covers.in=changedFiles? -->
- Updated `src/auth/session.ts` to cover the touched session logic.
<!-- mdv: endlist -->
<!-- mdv: endsection -->openIssues and changedFiles are logical source names. The template names the truth it
wants; where that truth comes from is decided outside the template.
The two spellings
A source ref is one of exactly two things, decided by spelling:
- A contract-relative file path — starts
./, posix-style, no..:<person|in: ./roster.txt>. The file sits next to the contract. - A logical name — an identifier (
[A-Za-z][A-Za-z0-9_-]*):<issue|in: openIssues?>. Bound by the host at check time.
Requiredness: the trailing ?
The template owns requiredness, and only the template:
openIssues?— optional. Unbound → the rule honest-null skips: no guess, no error. A freshly copied pack template validates shape-only and upgrades to grounded checking the moment the host wires the data.openIssues(no?) — required. Unbound → loud completeness failure naming the missing wiring.- Bound but failing (loader throws, file unreadable, timeout) → always loud,
?or not. The optional marker forgives absence, never failure.
Binding surfaces and precedence
Three ways to bind a name, with one precedence law — later entries win:
groundproducers (materialized data): commands or functions inmediva.config.mjswhose output is written to.mdv/<name>.jsonbymdv sources sync, then auto-bound.sources.declare(direct bindings): a path, literal data, or an async loader.--source NAME=FILE(CLI override): pin one name to a static dump for one run — the "reproduce a red CI run locally, offline" tool. Wins over everything, including replay.
// mediva.config.mjs
import { defineConfig } from "mediva/grounding";
export default defineConfig({
ground: {
// produced: mdv sources sync → .mdv/openIssues.json → auto-bound
openIssues:
"gh issue list --state open --limit 1000 --json number --jq '[.[].number | tostring]'",
},
sources: {
declare: {
// declared: bound directly (a declare entry for the same name wins over ground)
changedFiles: "./.mdv/changed.json",
// or a live loader: changedFiles: async () => computeChangedFiles(),
},
},
});defineConfig is optional but gives the config file editor type checking.
Config discovery walks up from the checked document to the nearest mediva.config.mjs,
stopping at the git root — a run from a subdirectory loads the same config CI loads at the
repo root.
Projections: shaping what a source means
A declare entry doesn't have to be a bare path to a flat array of strings. A projection
narrows the file to the exact structural slice that counts as canon, so illustrative content
(an example object, an unrelated heading) never silently leaks into membership:
{ path, members: [...] }— object-catalogue: the file is a JSON array of objects;membersnames which fields' string values become the membership set.{ path, markdown: { headings: { level, mask? } } }— the raw text of every ATX heading atlevel(optionally filtered by a mask), read through the real Markdown AST.{ path, entries: "stem" | "name" }— the filenames ofpath's immediate directory children.{ path, records: [...] }— an ordered tuple projection: every row of a JSON array of objects, kept as a relation rather than flattened into single-value membership — the shapetable rows.in=binds a table's body rows against, field name matched to header text:
declare: {
metrics: { path: "./metrics.json", records: ["metric", "period", "unit", "value"] },
},<!-- mdv: section required -->
## Metrics
<!-- mdv: table rows.in=metrics -->
| metric | period | unit | value |
| ------- | ------ | ---- | ----- |
| Latency | Q1 | ms | 120 |
<!-- mdv: endtable -->
<!-- mdv: endsection -->Compare modes
members and records projections (and a plain path via { path, compare }, no projection)
carry a value-identity policy, compare:
exact— NFC then code-point equality.folded— NFC then locale-independent lowercase, no@stripping.mention— folded, then strips at most one leading@— today's behavior, and the default whenevercompareis omitted.
declare: {
reviewers: { path: "./team.json", members: ["login"], compare: "exact" },
},The CLI verbs
mdv sources sync # run ground producers, write .mdv/<name>.json
mdv sources status --check # CI gate: absent or stale entries exit 1
mdv sources list # every binding and where it came from
mdv sources doctor # UNBOUND refs contracts want, ORPHANED config keys nothing wants
mdv check # binds automatically, then validates.mdv/ holds generated, gitignored grounding data only — produced in CI right before
mdv check, never hand-authored. A failed producer never overwrites the last-good file.
Record and replay
CI runs that ground against live data are not reproducible by default. Capture them:
mdv check --record-sources capture.json # resolve live, write a versioned capture
mdv check --source-snapshot capture.json # replay: no live resolution, no networkThe capture embeds the mediva version, a config hash, and a sha256 per contract — replaying
against a drifted contract or config fails loudly (--allow-capture-drift is the explicit
escape hatch). --source-snapshot with a missing file prints a note and records a fresh
capture — it never silently becomes a live run; --record-sources always resolves live.
Programmatic binding: schema.bind()
Embedders (a CI webhook, a Workers app, an agent pipeline) bind in memory — no filesystem, no config file:
import { compile } from "mediva";
const schema = compile(template);
const bound = await schema.bind({
sources: {
openIssues: ["123", "456"], // literal data
changedFiles: async ({ signal }) => fetchFiles(signal), // loader (AbortSignal threaded)
},
context: { breaking: false },
});
const report = bound.validate(markdown); // sync — loaders resolved once at bindbind() is strict by default: a bound name no template ref matches (binding-unknown —
a typo until proven otherwise) and a required ref with no binding (binding-missing) both
throw a GroundingError carrying every issue, at wiring time — never a silently shape-only
validation. Pass { partial: true } for editor/preview scenarios that want issues reported
instead of thrown. Loaders take { kind: "loader", load, timeoutMs, onUnavailable } for
timeout budgets and acquisition policy — onUnavailable: "skip" degrades an optional ref
when the fetch fails; it can never satisfy a required one.
The older resolveInjectedSources API remains as a compatibility adapter over the same
machinery; new integrations should use schema.bind().
Honesty invariants
Why none of this fabricates:
- Absent optional binding → silent skip. "Don't know" is never reported as "wrong".
- Absent required binding → a failure naming the missing wiring, not a document error.
- A binding that fails to load → loud, always.
- Grounded rules gate new documents against current truth. Re-validating old documents against today's data produces temporal-drift lies — don't.
CI recipe
- run: mdv sources sync # materialize ground producers into .mdv/
- run: mdv sources status --check # fail if anything is absent or stale
- run: mdv check --record-sources source-capture.json
- uses: actions/upload-artifact # keep the capture for offline reproduction
with: { name: source-capture, path: source-capture.json }To reproduce a red run locally: download the artifact, then
mdv check --source-snapshot source-capture.json — same data, no network, same result.