fix(session): keep KIND_DECISION_LOCK durable past generic 4h TTL (Closes #720)

Terminal review-decision ledgers are recovery-critical provenance, not
disposable session cache. A generic four-hour TTL previously made
fresh_review_on_current_head_allowed unreachable after age expiry on open
PRs (PR #616 / review 443 reproduction).

- Classify KIND_DECISION_LOCK as RECOVERY_CRITICAL so load/mark_final work
  after >4h without hand-editing session-state files
- Stamp kind + recovery_critical on save for compatibility
- Add inspect_state_envelope so assessment reports on-disk evidence instead
  of silent "no lock" when TTL would hide non-critical kinds
- Surface disk_inspect on stale decision-lock cleanup assessment

Preserves same-head #332 hard-stop, #620 head-scoped fresh review, #594
moot cleanup, and #709 irrecoverable authorization (no ordinary-profile
permission grant).
This commit is contained in:
2026-07-16 15:47:52 -04:00
parent 77746b08fc
commit 80f59b334e
3 changed files with 668 additions and 3 deletions
+125 -3
View File
@@ -46,8 +46,15 @@ KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
# Kinds that must survive the default session-state TTL (forensic / recovery).
#
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
# run #332 protections still apply once the ledger is loadable. Other session
# kinds (workflow load, drafts, etc.) remain TTL-bound.
RECOVERY_CRITICAL_KINDS = frozenset(
{
KIND_DECISION_LOCK,
KIND_DECISION_LOCK_ARCHIVE,
KIND_POST_MERGE_DECISION_RECOVERY,
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
@@ -245,6 +252,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
pass
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
"""True when a durable record must outlive the generic session-state TTL."""
if not record and not kind:
return False
record_kind = ((record or {}).get("kind") or kind or "").strip()
if record_kind in RECOVERY_CRITICAL_KINDS:
return True
return bool((record or {}).get("recovery_critical"))
def identity_match_reasons(
record: dict[str, Any] | None,
*,
@@ -288,9 +305,7 @@ def identity_match_reasons(
else:
age = _now_utc() - recorded_at
kind = (record.get("kind") or "").strip()
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
record.get("recovery_critical")
)
ttl_exempt = is_recovery_critical_record(record, kind=kind)
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
reasons.append(
f"session state expired after {ttl_hours():g}h (fail closed)"
@@ -300,6 +315,113 @@ def identity_match_reasons(
return reasons
def inspect_state_envelope(
*,
kind: str,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
profile_identity: str | None = None,
state_dir: str | None = None,
) -> dict[str, Any]:
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
Does **not** apply identity/TTL rejection to the returned presence flags.
Callers use this to distinguish:
* no file on disk
* file present but TTL would reject a non-critical kind
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
Never mutates files. Never returns secrets.
"""
profile = current_profile_identity(profile_identity=profile_identity)
path = state_file_path(
kind=kind,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
state_dir=state_dir,
)
result: dict[str, Any] = {
"kind": kind,
"profile_identity": profile,
"path_basename": os.path.basename(path),
"on_disk": False,
"has_payload": False,
"recorded_at": None,
"updated_at": None,
"age_hours": None,
"ttl_hours": ttl_hours(),
"age_exceeds_default_ttl": False,
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
"would_ttl_reject": False,
"identity_reasons": [],
"summary": "no session-state file on disk",
}
if not path or not os.path.exists(path):
return result
result["on_disk"] = True
envelope = _read_json(path)
if not envelope:
result["summary"] = "session-state file present but unreadable or empty"
return result
payload = envelope.get("payload")
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
result["has_payload"] = isinstance(payload, dict)
for key in (
"kind",
"remote",
"org",
"repo",
"profile_identity",
"session_profile_lock",
"recorded_at",
"updated_at",
"writer_pid",
"recovery_critical",
):
if key in envelope and key not in merged:
merged[key] = envelope[key]
if not merged.get("kind"):
merged["kind"] = kind
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
if recorded_at is not None:
age = _now_utc() - recorded_at
age_hours = age.total_seconds() / 3600.0
result["age_hours"] = age_hours
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
result["recovery_critical"] = ttl_exempt
result["ttl_exempt"] = ttl_exempt
identity_reasons = identity_match_reasons(
merged,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
)
result["identity_reasons"] = list(identity_reasons)
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
if result["has_payload"] and ttl_exempt:
result["summary"] = (
"recovery-critical session-state present on disk and TTL-exempt; "
"load via load_state for full payload"
)
elif result["has_payload"] and result["would_ttl_reject"]:
result["summary"] = (
"session-state file present on disk but generic TTL would reject load "
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
)
elif result["has_payload"]:
result["summary"] = "session-state file present and within TTL / identity gates"
else:
result["summary"] = "session-state file present without a dict payload"
return result
def load_state(
*,
kind: str,