feat(replay): canonical deterministic replay identity (Wave 10)#343
feat(replay): canonical deterministic replay identity (Wave 10)#343ctol3r wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
💡 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".
| // 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); |
There was a problem hiding this comment.
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 👍 / 👎.
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.
Summary
Establishes a survivability-by-construction identity contract: every passport snapshot now carries deterministic
lineageKey+runIdids 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
lineageKeyentityIdalonerunIdentityId+lastCheckedAt+ sorted artifact checksums + channelScheme prefixed with
v1(lin_v1_…,run_v1_…). Any future input-set change MUST bump tov2so older ids never collide with new ones.Survivability matrix
v1→ identical idcomputeRunId→ identical idFiles
apps/api/backend/src/services/replay/replayIdentity.tsapps/api/backend/src/services/replay/__tests__/replayIdentity.test.tsapps/api/backend/src/services/passport/npiPassportContract.tsreplayintoPassportDataContractapps/web/lib/trust/passport-contract.tsPassportData+ validatorapps/web/__tests__/passport-replay-identity.test.tsOut of scope (explicit follow-ups)
entityId,lastCheckedAt,artifact.idfields. No DB changes.RunIdentity,ReplayLineage) and Wave 2 surface adoption (feat(trust): adopt canonical primitives across 5 trust surfaces (Wave 2) #342) are ready to consumereplay.runId/replay.lineageKeyonce the surface props are threaded through. That threading is Wave 6./replay/[id]page — Wave 9 scope.Truth rules
Validation
src/services/replay/__tests__/replayIdentity.test.ts)__tests__/passport-replay-identity.test.ts)pnpm turbo run build --filter @vitalcv/web→ 13/13 tasks, 42s