fix(report): bind professional report hash to the delivered packet (BUG-693, BUG-694)
Independent Staging Quality Gate / validate (push) Successful in 9m45s
Independent Staging Quality Gate / publish (push) Successful in 2m14s

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:
jesse-ux
2026-09-15 10:09:26 +08:00
parent 039b0a2608
commit 1d2aeffaef
8 changed files with 403 additions and 32 deletions
+98 -14
View File
@@ -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