Package documentation: the surface, the reference, and the ratchets
Date: 2026-08-29
Status: implemented 2026-08-29, with deviations below.
Depends on: golden paths (2026-08-28-golden-paths-design.md), the
architecture roadmap (2026-08-21-architecture-roadmap.md).
Problem
Measured on 2026-08-29 against main at e5e511b:
src/attestation/__init__.pyis empty: no package docstring, no__version__, no statement of what is public.pyproject.tomlhas a good description, license, keywords and classifiers but no[project.urls], and the wheel ships nopy.typed, so a downstream type checker ignores annotations this repo checks withtyon every commit.- 190 of 292 public defs carry a docstring (65%). The 103 without are
concentrated in
cli.py'scmd_*handlers — whose one-line purpose already exists as argparsehelp=text, a second copy of the same fact — and in the citation readers'all/lookupmethods. - There is no changelog and no contributor guide. The conventions that
matter here (a spec before code, the eight gates, the docs index that
test_architecture.pyenforces, the golden-path shape, thenoqapolicy, no attribution in committed fixtures, the commit-message style) live inCLAUDE.md, which is written for an agent, not a contributor. - The README is 755 lines in 11 sections. It is the front door, the
install guide, the hermes-agent integration manual, the ledger manual,
the claims manual, the ranking explainer and the test guide at once.
docs/holds 27 design specs, an architecture narrative and the measurement-lessons record — the material a docs site is made of — with no site, no API reference, and no CLI reference beyond--help.
The repo's own rule is that a documented fact is a tested fact. Package documentation should be held to it: coverage that only ratchets down, a reference that fails the build when a cross-reference breaks, and a CLI page generated from the parser rather than transcribed.
Design
The package surface
attestation/__init__.py gets a docstring stating what the package is
(one paragraph, the same sentence pyproject.toml uses), __version__
read from importlib.metadata (so pyproject.toml stays the one source),
and an __all__ naming the modules a user is meant to import
(ledger, claims, citations, rank, ingest, features, simulate,
explain, symbolic, kg, emit, install, llm, embed, db,
ports). Nothing is re-exported: the modules are the API, as
docs/superpowers/specs/2026-08-21-onion-refactor-design.md decided.
A py.typed marker ships in the wheel (the wheel smoke test asserts it).
[project.urls] gains Homepage, Repository, Issues and Changelog.
Docstrings: one source, and a ratchet
cli.py's handlers keep their argparse help= as the one source: each
cmd_* function's docstring is set from the same string at definition
(build_parser already has the strings; a small helper attaches them), so
--help and the docstring cannot drift. The citation readers and the
remaining public functions get real docstrings written from their tests
and specs — the rationale-carrying kind this repo already writes, not
restatements of the signature.
tests/test_docstring_ratchet.py counts public defs (module, class,
function; names not starting with _; under src/attestation) with no
docstring and asserts the count is at most the pinned baseline, the way
scripts/check_complexity.py pins complexity. The baseline starts at
whatever this work leaves (target: 0) and may only go down; a new public
function without a docstring fails the suite with the offender's
file:line.
CONTRIBUTING.md and CHANGELOG.md
CONTRIBUTING.md is the contributor's view of CLAUDE.md's "Working
here": clone → uv sync → uv run pre-commit install; the eight gates and
what each catches; a spec in docs/superpowers/specs/ before a feature;
the docs index in CLAUDE.md that the architecture test enforces; how to
add a golden path (the seven sections, run.sh, the catalogue row, the
generator-and-scrub rule); the # noqa: BLE001 policy (seven sites, each
with its reason); no attribution or machine paths in committed files (and
the guard that checks); commit messages that say what changed and why in
a sentence.
CHANGELOG.md follows Keep a Changelog. An Unreleased section digests
what landed since the last tagged state, by area, with one line per
change and the commit that carries its reasoning — commits here are
narrative and the changelog points at them rather than repeating them.
Nothing before 2026-08-28 is reconstructed; the section says so.
The docs site
mkdocs.yml at the root, mkdocs-material + mkdocstrings[python] in a
new docs dependency group (never a runtime dependency; the src/-import
guard gains mkdocs). Navigation:
- Home —
README.md(included, not copied;mkdocs--strictrefuses a broken link). - Getting started — the golden-path catalogue (
examples/README.mdincluded) and one page per path? No: the catalogue links to each README on the repository, which already has the seven sections; a copy would drift. - Guides — pages that exist today under
docs/: the architecture narrative, measurement lessons, recommendation refinements, the hermes-agent research. - Design records — the 27 specs, listed by date (generated from the
directory at build time by a tiny plugin-free script that writes
docs/site/specs.md; the script is run by the build and by a test). - Reference — the CLI (
docs/reference/cli.md, generated frombuild_parser()byscripts/render_cli_reference.py, with a test that the committed page equals a fresh render — the same "docs are tested" rule as the golden paths) and the API (onemkdocstringspage per module in__all__,show_source: false, docstring style left as written — this repo's docstrings are prose, andmkdocstringsrenders prose).
mkdocs build --strict runs as a fourth CI job, docs, so a docstring
that breaks a cross-reference or a nav entry that points nowhere fails
the push. The site is not deployed by this spec (no Pages workflow); the
artifact is uploaded so a reviewer can open it.
The README, and what does not move yet
The README stays the front door and is not split by this spec. The
judgement of which 300-line sections become docs pages (the hermes-agent
integration manual is the obvious one) is the user's; the site mirrors
the README so nothing is lost either way, and a follow-up spec can move
sections once the site exists to receive them. What this spec does change
in the README: one "Documentation" line pointing at the site's build
command and CONTRIBUTING.md.
Not in scope
- Deploying the site (GitHub Pages) — a workflow with permissions the user should turn on deliberately.
- Splitting the README (above).
- Rewriting existing docstrings for style. Coverage, not house style; the ratchet counts presence.
Success criteria
uv run python -c "import attestation; print(attestation.__version__, attestation.__all__)"prints the version frompyproject.tomland the module list; the wheel containspy.typed(asserted in the CI wheel smoke job).tests/test_docstring_ratchet.pypasses at a baseline of 0 undocumented public defs, and fails — naming the offender — when one is added.attest <cmd> --helpandcmd_<cmd>.__doc__are the same text for every subcommand (one test). Amended 2026-08-29:cmd_*docstrings carry rationale paragraphs beyond the one-line help text (see Deviations), so "the same text" means the same FIRST line, by construction —_documentedsets it fromHELP, and the test checks only that line.uv run --group docs mkdocs build --strictexits 0; CI jobdocsis green;docs/reference/cli.mdis byte-equal to a fresh render (one test).CONTRIBUTING.mdandCHANGELOG.mdexist and are linked from the README;[project.urls]present.
Deviations and findings
docs/CONTRIBUTING.md, docs/CHANGELOG.md, and docs/examples are
symlinks to the repo-root files/directory, not copies or mkdocs
include-markdown includes. mkdocs serves docs_dir as its own root,
so a root-relative link inside a file included via pymdownx.snippets
(the README's own links to CONTRIBUTING.md, examples/, and so on)
resolves against docs/, not the repo root — a copy would need every such
link rewritten, and would drift the moment the source file's links
changed. A symlink sidesteps both problems: docs/CONTRIBUTING.md IS
CONTRIBUTING.md, so a link written relative to the repo root already
lands correctly once docs/ is the serving root. The cost is Windows:
without git config core.symlinks true (or Developer Mode enabled at
clone time), Windows checks a symlink out as a plain text file containing
the link's target path rather than the linked content, and mkdocs build
would either 404 on it or render the path as literal text. mkdocs.yml's
new comment block above nav and CONTRIBUTING.md's new "The docs site"
section both name this caveat.
cmd_* docstrings are not literally identical to their help= text —
only their first line is. The design's "one source" section anticipated
HELP feeding both --help and the docstring; what shipped
(_documented, cli.py) sets the docstring's first line from HELP and
keeps any rationale paragraph already written in the function's own
literal docstring below it (blank line, then prose) — argparse's
help= stays a one-line summary, matching how every other subcommand's
help text reads, while the docstring can still carry the "why" this
codebase's docstrings are written to carry (see CONTRIBUTING.md's
"Docstrings on every public def"). tests/test_cli.py::
test_every_cmd_docstring_is_its_helps_first_line checks exactly that: the
docstring's first line equals HELP[name], not the whole string. The
Success criteria entry above is amended in place to say so, since "the
same text" as originally written was already wrong the day this shipped.
The docstring ratchet's runtime-__doc__ fallback originally accepted
ANY decorator, not just @_documented — narrowed 2026-08-29 (see follow-up
item 2). As written for this spec, tests/test_docstring_ratchet.py
treated a module-level def as documented if it carried any decorator at
all and its runtime __doc__ was non-empty, reasoning that a decorator is
the only thing that can set __doc__ without a literal string in the
body. That reasoning missed @dataclass, which synthesizes a __doc__
of the form Foo(a: int) on the class itself with no literal docstring
anywhere — so an undocumented public dataclass passed the ratchet.
Narrowed the fallback to require _documented specifically among the
decorator's names (_decorator_names() resolves @foo, @foo(...),
@mod.foo, and @mod.foo(...) to their base name), and added two
regression tests: an undocumented @dataclass is reported, and a
@_documented-decorated def with no literal docstring is still accepted.
The collector (_undocumented) already took an arbitrary path and read
it directly, so no path-handling refactor was needed; what did need
generalizing was the module-loading step it uses to check a decorator's
runtime __doc__, which assumed the path was importable as
attestation.<...> — _load_module_for_import_check() now falls back to
loading an out-of-package path directly via importlib.util, which is
what lets the regression tests point the collector at a temp module
outside src/attestation instead of only asserting against the real tree.
The getting-started page includes the golden-path catalogue by
snippet, matching the pattern the design already set for the README.
getting-started.md is examples/README.md's catalogue via
pymdownx.snippets, the same "included, not copied" rule mkdocs.yml's
Home entry applies to the README — a hand-copied catalogue would drift
the first time a golden path was added or reordered, which
tests/test_golden_paths.py already guards on the examples/ side but
would have no counterpart on the docs-site side.
Two dead-docstring bugs found in server.py during this work:
require_user and reader each had a string literal placed AFTER their
first statement (conn = connection()), not before it — a comment-shaped
piece of dead code, not a docstring, since Python only recognizes a
literal string as __doc__ when it is the first statement in the body.
Both had real, substantial rationale written (why writes refuse rather
than autocreate; why the web UI's read path autocreates and only guards
the write against cross-origin), and both were silently inert: ast.
get_docstring and help() alike would have reported None, and the
docstring ratchet itself would have flagged them as undocumented public
functions had this landed before the fix rather than as part of it. Fixed
in 7bc20cf by moving conn = connection() below the docstring in both
functions — the same commit that added the ratchet, so the ratchet never
saw the bug. Recorded here because a docstring rewrite that reorders
statements is exactly the kind of change a diff reviewer skims past.
Deployment, 2026-09-01. Turned on by instruction ("make docs via github"):
.github/workflows/docs.yml runs the same mkdocs build --strict as the
docs site CI job and deploys the result to GitHub Pages from main;
Pages was switched to the Actions build type with
gh api -X POST repos/mgoldey/attestation/pages -f build_type=workflow. The
account's user-pages custom domain means the project site is served at
http://matthew.thegoldeys.com/attestation/ (HTTPS is not enforced on that
domain today), which site_url now states.