Repository navigation
feat: reappraise retained synthetic evidence for #199 #334
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0cbc5d4
ddd5d81
7f451c8
47b2c2e
16c2553
1d97461
e858fe1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| """Offline applicability checks for retained deterministic harness results. | ||
|
|
||
| Inputs are local, caller-trusted fixtures, not authenticated protocol evidence. | ||
| An applicable result is not permission to execute, a freshness attestation, or a | ||
| replacement for independent acceptance. No adapter or live transport is called. | ||
| """ | ||
| from dataclasses import asdict, dataclass | ||
| import hashlib | ||
| import json | ||
| import re | ||
|
|
||
| from .model import EvidenceClass, RunResult, Scenario | ||
| from .verdict import status, summarize | ||
|
|
||
|
|
||
| def _digest(value: object) -> str: | ||
| raw = json.dumps(value, sort_keys=True, separators=(",", ":"), | ||
| allow_nan=False).encode("utf-8") | ||
| return "sha256:" + hashlib.sha256(raw).hexdigest() | ||
|
|
||
|
|
||
| def _check_digest(value: str) -> None: | ||
| if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value): | ||
| raise ValueError("expected a lowercase sha256 digest") | ||
|
|
||
|
|
||
| def _check_time(value: int) -> None: | ||
| if type(value) is not int or value < 0: | ||
| raise ValueError("time must be a non-negative integer UTC Unix second") | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class EvaluationContext: | ||
| """Verifier-selected fixture inputs, supplied separately from the producer.""" | ||
|
|
||
| policy_digest: str | ||
| trust_digest: str | ||
| dependencies: tuple[tuple[str, str], ...] | ||
|
|
||
| def __post_init__(self): | ||
| _check_digest(self.policy_digest) | ||
| _check_digest(self.trust_digest) | ||
| if not isinstance(self.dependencies, tuple) or not self.dependencies: | ||
| raise ValueError("dependencies must be a non-empty immutable tuple") | ||
| names = [] | ||
| for entry in self.dependencies: | ||
| if not isinstance(entry, tuple) or len(entry) != 2: | ||
| raise ValueError("dependency entries must be (name, digest) tuples") | ||
| name, digest = entry | ||
| if not isinstance(name, str) or not name.strip(): | ||
| raise ValueError("dependency names must be non-empty") | ||
| _check_digest(digest) | ||
| names.append(name) | ||
| if len(set(names)) != len(names): | ||
| raise ValueError("duplicate dependency name") | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class EvidenceSnapshot: | ||
| """Immutable local record; the digest is identity, not authentication.""" | ||
|
|
||
| scenario_digest: str | ||
| result_json: str | ||
| result_digest: str | ||
| context: EvaluationContext | ||
| captured_at: int | ||
|
|
||
| @classmethod | ||
| def capture(cls, scenario: Scenario, result: RunResult, | ||
| context: EvaluationContext, *, captured_at: int): | ||
| _check_time(captured_at) | ||
| if not isinstance(context, EvaluationContext): | ||
| raise ValueError("explicit evaluation context required") | ||
| if (scenario.scenario_id, scenario.transaction_id, scenario.claim) != ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Bind the result to the full scenario evaluated by the runner. This check compares only ID, transaction ID and claim. I ran a passing result from the baseline with a same-ID scenario whose workload input changed; a fresh runner call refused the changed scenario, but |
||
| result.scenario_id, result.transaction_id, result.claim): | ||
| raise ValueError("result does not match scenario identity") | ||
| if result.scenario_digest != scenario.digest(): | ||
| raise ValueError("result does not match full scenario digest") | ||
| _check_time(result.observed_at) | ||
| if result.observed_at > captured_at: | ||
| raise ValueError("capture precedes observation") | ||
| if scenario.mutation is not None or result.mutation_proof != { | ||
| "mutation": None, "gate_weakened": False}: | ||
| raise ValueError("weakened-gate evidence cannot be retained for reuse") | ||
| if not result.observations or any( | ||
| o.evidence_class != EvidenceClass.SYNTHETIC | ||
| for o in result.observations): | ||
| raise ValueError("only explicit synthetic observations are supported") | ||
| boundaries = summarize(result.observations) | ||
| blocked = [scenario.waiting_on_release] if scenario.waiting_on_release else [] | ||
| if (result.boundary_outcomes != boundaries or result.blocked != blocked | ||
| or result.status != status(boundaries, blocked)): | ||
| raise ValueError("result conflicts with retained observations") | ||
| # Local retention is deliberately separate from public serialization. | ||
| data = result.to_dict() | ||
| data.update(scenario_digest=result.scenario_digest, observed_at=result.observed_at) | ||
| encoded = json.dumps(data, sort_keys=True, separators=(",", ":"), allow_nan=False) | ||
| return cls(result.scenario_digest, encoded, _digest(data), context, captured_at) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Reappraisal: | ||
| historical_status: str | ||
| applicability: str | ||
| reasons: tuple[str, ...] | ||
| evidence_class: str = "synthetic" | ||
|
|
||
|
|
||
| def appraise(snapshot: EvidenceSnapshot, scenario: Scenario, | ||
| current: EvaluationContext, *, evaluated_at: int, | ||
| max_age_seconds: int) -> Reappraisal: | ||
| """Compare a retained fixture against current verifier inputs. | ||
|
|
||
| The caller selects a freshness budget on EVERY appraisal. Exactly at the | ||
| deadline the evidence is expired. This is evidence-age checking, not a | ||
| credential-expiry implementation. A changed context requires a fresh run; | ||
| it does not imply that the new workload is incompatible or unauthorized. | ||
| """ | ||
| _check_time(evaluated_at) | ||
| _check_time(snapshot.captured_at) | ||
| if type(max_age_seconds) is not int or max_age_seconds <= 0: | ||
| raise ValueError("a positive verifier-selected freshness budget is required") | ||
| if not isinstance(current, EvaluationContext): | ||
| raise ValueError("explicit current evaluation context required") | ||
| data = json.loads(snapshot.result_json) | ||
| if _digest(data) != snapshot.result_digest: | ||
| raise ValueError("snapshot content identity mismatch") | ||
| _check_time(data["observed_at"]) | ||
| if data["observed_at"] > snapshot.captured_at: | ||
| raise ValueError("capture precedes observation") | ||
| if data["scenario_digest"] != snapshot.scenario_digest: | ||
| raise ValueError("snapshot scenario identity mismatch") | ||
| reasons = [] | ||
| if snapshot.scenario_digest != _digest(asdict(scenario)): | ||
| reasons.append("scenario_changed") | ||
| if snapshot.context.policy_digest != current.policy_digest: | ||
| reasons.append("policy_changed") | ||
| if snapshot.context.trust_digest != current.trust_digest: | ||
| reasons.append("trust_changed") | ||
| if dict(snapshot.context.dependencies) != dict(current.dependencies): | ||
| reasons.append("dependencies_changed") | ||
| if evaluated_at < snapshot.captured_at: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Base evidence age on when the result was observed, separately from when a snapshot object was created. Re-capturing the same old |
||
| reasons.append("evaluation_precedes_capture") | ||
| elif evaluated_at - data["observed_at"] >= max_age_seconds: | ||
| reasons.append("evidence_expired") | ||
| if data["status"] != "passed": | ||
| reasons.append("historical_result_not_passed") | ||
| return Reappraisal(data["status"], | ||
| "reassessment_required" if reasons else "applicable", | ||
| tuple(reasons)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These fields are now included by
RunResult.to_dict()on every run, butresult.schema.jsonrejects both because it hasadditionalProperties: falseand no matching properties. A passing positive-control result fails schema validation. Please add typed schema entries, choose whether legacy serialized results may omit them, and test the actual runner output against the schema.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@noah-ing fixed in
aff9e7eeeb0741ef898d2888645dae9a0c2bd360. The schema now defines the exact lowercase SHA-256 identity and non-negative integer observation time, requires the pair together when either is supplied, and continues accepting legacy serialized results with both absent. LegacyRunResult.to_dict()omits null defaults;EvidenceSnapshot.capturestill rejects legacy results for reuse.New tests validate actual emitted passed/refused/unknown/incomplete results, schema well-formedness, zero-time control, legacy serialization/refusal, partial metadata, invalid values and unknown-field rejection. All 50 harness tests pass normally and under
-O; 28 repository tests pass; Ruff and whitespace pass. Restoring the old schema causes 5 assertion failures; removing metadata constraints causes 12. Published tree equals tested tree7f548c6937c0a99f83b04f68dc783742acf08afc. The existing CI installs hash-pinned jsonschema and discovers the new test module; README local setup now uses that same dependency file.All 11 current-head technical workflow runs, including CodeQL, now succeed. Please re-review this exact head. Formal re-review request returned connector HTTP 403, so this inline handoff records the request; independent maintainer acceptance remains separate.