Skip to content

provenance verification cannot be pinned to an explicit verification time #407

Description

@altrudev

Problem

provenance.verify_record() now enforces freshness, but its verification time is still taken implicitly from the host clock:

age = time.time() - int(record["issued_at"])

The function accepts decision-relevant freshness policy:

max_age_seconds: int | None = None
max_future_skew_seconds: int = 300

but unlike the main Trust Record verifier and the PIC/TRACE bridge, it has no now= input.

That means a caller cannot deterministically ask the provenance verifier:

evaluate this exact signed record under this exact freshness policy at this exact verification instant.

Boundary reproduction

For a provenance record with:

issued_at = 1000
max_age_seconds = 300

the semantic boundary is clear:

  • at verification time 1300: valid under the age bound;
  • at verification time 1301: stale.

The sibling TRACE verifier can express that boundary directly because it accepts now:

verify_record(record, key, max_age_seconds=300, now=1300)  # pass
verify_record(record, key, max_age_seconds=300, now=1301)  # stale

provenance.verify_record() cannot express either call. It always evaluates against the ambient wall clock.

Why this is not just test convenience

The clock value is part of the decision function:

decision = f(record.issued_at, max_age_seconds, max_future_skew_seconds, verification_time)

Changing only verification_time can change the result for the same signed record, trusted key and freshness policy.

So the clock is a decision-relevant environmental input.

Leaving that input implicit has four consequences:

  1. Replayability
    A retained provenance verification cannot be deterministically rerun at the original decision instant.

  2. Conformance / regression tests
    Exact freshness-boundary cases require monkeypatching the host clock or constructing timestamps relative to the running process instead of supplying the decision input.

  3. Cross-verifier comparison
    Two verifiers with identical record, key and policy can disagree solely because they ran at different instants, without an explicit input that explains the divergence.

  4. Evidence reconstruction
    Recording a timestamp externally after verification is weaker than evaluating against that exact timestamp. The verifier has already sampled time.time() independently.

Relationship to #151

#151 identified that provenance records had issued_at but no freshness or revocation hook. That work is complete: current provenance.verify_record() now accepts max_age_seconds, max_future_skew_seconds and revocation.

This issue is the residual determinism gap in that implementation: freshness exists, but the time input to the freshness calculation is not injectable.

Relationship to #342

#342 is about the semantics of a successful VerificationResult and whether decision-relevant verifier context must be retained for later assurance use.

The maintainer ruling there was that the result is call-local, and callers creating a durable assurance statement should retain the effective freshness bounds and the actual verification time separately.

This issue is one layer earlier:

the provenance verifier currently provides no way to guarantee that the recorded verification time is the exact time used by the freshness calculation.

So this does not propose changing VerificationResult, reopening #342, or adding anything to the signed Trust Record.

Existing repository pattern

The repository already makes verifier time explicit on adjacent decision surfaces:

  • sign.verify_record(..., now: int | None = None)
  • intent_bridge.verify_bridge(..., now: int | None = None)
  • successor observation evaluation takes explicit now
  • revocation bundle evaluation takes explicit now

provenance.verify_record() is the freshness-sensitive outlier.

Bounded fix direction

Match the existing verifier pattern:

def verify_record(
    ...,
    max_age_seconds: int | None = None,
    max_future_skew_seconds: int = 300,
    now: int | None = None,
) -> None:
    verification_time = int(time.time()) if now is None else now
    age = verification_time - int(record["issued_at"])

The supplied value should use the same validation discipline already applied to verifier-time overrides elsewhere in the package: non-negative integer, with bool refused explicitly.

Regression coverage should include the exact boundary:

  • now == issued_at + max_age_seconds -> accepted;
  • now == issued_at + max_age_seconds + 1 -> stale;
  • future-skew boundary on both sides;
  • now=None preserves current wall-clock behavior;
  • malformed explicit now is refused as verifier configuration, not classified as a bad provenance record.

No schema, wire-format or normative Trust Record change appears necessary.

Duplicate check

I searched current TRACE issues for provenance verification-time / now / clock-override / freshness-replay variants.

I found no issue carrying this specific residual input-determinism gap.

AI-assisted FDDCRF source sweep and falsification design; @altrudev remains responsible for the contribution.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-review:HIGHContributor check flagged HIGH risk

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions