T-S0PK-add-lease-protocol-library-and-schemas
Status: closed/done · Impact: high · Complexity: large
Build the foundation that the entire
GitHub Ref Leases ADR sits on: a small
library that exposes the
five ref operation primitives
(CAS-CREATE, CAS-REPLACE, CAS-DELETE, FETCH-REF, FETCH-NAMESPACE) as callable functions, plus the
JSON-schema definitions and validators for every payload the protocol writes: lease.json (task
lifecycle), the operation lease.json, control-plane.json, and handoff.md. After this task
lands, no protocol mechanic exists in user-facing skills yet — but the building blocks every later
slice depends on are real, callable, schema-validated, and tested.
There is no lease library and no schema definitions. The ADR specifies the primitives and payloads
in prose + code-block examples (docs/planning/decisions/github-ref-leases/protocol.md lines
~120–290), but nothing in plugin/ or bosun/ implements or validates them.
Worker skills today shell out to git push --force-with-lease directly in a couple of places (e.g.
/sdlc:task-work), but never against refs/sdlc/... because the namespace does not yet exist. The
shape this library replaces is “ad-hoc git commands wrapped in skill prose”; the replacement is
“deterministic library calls that every later skill goes through.”
The repo’s existing Python conventions live under plugin/scripts/ and plugin/skills/<skill>/.
Schemas elsewhere in the plugin (e.g., the task frontmatter schema enforced by entities-audit /
entities-migrate) are defined as Pydantic models or hand-rolled validator scripts depending on the
skill. There is no consolidated schemas/ directory yet.
Proposed
Section titled “Proposed”This task is the first real inhabitant of plugin/lib/, the importable-Python-library directory
established by T-75LX-establish-plugin-lib-convention. That task lands first and produces the
project-wide convention doc (plugin/conventions/python-runtime.md), the plugin/lib/ directory,
the root-level pytest.ini, and the smoke-test example library that validates the bootstrap
pattern. This task follows the convention as-documented; it does not redefine it.
The lease library lives at plugin/lib/lease/ and exposes:
- Primitives —
cas_create(ref, payload),cas_replace(ref, old_sha, payload),cas_delete(ref, old_sha),fetch_ref(ref),fetch_namespace(prefix). Each is a thin wrapper overgit push --force-with-lease=<ref>:<sha>/git fetch --prune <refspec>/ etc., with explicitCASFailed/RefNotFoundexception types so callers can branch on outcome without parsing stderr. - Payload schemas — Pydantic models for the four payloads, exactly matching the field
definitions in the ADR. Models live in
plugin/lib/lease/schemas.py. Validation rules:sdlc_versionmust be a valid semver string;lease_idandlease_tokenare UUIDv4;phaseis the enum from the ADR;expires_atis RFC3339 UTC;ownerandprepared_onare UUIDv4 (cross-validated to be a knownhost_idlater, but in this task only structural). - Tree-builder helpers —
build_lease_commit(lease_payload, parent_sha=None, handoff_md=None)that takes a validated payload (and optional handoff content forawaiting-reviewleases), constructs the in-memory Git tree (lease.jsonblob + optionalhandoff.mdblob → tree object), wraps it in a commit, and returns the new commit SHA ready to be pushed viaCAS-CREATE/CAS-REPLACE. This is what isolates callers from rawgit hash-object/git mktree/git commit-treeplumbing. - A test fixture for local-bare-repo authority —
conftest.py(or equivalent helper) that spins up a local bare repo as the configured authority for unit tests. Lets every primitive be exercised against real Git ref operations without hitting GitHub.
Optimization-by-batching (the ADR’s note that a single git fetch invocation may combine multiple
FETCH-NAMESPACE calls + a FETCH-REF) is out of scope for this task. Primitives are 1:1 with
logical operations here; the optimization layer comes later when call patterns are observable.
Approach
Section titled “Approach”- Scaffold the module. Create
plugin/lib/lease/__init__.py,plugin/lib/lease/primitives.py,plugin/lib/lease/schemas.py,plugin/lib/lease/tree.py,plugin/lib/lease/exceptions.py. Add a top-levelplugin/lib/lease/README.mdthat points back at the ADR as the spec and atplugin/conventions/python-runtime.md(established by T-75LX-establish-plugin-lib-convention) for the layout convention, and explains what each submodule covers. Includeplugin/lib/lease/tests/__init__.pyper the convention so pytest computes the package rootdir correctly. - Define the exception hierarchy in
plugin/lib/lease/exceptions.py. At minimum:LeaseError(base),CASFailed(withref,expected_sha,actual_shaattrs),RefNotFound,SchemaError,NamespaceConflict. - Implement the schemas in
plugin/lib/lease/schemas.py. Four Pydantic models:TaskLifecycleLease,OperationLease,ControlPlane,HandoffMd. Field definitions track the ADR exactly. Add avalidate_dict(model_class, payload)helper that raisesSchemaErrorwith a deterministic error message on failure (line-friendly for stderr). - Implement the primitives in
plugin/lib/lease/primitives.py. Each function takes a configured authority (thegit remotename or a local bare-repo path; default reads fromsdlc.yamlonce that’s wired) and the ref/payload/SHA arguments. Subprocess out togitwith explicit argv (never shell strings). Parse stderr to distinguish CAS failure from network failure from genuine errors. Document the exit-code → exception mapping in module docstrings. - Implement the tree builder in
plugin/lib/lease/tree.py.build_lease_commit(payload, parent_sha=None, handoff_md=None)validates the payload via the schema layer, serializes to canonical JSON (sorted keys, no trailing newline difference issues — matchjq -Soutput for stability), writes a blob viagit hash-object -w --stdin, optionally writeshandoff.mdas another blob, runsgit mktreeto assemble the tree, andgit commit-treeto produce the commit. Returns the SHA. - Write unit tests under
plugin/lib/lease/tests/. Each primitive gets a happy-path test and a failure-mode test (CAS conflict, ref-not-found, schema-invalid input, namespace miss). Use the local-bare-repo fixture so tests are network-free and deterministic. Aim for >90% coverage onprimitives.pyandtree.py; schemas are inherently covered by their own validation tests. Tests resolvefrom lease import primitivesvia thepytest.inipythonpathset up by the convention task — no per-test sys.path hacks. - Document the module in
plugin/lib/lease/README.mdwith: what each file contains, how callers import (from lease import cas_create, TaskLifecycleLeaseafter applying the bootstrap frompython-runtime.md), and a 5-line worked example of “claim a lease” using only library functions (no CLI yet). Link back to the ADR and topython-runtime.md.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/lease/__init__.py | new | Re-export public surface (primitives, schemas, exceptions, tree-builder) |
plugin/lib/lease/primitives.py | new | Five ref operation primitives (cas_create, cas_replace, cas_delete, fetch_ref, fetch_namespace) wrapping git push --force-with-lease / git fetch --prune via subprocess |
plugin/lib/lease/schemas.py | new | Four Pydantic models (TaskLifecycleLease, OperationLease, ControlPlane, HandoffMd) + validate_dict helper raising deterministic SchemaError |
plugin/lib/lease/tree.py | new | build_lease_commit(payload, parent_sha=None, handoff_md=None) + canonical-JSON serialization helpers; isolates callers from raw git hash-object / git mktree / git commit-tree |
plugin/lib/lease/exceptions.py | new | Exception hierarchy: LeaseError (base), CASFailed (with ref / expected_sha / actual_sha), RefNotFound, SchemaError, NamespaceConflict |
plugin/lib/lease/README.md | new | Module-level docs; pointers to ADR + plugin/conventions/python-runtime.md; worked “claim a lease” example |
plugin/lib/lease/tests/__init__.py | new | Empty marker so pytest computes the package rootdir correctly |
plugin/lib/lease/tests/conftest.py | new | Local-bare-repo authority fixture spinning up a bare repo per test |
plugin/lib/lease/tests/test_primitives.py | new | Happy-path + failure-mode tests for the five primitives (CAS conflict, ref-not-found, schema-invalid input) |
plugin/lib/lease/tests/test_schemas.py | new | Validation tests for the four Pydantic models incl. deterministic SchemaError messages |
plugin/lib/lease/tests/test_tree.py | new | Tests for build_lease_commit shape, determinism, and handoff.md inclusion |
.gitignore | modify | Add plugin/lib/lease/__pycache__/ carve-out only if no top-level **/__pycache__/ rule already covers it; no-op otherwise |
Explicitly NOT touched (delivered by T-75LX-establish-plugin-lib-convention before this task starts):
| Location | Kind | Change |
|---|---|---|
plugin/conventions/python-runtime.md | modify | (not edited — convention doc; this task follows it as-is) |
plugin/lib/ | modify | (not edited — directory + _example/ smoke library created by the convention task) |
pytest.ini | modify | (not edited — pythonpath = plugin/lib set by the convention task) |
Acceptance criteria
Section titled “Acceptance criteria”-
AC-1: With the bootstrap pattern documented in
plugin/conventions/python-runtime.mdapplied (a 2-linesys.path.insert(...)pointing atplugin/lib/),from lease import cas_create, cas_replace, cas_delete, fetch_ref, fetch_namespace, TaskLifecycleLease, OperationLease, ControlPlane, build_lease_commitsucceeds. The module is importable without side effects. (For pytest runs the same is achieved by the
pytest.inipythonpathentry — no manual bootstrap needed inside tests.) -
AC-1b: This task does NOT modify
plugin/conventions/python-runtime.md, theplugin/lib/directory structure, or the rootpytest.ini— all three are pre-existing as of the dependency on T-75LX-establish-plugin-lib-convention. The lease library follows the convention as-documented, by example, without redefining it. Negative AC. -
AC-2: Every primitive has a happy-path test that exercises it against the local-bare-repo fixture and asserts the post-state ref points where expected.
pytest plugin/lib/lease/tests/test_primitives.pyruns green. -
AC-3: Every primitive has a failure-mode test that asserts the correct exception type is raised on the expected condition (CAS-CREATE on an existing ref →
CASFailed; CAS-REPLACE with wrong old_sha →CASFailed; FETCH-REF on missing ref →RefNotFound). -
AC-4: Every schema rejects an obviously-invalid payload with a
SchemaErrorcarrying a deterministic, line-friendly error message (no Pydantic error-object spew leaking through). Asserted bytest_schemas.py. -
AC-5:
build_lease_commit(valid_payload)returns a commit SHA that, when pushed viacas_createto a fresh ref, produces a Git tree containing exactlylease.json(orlease.json+handoff.mdifhandoff_md=was passed). Verified bygit ls-tree <sha>in the fixture. -
AC-6:
build_lease_commitproduces byte-identical commits for byte-identical input payloads across runs (canonical JSON serialization, no time-dependent fields injected). Asserted by a determinism test that builds the same commit twice and compares SHAs. -
AC-7: Running
pytest plugin/lib/lease/tests/from the repo root produces zero failures, zero errors, and >90% line coverage onplugin/lib/lease/primitives.pyandplugin/lib/lease/tree.py. Coverage report committed as part of test output documentation (or asserted in CI if/when CI exists). -
AC-8:
plugin/lib/lease/README.mdexists, links to the ADR, and contains a worked example that copy-pastes into a Python REPL and runs successfully against the local-bare-repo fixture.
Out of scope
Section titled “Out of scope”- CLI commands (
sdlc lease task claim, etc.). Lives in T-QC31-add-sdlc-lease-cli-commands. - Namespace-conflict guard. Lives in T-K3RR-add-lease-namespace-conflict-guard.
- Reading authority config from
sdlc.yaml. This task accepts the authority as an explicit constructor argument or function parameter. Wiring tosdlc.yamlhappens in the CLI task or later. - Authority-side
git configsetup (receive.denyCurrentBranch, etc.). Documented in the ADR’s Local backend setup; fixture handles it for tests, real-authority setup is part of slice 2 or 3. - Heartbeat thread / PreToolUse hook. The library exposes a
heartbeat()function; the trigger mechanism (background thread vs hook) is implemented per harness in slice 2. - Batching multiple
FETCH-NAMESPACEcalls into onegit fetch. Implementation optimization deferred until call patterns are observable. - Caching layer for
FETCH-REF. Mentioned in the ADR’s README under “Likely expansion areas”; not in this task.
Dependencies
Section titled “Dependencies”- T-75LX-establish-plugin-lib-convention — must land first. That task creates
plugin/conventions/python-runtime.md, theplugin/lib/directory,pytest.iniat repo root, and the smoke-test example library that validates the bootstrap pattern. This task follows that convention as the first real (non-smoke-test) inhabitant ofplugin/lib/.
Discovery context
Section titled “Discovery context”This task realizes Rollout Plan items 1 and 2 from the ADR’s protocol.md. The two items are deliberately merged into one task because primitives and schemas are too interdependent to ship separately — the primitives produce schema-valid payloads, the schemas describe what the primitives serialize, and any seam between them would have to be torn down on the next task anyway.
A precursor sibling — T-75LX-establish-plugin-lib-convention — was spawned off during planning
to keep the project-wide Python organization convention (the plugin/lib/ layout,
python-runtime.md doc, root pytest.ini) separate from this lease-specific work. That gives the
convention independent reviewability and lets the smoke-test example library validate the bootstrap
pattern before this task commits to using it.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-23. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”-
AC-1: agent-manual —
from lease import cas_create, cas_replace, cas_delete, fetch_ref, fetch_namespace, TaskLifecycleLease, OperationLease, ControlPlane, build_lease_commitsucceeded both via a manual REPL with the 2-line
sys.path.insert(...)bootstrap AND inside every pytest test viapytest.ini’spythonpath. No side effects on import. -
AC-1b: agent-manual —
git diff main..HEAD -- plugin/conventions/python-runtime.md plugin/lib/_example/ pytest.iniproduces empty output across the full task branch. The convention task’s smoke test (pytest plugin/lib/_example/tests/) still passes. -
AC-2: auto —
test_primitives.pyhas happy-path tests for all five primitives against the local-bare-repo fixture (test_cas_create_writes_new_ref,test_cas_replace_updates_existing_ref,test_cas_delete_removes_ref,test_fetch_ref_returns_sha_and_payload,test_fetch_namespace_returns_all_refs_under_prefix). -
AC-3: auto — failure-mode tests cover
CASFailed(on create-when-exists at same SHA, create-when-exists at different SHA, replace-with-wrong-old_sha, delete-with-wrong-old_sha) andRefNotFound(on fetch-missing-ref). -
AC-4: auto —
test_schema_error_message_is_single_lineplus per-field rejection tests assertSchemaError’s deterministic line-friendly format (SchemaError on <Model>: <field-path>: <reason>; ...); no Pydantic error-object spew leaks through. -
AC-5: auto —
test_build_lease_commit_tree_contains_only_lease_jsonand_contains_lease_and_handoffverify the produced tree exactly matches expectation viagit ls-tree. -
AC-6: auto — three determinism tests (same input → byte-identical SHA across builds, across repos, with handoff). Achieved by fixing
GIT_{AUTHOR,COMMITTER}_{NAME,EMAIL,DATE}to a sentinel identity at epoch on everygit commit-treeinvocation — documented intree.py’s module docstring. -
AC-7: auto —
pytest plugin/lib/lease/tests/passes 46/46 in ~5.7s. Coverage:primitives.py93%,tree.py97%,schemas.py100% (overall 98%). -
AC-8: agent-manual — README’s “claim a lease” worked example exercised end-to-end in a temp dir: built the commit, CAS-created against the authority, verified the ref points at the expected SHA, confirmed a second claim against the same ref raises
CASFailed.
What worked
Section titled “What worked”- Splitting the convention into T-75LX-establish-plugin-lib-convention before this task paid
off: the bootstrap,
pytest.inipythonpath, andplugin/lib/_example/smoke library were already proven working when this task started, sofrom lease import ...resolved cleanly in tests from the first commit. - The 7-step Approach decomposed into 6 logical commits (scaffold+exceptions → schemas → primitives → tree → tests → README). Each was reviewable in isolation; no monster commits.
- The local-bare-repo
conftest.pyfixture made every primitive testable against realgitoperations without hitting the network — failure-mode tests for CAS conflicts were straightforward to construct.
Friction and automation gaps
Section titled “Friction and automation gaps”git push --force-with-leaseexits 0 on same-SHA no-op. Reproduced empirically: when CAS-CREATE pushes a SHA that happens to already be at the ref, git reports “Everything up-to-date” and exits 0 — silently masking a CAS loss that the protocol’s “atomic create on a ref that must not exist” contract demands be treated as a failure. Added a defensive stdout/stderr scan incas_createand a dedicated test. The wrapping library is the right place for this; rawgit pushcallers would otherwise miss it. Suggests a broader pattern: every primitive’s wrapper needs to validate “did git actually do what I asked, or did it short-circuit?” → T-5IG1-lease-primitives-validate-git-actually-acted- Pre-existing
schema_versiondrift on the three slice-1 task files (2026-05-23-add-lease-protocol-library-and-schemas,-cli-commands,-namespace-conflict-guard) — drafted at v2 before the task schema bumped to v3.audit_entities.pyflagged all three as[schema_version/auto-fixable]. Bumped all three in a small dedicated commit (fix(tasks): bump slice 1 task files to schema_version 3) to clear the gate. Suggests: when a schema_version bumps,/sdlc:entities-migratecould be wired into/sdlc:setupor CI so drift surfaces sooner than at task-pickup time. → T-NF26-setup-runs-entities-migrate-after-schema-bump - AC checklist format / Files-to-touch table format weren’t surfaced at task-authoring time.
This task’s spec was originally drafted with
- **AC-N:**bullets and a bulleted Files-to-touch list — both rejected by/sdlc:task-ensure-ready. The canonical formats live inplugin/entities/task/template.mdandplugin/entities/task/implementation-ready.md, but a planning-session author writing task files directly via theWritetool (without going through/sdlc:task-new) doesn’t see them. Three iterations of fix-ups during this session before the readiness gate passed. Suggests: either (a) a CLAUDE.md line steering authoring back through/sdlc:task-neworplugin/entities/task/template.md, or (b) a strictervalidate_task.pythat catches body-shape drift at commit-time, not just at task-pickup time. → T-G39V-validate-task-body-shape-at-commit-time /sdlc:task-workStep 5b rebase conflict on first run when the verify-stamp commit included body fixes alongside the readiness stamp. The verify-stamp committed both the body-edits (Files-to-touch table, AC checklist) and thereadiness_verified_at:stamp as one commit; the start-commit on main only touched frontmatter (status + last_reviewed). The rebase merged the body cleanly but conflicted on the frontmatter (both sides added a key). Resolution was mechanical (keep both keys), but the conflict surfaced because ensure-ready’s commit boundary doesn’t isolate the stamp from any unrelated body edits made before the gate ran. Suggests: ensure-ready could refuse to run if the worktree has unstaged body edits at gate-time, OR/sdlc:task-workStep 5 prose could warn that body edits should be committed separately before invoking the gate. → T-XBJY-ensure-ready-refuses-with-unstaged-body-edits- Pytest invocation surface.
pytest plugin/lib/lease/tests/fails on a fresh shell because pytest isn’t onPATHandpython3 -m pytestreports “no module named pytest” (the project’s interpreter is mise-managed but lacks pytest installed globally). The working invocation isuv run --with pytest --with pydantic pytest .... The convention task documented the pytest.ini pythonpath piece but didn’t pin a uv-flavored test command for libraries. Worth adding toplugin/conventions/python-runtime.mdas the canonical “how to run library tests” recipe, since other contributors will hit the same wall. → T-HHYG-python-runtime-doc-uv-test-recipe
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- T-5IG1-lease-primitives-validate-git-actually-acted — audit every lease primitive to post-validate the ref state, not just git’s exit code (created)
- T-NF26-setup-runs-entities-migrate-after-schema-bump — /sdlc:setup runs entities-audit and offers entities-migrate so schema-version drift surfaces at upgrade time (created)
- T-G39V-validate-task-body-shape-at-commit-time — new validate_task.py rejects bulleted AC checklist and bulleted Files-to-touch sections at the quality-check gate (created)
- T-XBJY-ensure-ready-refuses-with-unstaged-body-edits — task-ensure-ready refuses to run when the target task file has uncommitted body edits (created)
- T-HHYG-python-runtime-doc-uv-test-recipe — python-runtime.md documents the canonical
uv run --with pytest --with <deps> pytestrecipe for library tests (created)