fix(drain-proof): bind acknowledgement coverage to session identity (#661)
Review 582 (REQUEST_CHANGES at95178349) found a residual fail-open of the same class the PR set out to close. Acknowledgement coverage was decided by comparing a count against a count: covers_live_sessions = live_count_known and acked_count >= sessions_live_other Nothing bound an acknowledgement to the identity of a session that actually owed one, so acknowledgements supplied for the requesting session and for a session that does not exist satisfied the obligations of two live sessions that never answered - minting a clean, correctly signed proof and an allow verdict from the restart gate. Coverage is now derived from authoritative impact-report evidence: - New `_required_ack_sessions()` derives the required session ids from the report itself, via `ack_state` keys and/or `affected_sessions` filtered on `live and not is_requester`. The requester is excluded only on explicit `is_requester` evidence, never inferred. - When both views are present they must name the same set, and the result is reconciled against `counts.sessions_live_other`. Missing, malformed, duplicated, contradictory, or unreconcilable identity evidence fails closed and outranks every permitting path, including the timeout policy. - Coverage requires every required id to carry an explicit acknowledgement token keyed by that id. Acknowledgements for the requester, for unknown ids, or for fabricated ids never increase coverage. - Caller-supplied acknowledgement cardinality is no longer proof of anything. Failure propagates unchanged through `acks_or_timeout` -> `proof.clean` -> `failed_checks` -> `gate_apply_restart` verdict `deny` / `allow=False`. The earlier missing-acknowledgement remediation is preserved in full: absent, None, non-mapping, empty, partial, stale, and unparseable acks still fail closed, `ack_timeout_policy_applied` stays strict `value is True`, and the legitimate zero-live-sessions and explicit-timeout paths still pass. Reviewer's reproduction, before and after this commit: sessions_live_other = 2 report ack_state = {'other-0': 'pending', 'other-1': 'pending'} supplied acks = {'req': 'ack', 'totally-bogus-session': 'ack'} before: acks_or_timeout = True | proof.clean = True | gate allow after: acks_or_timeout = False | proof.clean = False | gate deny Tests: 22 new cases in `AcknowledgementIdentityBindingTests` covering the reviewer's exact exploit, wrong-ids-with-sufficient-count, partial identity match, requester-only acks, fabricated ids, unproven per-session states, missing/malformed/contradictory identity evidence, count mismatch, and the preserved success paths. Verification: - `pytest tests/test_drain_proof.py` -> 61 passed, 56 subtests (baseline at95178349: 39 passed, 26 subtests) - Restart surface (6 modules) -> 154 passed, 68 subtests, exit 0 (baseline at95178349: 132 passed, 38 subtests) - Full `pytest tests/` -> 5177 passed vs baseline 5155 passed; the 23 failures are identical in both runs and pre-exist at95178349. Scope: drain_proof.py, tests/test_drain_proof.py. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VRUZAf3Fr5n3kqhhiayN6C (cherry picked from commit 4193b63f415b066ee292386c2c89bc3d2651a0cc)
This commit is contained in:
+211
-27
@@ -328,6 +328,160 @@ def _is_acknowledged(value: Any) -> bool:
|
||||
return value.strip().lower() in _ACK_TOKENS
|
||||
|
||||
|
||||
def _session_ids_from_affected_sessions(
|
||||
raw: Any,
|
||||
) -> tuple[set[str] | None, str | None]:
|
||||
"""Live, non-requester session ids from the report's ``affected_sessions``.
|
||||
|
||||
The requester is excluded only on authoritative ``is_requester`` evidence;
|
||||
an entry whose ``live`` or ``is_requester`` flag is absent or not a real
|
||||
bool is malformed, never assumed. Returns ``(ids, None)`` or
|
||||
``(None, detail)``.
|
||||
"""
|
||||
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return None, (
|
||||
"impact report 'affected_sessions' is malformed: expected a list, "
|
||||
f"got {type(raw).__name__} (fail closed)"
|
||||
)
|
||||
collected: list[str] = []
|
||||
for index, entry in enumerate(raw):
|
||||
if not isinstance(entry, Mapping):
|
||||
return None, (
|
||||
f"impact report 'affected_sessions[{index}]' is malformed: "
|
||||
f"expected a mapping, got {type(entry).__name__} (fail closed)"
|
||||
)
|
||||
session_id = entry.get("session_id")
|
||||
if not isinstance(session_id, str) or not session_id.strip():
|
||||
return None, (
|
||||
f"impact report 'affected_sessions[{index}]' has no usable "
|
||||
"session_id (fail closed)"
|
||||
)
|
||||
live = entry.get("live")
|
||||
is_requester = entry.get("is_requester")
|
||||
if not isinstance(live, bool) or not isinstance(is_requester, bool):
|
||||
return None, (
|
||||
f"impact report 'affected_sessions[{index}]' "
|
||||
f"({session_id.strip()}) does not prove live/is_requester with "
|
||||
"explicit booleans (fail closed)"
|
||||
)
|
||||
if live and not is_requester:
|
||||
collected.append(session_id.strip())
|
||||
if len(set(collected)) != len(collected):
|
||||
return None, (
|
||||
"impact report 'affected_sessions' names the same live session more "
|
||||
"than once; required acknowledgement identities are ambiguous "
|
||||
"(fail closed)"
|
||||
)
|
||||
return set(collected), None
|
||||
|
||||
|
||||
def _session_ids_from_ack_state(raw: Any) -> tuple[set[str] | None, str | None]:
|
||||
"""Session ids keyed by the report's ``ack_state``.
|
||||
|
||||
``ack_state`` is minted as ``{s.session_id: "pending" for s in
|
||||
other_live_sessions}``, so its keys *are* the required set. Only the keys
|
||||
are trusted here; the per-session value is the report's own placeholder and
|
||||
is never read as an acknowledgement (acknowledgements come from the drain
|
||||
state and must be explicit).
|
||||
"""
|
||||
|
||||
if not isinstance(raw, Mapping):
|
||||
return None, (
|
||||
"impact report 'ack_state' is malformed: expected a mapping, got "
|
||||
f"{type(raw).__name__} (fail closed)"
|
||||
)
|
||||
keys: set[str] = set()
|
||||
for key in raw:
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
return None, (
|
||||
"impact report 'ack_state' carries a non-string or empty "
|
||||
"session id (fail closed)"
|
||||
)
|
||||
keys.add(key.strip())
|
||||
if len(keys) != len(raw):
|
||||
return None, (
|
||||
"impact report 'ack_state' names the same live session more than "
|
||||
"once; required acknowledgement identities are ambiguous "
|
||||
"(fail closed)"
|
||||
)
|
||||
return keys, None
|
||||
|
||||
|
||||
def _required_ack_sessions(
|
||||
impact_report: Mapping[str, Any],
|
||||
) -> tuple[frozenset[str] | None, str | None]:
|
||||
"""Identities of the live, non-requester sessions that owe an acknowledgement.
|
||||
|
||||
Derived only from authoritative impact-report evidence, never from the
|
||||
caller-supplied drain state — the same principle checks 1 and 5 already
|
||||
apply. Returns ``(ids, None)`` when the report proves the required set, or
|
||||
``(None, detail)`` when the evidence is missing, malformed, contradictory,
|
||||
or cannot be reconciled; every one of those fails the checklist closed.
|
||||
|
||||
The report carries two independent views of the same set and both are
|
||||
validated: ``affected_sessions`` filtered on ``live and not is_requester``,
|
||||
and ``ack_state`` whose keys are exactly those sessions. When both are
|
||||
present they must name the same set, and the result is reconciled against
|
||||
``counts.sessions_live_other`` — a report that disagrees with itself can
|
||||
never authorise a restart.
|
||||
"""
|
||||
|
||||
from_sessions: set[str] | None = None
|
||||
if "affected_sessions" in impact_report:
|
||||
from_sessions, detail = _session_ids_from_affected_sessions(
|
||||
impact_report.get("affected_sessions")
|
||||
)
|
||||
if detail is not None:
|
||||
return None, detail
|
||||
|
||||
from_ack_state: set[str] | None = None
|
||||
if "ack_state" in impact_report:
|
||||
from_ack_state, detail = _session_ids_from_ack_state(
|
||||
impact_report.get("ack_state")
|
||||
)
|
||||
if detail is not None:
|
||||
return None, detail
|
||||
|
||||
if from_sessions is None and from_ack_state is None:
|
||||
return None, (
|
||||
"impact report carries no session-identity evidence (neither "
|
||||
"ack_state nor affected_sessions); required acknowledgements "
|
||||
"cannot be attributed to a session (fail closed)"
|
||||
)
|
||||
|
||||
if (
|
||||
from_sessions is not None
|
||||
and from_ack_state is not None
|
||||
and from_sessions != from_ack_state
|
||||
):
|
||||
only_ack_state = sorted(from_ack_state - from_sessions)
|
||||
only_sessions = sorted(from_sessions - from_ack_state)
|
||||
return None, (
|
||||
"impact report contradicts itself about which sessions must "
|
||||
f"acknowledge: ack_state-only={only_ack_state}, "
|
||||
f"affected_sessions-only={only_sessions} (fail closed)"
|
||||
)
|
||||
|
||||
required = from_ack_state if from_ack_state is not None else from_sessions
|
||||
if required is None: # unreachable; guarded above, kept fail-closed
|
||||
return None, "required acknowledgement identities unresolved (fail closed)"
|
||||
|
||||
live_count = _live_session_count(impact_report)
|
||||
if live_count is None:
|
||||
return None, (
|
||||
"impact report does not prove the live-session count; required "
|
||||
"acknowledgement identities cannot be reconciled (fail closed)"
|
||||
)
|
||||
if len(required) != live_count:
|
||||
return None, (
|
||||
f"impact report names {len(required)} live session(s) requiring "
|
||||
f"acknowledgement but counts.sessions_live_other={live_count}; "
|
||||
"identity evidence cannot be reconciled (fail closed)"
|
||||
)
|
||||
return frozenset(required), None
|
||||
|
||||
|
||||
def _evaluate_checks(
|
||||
impact_report: Mapping[str, Any],
|
||||
drain_state: Mapping[str, Any],
|
||||
@@ -444,27 +598,55 @@ def _evaluate_checks(
|
||||
|
||||
# 6. Acknowledgements received, or an explicit timeout policy was applied.
|
||||
#
|
||||
# Whether acknowledgements are *required* is answered by the impact report,
|
||||
# never inferred from the shape of the drain state. Previously an absent
|
||||
# ``acks`` key collapsed to ``{}`` and was read as "no session needed to
|
||||
# acknowledge", so a proof minted clean — and the restart gate allowed —
|
||||
# while the report still showed other live sessions. Absence of evidence is
|
||||
# not evidence of absence: missing, malformed, stale, or otherwise unproven
|
||||
# acknowledgement data fails closed whenever live sessions require
|
||||
# acknowledgement, and only explicitly verified acknowledgement evidence
|
||||
# may permit the operation.
|
||||
# Whether acknowledgements are *required*, and *which sessions owe them*,
|
||||
# are both answered by the impact report — never inferred from the shape or
|
||||
# the cardinality of the drain state. Two fail-opens of the same class were
|
||||
# closed here in turn. First, an absent ``acks`` key collapsed to ``{}`` and
|
||||
# was read as "no session needed to acknowledge", so a proof minted clean
|
||||
# while the report still showed other live sessions. Then coverage was
|
||||
# decided by comparing an acknowledgement *count* against
|
||||
# ``sessions_live_other``, so acknowledgements from the requesting session
|
||||
# or from ids that do not exist satisfied the obligations of the live
|
||||
# sessions that never answered.
|
||||
#
|
||||
# Coverage is therefore bound to identity: every live, non-requester session
|
||||
# the report names must itself carry an explicit acknowledgement. An
|
||||
# acknowledgement from any other id — the requester, an unknown id, a
|
||||
# fabricated one — is not evidence about a required session and never
|
||||
# increases coverage.
|
||||
sessions_live_other = _live_session_count(impact_report)
|
||||
live_count_known = sessions_live_other is not None
|
||||
# An unknown or malformed count fails closed: it cannot prove nobody had to
|
||||
# acknowledge, so acknowledgement stays required.
|
||||
acks_required = (not live_count_known) or sessions_live_other > 0
|
||||
required_sessions, identity_detail = _required_ack_sessions(impact_report)
|
||||
# An unknown count or unresolvable identity evidence fails closed: neither
|
||||
# can prove nobody had to acknowledge, so acknowledgement stays required.
|
||||
acks_required = required_sessions is None or bool(required_sessions)
|
||||
|
||||
raw_acks = drain_state.get("acks")
|
||||
acks_present = isinstance(raw_acks, Mapping)
|
||||
ack_values = list(raw_acks.values()) if acks_present else []
|
||||
acked_count = sum(1 for value in ack_values if _is_acknowledged(value))
|
||||
every_entry_acked = bool(ack_values) and acked_count == len(ack_values)
|
||||
covers_live_sessions = live_count_known and acked_count >= sessions_live_other
|
||||
# Identity-bound coverage. Only an entry keyed by a required session id and
|
||||
# carrying an explicit token counts, so neither a requester acknowledgement
|
||||
# nor an unknown id can stand in for a session that stayed silent.
|
||||
if acks_present and required_sessions is not None:
|
||||
acked_sessions = {
|
||||
session_id
|
||||
for session_id in required_sessions
|
||||
if any(
|
||||
isinstance(key, str)
|
||||
and key.strip() == session_id
|
||||
and _is_acknowledged(value)
|
||||
for key, value in raw_acks.items()
|
||||
)
|
||||
}
|
||||
else:
|
||||
acked_sessions = set()
|
||||
missing_sessions = (
|
||||
sorted(required_sessions - acked_sessions)
|
||||
if required_sessions is not None
|
||||
else []
|
||||
)
|
||||
covers_live_sessions = required_sessions is not None and not missing_sessions
|
||||
# Strict by construction: only an explicit ``True`` counts, so an absent,
|
||||
# null, or non-boolean ``ack_timeout_policy_applied`` can never open this
|
||||
# gate on its own.
|
||||
@@ -476,7 +658,13 @@ def _evaluate_checks(
|
||||
# is that an acknowledgement is still owed.
|
||||
outstanding_entries = acks_present and bool(ack_values) and not every_entry_acked
|
||||
|
||||
if timeout_policy and (acks_required or outstanding_entries):
|
||||
if identity_detail is not None:
|
||||
# Unresolvable identity evidence outranks every permitting path,
|
||||
# including the timeout policy: when the report cannot say who owed an
|
||||
# acknowledgement, nothing can show the obligation was discharged.
|
||||
acks_ok = False
|
||||
detail = identity_detail
|
||||
elif timeout_policy and (acks_required or outstanding_entries):
|
||||
acks_ok = True
|
||||
detail = "explicit ack timeout policy applied and recorded"
|
||||
elif outstanding_entries:
|
||||
@@ -492,21 +680,16 @@ def _evaluate_checks(
|
||||
"impact report proves no other live sessions required to "
|
||||
"acknowledge (sessions_live_other=0)"
|
||||
)
|
||||
elif every_entry_acked and covers_live_sessions:
|
||||
elif covers_live_sessions:
|
||||
acks_ok = True
|
||||
detail = (
|
||||
f"{acked_count} acknowledgement entr"
|
||||
f"{'y' if acked_count == 1 else 'ies'} verified; covers "
|
||||
f"{len(acked_sessions)} required live session(s) each acknowledged "
|
||||
f"by identity: {sorted(acked_sessions)}; covers "
|
||||
f"sessions_live_other={sessions_live_other}"
|
||||
)
|
||||
else:
|
||||
acks_ok = False
|
||||
if not live_count_known:
|
||||
detail = (
|
||||
"impact report does not prove the live-session count; "
|
||||
"acknowledgement required and unproven (fail closed)"
|
||||
)
|
||||
elif raw_acks is None:
|
||||
if raw_acks is None:
|
||||
detail = (
|
||||
"acknowledgement evidence absent while "
|
||||
f"sessions_live_other={sessions_live_other} require "
|
||||
@@ -525,9 +708,10 @@ def _evaluate_checks(
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
f"acknowledgements cover only {acked_count} session(s) but the "
|
||||
f"report shows sessions_live_other={sessions_live_other} "
|
||||
"(fail closed)"
|
||||
"no valid acknowledgement attributed to required live "
|
||||
f"session(s) {missing_sessions}; the report shows "
|
||||
f"sessions_live_other={sessions_live_other} and supplied "
|
||||
"acknowledgements for other ids do not count (fail closed)"
|
||||
)
|
||||
checks.append(DrainCheck(CHECK_ACKS_OR_TIMEOUT, acks_ok, detail))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user