fix(report): bind professional report hash to the delivered packet (BUG-693, BUG-694)
Rebind after sanitize, write an explicit binding_scope, and make the quality gate recompute coverage. Wall-clock fields stay in the packet but out of the hash.
This commit is contained in:
@@ -70,6 +70,27 @@ EPHEMERIS_FIELDS = (
|
||||
POSITION_MODES = ("legacy", "mean", "apparent")
|
||||
NODE_MODES = ("mean", "true")
|
||||
|
||||
# Envelope keys never enter the result hash (self-describing binding metadata).
|
||||
RESULT_BINDING_ENVELOPE_KEYS = frozenset({
|
||||
"result_hash",
|
||||
"result_binding",
|
||||
"calculation_profile",
|
||||
"calculation_profile_id",
|
||||
})
|
||||
# D2: only these three classes may be excluded from the delivered-object hash.
|
||||
ALLOWED_BINDING_EXCLUDED_TOP_KEYS = (
|
||||
"generated_at",
|
||||
"report_quality_gate",
|
||||
"shared_full_report_authority",
|
||||
)
|
||||
ALLOWED_BINDING_EXCLUDED_PATHS = (
|
||||
"ai_and_audit.summary.elapsed_seconds",
|
||||
"**.elapsed_seconds",
|
||||
"**.called_at",
|
||||
"**.cache_created_at",
|
||||
"**.cache_expires_at",
|
||||
)
|
||||
|
||||
|
||||
class CalculationProfileError(ValueError):
|
||||
"""Raised for inputs that cannot be normalized without data loss."""
|
||||
@@ -431,6 +452,76 @@ def attach_calculation_profile(result: dict[str, Any], args: Any) -> dict[str, A
|
||||
return bind_result_to_profile(result, profile)
|
||||
|
||||
|
||||
def default_result_binding_scope() -> dict[str, list[str]]:
|
||||
"""Explicit exclusion set written onto every result_binding receipt."""
|
||||
return {
|
||||
"excluded_top_keys": list(ALLOWED_BINDING_EXCLUDED_TOP_KEYS),
|
||||
"excluded_paths": list(ALLOWED_BINDING_EXCLUDED_PATHS),
|
||||
}
|
||||
|
||||
|
||||
def _drop_key_recursive(payload: Any, key_name: str) -> Any:
|
||||
if isinstance(payload, dict):
|
||||
return {
|
||||
key: _drop_key_recursive(value, key_name)
|
||||
for key, value in payload.items()
|
||||
if key != key_name
|
||||
}
|
||||
if isinstance(payload, list):
|
||||
return [_drop_key_recursive(item, key_name) for item in payload]
|
||||
return payload
|
||||
|
||||
|
||||
def _drop_dotted_path(payload: Any, path: tuple[str, ...]) -> Any:
|
||||
if len(path) == 2 and path[0] == "**":
|
||||
return _drop_key_recursive(payload, path[1])
|
||||
if not path or not isinstance(payload, dict) or path[0] not in payload:
|
||||
return payload
|
||||
cloned = dict(payload)
|
||||
key, *rest = path
|
||||
if not rest:
|
||||
cloned.pop(key, None)
|
||||
return cloned
|
||||
cloned[key] = _drop_dotted_path(payload[key], tuple(rest))
|
||||
return cloned
|
||||
|
||||
|
||||
def result_payload_for_binding(
|
||||
result: dict[str, Any],
|
||||
scope: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Business payload that participates in result_hash."""
|
||||
binding_scope = scope if isinstance(scope, dict) else default_result_binding_scope()
|
||||
excluded_top = RESULT_BINDING_ENVELOPE_KEYS | {
|
||||
str(key) for key in (binding_scope.get("excluded_top_keys") or ())
|
||||
}
|
||||
payload = {key: value for key, value in result.items() if key not in excluded_top}
|
||||
for raw_path in binding_scope.get("excluded_paths") or ():
|
||||
parts = tuple(part for part in str(raw_path).split(".") if part)
|
||||
if parts:
|
||||
payload = _drop_dotted_path(payload, parts)
|
||||
return payload
|
||||
|
||||
|
||||
def hash_bound_result(
|
||||
result: dict[str, Any],
|
||||
input_hash: str,
|
||||
scope: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""SHA-256 of the canonical bound payload. Does not mutate ``result``."""
|
||||
encoded = json.dumps(
|
||||
canonicalize_result_payload({
|
||||
"input_hash": input_hash,
|
||||
"result": result_payload_for_binding(result, scope),
|
||||
}),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Bind one concrete calculation result to its normalized input profile."""
|
||||
if not isinstance(result, dict) or not isinstance(profile, dict):
|
||||
@@ -438,21 +529,14 @@ def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> d
|
||||
input_hash = profile.get("input_hash")
|
||||
if not isinstance(input_hash, str) or len(input_hash) != 64:
|
||||
raise ValueError("calculation profile is missing a valid input_hash")
|
||||
result_payload = {
|
||||
key: value
|
||||
for key, value in result.items()
|
||||
if key not in {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"}
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonicalize_result_payload({"input_hash": input_hash, "result": result_payload}),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
result_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
scope = default_result_binding_scope()
|
||||
result_hash = hash_bound_result(result, input_hash, scope)
|
||||
result["result_hash"] = result_hash
|
||||
result["result_binding"] = {"input_hash": input_hash, "result_hash": result_hash}
|
||||
result["result_binding"] = {
|
||||
"input_hash": input_hash,
|
||||
"result_hash": result_hash,
|
||||
"binding_scope": scope,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user