DocsUsing mediva

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:

  1. ground producers (materialized data): commands or functions in mediva.config.mjs whose output is written to .mdv/<name>.json by mdv sources sync, then auto-bound.
  2. sources.declare (direct bindings): a path, literal data, or an async loader.
  3. --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; members names which fields' string values become the membership set.
  • { path, markdown: { headings: { level, mask? } } } — the raw text of every ATX heading at level (optionally filtered by a mask), read through the real Markdown AST.
  • { path, entries: "stem" | "name" } — the filenames of path'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 shape table 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 whenever compare is 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 network

The 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 bind

bind() 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.

On this page