Skip to content

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.

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:

  • Primitivescas_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 over git push --force-with-lease=<ref>:<sha> / git fetch --prune <refspec> / etc., with explicit CASFailed / RefNotFound exception 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_version must be a valid semver string; lease_id and lease_token are UUIDv4; phase is the enum from the ADR; expires_at is RFC3339 UTC; owner and prepared_on are UUIDv4 (cross-validated to be a known host_id later, but in this task only structural).
  • Tree-builder helpersbuild_lease_commit(lease_payload, parent_sha=None, handoff_md=None) that takes a validated payload (and optional handoff content for awaiting-review leases), constructs the in-memory Git tree (lease.json blob + optional handoff.md blob → tree object), wraps it in a commit, and returns the new commit SHA ready to be pushed via CAS-CREATE / CAS-REPLACE. This is what isolates callers from raw git hash-object / git mktree / git commit-tree plumbing.
  • A test fixture for local-bare-repo authorityconftest.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.

  1. 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-level plugin/lib/lease/README.md that points back at the ADR as the spec and at plugin/conventions/python-runtime.md (established by T-75LX-establish-plugin-lib-convention) for the layout convention, and explains what each submodule covers. Include plugin/lib/lease/tests/__init__.py per the convention so pytest computes the package rootdir correctly.
  2. Define the exception hierarchy in plugin/lib/lease/exceptions.py. At minimum: LeaseError (base), CASFailed (with ref, expected_sha, actual_sha attrs), RefNotFound, SchemaError, NamespaceConflict.
  3. Implement the schemas in plugin/lib/lease/schemas.py. Four Pydantic models: TaskLifecycleLease, OperationLease, ControlPlane, HandoffMd. Field definitions track the ADR exactly. Add a validate_dict(model_class, payload) helper that raises SchemaError with a deterministic error message on failure (line-friendly for stderr).
  4. Implement the primitives in plugin/lib/lease/primitives.py. Each function takes a configured authority (the git remote name or a local bare-repo path; default reads from sdlc.yaml once that’s wired) and the ref/payload/SHA arguments. Subprocess out to git with 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.
  5. 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 — match jq -S output for stability), writes a blob via git hash-object -w --stdin, optionally writes handoff.md as another blob, runs git mktree to assemble the tree, and git commit-tree to produce the commit. Returns the SHA.
  6. 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 on primitives.py and tree.py; schemas are inherently covered by their own validation tests. Tests resolve from lease import primitives via the pytest.ini pythonpath set up by the convention task — no per-test sys.path hacks.
  7. Document the module in plugin/lib/lease/README.md with: what each file contains, how callers import (from lease import cas_create, TaskLifecycleLease after applying the bootstrap from python-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 to python-runtime.md.
LocationKindChange
plugin/lib/lease/__init__.pynewRe-export public surface (primitives, schemas, exceptions, tree-builder)
plugin/lib/lease/primitives.pynewFive 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.pynewFour Pydantic models (TaskLifecycleLease, OperationLease, ControlPlane, HandoffMd) + validate_dict helper raising deterministic SchemaError
plugin/lib/lease/tree.pynewbuild_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.pynewException hierarchy: LeaseError (base), CASFailed (with ref / expected_sha / actual_sha), RefNotFound, SchemaError, NamespaceConflict
plugin/lib/lease/README.mdnewModule-level docs; pointers to ADR + plugin/conventions/python-runtime.md; worked “claim a lease” example
plugin/lib/lease/tests/__init__.pynewEmpty marker so pytest computes the package rootdir correctly
plugin/lib/lease/tests/conftest.pynewLocal-bare-repo authority fixture spinning up a bare repo per test
plugin/lib/lease/tests/test_primitives.pynewHappy-path + failure-mode tests for the five primitives (CAS conflict, ref-not-found, schema-invalid input)
plugin/lib/lease/tests/test_schemas.pynewValidation tests for the four Pydantic models incl. deterministic SchemaError messages
plugin/lib/lease/tests/test_tree.pynewTests for build_lease_commit shape, determinism, and handoff.md inclusion
.gitignoremodifyAdd 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):

LocationKindChange
plugin/conventions/python-runtime.mdmodify(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.inimodify(not edited — pythonpath = plugin/lib set by the convention task)
  • AC-1: With the bootstrap pattern documented in plugin/conventions/python-runtime.md applied (a 2-line sys.path.insert(...) pointing at plugin/lib/),

    from lease import cas_create, cas_replace, cas_delete, fetch_ref, fetch_namespace, TaskLifecycleLease, OperationLease, ControlPlane, build_lease_commit

    succeeds. The module is importable without side effects. (For pytest runs the same is achieved by the pytest.ini pythonpath entry — no manual bootstrap needed inside tests.)

  • AC-1b: This task does NOT modify plugin/conventions/python-runtime.md, the plugin/lib/ directory structure, or the root pytest.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.py runs 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 SchemaError carrying a deterministic, line-friendly error message (no Pydantic error-object spew leaking through). Asserted by test_schemas.py.

  • AC-5: build_lease_commit(valid_payload) returns a commit SHA that, when pushed via cas_create to a fresh ref, produces a Git tree containing exactly lease.json (or lease.json + handoff.md if handoff_md= was passed). Verified by git ls-tree <sha> in the fixture.

  • AC-6: build_lease_commit produces 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 on plugin/lib/lease/primitives.py and plugin/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.md exists, 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.

  • 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 to sdlc.yaml happens in the CLI task or later.
  • Authority-side git config setup (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-NAMESPACE calls into one git 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.
  • T-75LX-establish-plugin-lib-convention — must land first. That task creates plugin/conventions/python-runtime.md, the plugin/lib/ directory, pytest.ini at 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 of plugin/lib/.

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.

Captured by /sdlc:task-work on 2026-05-23. PR: pending.

  • AC-1: agent-manual —

    from lease import cas_create, cas_replace, cas_delete, fetch_ref, fetch_namespace, TaskLifecycleLease, OperationLease, ControlPlane, build_lease_commit

    succeeded both via a manual REPL with the 2-line sys.path.insert(...) bootstrap AND inside every pytest test via pytest.ini’s pythonpath. No side effects on import.

  • AC-1b: agent-manual — git diff main..HEAD -- plugin/conventions/python-runtime.md plugin/lib/_example/ pytest.ini produces 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.py has 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) and RefNotFound (on fetch-missing-ref).

  • AC-4: auto — test_schema_error_message_is_single_line plus per-field rejection tests assert SchemaError’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_json and _contains_lease_and_handoff verify the produced tree exactly matches expectation via git 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 every git commit-tree invocation — documented in tree.py’s module docstring.

  • AC-7: auto — pytest plugin/lib/lease/tests/ passes 46/46 in ~5.7s. Coverage: primitives.py 93%, tree.py 97%, schemas.py 100% (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.

  • Splitting the convention into T-75LX-establish-plugin-lib-convention before this task paid off: the bootstrap, pytest.ini pythonpath, and plugin/lib/_example/ smoke library were already proven working when this task started, so from 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.py fixture made every primitive testable against real git operations without hitting the network — failure-mode tests for CAS conflicts were straightforward to construct.
  • git push --force-with-lease exits 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 in cas_create and a dedicated test. The wrapping library is the right place for this; raw git push callers 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_version drift 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.py flagged 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-migrate could be wired into /sdlc:setup or 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 in plugin/entities/task/template.md and plugin/entities/task/implementation-ready.md, but a planning-session author writing task files directly via the Write tool (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-new or plugin/entities/task/template.md, or (b) a stricter validate_task.py that catches body-shape drift at commit-time, not just at task-pickup time. → T-G39V-validate-task-body-shape-at-commit-time
  • /sdlc:task-work Step 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 the readiness_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-work Step 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 on PATH and python3 -m pytest reports “no module named pytest” (the project’s interpreter is mise-managed but lacks pytest installed globally). The working invocation is uv 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 to plugin/conventions/python-runtime.md as the canonical “how to run library tests” recipe, since other contributors will hit the same wall. → T-HHYG-python-runtime-doc-uv-test-recipe

← Back to Tasks