Skip to content

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.

LocationRole today
plugin/lib/lease/runtime.py#release_to_archiveThree-step archive flow: probe active SHA → cas_create archive ref pointing at the active SHAcas_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_closingCalls _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__.pyRe-exports release_to_archive; consumers (/sdlc:task-close-out skill via sdlc lease task archive) call it.
plugin/lib/lease/tests/test_runtime.pyExisting 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.pyCLI 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.

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).

  1. In release_to_archive, between the active-SHA probe (current step 1) and the cas_create on the archive ref (current step 2): fetch the active ref’s lease.json payload via fetch_ref, build a new TaskLifecycleLease with phase: closing and expires_at: null (preserving all other fields including owner, lease_id, lease_token, task_id), and use build_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 via git show <active_sha>:handoff.md if it exists in the tree, None otherwise.
  2. Replace the existing cas_create call so the archive ref points at the new closing-commit SHA instead of active_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.
  3. cas_delete of the active ref stays unchanged at the end.
  4. Tests in plugin/lib/lease/tests/test_runtime.py covering: (a) freshly-archived lease’s archive commit timestamp is within 5 seconds of now(); (b) freshly-archived lease’s archive commit tree contains lease.json with phase == "closing"; (c) handoff.md from a working/awaiting-review lease 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.
  5. 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 new release_to_archive path) with a recent post-mortem edit produces ZERO post-mortem-edited-after-closing records 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 closing out 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.
LocationKindChange
plugin/lib/lease/runtime.py#release_to_archivemodifyInsert 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.pymodifyAdd 5 new tests covering closing-timestamp correctness, handoff preservation, idempotency, and lease-metadata preservation across the closing transition.
plugin/lib/lease/tests/test_reconcile.pymodifyAdd one positive + one negative case asserting the detector’s behavior against the new archive shape.
  • AC-1: After release_to_archive(task_id), the archive ref’s commit timestamp is within 5 seconds of the call time. Verified by test_release_to_archive_writes_fresh_closing_timestamp.

  • AC-2: After release_to_archive(task_id), the archive commit’s tree contains a lease.json blob whose phase field is the literal string "closing". Verified by test_release_to_archive_archive_commit_has_closing_phase.

  • AC-3: If the active lease had a handoff.md in its tree (e.g. coming from awaiting-review), the archive commit’s tree contains the same handoff.md blob byte-for-byte. Verified by test_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’s lease.json carries the same UUIDs as the active ref’s last commit did. Verified by test_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 by test_release_to_archive_idempotent_second_call_is_noop (existing test continues to pass) plus a new test_release_to_archive_idempotent_does_not_build_redundant_closing_commit asserting no closing-commit build attempt is made on the second call.

  • AC-6: The reconcile detector detect_post_mortem_edited_after_closing returns zero records for a freshly-archived lease (via the new path) whose post-mortem section edit predates the archive call. Verified by test_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>.

<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
  • The reconcile task (T-Y1JN-add-sdlc-reconcile-reporter, PR #131) provides the detect_post_mortem_edited_after_closing detector 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.py on the task/2026-05-25-add-sdlc-reconcile-reporter branch.

  • 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.

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

  • AC-1: auto — test_release_to_archive_writes_fresh_closing_timestamp (archive commit %ct bounded within ±5s of call, asserts > 0)
  • AC-2: auto — test_release_to_archive_archive_commit_has_closing_phase (reads lease.json from archive tree, asserts phase == "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) + new test_release_to_archive_idempotent_does_not_build_redundant_closing_commit (spies build_lease_commit on 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.py reports OK 12/12
  • 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.
  • start_task.py reported

    error: no cached lease for task_id='...'; acquire_lease must have populated the cache before start_task.py runs

    AND exited 0 — the CAS-REPLACE to working was 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 than cache.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, but quality_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 .git but 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 capture should 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_commit API didn’t previously support overriding commit_date; it always used the deterministic epoch for reproducibility. The fix needed a commit_date parameter so the closing commit’s timestamp could be now() (which is the whole point of this task). Touching tree.py was 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


← Back to Tasks