/** * Typed freshness model for gateway-fetched collections (RI-5-001). * * A failed or stale fetch must never be indistinguishable from an empty * healthy collection. Every fetched surface carries an explicit freshness * state, a verified snapshot identity (source, workspace, version, age), and * a mutation guard that refuses state-changing operations unless the data is * verified current. */ /** Freshness states for fetched data. Never inferred from emptiness. */ export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable'; /** * Reasons a snapshot is invalidated. An invalidated snapshot is treated as * unavailable and is never rendered as current. */ export type InvalidationReason = | 'cache-corruption' | 'cross-workspace' | 'schema-mismatch' | 'version-regression'; /** Human-readable labels for invalidation reasons (UI + error messages). */ export const invalidationReasonLabels: Record = { 'cache-corruption': 'cached snapshot failed integrity checks', 'cross-workspace': 'data belongs to a different workspace', 'schema-mismatch': 'response did not match the expected schema', 'version-regression': 'snapshot version regressed below the accepted version', }; /** A verified snapshot of fetched data with full provenance. */ export interface FreshSnapshot { readonly data: T; /** Source identity of the fetch, e.g. `gateway:/api/tasks`. */ readonly source: string; /** Workspace scope the data belongs to. */ readonly workspace: string; /** Monotonic snapshot sequence number for this surface. */ readonly version: number; /** Schema version of the validator that accepted this snapshot. */ readonly schemaVersion: number; /** Epoch ms at which the data was verified. */ readonly fetchedAt: number; /** Integrity digest of `data`, used to detect cache corruption. */ readonly digest: string; } /** Provenance label rendered next to last-known data. */ export interface FreshnessLabel { readonly source: string; readonly version: number; readonly fetchedAt: number; } /** Policy governing freshness for a surface. */ export interface FreshnessPolicy { /** Active workspace scope. Snapshots from other scopes are invalidated. */ readonly workspace: string; /** Schema version of the current validator. */ readonly schemaVersion: number; /** Age after which a verified snapshot degrades from current to stale. */ readonly staleAfterMs: number; } export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = { workspace: 'default', schemaVersion: 1, staleAfterMs: 60_000, }; /** Payload returned by a successful schema validation. */ export interface FreshPayload { readonly data: T; /** * Workspace identity extracted from the payload itself when the collection * carries one (e.g. a uniform `userId` on projects). `null` when the * collection has no intrinsic workspace identity. */ readonly workspace: string | null; } /** Error thrown when a mutation is attempted on non-current data. */ export class StaleMutationError extends Error { readonly freshness: FreshnessState; constructor(freshness: FreshnessState) { super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`); this.name = 'StaleMutationError'; this.freshness = freshness; } } /** Stable JSON digest used for snapshot integrity checks. */ export function computeDigest(value: unknown): string { // FNV-1a 32-bit over the stable JSON serialization. This is an integrity // check against corruption, not a cryptographic guarantee. let hash = 0x811c9dc5; for (const byte of stableStringify(value)) { hash ^= byte.charCodeAt(0); hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(16).padStart(8, '0'); } function stableStringify(value: unknown): string { return serialize(value); } function serialize(value: unknown): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`; const entries = Object.entries(value as Record) .filter(([, item]) => item !== undefined) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`); return `{${entries.join(',')}}`; } export type AcceptSnapshotResult = | { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot } | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; export interface AcceptSnapshotOptions { /** Raw fetched value (untrusted JSON). */ readonly value: unknown; /** Schema validator; returns `null` when the value does not match. */ readonly validate: (value: unknown) => FreshPayload | null; /** Previously accepted snapshot for this surface, if any. */ readonly previous: FreshSnapshot | null; readonly policy: FreshnessPolicy; readonly source: string; /** * Version carried by the incoming payload when the transport exposes one. * Must not regress below the accepted snapshot's version. */ readonly incomingVersion?: number; readonly now: number; } /** * Validate and accept a fetched value as a snapshot, or invalidate it. * * Invalidation rules (each treated as unavailable, never rendered current): * - schema mismatch: the payload fails validation * - cross-workspace: the payload's workspace differs from the verified one * - version regression: payload/schema version is below the accepted one */ export function acceptSnapshot(options: AcceptSnapshotOptions): AcceptSnapshotResult { const payload = options.validate(options.value); if (payload === null) { return { outcome: 'invalidated', reason: 'schema-mismatch' }; } // Workspace identity: the payload's own scope wins; a collection with no // intrinsic identity (e.g. an empty list after every project was deleted) // keeps the previously verified scope rather than resetting to the policy // default, so a legitimately empty response is not mistaken for a scope // change. const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace; if (options.previous !== null && options.previous.workspace !== workspace) { return { outcome: 'invalidated', reason: 'cross-workspace' }; } if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) { return { outcome: 'invalidated', reason: 'version-regression' }; } if ( options.incomingVersion !== undefined && options.previous !== null && options.incomingVersion < options.previous.version ) { return { outcome: 'invalidated', reason: 'version-regression' }; } const snapshot: FreshSnapshot = { data: payload.data, source: options.source, workspace, version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1, schemaVersion: options.policy.schemaVersion, fetchedAt: options.now, digest: computeDigest(payload.data), }; return { outcome: 'accepted', snapshot }; } export interface ComputeFreshnessOptions { readonly snapshot: FreshSnapshot | null; readonly policy: FreshnessPolicy; readonly now: number; /** * True when the snapshot cannot be trusted as current regardless of age: * the latest revalidation failed, or the snapshot was restored from cache * and has not been verified by a fetch in this session. */ readonly degraded?: boolean; } /** * Compute the freshness state of a snapshot. A missing snapshot is * `unavailable` (never "empty and healthy"); a degraded or aged snapshot is * `stale` (situational awareness only). */ export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState { const { snapshot, policy, now, degraded = false } = options; if (snapshot === null) return 'unavailable'; if (degraded) return 'stale'; if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale'; return 'current'; } /** Only verified-current data may back a state-changing action. */ export function canMutate(state: FreshnessState): boolean { return state === 'current'; } /** Defense in depth: reject the mutation call itself on non-current data. */ export function assertMutable(state: FreshnessState): void { if (!canMutate(state)) { throw new StaleMutationError(state); } } /** * Combine freshness across a multi-collection surface (primary + secondaries). * The primary collection gates the surface: unknown while it loads, * unavailable when it fails. Missing secondaries degrade the surface to * `partial`; aged collections degrade it to `stale`. */ export function combineFreshness( primary: FreshnessState, secondaries: readonly FreshnessState[], ): FreshnessState { if (primary === 'unavailable') return 'unavailable'; if (primary === 'unknown') return 'unknown'; if (secondaries.includes('unavailable')) return 'partial'; if (secondaries.includes('unknown')) return 'unknown'; if (secondaries.includes('stale') || primary === 'stale') return 'stale'; if (secondaries.includes('partial')) return 'partial'; return 'current'; } /** Render-safe age label for snapshot provenance. */ export function formatAge(fetchedAt: number, now: number): string { const ageMs = Math.max(0, now - fetchedAt); if (ageMs < 10_000) return 'just now'; const minutes = Math.floor(ageMs / 60_000); if (minutes < 1) return 'under a minute ago'; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } /** Derived verdict placeholder for non-current inputs — never a green value. */ export const UNKNOWN_VERDICT = '?'; export function verdictValue(verified: boolean, value: string): string { return verified ? value : UNKNOWN_VERDICT; }