module-contract-first
Designs and enforces module contracts in one working tree: public API surface, versioned schemas, drift tests against reference snapshots, and boundary rules between sibling modules. Use when defining
SKILL.mdskills/engineering/module-contract-first/SKILL.md
GitHubStandard: name/description/length rules — agentskills.io standard.
Module Contract First Skill — Quick Reference (body <500 lines; full procedure in references/module-contract-procedure.md)
When to use this skill (trigger)
- You are starting a new module or adding public exports to an existing one.
- A consumer imports a symbol that is not in the committed contract snapshot.
- You need to decide whether a change is breaking (major bump) or non-breaking (minor/patch).
- Sibling modules import each other and you must enforce a boundary (no cycles).
- CI is red with "DRIFT DETECTED" or "CYCLE DETECTED".
Disambiguation
- Fires: starting a new module or public exports, a consumer imports an unlisted symbol, deciding breaking vs non-breaking, sibling-module cycle detection, CI red with DRIFT/CYCLE.
- Does NOT fire for: an HTTP API error contract (use problem-json-contracts) or a single internal helper with no consumers.
When NOT to use this skill
- Private/internal helpers that no consumer imports — they are not part of the public surface, so no snapshot covers them.
- A change to a private helper that no symbol table can see — the skill governs the public surface, not your local functions.
- Single-file scripts with no consumers and no planned exports — no snapshot is needed, but if the file might be imported later, snapshot it anyway.
- Fully greenfield work where no other module depends on the output yet — the skill still fires once the first consumer lands; until then, snapshot the surface anyway so the first import is not a surprise.
- A pre-release version bump (1.2.3-alpha.1) — the decision table still applies; a pre-release is not a free pass to break the API, and the snapshot is regenerated alongside the bump.
Decision table — change classification
| Change | Semver | Action |
|---|---|---|
| Add new export, route, table, or message | minor | regenerate snapshot, commit both together |
| Rename export, change signature, remove export | major | bump major, notify all consumers, regenerate snapshot |
| Add optional field to a request object | patch | regenerate snapshot only |
| Change field type or required->optional | major | bump major, regenerate snapshot, update consumers |
Numbered procedure (summary; full recipe in references/module-contract-procedure.md)
- Enumerate the public surface — run
python3 scripts/check_contract_drift.py --enumerate <module-dir>and commit the generated snapshot as the source of truth. - Freeze and version — tag the snapshot commit with semver per the decision table; never hand-edit a committed snapshot.
- Write the drift test —
python3 scripts/check_contract_drift.py --check <module-dir>must run in CI on every PR; exit 1 on any drift. - Enforce boundaries — run
python3 scripts/check_contract_drift.py --cycles <root>; any back-edge is an automatic fail. - Document the boundary — write a boundary doc listing allowed importers and forbidden import directions.
Worked examples
Good (additive change — minor bump, regenerate snapshot in the same PR):
$ python3 scripts/check_contract_drift.py --enumerate src/users
ok: enumerated 15 symbols -> src/users/module-contracts/users.json
$ python3 scripts/check_contract_drift.py --classify src/users/module-contracts/users.json src/users/module-contracts/users.json
{
"bump": "patch",
"added": [],
"removed": [],
"old_count": 15,
"new_count": 15
}
Bad (breaking rename without a major bump — consumer breaks at import time):
# before: public export get_user(id)
# after: public export fetch_user(id)
$ python3 scripts/check_contract_drift.py --classify old.json new.json
{
"bump": "major",
"added": ["fetch_user"],
"removed": ["get_user"],
...
}
# Do NOT ship this as a patch. Bump major, notify consumers, regenerate snapshot.
Bad (hand-edited snapshot to make CI green — hides a real breaking change for weeks):
// users.json hand-edited to add "fetch_user" without removing "get_user"
{"symbols": ["fetch_user", "get_user", ...]}
# --check now passes, but the live code no longer exports get_user.
# Rule: never hand-edit; always regenerate with --enumerate.
Pitfalls from research (real incidents)
- Hand-edited snapshots hide new exports; the next
--checkshows full-add drift and CI goes red until regenerated honestly alongside the code change in the same PR. - A snapshot that is not regenerated on every PR touching public API surface is the #1 cause of silent breakage across Ottili modules.
- Import cycles break static analysis tooling downstream even when snapshots match; cycle detection is an automatic fail regardless of snapshot state.
- An empty module must have an empty snapshot; a missing snapshot file is treated as full-add drift, not an error.
Near-miss trigger prompts (act on these immediately)
- "I added an export but forgot to update the snapshot" — run
--checkbefore committing; if drift, regenerate then commit both files together. - "Module A imports B and B imports A" — run
--cyclesimmediately; extract shared types into an internal-only module. - "The snapshot looks right but CI is red" — diff spans >5 lines with no corresponding code change = probable manual edit; block merge.
Verification checklist (all must pass before you finish)
Snapshot regenerated via
--enumerate, never hand-edited--checkexits 0 against the committed snapshot--cyclesreports no back-edgesSemver bump matches the change classification in the decision table
Boundary doc lists allowed importers and forbidden directions
Drift test is wired into CI config
Enumeration completes in <2s for modules under 500 symbols
References
- Full procedure, decision table and worked examples:
references/module-contract-procedure.md. - Eval prompts with expected behaviour and failure signs:
references/EVALS.md. - Primary sources with URLs and retrieval dates:
references/SOURCES.md.
Related skills
- problem-json-contracts — contract shapes are also problem-detail shapes.
- honest-status — a contract that drifts is a status item; mark it VERIFIED only after
--checkran green. - long-test-discipline — the snapshot enumeration and the drift check are long tests; run them in the foreground with a recorded seed.
- shared-tree-git — snapshot and boundary doc are committed with a task-id prefix.