Skip to content

Commit 9b9e0ce

Browse files
Pigbibicodex
andcommitted
feat: bind v6 advisory artifacts to input digest
Co-Authored-By: Codex <noreply@openai.com>
1 parent 8fe1303 commit 9b9e0ce

8 files changed

Lines changed: 144 additions & 4 deletions

File tree

docs/advisory_contract.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,43 @@ final_decisions: object, optional
1919
policy: object
2020
```
2121

22+
The current CLI and scheduled workflows remain explicit v5 producers. Readers
23+
accept both v5 and v6, but a producer must opt in to v6 only after it can emit
24+
all v6 provenance fields. No v6 field is silently added to, or accepted on, a
25+
v5 report.
26+
27+
### V6 dual-read migration contract
28+
29+
A v6 report uses `contract_version = model_recommendations.v6` and adds these
30+
required top-level fields:
31+
32+
```text
33+
reference_time: exclusive start of the UTC day after as_of
34+
expires_at: generated_at + 7 days
35+
input_digest: 64 lowercase hexadecimal SHA-256 characters
36+
freshness.ai_signal
37+
freshness.theme_momentum
38+
```
39+
40+
`freshness` is evaluated relative to the report's fixed `reference_time`, not
41+
the reader's wall clock. Declared source context and freshness state must agree;
42+
malformed, future, stale, or expired context fails closed. The checked-in
43+
contract fixture is `tests/fixtures/advisory_report_v6.json`.
44+
45+
For a v6 producer, `input_digest` is the aggregate content identity of the
46+
complete logical input set. The producer computes the SHA-256 of canonical JSON
47+
whose keys are the sorted logical source roles and whose values are each source
48+
file's lowercase SHA-256, or `null` when that optional role is absent. Canonical
49+
JSON uses UTF-8, sorted keys, and separators `(',', ':')`. This binds the output
50+
to source contents instead of mutable file paths. Changing this algorithm
51+
requires a later schema version.
52+
53+
Readers validate the digest shape and the full report artifact digest. A reader
54+
that also holds the original inputs may recompute `input_digest`; a report alone
55+
cannot prove that its producer hashed honest source bytes. V6 manifests repeat
56+
the report's exact `input_digest` so downstream lineage checks do not need to
57+
infer it from `source_artifacts` paths.
58+
2259
## Policy
2360

2461
The policy block allows non-personalized intelligent-advisory research output but keeps account
@@ -285,6 +322,9 @@ policy
285322
generated_at
286323
```
287324

325+
For a v6 report, the manifest additionally carries the exact top-level
326+
`input_digest`. V5 manifests remain unchanged and omit this field.
327+
288328
## Source Mode
289329

290330
`summary.source_mode` is:

src/quant_advisor_research/artifacts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,6 @@ def write_report_manifest(
120120
"policy": dict(on_disk_report.get("policy") or {}),
121121
"generated_at": datetime.now(timezone.utc).isoformat(),
122122
}
123+
if on_disk_report.get("schema_version") == "6":
124+
payload["input_digest"] = on_disk_report["input_digest"]
123125
return write_json(manifest_path, payload)

src/quant_advisor_research/contracts.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ def _require_contract_datetime(value: Any, name: str) -> dt.datetime:
129129
raise AdvisoryValidationError(f"{name}_invalid") from exc
130130

131131

132+
def _require_sha256_digest(value: Any, name: str) -> str:
133+
if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None:
134+
raise AdvisoryValidationError(f"{name}_invalid")
135+
return value
136+
137+
132138
def _validate_v6_freshness(
133139
freshness: Any,
134140
*,
@@ -255,13 +261,13 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
255261
if actual_contract is not None and actual_contract != expected_contract:
256262
raise AdvisoryValidationError("contract_version_mismatch")
257263
if schema_version == "5":
258-
v6_only_keys = {"reference_time", "expires_at", "freshness"} & set(payload)
264+
v6_only_keys = {"reference_time", "expires_at", "freshness", "input_digest"} & set(payload)
259265
if v6_only_keys:
260266
raise AdvisoryValidationError("v6_fields_on_v5")
261267
elif schema_version == "6":
262268
if actual_contract != "model_recommendations.v6":
263269
raise AdvisoryValidationError("v6_contract_version_required")
264-
for key in ("reference_time", "expires_at", "freshness"):
270+
for key in ("reference_time", "expires_at", "freshness", "input_digest"):
265271
if key not in payload:
266272
raise AdvisoryValidationError("v6_fields_incomplete")
267273
else:
@@ -280,6 +286,7 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
280286
raise AdvisoryValidationError("generated_at_before_reference")
281287
if expires_at != report_generated_at + dt.timedelta(days=REPORT_EXPIRY_DAYS):
282288
raise AdvisoryValidationError("expires_at_mismatch")
289+
_require_sha256_digest(payload["input_digest"], "input_digest")
283290
_validate_v6_freshness(
284291
payload["freshness"],
285292
report_as_of=report_as_of,

src/quant_advisor_research/publisher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _is_compatibility_warning(value: Any) -> bool:
111111
def report_content_fingerprint(report: dict[str, Any]) -> str:
112112
ignored_top_level_keys = {
113113
"as_of", "generated_at", "reference_time", "expires_at", "schema_version",
114-
"contract_version", "source_artifacts",
114+
"contract_version", "source_artifacts", "input_digest",
115115
}
116116
normalized = {key: value for key, value in report.items() if key not in ignored_top_level_keys}
117117
freshness = normalized.get("freshness")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"as_of": "2026-08-22",
3+
"audience_scope": "non_personalized_model_research",
4+
"cadence": "daily",
5+
"contract_version": "model_recommendations.v6",
6+
"expires_at": "2026-08-30T00:00:00Z",
7+
"freshness": {
8+
"ai_signal": {
9+
"present": false,
10+
"reason": "not_provided",
11+
"valid": false
12+
},
13+
"theme_momentum": {
14+
"present": false,
15+
"reason": "not_provided",
16+
"valid": false
17+
}
18+
},
19+
"generated_at": "2026-08-23T00:00:00Z",
20+
"input_digest": "f9b98eb8d22f5ee5c403480c797757240afd76e117f4f7edf157dc330a87d014",
21+
"mode": "model_recommendations",
22+
"policy": {
23+
"account_specific_advice_allowed": false,
24+
"downstream_use": "Asset idea research observation only; do not route to portfolio allocation or broker execution.",
25+
"execution_allowed": false,
26+
"non_personalized_recommendations_allowed": true,
27+
"personalized_advice_allowed": false,
28+
"portfolio_allocation_allowed": false
29+
},
30+
"recommendations": [],
31+
"reference_time": "2026-08-23T00:00:00Z",
32+
"schema_version": "6",
33+
"source_artifacts": {
34+
"ai_signal": "",
35+
"market_confirmation": "",
36+
"political_events": "",
37+
"political_watchlist": "",
38+
"theme_momentum": ""
39+
},
40+
"summary": {}
41+
}

tests/test_artifact_integrity.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626

2727
ROOT = Path(__file__).resolve().parents[1]
28+
V6_INPUT_DIGEST = "a" * 64
2829

2930

3031
def build_report(*, schema_version: str = "5") -> dict:
@@ -43,6 +44,7 @@ def build_report(*, schema_version: str = "5") -> dict:
4344
"contract_version": "model_recommendations.v6",
4445
"reference_time": reference_time.isoformat().replace("+00:00", "Z"),
4546
"expires_at": (generated_at + dt.timedelta(days=7)).isoformat().replace("+00:00", "Z"),
47+
"input_digest": V6_INPUT_DIGEST,
4648
"freshness": {
4749
"ai_signal": {"present": False, "valid": False, "reason": "not_provided"},
4850
"theme_momentum": {"present": False, "valid": False, "reason": "not_provided"},
@@ -96,6 +98,14 @@ def test_v6_time_metadata_changes_artifact_digest_but_not_semantic_digest(field:
9698
assert artifact_integrity_digest(changed) != artifact_integrity_digest(report)
9799

98100

101+
def test_v6_input_digest_changes_artifact_digest_but_not_semantic_digest() -> None:
102+
report = build_report(schema_version="6")
103+
changed = dict(report, input_digest="b" * 64)
104+
105+
assert report_content_fingerprint(changed) == report_content_fingerprint(report)
106+
assert artifact_integrity_digest(changed) != artifact_integrity_digest(report)
107+
108+
99109
def test_schema_version_change_is_reflected_in_artifact_digest() -> None:
100110
v5 = build_report()
101111
v6 = build_report(schema_version="6")

tests/test_publisher_period_redesign.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222

2323
ROOT = Path(__file__).resolve().parents[1]
24+
V6_INPUT_DIGEST = "a" * 64
2425

2526

2627
def build_v5(as_of: str = "2026-06-20") -> dict:
@@ -41,6 +42,7 @@ def build_v6(as_of: str = "2026-06-20") -> dict:
4142
"reference_time": canonical_reference_time(dt.date.fromisoformat(as_of)).isoformat().replace("+00:00", "Z"),
4243
"generated_at": "2026-06-21T12:00:00.123456Z",
4344
"expires_at": "2026-06-28T12:00:00.123456Z",
45+
"input_digest": V6_INPUT_DIGEST,
4446
"freshness": {
4547
"ai_signal": {"present": False, "valid": False, "reason": "not_provided"},
4648
"theme_momentum": {"present": False, "valid": False, "reason": "not_provided"},

tests/test_publisher_redesign_stage2a.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616

1717
ROOT = Path(__file__).resolve().parents[1]
18+
V6_INPUT_DIGEST = "a" * 64
1819

1920

2021
def build_v5() -> dict:
@@ -35,6 +36,7 @@ def build_v6() -> dict:
3536
"reference_time": canonical_reference_time(dt.date(2026, 5, 30)).isoformat().replace("+00:00", "Z"),
3637
"generated_at": "2026-05-31T12:00:00.123456Z",
3738
"expires_at": "2026-06-07T12:00:00.123456Z",
39+
"input_digest": V6_INPUT_DIGEST,
3840
"freshness": {
3941
"ai_signal": {"present": False, "valid": False, "reason": "not_provided"},
4042
"theme_momentum": {"present": False, "valid": False, "reason": "not_provided"},
@@ -72,6 +74,37 @@ def test_v5_legacy_missing_marker_remains_readable() -> None:
7274
validate_advisory_report(report)
7375

7476

77+
@pytest.mark.parametrize("input_digest", [None, "", "a" * 63, "A" * 64, "sha256:" + "a" * 64, True])
78+
def test_v6_input_digest_is_required_lowercase_sha256(input_digest: object) -> None:
79+
report = build_v6()
80+
if input_digest is None:
81+
report.pop("input_digest")
82+
expected = "v6_fields_incomplete"
83+
else:
84+
report["input_digest"] = input_digest
85+
expected = "input_digest_invalid"
86+
with pytest.raises(AdvisoryValidationError, match=expected):
87+
validate_advisory_report(report)
88+
89+
90+
def test_v5_does_not_silently_accept_v6_input_digest() -> None:
91+
report = build_v5()
92+
report["input_digest"] = V6_INPUT_DIGEST
93+
with pytest.raises(AdvisoryValidationError, match="v6_fields_on_v5"):
94+
validate_advisory_report(report)
95+
96+
97+
def test_checked_in_v6_contract_fixture_is_readable() -> None:
98+
fixture = json.loads((ROOT / "tests/fixtures/advisory_report_v6.json").read_text(encoding="utf-8"))
99+
empty_input_set = {
100+
name: None
101+
for name in ("political_events", "political_watchlist", "ai_signal", "theme_momentum", "market_confirmation")
102+
}
103+
canonical = json.dumps(empty_input_set, sort_keys=True, separators=(",", ":")).encode("utf-8")
104+
assert fixture["input_digest"] == hashlib.sha256(canonical).hexdigest()
105+
validate_advisory_report(fixture)
106+
107+
75108
@pytest.mark.parametrize(
76109
("field", "value"),
77110
[
@@ -280,7 +313,12 @@ def test_manifest_version_is_report_derived_without_global_v6_contamination(tmp_
280313
markdown_path=markdown_path,
281314
manifest_path=tmp_path / f"{name}.manifest.json",
282315
)
283-
assert json.loads(manifest.read_text(encoding="utf-8"))["contract_version"] == expected
316+
payload = json.loads(manifest.read_text(encoding="utf-8"))
317+
assert payload["contract_version"] == expected
318+
if name == "v6":
319+
assert payload["input_digest"] == V6_INPUT_DIGEST
320+
else:
321+
assert "input_digest" not in payload
284322

285323

286324
def test_manifest_rejects_incomplete_v6_but_keeps_v5_legacy_omission(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)