Query the record instead of reading it.
A governed SQLite+FTS5 map over a markdown decision record, so a session reads the forty lines the query pointed at rather than the six hundred it would have skimmed.
The markdown stays canonical. The index is derived, regenerated on every fold, gated by a four-leg verify, and wrong by definition the day it is hand-edited.
A repo declares what its documents mean
docs/index/conventions.json
52 required fields
A consumer binds in four lines
B = record_index.bind(__file__)
globals().update(B.exports())
Then asks the record
q "dangling pointers"
verify # four legs, all must pass
What it does
The record becomes queryable
Rulings, laws, experiments, handoffs, artifacts, phenomena and decisions are parsed out of a repo's own markdown into SQLite with an FTS5 index over it. Every row carries the file, the line, a human anchor to cite, and a locator string findable in the source.
Conventions are declared, not guessed
Which files carry rulings, which header forms open one, what the verdict vocabulary is, which corpora exist — a repo states all of it. There is no "if the repo did not say, use the first repo's value" branch anywhere in conventions.py, on purpose: that branch ships one repo's history as every other repo's silent default.
Every vocabulary reports what it dropped
An empty table and a table that silently discarded six artifacts are indistinguishable at the call site, and only one of them is correct. Each vocabulary declares a probe defining its population, and counts the inputs inside it that matched no entry — reported, never gating.
The numbers, as measured
Measured on 2026-08-11 against the tree at that date, not estimated. Where something has not happened, the row says so rather than being left off.
| Counter | As of 2026-08-11 |
|---|---|
| Checks in the suite | 455 — 451 passing, 4 pinned as xfail(strict=True) |
| Interpreters in CI | 3.11 and 3.13 — the floor requires-python declares and the ceiling the classifiers claim, and nothing between them |
| Runtime dependencies | 0. sqlite3 + re + json, all stdlib — asserted twice, once against the project metadata and once by walking every module's import statements |
| Modules | 10, pinned by name so one added to the tree and not to the distribution is visible |
| Fixture record-repos | 2 — alpha and beta, disagreeing on the markers, the corpus root, the word for a ruling, both arc rules, the verdict vocabulary, the header forms and the location of the declaration itself |
| Declared conventions fields | 52 required; 21 of them may be declared empty, 31 may not |
| Consumers | 2 — facet, whose ~2,462 in-tree lines became a declaration plus an adapter, and armature, whose own index seeded 15/15 with 47 rulings |
| Known defects | 4, reproduced and pinned in-tree as strict-xfail tests rather than hidden |
| On PyPI | Not yet. release.yml publishes via OIDC Trusted Publishing when a GitHub release is created; nothing publishes on push |
What you declare, what the tool supplies
The whole design fits in one sentence: you must declare what your repo MEANS; you may inherit how the search is TUNED.
A repo declares its meaning — in full
Every one of the 52 fields is required. Absence is an error that names the field and is raised at load time, rather than surfacing as an empty table six steps later. A repo states its own meaning; it never inherits another repo's history by omission.
The tool supplies the mechanism
Parsing, ranking, determinism, the verify legs. Nobody adopting this has evidence to tune BM25 with, and inviting them to turn knobs they cannot evaluate is worse than a default.
Every tuning value carries its calibration
bm25(8.0, 1.0), 400 candidates, 3 phrase slots — each annotated at the site with the corpus and month it was fit on. That is the difference between a default and a number somebody once measured somewhere: a reader who wants to know whether 400 means anything for their record can see that it does not yet.
Arc is identity; experiment is grouping
The schema says both rather than picking one, because keying on the E-number alone collides on seven primary keys against a real corpus where one experiment ran two ruling series. That collision was measured before it was ruled on, and the extraction halted until it was.
Four legs, and a certificate that names the bytes
Legs 1–4 gate; the declaration audit and the vocabulary report are printed with the word DIAGNOSTIC on them, because which unrecognised inputs matter is a judgement about a record and not a property of one.
1 — Determinism
Two builds from an unchanged record, compared byte for byte, with a pre-registered .dump-identity fallback for the case where SQLite's own file header defeats byte equality. The transcript names which leg held.
2 — Counts against the record's own numbering
Declared count checks grep the markdown and compare against the database; declared sequences report gaps in a numbered range. A record carrying more than a declared bound prints a completeness note — the bound stays as declared rather than silently widening.
3 — Zero dangling pointers
Every row in all seven tables plus the FTS index must name a file that exists and a locator string that occurs inside it. This is the leg that catches a pointer the tool invented.
4 — The seeded question set
A set of questions declared by the repo, each with the file and anchor that should answer it. The gate is that the target lands within the top N; a miss prints what came back instead.
The certificate is written by the same verb
build_and_certify builds, verifies in-process, and writes the certificate from that verify's own transcript and exit code. There is no path that writes a database without writing a certificate for it — because build and verify as separate verbs let a fresh database sit beside a stale certificate indefinitely, reading as verified.
Staleness warns; it does not refuse
The certificate carries the index's size and digest and a per-file manifest of the corpus, so a certificate found beside a different index is detected rather than trusted. A corpus that has moved since the build reports STALE and keeps serving, because bounded staleness is the normal state of a fresh clone.
What is not solved
Four defects, each reproduced and pinned in the suite as an xfail(strict=True) test — which means the day one is fixed, the suite fails until this page and that test are both updated. None affects the two current consumers. A page that only lists wins is not a status report.
| Defect | Measured | Where |
|---|---|---|
| verify() reports its diagnostic counts twice | On alpha, one build reports verdicts 4/3, artifact kinds 5/1, law paid_for_by 2/1, experiment status 2/1, phenomenon markers 1/2, ruling headers 6/0, 8 total unrecognised and 1 declaration finding. The transcript reports 8/6, 10/2, 4/2, 4/2, 2/4, 12/0, 16, and the same finding listed twice — exactly double, every row. | index.py — Record.record() hands back a fresh Record per call precisely so counters do not accumulate, but verify() passes one Record to both of leg 1's builds. The accumulation is in the two REPORT-ONLY sections; every gating leg reads the database and is unaffected, and the run still exits 0. |
| The claim-arc pattern assumes E-numbered arcs | On beta, whose arcs are A01 and A02, both declared-family sites land in the unparseable list with "no arc attributable on this line", the STALE count is 0, and the claim that one of its own documents is wrong by five is never made. | claims.py — ARC_RE is the module constant \bE(\d\d)\b, hard-coded from the repo this was extracted from, while every other arc-shaped value in the package is declared. measurements() carries the same assumption in CAST(substr(id,2) AS INTEGER). |
| The sub-ruling locator is not derived from the declared header form | With headers.sub_ruling declared as ^\*\*(\d+)\.([a-z])\s+[—–-] and the document written **1.a — , the row's locator comes out **1a , which does not occur in that document. Verify leg 3 then reports dangling pointers produced by the tool rather than by the record. | parse.py — the locator is built as the literal '**%s%s ' % (num, letter), the form of the repo this was extracted from. Same for sub_closure. |
| Four fields refuse declared-emptiness | sweep.current_state_dirs, sweep.historical_dirs, headers.handoff and vocabularies.supersede_verbs are not in MAY_BE_EMPTY, so a repo whose honest answer is "none" cannot say so — the loader refuses []. Both fixture repos carry a directory they would otherwise not have, for exactly this reason. | conventions.py — the file's own law is that declaring a corpus empty is a statement where omitting the key is not, and MAY_BE_EMPTY grants that to 21 fields. These four are outside it. |
The surface a consumer takes on
No console script ships with this package, deliberately: the command a record repo runs is named after that repo, so the entry point belongs to the consumer and this package supplies the contract it runs under.
The adapter a consuming repo writes
import record_index
from record_index import cli as _cli
BINDING = record_index.bind(__file__)
globals().update(BINDING.exports())
def main(argv=None):
return _cli.run_contract(
lambda a: _cli.main(BINDING, a), argv,
db_env=BINDING.conv.db_env)The exit-code contract it runs under
0 ok
1 the operator's invocation was wrong
2 the tool broke on something it did
not expect
3 declared and DELIBERATELY UNUSED —
no verb has a partial-completion
path, and a code is not populated
by inventing a path for it
4 the tool ran correctly and is
telling you not to proceedLinks
Handbook
What it is, how to adopt it, the declaration fields, the verify legs, and the defects in full.
GitHub
The source. Ten modules, stdlib only, and the suite that measures both.
The suite
455 checks over two fixture record-repos that disagree on every declarable axis.
Changelog
What 0.1.0 marks, and what it deliberately does not.
Security
Data touched, egress measured rather than asserted, and the sharp edges disclosed.
facet
Where every convention here was paid for, and the first consumer.