Skip to content

feat(replay): canonical deterministic replay identity (Wave 10)#343

Open
ctol3r wants to merge 1 commit into
mainfrom
wave/replay-identity-w10
Open

feat(replay): canonical deterministic replay identity (Wave 10)#343
ctol3r wants to merge 1 commit into
mainfrom
wave/replay-identity-w10

Conversation

@ctol3r
Copy link
Copy Markdown
Owner

@ctol3r ctol3r commented May 12, 2026

Summary

Establishes a survivability-by-construction identity contract: every passport snapshot now carries deterministic lineageKey + runId ids derived from stable persisted inputs. Survives refresh / restart / deploy by definition, because the ids are pure functions over data, not in-memory state.

Identity contract

Id Stable across Inputs
lineageKey every check / snapshot / review for the same entity, forever entityId alone
runId identical-evidence snapshots; changes when evidence changes entityId + lastCheckedAt + sorted artifact checksums + channel

Scheme prefixed with v1 (lin_v1_…, run_v1_…). Any future input-set change MUST bump to v2 so older ids never collide with new ones.

Survivability matrix

Scenario Outcome
Hard refresh pure recomputation → identical id
Process restart reads same persisted inputs → identical id
Deploy (new code, same data) algorithm version v1 → identical id
Degraded run (no artifacts) distinct deterministic id; verifier can tell apart from a complete run by comparing ids
Artifact order changes in DB normalized inside computeRunId → identical id

Files

File Status
apps/api/backend/src/services/replay/replayIdentity.ts new — pure generators
apps/api/backend/src/services/replay/__tests__/replayIdentity.test.ts new — 24 cases
apps/api/backend/src/services/passport/npiPassportContract.ts wires replay into PassportDataContract
apps/web/lib/trust/passport-contract.ts extends PassportData + validator
apps/web/__tests__/passport-replay-identity.test.ts new — 8 validator cases

Out of scope (explicit follow-ups)

  • Schema migration — relies on already-persisted entityId, lastCheckedAt, artifact.id fields. No DB changes.
  • Frontend rendering — Lane B primitives (RunIdentity, ReplayLineage) and Wave 2 surface adoption (feat(trust): adopt canonical primitives across 5 trust surfaces (Wave 2) #342) are ready to consume replay.runId / replay.lineageKey once the surface props are threaded through. That threading is Wave 6.
  • Recent-NPIs / history-row UI — Wave 6 scope.
  • /replay/[id] page — Wave 9 scope.

Truth rules

  • Banned-strings scan: CLEAN
  • No new claims about what the system verifies — only what it identifies

Validation

  • Backend jest: 24/24 passing (src/services/replay/__tests__/replayIdentity.test.ts)
  • Web vitest: 8/8 passing (__tests__/passport-replay-identity.test.ts)
  • Full web build: pnpm turbo run build --filter @vitalcv/web13/13 tasks, 42s

Establishes a survivability-by-construction identity contract for
every replayable object. Two ids per passport snapshot:

  lineageKey  Stable per entity for life. Hash over entityId alone, so
              every check / snapshot / review for the same subject
              returns the same lineageKey forever.

  runId       Stable per evidence-snapshot. Hash over entityId +
              lastCheckedAt + sorted artifact checksums + channel. Two
              processes on two machines with the same persisted inputs
              compute the same runId. Different evidence → different
              runId.

Survivability:
  refresh   pure function; recomputed identically every request.
  restart   same DB inputs → same outputs.
  deploy    no in-memory state; scheme prefixed with `v1` for forward
            compatibility (any future input-set change must bump to v2).
  degraded  a run that produced no artifacts gets a distinct
            deterministic id (NOT a fallback random); a verifier
            comparing two ids can tell a degraded run apart from a
            complete one by their hashes.

Wiring:
  - apps/api/backend/src/services/replay/replayIdentity.ts
      new: computeLineageKey, computeRunId, computeReplayIdentity,
           isV1LineageKey, isV1RunId. 24 jest tests pinning
           determinism, sensitivity, ordering invariance, degraded
           distinguishability, scheme-version recognition.
  - apps/api/backend/src/services/passport/npiPassportContract.ts
      buildPassportDataByNpi now emits a `replay` field on every
      PassportDataContract response. Inputs: entityId, computed
      lastCheckedAt, the legacy verification-artifact ids that
      contributed to the credential set.
  - apps/web/lib/trust/passport-contract.ts
      PassportData gains optional `replay: PassportReplayIdentity`.
      Validator accepts undefined for backward compat; rejects
      malformed (missing lineageKey/runId or wrong schemeVersion).
  - apps/web/__tests__/passport-replay-identity.test.ts
      8 vitest cases on the validator contract.

What this PR does NOT do (explicit deferred scope):
  - No schema migration. Persistence relies on the existing inputs
    (entityId, lastCheckedAt, artifact.id) which are already DB-backed.
  - No frontend rendering wiring. The Lane B primitives RunIdentity /
    ReplayLineage already exist; threading `replay.runId` into the
    surface props is the Wave 6 follow-up.
  - No Recent-NPIs / history-rows UI (Wave 6).

Validation: 24/24 backend jest + 8/8 web vitest + 13/13 web build.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vcv-web Ready Ready Preview, Comment May 12, 2026 1:27pm
vitalcv Ready Ready Preview, Comment May 12, 2026 1:27pm

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92f9cef284

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +703 to +706
// Legacy PassportCredential carries an `id` (the VerificationArtifact
// primary key) — that is itself stable per artifact and persisted.
if (typeof cred.id === 'string' && cred.id.length > 0) {
out.push(cred.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive replay inputs from artifact checksums, not credential IDs

collectReplayArtifactChecksums currently feeds computeRunId with cred.id values, but these are record IDs, not evidence fingerprints. Because artifacts can be updated in place (e.g. monitoring updates rawPayload/checksum on the same VerificationArtifact row), evidence may change while IDs stay constant, so runId can remain unchanged even though the snapshot content changed. This breaks the stated replay contract that run identity changes when evidence changes; use persisted checksum/fingerprint fields from the underlying artifacts instead of credential IDs.

Useful? React with 👍 / 👎.

ctol3r pushed a commit that referenced this pull request May 12, 2026
Adds the cryptographically-signed receipt surface that an external
verifier needs to validate a clinician passport snapshot end-to-end:

  1. fetch the receipt at /api/receipt/<npi>
  2. fetch the JWKS at /.well-known/jwks.json (from Wave 9)
  3. validate the JWT signature against the JWKS

The new route signs an ES256 JWT using the same getOrInitKeypair()
that produces the public key published at /.well-known/jwks.json
(Wave 9), so kid + key never diverge between issuance and
discoverability.

Receipt shape (ES256 JWT):
  header   { alg: 'ES256', kid: <active-kid>, typ: 'vc+jwt' }
  payload  { iss: <did:web issuer>, sub: 'npi:<npi>',
             jti: 'receipt:<runId>' (deterministic per snapshot),
             iat: <lastCheckedAt seconds>, nbf, exp = iat + 90d,
             vc: W3C VC 2.0 credential block (VitalCVTrustReceipt),
             vcv: VitalCV claim block incl. replay identity }

Determinism contract (test-pinned):
  - jti = 'receipt:<replay.runId>' so the same evidence snapshot
    yields the same receipt jti; the JWT signature is non-
    deterministic per ES256 spec but the payload is byte-identical.
  - iat is pinned to lastCheckedAt seconds so two receipts for the
    same snapshot share iat. A verifier joining receipts by
    (lineageKey, runId) gets unambiguous snapshot identity.

Verifier readability:
  - vcv.jwksUri and vcv.didDocumentUri point at the well-known
    surfaces #349 published, so a receipt is self-describing.
  - X-Receipt-Kid + X-Receipt-Issuer response headers let a verifier
    short-circuit JWKS resolution if it already has the key cached.
  - ?format=download adds Content-Disposition: attachment so a
    verifier engineer can save the JWT for offline validation.

Tests (10 vitest cases, all passing):
  - 400 on non-10-digit NPI, 502 on malformed upstream, propagated
    backend status on upstream non-ok
  - 200 + application/jwt + ES256 + kid header + 'vc+jwt' typ
  - W3C VC 2.0 + VitalCV claim block shape (credentialSubject,
    replay.{lineageKey,runId,schemeVersion}, jwksUri, didDocumentUri)
  - Determinism: same fetch → same jti, same iat
  - End-to-end verifier flow: receipt JWT validates against the
    JWKS the well-known route exposes (createLocalJWKSet + jwtVerify)
  - ?format=download adds Content-Disposition: attachment
  - Graceful no-replay fallback (jti from npi when replay absent)

Validation: 10/10 vitest passing; pnpm turbo run build --filter
@vitalcv/web → 13/13 tasks; truth-strings scan CLEAN.

Stacked on the rest of this branch (#349's well-known surfaces +
wellKnownIdentity helper). Brief 1 of this turn (replay-lineage
rendering) is structurally already shipped across #341 + #342 +
#343 + #345 — no rendering changes needed here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants