T-174G-archive-records-closing-timestamp-for-post-mortem-detector
Status: closed/done · Impact: medium · Complexity: small
Make release_to_archive (the library function backing sdlc lease task archive and
/sdlc:task-close-out) embed a fresh closing-phase commit at archive time, so the archive ref’s
commit timestamp captures the actual close-out moment rather than whatever the lease’s last activity
commit happened to be. After this lands, the reconcile post-mortem-edited-after-closing detector
compares post-mortem edits against a real closing timestamp instead of inheriting bogus dates —
clearing the 4 false-positive warnings that PR #131 documented and ensuring future close-outs don’t
accumulate the same drift.
| Location | Role today |
|---|---|
plugin/lib/lease/runtime.py#release_to_archive | Three-step archive flow: probe active SHA → cas_create archive ref pointing at the active SHA → cas_delete active. The archive ref inherits the active ref’s tail commit (whatever phase + timestamp the lease last landed in). No fresh closing-phase commit is built. |
plugin/lib/lease/reconcile.py#detect_post_mortem_edited_after_closing | Calls _commit_timestamp(archive_local, ...) to read the archive ref’s commit time as the “close-out marker”, then compares against the last commit touching the task file’s Post-mortem section. When the archive ref inherits an old/epoch commit time (e.g. migration-then-archive bypassed the closing transition), every real post-mortem edit appears to be “after closing” → false-positive warning. |
plugin/lib/lease/__init__.py | Re-exports release_to_archive; consumers (/sdlc:task-close-out skill via sdlc lease task archive) call it. |
plugin/lib/lease/tests/test_runtime.py | Existing test coverage for release_to_archive asserts the archive ref exists post-call but does not pin a closing timestamp. |
plugin/scripts/lease_cli/task_archive.py | CLI wrapper; no change needed unless we add a --closing-note flag (deferred). |
Empirical evidence: the post-cutover reconcile run reports 4 records with
after the close-out at 1970-01-01T00:00:00Z for tasks whose archive refs trace back to
migration-minted leases. The epoch timestamp is the symptom; the cause is that release_to_archive
never wrote a fresh commit.
Proposed
Section titled “Proposed”release_to_archive builds a fresh closing-phase commit (parent = active ref’s current SHA,
lease.json with phase: closing + expires_at: null, handoff.md preserved if present), then
cas_creates the archive ref pointing at that new commit (instead of pointing at the active ref’s
tail SHA), then cas_deletes the active ref. Archive ref’s commit timestamp is now the close-out
moment by construction.
The detect_post_mortem_edited_after_closing detector reading semantics stay unchanged — it still
calls _commit_timestamp(archive_local, ...). The reading is now correct because every
newly-archived lease has a closing commit at archive time.
Idempotency is preserved: re-invoking release_to_archive against a task whose archive ref already
exists short-circuits via the existing RefNotFound branch on the active ref probe (the active ref
is already gone, so the second call exits clean without building a redundant closing commit).
Approach
Section titled “Approach”- In
release_to_archive, between the active-SHA probe (current step 1) and thecas_createon the archive ref (current step 2): fetch the active ref’slease.jsonpayload viafetch_ref, build a newTaskLifecycleLeasewithphase: closingandexpires_at: null(preserving all other fields includingowner,lease_id,lease_token,task_id), and usebuild_lease_commit(payload, parent_sha=active_sha, handoff_md=<existing-handoff-if-any>)to produce a fresh closing commit SHA. The handoff blob carries over viagit show <active_sha>:handoff.mdif it exists in the tree,Noneotherwise. - Replace the existing
cas_createcall so the archive ref points at the new closing-commit SHA instead ofactive_sha. The CASFailed-on-existing-archive idempotency path (current behavior) stays the same — if the archive already exists, the new closing commit is built-but-discarded and the idempotent marker is emitted. cas_deleteof the active ref stays unchanged at the end.- Tests in
plugin/lib/lease/tests/test_runtime.pycovering: (a) freshly-archived lease’s archive commit timestamp is within 5 seconds ofnow(); (b) freshly-archived lease’s archive commit tree containslease.jsonwithphase == "closing"; (c) handoff.md from aworking/awaiting-reviewlease is preserved into the archive commit’s tree if it was present; (d) idempotency — a second call against the same task_id is still a no-op success; (e) lease metadata (lease_id,lease_token,owner,task_id) is preserved across the closing transition. - A targeted test for the detector in
plugin/lib/lease/tests/test_reconcile.py: pre-existing positive/negative tests continue to pass against the new archive shape. Add one case asserting that a freshly-archived lease (via the newrelease_to_archivepath) with a recent post-mortem edit produces ZEROpost-mortem-edited-after-closingrecords when the post-mortem timestamp precedes the archive commit, and EXACTLY ONE record when the post-mortem timestamp post-dates it.
Out of scope (called out so the implementer doesn’t fall down rabbit holes):
- Re-archiving the 4 existing migration-minted leases on origin. Their archive refs will retain epoch timestamps; the false-positive warnings persist for those 4 records until the operator does a one-shot rewrite. A separate follow-up task can address that retrospective fix if needed; the value is low.
- Splitting
closingout as an explicit standalone CLI subcommand (sdlc lease task transition --phase closing). The closing phase is already in the schema; this task threads it through the archive path only.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/lease/runtime.py#release_to_archive | modify | Insert closing-commit build between active-SHA probe and cas_create. Archive ref points at new commit, not active SHA. |
plugin/lib/lease/tests/test_runtime.py | modify | Add 5 new tests covering closing-timestamp correctness, handoff preservation, idempotency, and lease-metadata preservation across the closing transition. |
plugin/lib/lease/tests/test_reconcile.py | modify | Add one positive + one negative case asserting the detector’s behavior against the new archive shape. |
Acceptance criteria
Section titled “Acceptance criteria”-
AC-1: After
release_to_archive(task_id), the archive ref’s commit timestamp is within 5 seconds of the call time. Verified bytest_release_to_archive_writes_fresh_closing_timestamp. -
AC-2: After
release_to_archive(task_id), the archive commit’s tree contains alease.jsonblob whosephasefield is the literal string"closing". Verified bytest_release_to_archive_archive_commit_has_closing_phase. -
AC-3: If the active lease had a
handoff.mdin its tree (e.g. coming fromawaiting-review), the archive commit’s tree contains the samehandoff.mdblob byte-for-byte. Verified bytest_release_to_archive_preserves_handoff_md. -
AC-4: Lease identity fields (
lease_id,lease_token,owner,task_id) are preserved across the closing transition — the archive commit’slease.jsoncarries the same UUIDs as the active ref’s last commit did. Verified bytest_release_to_archive_preserves_lease_identity. -
AC-5: Idempotency is preserved — calling
release_to_archive(task_id)a second time against an already-archived task is still a no-op success (no exception, no new commits, no ref mutation). Verified bytest_release_to_archive_idempotent_second_call_is_noop(existing test continues to pass) plus a newtest_release_to_archive_idempotent_does_not_build_redundant_closing_commitasserting no closing-commit build attempt is made on the second call. -
AC-6: The reconcile detector
detect_post_mortem_edited_after_closingreturns zero records for a freshly-archived lease (via the new path) whose post-mortem section edit predates the archive call. Verified bytest_post_mortem_detector_clean_against_fresh_archive_path. -
AC-7: The reconcile detector returns exactly one record for a freshly-archived lease whose post-mortem section was edited AFTER the archive call. Verified by
test_post_mortem_detector_flags_post_archive_edits. -
AC-8: All existing tests pass (no regressions). Verified by running
uv run --with pytest --with pyyaml --with pydantic python -m pytest plugin/lib/lease/tests/and seeing the pre-change suite-size + 7 = the post-change suite-size, all green. -
AC-9: Project quality checks still pass. Verified by
/Users/sksizer2/.claude/plugins/sdlc/scripts/run_quality_checks.py --config sdlc.yaml --project-root .returning
OK <N>/<N>.
Out of scope
Section titled “Out of scope”<Things adjacent to this task that are deliberately NOT being addressed here. Useful for keeping PR review focused and for future tasks to point back to. Always required: if scope is obvious and nothing is excluded, leave a single ”- none” bullet so the explicit signal is “scope considered, nothing to exclude.”>
- none
Dependencies
Section titled “Dependencies”-
The reconcile task (T-Y1JN-add-sdlc-reconcile-reporter, PR #131) provides the
detect_post_mortem_edited_after_closingdetector that this task’s AC-6 and AC-7 test against. PR#131 must merge before this task’s tests can be authored. The reconcile detector exists in the worktree at
plugin/lib/lease/reconcile.pyon thetask/2026-05-25-add-sdlc-reconcile-reporterbranch.
Discovery context
Section titled “Discovery context”- Surfaced as a friction note in PR #131’s body (“Worth surfacing — false positive on post-mortem
detector”) during the E0002 slice 3 cutover. The reconcile run against the post-cutover repo state
produced 4 false-positive warnings where the archive ref’s commit timestamp was
1970-01-01T00:00:00Z(epoch) because the migration-then-archive flow bypassed the closing-phase transition. - The two-option fix space was enumerated in #131: (a) detector-side skip on epoch, (b) source-side closing-timestamp commit. This task implements (b) — the correctness fix at the source. The detector logic stays unchanged; the input data becomes clean instead.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-26. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
test_release_to_archive_writes_fresh_closing_timestamp(archive commit%ctbounded within ±5s of call, asserts > 0) - AC-2: auto —
test_release_to_archive_archive_commit_has_closing_phase(readslease.jsonfrom archive tree, assertsphase == "closing"+expires_at is None) - AC-3: auto —
test_release_to_archive_preserves_handoff_md(byte-for-byte handoff blob equality) - AC-4: auto —
test_release_to_archive_preserves_lease_identity(lease_id / lease_token / owner / task_id all preserved) - AC-5: auto —
test_release_to_archive_idempotent_second_call_is_noop(existing) + newtest_release_to_archive_idempotent_does_not_build_redundant_closing_commit(spiesbuild_lease_commiton second call, asserts zero invocations) - AC-6: auto —
test_post_mortem_detector_clean_against_fresh_archive_path(live archive path + past Post-mortem → 0 detector records) - AC-7: auto —
test_post_mortem_detector_flags_post_archive_edits(Post-mortem added at 2099 → exactly 1 detector record) - AC-8: auto — 250/250 pytest pass (243 baseline + 7 new)
- AC-9: auto —
run_quality_checks.pyreportsOK 12/12
What worked
Section titled “What worked”- Lease acquire end-to-end through
sdlc lease task acquire→ CAS-CREATE succeeded on first try post-cutover. The protocol is now genuinely load-bearing. - Background heartbeat (
lease_heartbeat_loop.py) ran throughout implementation; SIGTERM-on-kill clean. - Sub-agent dispatch with the task spec as the contract produced clean output; AC list shape transferred cleanly without follow-up questions.
- The reconcile detector behavior was verified to NOT need modification — the new archive shape feeds it the right input data and false-positives clear.
Friction and automation gaps
Section titled “Friction and automation gaps”-
start_task.pyreportederror: no cached lease for task_id='...'; acquire_lease must have populated the cache before start_task.py runsAND exited 0 — the CAS-REPLACE to
workingwas silently skipped, the task branch rebase didn’t happen, but the operator only knew because the next step’s lease-state inspection caught it. The script should exit non-zero on this branch and the cache-lookup logic should be reviewed (the cache file existed at the expected path; lookup may be using a different project-root resolution thancache.py’s_find_project_root). → T-Y7QU-start-task-fails-loud-on-cache-miss -
run_quality_checks.py --diff-against-baseline <SHA>looks for the baseline in the WORKTREE’s.sdlc/quality-baselines/<SHA>.json, butquality_baseline.py capture(run in Step 3a from the main repo) writes to the MAIN REPO’s.sdlc/quality-baselines/<SHA>.json. The worktree and main share.gitbut NOT runtime state directories. Either (a) the executor should fall back to<git-common-dir>/../.sdlc/quality-baselines/when the worktree-local file is missing, or (b)quality_baseline.py captureshould also write a copy into all known worktree paths, or (c) the baseline directory should be symlinked. The Step-3a-vs-Step-7 path mismatch is a real protocol friction. → T-5X6Y-task-work-step7-explicit-baseline-dir -
The
build_lease_commitAPI didn’t previously support overridingcommit_date; it always used the deterministic epoch for reproducibility. The fix needed acommit_dateparameter so the closing commit’s timestamp could benow()(which is the whole point of this task). Touchingtree.pywas implicit broader scope than the spec’s “modify runtime.py only” — worth surfacing because future bug fixes in this area may also need similar reach. → T-9IXF-build-lease-commit-overridable-commit-date
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- T-Y7QU-start-task-fails-loud-on-cache-miss — created; new task for the silent exit-0
cache-miss path in
start_task.py. - T-5X6Y-task-work-step7-explicit-baseline-dir — linked; existing task already owns the Step-3a-vs-Step-7 baseline-path mismatch.
- T-9IXF-build-lease-commit-overridable-commit-date — created; codifies the
commit_dateoverride onbuild_lease_commitand pins reproducibility for callers that depend on the epoch default.