Stage 4: data model — typed contracts + validation + time model

- src/data/festival-package/types.ts: PackageFormat, FestivalManifest, FestivalMetadata, AppCompatibility, SectionEntry/SectionMap, Counts/Limits, PackageSignature, AssetEntry/AssetsInventory, LatestPointer, Emergency/Schedule/Map/Info sections, FestivalPackageV1 — per SPIKE-04 §2 normative fragments, stable IDs, required flag, SHA-256 hex, 6MB per-file, 50MB ceiling, edition+packageVersion identity, no expiration (SPIKE-04 F-2), emergency sub-versioning F-3
- src/data/festival-package/validation.ts: pure validation (malformed/missing/invalid IDs/versions/timestamps/ranges/incompatible refs/unknown forward-compat allowed per SPIKE-04 gate 1) — validateManifest/validateSignature/validateScheduleEvent/validateMapPoi/isCompatible (schemaRange + semver), validatePackage/validateEmergencyFloor/isStableId; budgets enforced, 24h event cap, dayKey YYYY-MM-DD, x/y 0..1, lat/lng null GPS optional I-7
- src/data/user/types.ts: FavoriteEntry, FavoriteMap, UserPrefs, DiagEntry, UserStoreShape (schemaVersion separate per SPIKE-04 F-4, B-6 never part of package)
- src/emergency-baseline/types.ts: EmergencyFloor floor:true + floorVersion + emergencySchemaVersion + generatedAt + sourceContentVersion, FLOOR_SIZE_BUDGET 16KB (ADR-007, SPIKE-06 §1, IMP §14)
- src/domain/clock/types.ts + logic.ts: UTC epoch + IANA zone + dayKeyFor via Intl ECMA-402 offline, ClockSkew (capturedAtDevice/Mono), FestivalWindow, renderInZone, dayKeyFor, correctedNow, hasDrift (2min threshold), shouldWarnOutsideWindow with F-3 suppression (now>=start-45d or skew), classifyNowNext (start<=now<end, next 2 sorted) — EXP-1 authoritative 24/24 reproduced
- src/domain/readiness/types.ts: ReadinessInput C1..C8 + computeReadiness time-independent (SPIKE-05), 6-state taxonomy
- Tests: tests/unit/data-package.test.ts (32 tests: valid package, missing, malformed IDs/versions, timestamps/ranges, window, POI, stable IDs, optional/forward-compat, incompatible versions, emergency floor, separation, metadata, readiness), tests/unit/time-model.test.ts (19 tests: T1a/b, T2a/b/c, T3a/b/c/d/e, T4a/b/c/d, T5 skew/drift, T6 F-3, T7 Now/Next, offline-ready independence) — covers Stage 4 checklist, 85 tests total
- Boundaries: B-1..B-7 intact, no IDB/Cache/SW/sync/dataset activation, no runtime deps, separation authoritative vs user vs baseline enforced
This commit is contained in:
Lumen Stage1 2026-08-30 23:42:12 -05:00
commit 8e71537d57
10 changed files with 1249 additions and 8 deletions

View file

@ -0,0 +1,249 @@
/**
* Festival Data Package v1 — typed contracts.
* Authoritative festival data (organizer-supplied, signed, distributed).
* Not user data, not emergency baseline (separate concerns per IMP contract §CRITICAL).
* Trace: ADR-005, SPIKE-04 §2, ARCHITECTURE-DESIGN.md §10, §15
*/
export const PACKAGE_FORMAT = "lumen.package/1" as const;
export type PackageFormat = typeof PACKAGE_FORMAT;
// ——— Manifest ———
export interface FestivalMetadata {
readonly name: string;
readonly timezone: string; // IANA, e.g. "America/Chicago"
readonly startUtc: number; // epoch ms
readonly endUtc: number; // epoch ms
}
export interface AppCompatibility {
readonly minAppVersion: string; // semver, e.g. "1.0.0"
readonly maxAppVersion: string | null;
}
export interface SectionEntry {
readonly file: string; // e.g. "emergency.json"
readonly sha256: string; // hex 64
readonly bytes: number;
/** default true — readiness gates on required only (SPIKE-04 F-1, SPIKE-05) */
readonly required?: boolean;
}
export type SectionId = "emergency" | "schedule" | "map" | "info" | "assets";
export type SectionMap = Record<SectionId, SectionEntry>;
export interface PackageCounts {
readonly events: number;
readonly pois: number;
readonly assets: number;
}
export interface PackageLimits {
readonly totalBytes: number;
}
export interface FestivalManifest {
readonly format: PackageFormat;
readonly edition: string; // e.g. "lumen-2026"
readonly packageVersion: number; // monotonic int per edition
readonly schemaVersion: number; // int
readonly generatedAt: string; // ISO8601 UTC
readonly festival: FestivalMetadata;
readonly appCompatibility: AppCompatibility;
readonly sections: SectionMap;
readonly counts: PackageCounts;
readonly limits: PackageLimits;
}
// ——— Signature ———
export interface PackageSignature {
readonly algorithm: "ed25519";
readonly over: string; // "sha256(manifest.json exact bytes)"
readonly manifestSha256: string; // hex 64
readonly publicKeyFingerprint: string; // "sha256:…" hex
readonly signature: string; // base64
}
// ——— Assets ———
export type AssetKind = "map-base" | "poi-icon" | "photo" | "icon";
export type AssetRole = string; // e.g. "overview" | "detail" | "map-base/overview-night"
export interface AssetEntry {
readonly id: string; // e.g. "map-base-overview"
readonly file: string; // "assets/map-base-overview.webp"
readonly kind: AssetKind;
readonly role: AssetRole;
readonly sha256: string;
readonly bytes: number;
}
export interface AssetsInventory {
readonly assets: readonly AssetEntry[];
}
// ——— Latest pointer ———
export interface LatestPointer {
readonly edition: string;
readonly packageVersion: number;
readonly manifestUrl: string;
readonly generatedAt: string;
}
// ——— Section: Emergency ———
export interface EmergencySection {
readonly section: "emergency";
readonly emergencySchemaVersion: number; // independent sub-versioning (SPIKE-04 F-3)
readonly contentVersion: number; // monotonic
readonly updatedAt: string; // ISO8601
readonly services: {
readonly emergencyNumber: string; // data-driven, e.g. "911"
readonly security: { readonly phone: string; readonly location?: string };
readonly firstAid: { readonly location: string; readonly hours?: string };
};
readonly locations: {
readonly musterPoints: readonly {
readonly id: string;
readonly name: string;
readonly poi: string;
}[];
readonly exits: readonly { readonly id: string; readonly name: string; readonly poi: string }[];
readonly aeds: readonly { readonly poi: string }[];
};
readonly address: {
readonly lines: readonly string[];
readonly coordinates: { readonly lat: number; readonly lon: number };
};
readonly procedures: readonly {
readonly id: string;
readonly title: string;
readonly steps: readonly string[];
}[];
readonly notices?: readonly {
readonly id: string;
readonly severity: string;
readonly title: string;
readonly body: readonly unknown[];
readonly expiresAtUtc: number | null;
}[];
}
// ——— Section: Schedule ———
export type EventStatus = "scheduled" | "moved" | "cancelled";
export interface ScheduleStage {
readonly id: string;
readonly name: string;
}
export interface ScheduleArtist {
readonly id: string;
readonly name: string;
}
export interface ScheduleEvent {
readonly id: string; // stable, contractual
readonly title: string;
readonly description?: string;
readonly stageId: string;
readonly artistIds: readonly string[];
readonly startUtc: number;
readonly endUtc: number;
readonly dayKey: string; // precomputed festival-zone date YYYY-MM-DD
readonly tags: readonly string[];
readonly status: EventStatus;
readonly originalStartUtc?: number;
}
export interface ScheduleSection {
readonly section: "schedule";
readonly stages: readonly ScheduleStage[];
readonly artists: readonly ScheduleArtist[];
readonly events: readonly ScheduleEvent[];
}
// ——— Section: Map ———
export interface MapLevel {
readonly id: string; // "overview" | "detail"
readonly assetId: string;
readonly width: number;
readonly height: number;
readonly nightAssetId?: string; // optional F-4 dark variant
}
export type PoiCategory =
| "stage"
| "restroom"
| "water"
| "food"
| "first-aid"
| "aed"
| "security"
| "entrance"
| "exit"
| "parking"
| "camping"
| "vip"
| "muster"
| "info"
| "vendor"
| "other";
export interface MapPoi {
readonly id: string;
readonly name: string;
readonly category: PoiCategory;
readonly x: number; // normalized 0..1
readonly y: number; // normalized 0..1
readonly description?: string;
readonly lat: number | null;
readonly lng: number | null;
}
export interface MapSection {
readonly section: "map";
readonly base: { readonly levels: readonly MapLevel[] };
readonly pois: readonly MapPoi[];
readonly categories: readonly PoiCategory[];
}
// ——— Section: Info ———
export type InfoBlockKind =
"paragraph" | "list" | "link" | "emphasis" | "contact" | "address" | "hours";
export interface InfoBlock {
readonly id: string;
readonly title: string;
readonly kind: string; // data-driven category: About, Rules, FAQ...
readonly body: readonly {
readonly kind: InfoBlockKind;
readonly text?: string;
readonly items?: readonly string[];
readonly href?: string;
}[];
}
export interface InfoSection {
readonly section: "info";
readonly blocks: readonly InfoBlock[];
}
// ——— Package as distributed (immutable directory) ———
export interface FestivalPackageV1 {
readonly manifest: FestivalManifest;
readonly signature: PackageSignature;
readonly emergency: EmergencySection;
readonly schedule: ScheduleSection;
readonly map: MapSection;
readonly info: InfoSection;
readonly assets: AssetsInventory;
}

View file

@ -0,0 +1,245 @@
/**
* Festival Data Package validation — pure, no I/O, no crypto runtime (Stage 4).
* Rejects malformed/missing/invalid; allows unknown optional fields (forward compat per SPIKE-04 gate 1).
* Trace: ADR-005, SPIKE-04 §3, IMPLEMENTATION-CONTRACT.md §11, §15
*/
import type { FestivalManifest, SectionId } from "./types.js";
export type ValidationResult =
{ readonly ok: true } | { readonly ok: false; readonly reason: string };
const HEX64 = /^[0-9a-f]{64}$/i;
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].+)?$/;
const DAYKEY = /^\d{4}-\d{2}-\d{2}$/;
const PRINTABLE_ID = /^[a-z0-9][a-z0-9-_]*$/i;
function isFiniteNumber(n: unknown): n is number {
return typeof n === "number" && Number.isFinite(n);
}
function isNonEmptyString(s: unknown): s is string {
return typeof s === "string" && s.trim().length > 0;
}
export function validateEdition(v: unknown): ValidationResult {
if (!isNonEmptyString(v)) return { ok: false, reason: "edition must be non-empty string" };
return { ok: true };
}
export function validatePackageVersion(v: unknown): ValidationResult {
if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)
return { ok: false, reason: "packageVersion must be integer >=1" };
return { ok: true };
}
export function validateSchemaVersion(v: unknown): ValidationResult {
if (!isFiniteNumber(v) || !Number.isInteger(v) || v < 1)
return { ok: false, reason: "schemaVersion must be integer >=1" };
return { ok: true };
}
export function validateIso8601(s: unknown): ValidationResult {
if (typeof s !== "string" || Number.isNaN(Date.parse(s)))
return { ok: false, reason: "must be ISO8601 UTC" };
return { ok: true };
}
export function validateSha256(s: unknown): ValidationResult {
if (typeof s !== "string" || !HEX64.test(s))
return { ok: false, reason: "sha256 must be 64 hex" };
return { ok: true };
}
export function validateBytes(n: unknown): ValidationResult {
if (!isFiniteNumber(n) || !Number.isInteger(n) || n < 0 || n > 6 * 1024 * 1024)
return { ok: false, reason: "bytes must be 0..6MB (per-file cap)" };
return { ok: true };
}
export function validateAppVersion(s: unknown): ValidationResult {
if (typeof s !== "string" || !SEMVER.test(s))
return { ok: false, reason: "appVersion must be semver" };
return { ok: true };
}
export function validateFestivalWindow(startUtc: unknown, endUtc: unknown): ValidationResult {
if (!isFiniteNumber(startUtc) || !isFiniteNumber(endUtc))
return { ok: false, reason: "festival start/end must be epoch ms" };
if (endUtc <= startUtc) return { ok: false, reason: "festival end must be after start" };
return { ok: true };
}
export function validateManifest(m: unknown): ValidationResult {
if (typeof m !== "object" || m === null) return { ok: false, reason: "manifest must be object" };
const o = m as Record<string, unknown>;
if (o.format !== "lumen.package/1")
return { ok: false, reason: "format must be lumen.package/1" };
const edition = validateEdition(o.edition);
if (!edition.ok) return edition;
const pv = validatePackageVersion(o.packageVersion);
if (!pv.ok) return pv;
const sv = validateSchemaVersion(o.schemaVersion);
if (!sv.ok) return sv;
const ga = validateIso8601(o.generatedAt);
if (!ga.ok) return { ok: false, reason: `generatedAt: ${ga.reason}` };
const fest = o.festival as Record<string, unknown> | undefined;
if (!fest || typeof fest !== "object") return { ok: false, reason: "festival missing" };
if (!isNonEmptyString(fest.name)) return { ok: false, reason: "festival.name required" };
if (!isNonEmptyString(fest.timezone)) return { ok: false, reason: "festival.timezone required" };
const win = validateFestivalWindow(fest.startUtc, fest.endUtc);
if (!win.ok) return win;
const ac = o.appCompatibility as Record<string, unknown> | undefined;
if (!ac || typeof ac !== "object") return { ok: false, reason: "appCompatibility missing" };
const minV = validateAppVersion(ac.minAppVersion);
if (!minV.ok) return minV;
if (ac.maxAppVersion !== null) {
const maxV = validateAppVersion(ac.maxAppVersion);
if (!maxV.ok) return { ok: false, reason: `maxAppVersion: ${maxV.reason}` };
}
const sections = o.sections as Record<string, unknown> | undefined;
if (!sections) return { ok: false, reason: "sections missing" };
const requiredIds: SectionId[] = ["emergency", "schedule", "map", "info", "assets"];
for (const id of requiredIds) {
const e = sections[id] as Record<string, unknown> | undefined;
if (!e) return { ok: false, reason: `section ${id} missing` };
if (!isNonEmptyString(e.file)) return { ok: false, reason: `${id}.file required` };
const sha = validateSha256(e.sha256);
if (!sha.ok) return { ok: false, reason: `${id}.sha256: ${sha.reason}` };
const b = validateBytes(e.bytes);
if (!b.ok) return { ok: false, reason: `${id}.bytes: ${b.reason}` };
if (e.required !== undefined && typeof e.required !== "boolean")
return { ok: false, reason: `${id}.required must be boolean` };
}
// counts/limits presence (values validated elsewhere by budgets)
const counts = o.counts as Record<string, unknown> | undefined;
if (!counts || !isFiniteNumber(counts.events) || !isFiniteNumber(counts.pois))
return { ok: false, reason: "counts missing or invalid" };
const limits = o.limits as Record<string, unknown> | undefined;
if (!limits || !isFiniteNumber(limits.totalBytes))
return { ok: false, reason: "limits.totalBytes missing" };
if (limits.totalBytes > 50 * 1024 * 1024)
return { ok: false, reason: "totalBytes exceeds 50MB hard ceiling" };
// monotonic and budgets are cross-package checks, not single-manifest — allow here
return { ok: true };
}
export function validateSignature(s: unknown): ValidationResult {
if (typeof s !== "object" || s === null) return { ok: false, reason: "signature must be object" };
const o = s as Record<string, unknown>;
if (o.algorithm !== "ed25519") return { ok: false, reason: "algorithm must be ed25519" };
if (!isNonEmptyString(o.over)) return { ok: false, reason: "over required" };
const sha = validateSha256(o.manifestSha256);
if (!sha.ok) return { ok: false, reason: `manifestSha256: ${sha.reason}` };
if (!isNonEmptyString(o.publicKeyFingerprint))
return { ok: false, reason: "publicKeyFingerprint required" };
if (!isNonEmptyString(o.signature)) return { ok: false, reason: "signature required" };
return { ok: true };
}
export function validateEventId(id: unknown): ValidationResult {
if (!isNonEmptyString(id) || !PRINTABLE_ID.test(id) || id.length > 64)
return { ok: false, reason: "event id must match printable id, max 64" };
return { ok: true };
}
export function validateScheduleEvent(e: unknown): ValidationResult {
if (typeof e !== "object" || e === null) return { ok: false, reason: "event must be object" };
const o = e as Record<string, unknown>;
const id = validateEventId(o.id);
if (!id.ok) return id;
if (!isNonEmptyString(o.title)) return { ok: false, reason: "title required" };
if (!isNonEmptyString(o.stageId)) return { ok: false, reason: "stageId required" };
if (!Array.isArray(o.artistIds)) return { ok: false, reason: "artistIds must be array" };
if (!isFiniteNumber(o.startUtc) || !isFiniteNumber(o.endUtc))
return { ok: false, reason: "start/end must be epoch ms" };
if (o.endUtc <= o.startUtc) return { ok: false, reason: "end must be after start" };
if (o.endUtc - o.startUtc > 24 * 3600 * 1000)
return { ok: false, reason: "event longer than 24h" };
if (typeof o.dayKey !== "string" || !DAYKEY.test(o.dayKey))
return { ok: false, reason: "dayKey must be YYYY-MM-DD" };
const status = o.status;
if (status !== "scheduled" && status !== "moved" && status !== "cancelled")
return { ok: false, reason: "status must be scheduled|moved|cancelled" };
// forward-compat: allow unknown optional fields, no fail
return { ok: true };
}
export function validateMapPoi(p: unknown): ValidationResult {
if (typeof p !== "object" || p === null) return { ok: false, reason: "poi must be object" };
const o = p as Record<string, unknown>;
if (!isNonEmptyString(o.id)) return { ok: false, reason: "poi id required" };
if (!isFiniteNumber(o.x) || o.x < 0 || o.x > 1) return { ok: false, reason: "x must be 0..1" };
if (!isFiniteNumber(o.y) || o.y < 0 || o.y > 1) return { ok: false, reason: "y must be 0..1" };
// lat/lng may be null (GPS optional)
if (o.lat !== null && o.lat !== undefined && !isFiniteNumber(o.lat))
return { ok: false, reason: "lat must be number or null" };
if (o.lng !== null && o.lng !== undefined && !isFiniteNumber(o.lng))
return { ok: false, reason: "lng must be number or null" };
return { ok: true };
}
export function isCompatible(
manifest: FestivalManifest,
shellSupportedRange: readonly number[],
currentAppVersion: string,
): boolean {
if (!shellSupportedRange.includes(manifest.schemaVersion)) return false;
// simple semver compare for min/max — compare major.minor.patch numerically
const cmp = (a: string, b: string): number => {
const pa = a.split(".").map((n) => Number.parseInt(n, 10));
const pb = b.split(".").map((n) => Number.parseInt(n, 10));
for (let i = 0; i < 3; i++) {
const av = pa[i] ?? 0;
const bv = pb[i] ?? 0;
if (av !== bv) return av - bv;
}
return 0;
};
if (cmp(currentAppVersion, manifest.appCompatibility.minAppVersion) < 0) return false;
if (
manifest.appCompatibility.maxAppVersion !== null &&
cmp(currentAppVersion, manifest.appCompatibility.maxAppVersion) > 0
)
return false;
return true;
}
export function validatePackage(pkg: unknown): ValidationResult {
if (typeof pkg !== "object" || pkg === null)
return { ok: false, reason: "package must be object" };
const o = pkg as Record<string, unknown>;
const mv = validateManifest(o.manifest);
if (!mv.ok) return { ok: false, reason: `manifest: ${mv.reason}` };
const sig = validateSignature(o.signature);
if (!sig.ok) return { ok: false, reason: `signature: ${sig.reason}` };
// Sections presence is already via manifest sections; validate at least emergency shape
const emergency = o.emergency as Record<string, unknown> | undefined;
if (emergency?.section !== "emergency")
return { ok: false, reason: "emergency section missing or wrong" };
if (!isFiniteNumber(emergency.emergencySchemaVersion))
return { ok: false, reason: "emergencySchemaVersion required" };
// schedule/map/info/assets validated via manifest presence; deep validation is stage pipeline
return { ok: true };
}
// Pure helper for stable-ID check (favorites survive updates) — not a full diff, just contract
export function isStableId(id: string, prevIds: ReadonlySet<string>): boolean {
return prevIds.has(id);
}
export function validateEmergencyFloor(floor: unknown): ValidationResult {
if (typeof floor !== "object" || floor === null)
return { ok: false, reason: "floor must be object" };
const o = floor as Record<string, unknown>;
if (o.floor !== true) return { ok: false, reason: "floor must be true" };
if (!isNonEmptyString(o.floorVersion)) return { ok: false, reason: "floorVersion required" };
if (!isFiniteNumber(o.emergencySchemaVersion))
return { ok: false, reason: "emergencySchemaVersion required" };
return { ok: true };
}

35
src/data/user/types.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Local user data — never part of Festival Data Package, never overwritten by dataset updates.
* Trace: IMPLEMENTATION-CONTRACT.md §24, DISCOVERY.md:545 §15.8, SPIKE-04 F-4
*/
export interface FavoriteEntry {
readonly eventId: string; // stable event id
readonly addedAt: number; // epoch ms
}
export type FavoriteMap = ReadonlyMap<string, FavoriteEntry>;
export interface UserPrefs {
readonly theme?: "light" | "dark" | "system";
readonly festivalTimezone: string; // IANA zone user has chosen for display; default festival zone
readonly useDeviceTimezone: boolean;
}
export interface DiagEntry {
readonly at: number;
readonly kind: string;
readonly detail?: string;
}
export interface UserStoreShape {
readonly favorites: readonly FavoriteEntry[]; // persisted as array, keyed by eventId at runtime
readonly prefs: UserPrefs;
readonly diag: readonly DiagEntry[];
/** separate schema version for user data (SPIKE-04 F-4) */
readonly schemaVersion: number;
}
export function isFavoriteMap(map: ReadonlyMap<string, unknown>, eventId: string): boolean {
return map.has(eventId);
}

89
src/domain/clock/logic.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Clock logic — pure, no browser globals except where injected.
* EXP-1 authoritative behavior is reproduced here.
* Trace: SPIKE-08 §3, experiments/exp1-time-model.mjs
*/
import type { ClockOptions, ClockSkew, FestivalWindow } from "./types.js";
export const DEFAULT_WINDOW_DAYS = 45;
const MS_PER_DAY = 24 * 3600 * 1000;
/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */
export function renderInZone(epochMs: number, timeZone: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date(epochMs));
}
/** Precomputed dayKey at publish time: calendar date in festival zone. */
export function dayKeyFor(epochMs: number, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date(epochMs));
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "";
return `${get("year")}-${get("month")}-${get("day")}`;
}
/** Corrected now: device + skew if present. */
export function correctedNow(deviceNow: number, skew: ClockSkew | null): number {
return skew ? deviceNow + skew.skewMs : deviceNow;
}
/** Monotonic drift detection: |observed - (base + monoElapsed)| > threshold */
export function hasDrift(
baseDevice: number,
monoElapsed: number,
observedDevice: number,
thresholdMs = 120_000,
): boolean {
const expected = baseDevice + monoElapsed;
return Math.abs(observedDevice - expected) > thresholdMs;
}
/** Sanity window check with F-3 suppression (SPIKE-08 F-3). */
export function shouldWarnOutsideWindow(
nowCorrected: number,
win: FestivalWindow,
skew: ClockSkew | null,
opts: ClockOptions = { windowDays: DEFAULT_WINDOW_DAYS },
): boolean {
const windowMs = opts.windowDays * MS_PER_DAY;
const outside = nowCorrected < win.startUtc - windowMs || nowCorrected > win.endUtc + windowMs;
if (!outside) return false;
// F-3: suppress until near festival or evidence of skew
if (nowCorrected >= win.startUtc - windowMs) return true;
if (skew !== null) return true;
return false;
}
/** Now/Next classification — epoch-ms only, no Intl. */
export interface SchedEventRef {
readonly id: string;
readonly startUtc: number;
readonly endUtc: number;
}
export function classifyNowNext<T extends SchedEventRef>(
events: readonly T[],
now: number,
): { readonly now: readonly T[]; readonly next: readonly T[] } {
const nowEvents = events.filter((e) => e.startUtc <= now && now < e.endUtc);
const next = events
.filter((e) => e.startUtc > now)
.slice()
.sort((a, b) => a.startUtc - b.startUtc)
.slice(0, 2);
return { now: nowEvents, next };
}

26
src/domain/clock/types.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* Time model — ClockService and related types.
* Trace: ADR-009, SPIKE-08, IMPLEMENTATION-CONTRACT.md §21
*/
export interface ClockSkew {
readonly skewMs: number; // serverNow - deviceNow
readonly capturedAtDevice: number; // epoch ms at capture
readonly capturedAtMono: number; // performance.now at capture
readonly source: string; // e.g. "Date header"
}
export interface FestivalWindow {
readonly startUtc: number;
readonly endUtc: number;
readonly timezone: string; // IANA
}
export interface ClockOptions {
readonly windowDays: number; // 45 per ARCH §14.3
}
export interface ClockState {
readonly skew: ClockSkew | null;
readonly window: FestivalWindow;
}

View file

@ -0,0 +1,40 @@
/**
* Offline Ready — inputs for predicate C1…C8.
* Pure types, no I/O.
* Trace: SPIKE-05, IMPLEMENTATION-CONTRACT.md §20
*/
export interface ReadinessInput {
readonly shellValid: boolean; // C1
readonly datasetPresent: boolean; // C2
readonly authenticityOk: boolean; // C3
readonly integrityOk: boolean; // C4
readonly schemaCompatible: boolean; // C5
readonly requiredSectionsOk: boolean; // C6
readonly requiredAssetsOk: boolean; // C7
readonly emergencyFloorOk: boolean; // C8
}
export type ReadinessState =
"READY" | "PARTIAL" | "NOT_READY" | "RECOVERY" | "BASELINE_ONLY" | "FAILED";
export function computeReadiness(i: ReadinessInput): ReadinessState {
if (!i.shellValid) return "FAILED"; // shell itself invalid — not a normal state but keep
// All true => READY (time-independent per SPIKE-05 F-1)
if (
i.datasetPresent &&
i.authenticityOk &&
i.integrityOk &&
i.schemaCompatible &&
i.requiredSectionsOk &&
i.requiredAssetsOk &&
i.emergencyFloorOk
) {
return "READY";
}
// If any of C2…C7 false due to missing data but shell ok, PARTIAL/NOT_READY
// For Stage 4 we distinguish only READY vs not-READY; detailed taxonomy is Stage 9.
// Return PARTIAL as representative for missing required data.
if (!i.datasetPresent) return "NOT_READY";
return "PARTIAL";
}

View file

@ -0,0 +1,28 @@
/**
* Emergency baseline (Ring-0) types — compiled into shell bytes.
* Generated from same emergency source sheet as dataset section (no drift).
* Trace: ADR-007, SPIKE-06 §1, IMPLEMENTATION-CONTRACT.md §14
*/
import type { EmergencySection } from "../data/festival-package/types.js";
export interface EmergencyFloor {
readonly floor: true;
readonly floorVersion: string; // e.g. "1.0.0+2026.08.20"
readonly emergencySchemaVersion: number;
readonly generatedAt: string; // ISO8601
readonly sourceContentVersion: number;
readonly services: EmergencySection["services"];
readonly address: EmergencySection["address"];
readonly musterPoints: readonly { readonly name: string; readonly directions?: string }[];
readonly exits: readonly { readonly name: string }[];
readonly aedSummary: string;
readonly procedures: readonly {
readonly id: string;
readonly title: string;
readonly steps: readonly string[];
}[];
}
// Floor must be ≤16 KB — enforced by build gate, not by types.
export const FLOOR_SIZE_BUDGET = 16 * 1024;

View file

@ -17,24 +17,30 @@ function walkFiles(dir: string, exts = [".ts", ".js"]): string[] {
}
describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
it("src/ contains only Stage 2 shell — no forbidden stores yet", () => {
it("src/ contains shell + Stage 4 data contracts — no forbidden persistence/sync yet", () => {
const tsFiles = walkFiles("src", [".ts"]).sort();
// Stage 2 adds shell (app/layout, router, 4 views) but no IDB/Cache/SW/sync per contract
// Stage 4 adds data contracts (festival-package, user, emergency-baseline, clock/readiness) on top of shell; no IDB/Cache storage/sync persistence
expect(tsFiles).toEqual(
expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]),
expect.arrayContaining([
"src/app/layout.ts",
"src/app/main.ts",
"src/ui/router/router.ts",
"src/data/festival-package/types.ts",
"src/domain/clock/logic.ts",
]),
);
// Forbidden stores must remain empty — Stage 3 allows platform/sw/register only
// Forbidden persistence/sync must remain empty in Stage 4 (types/validation allowed)
const forbidden = tsFiles.filter(
(f) =>
f.startsWith("src/platform/idb/") ||
f.startsWith("src/platform/cache/") ||
f.startsWith("src/storage/") ||
f.startsWith("src/data/") ||
f.startsWith("src/sync/"),
);
expect(forbidden, `Stage 3 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual(
[],
);
expect(
forbidden,
`Stage 4 must not have forbidden persistence/sync: ${forbidden.join(", ")}`,
).toEqual([]);
});
it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => {

View file

@ -0,0 +1,370 @@
/**
* Stage 4 — data contracts tests.
* Covers: valid package, missing/malformed, versions, timestamps, ranges,
* stable IDs, optional sections, forward compat, incompatible, emergency, separation, metadata, readiness inputs.
* Trace: SPIKE-04, IMPLEMENTATION-CONTRACT.md §15, §20, §24, §40
*/
import { describe, it, expect } from "vitest";
import {
validateManifest,
validateSignature,
validateScheduleEvent,
validateMapPoi,
validatePackage,
isCompatible,
validateEdition,
validatePackageVersion,
validateEmergencyFloor,
} from "../../src/data/festival-package/validation.js";
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
import { isStableId } from "../../src/data/festival-package/validation.js";
function validManifest(): FestivalManifest {
return {
format: "lumen.package/1",
edition: "lumen-2026",
packageVersion: 7,
schemaVersion: 1,
generatedAt: "2026-08-28T14:02:11Z",
festival: {
name: "Lumen Festival 2026",
timezone: "America/Chicago",
startUtc: Date.UTC(2026, 8, 10),
endUtc: Date.UTC(2026, 8, 13),
},
appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 41233, required: true },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 412201, required: true },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 38122, required: true },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 96710, required: true },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 7111, required: true },
},
counts: { events: 312, pois: 87, assets: 14 },
limits: { totalBytes: 31240012 },
};
}
function validSignature() {
return {
algorithm: "ed25519" as const,
over: "sha256(manifest.json exact bytes)",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "sha256:abc",
signature: "base64==",
};
}
describe("Festival Data Package — valid package", () => {
it("valid manifest passes", () => {
expect(validateManifest(validManifest()).ok).toBe(true);
});
it("valid signature passes", () => {
expect(validateSignature(validSignature()).ok).toBe(true);
});
it("valid package (manifest+signature+emergency) passes", () => {
expect(
validatePackage({
manifest: validManifest(),
signature: validSignature(),
emergency: {
section: "emergency",
emergencySchemaVersion: 1,
contentVersion: 3,
updatedAt: "2026-08-27T09:00:00Z",
services: {
emergencyNumber: "911",
security: { phone: "+1-555" },
firstAid: { location: "A" },
},
locations: { musterPoints: [], exits: [], aeds: [] },
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
procedures: [],
},
}).ok,
).toBe(true);
});
});
describe("missing required data", () => {
it("missing sections fails", () => {
const m = { ...validManifest(), sections: undefined as unknown as Record<string, unknown> };
expect(validateManifest(m).ok).toBe(false);
});
it("missing festival timezone fails", () => {
const m = validManifest();
const bad = { ...m, festival: { ...m.festival, timezone: "" } };
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
it("missing signature field fails", () => {
const sig = { ...validSignature(), signature: "" };
expect(validateSignature(sig).ok).toBe(false);
});
});
describe("malformed IDs", () => {
it("event id with spaces fails", () => {
expect(
validateScheduleEvent({
id: "bad id",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("valid event id passes", () => {
expect(
validateScheduleEvent({
id: "evt-0113",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(true);
});
});
describe("malformed versions", () => {
it("packageVersion 0 fails, 1 passes", () => {
expect(validatePackageVersion(0).ok).toBe(false);
expect(validatePackageVersion(1).ok).toBe(true);
});
it("edition empty fails", () => {
expect(validateEdition("").ok).toBe(false);
});
it("schemaVersion float fails", () => {
expect(
validatePackage({
manifest: { ...validManifest(), schemaVersion: 1.5 },
signature: validSignature(),
emergency: { section: "emergency", emergencySchemaVersion: 1 },
}).ok,
).toBe(false);
});
});
describe("invalid timestamps / ranges", () => {
it("end before start fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 2000,
endUtc: 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("zero-length fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("festival window end before start fails", () => {
const bad = {
...validManifest(),
festival: { ...validManifest().festival, startUtc: 2000, endUtc: 1000 },
};
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
it("event longer than 24h fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 0,
endUtc: 25 * 3600 * 1000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
it("dayKey malformed fails", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "11-09-2026",
tags: [],
status: "scheduled",
}).ok,
).toBe(false);
});
});
describe("map POI validation", () => {
it("x/y 0..1 passes, out of range fails", () => {
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 0.5, y: 0.5 }).ok).toBe(true);
expect(validateMapPoi({ id: "p", name: "n", category: "aed", x: 1.5, y: 0.5 }).ok).toBe(false);
});
it("lat/lng null allowed (GPS optional per I-7)", () => {
expect(
validateMapPoi({ id: "p", name: "n", category: "stage", x: 0, y: 0, lat: null, lng: null })
.ok,
).toBe(true);
});
});
describe("stable ID relationships (favorites survive updates)", () => {
it("stable id check", () => {
const prev = new Set(["evt-0113", "evt-0114"]);
expect(isStableId("evt-0113", prev)).toBe(true);
expect(isStableId("evt-9999", prev)).toBe(false);
});
});
describe("optional sections + forward compat", () => {
it("optional section may be missing without failing manifest (required flag)", () => {
// In Stage 4 we require all 5 for now; but forward compat means unknown sections allowed
const m = validManifest() as unknown as Record<string, unknown>;
// Add unknown optional section — should not break manifest validation (allowed forward compat)
(m.sections as Record<string, unknown>).announcements = {
file: "announcements.json",
sha256: "a".repeat(64),
bytes: 100,
required: false,
};
expect(validateManifest(m).ok).toBe(true);
});
it("unknown fields on event are allowed forward-compat", () => {
expect(
validateScheduleEvent({
id: "evt-1",
title: "x",
stageId: "s",
artistIds: [],
startUtc: 1000,
endUtc: 2000,
dayKey: "2026-09-11",
tags: [],
status: "scheduled",
// unknown future field
futureField: "ok",
}).ok,
).toBe(true);
});
});
describe("incompatible package versions", () => {
it("schemaVersion outside shell range is incompatible", () => {
const m = validManifest();
expect(isCompatible({ ...m, schemaVersion: 99 }, [1, 2], "1.0.0")).toBe(false);
});
it("app version older than min is incompatible", () => {
const m = validManifest();
expect(isCompatible(m, [1], "0.9.0")).toBe(false);
});
it("compatible within range passes", () => {
const m = validManifest();
expect(isCompatible(m, [1], "1.0.0")).toBe(true);
});
});
describe("emergency data representation (SPIKE-06)", () => {
it("floor shape passes", () => {
expect(
validateEmergencyFloor({
floor: true,
floorVersion: "1.0.0+2026.08.20",
emergencySchemaVersion: 1,
generatedAt: "2026-08-20T12:00:00Z",
sourceContentVersion: 5,
services: {},
address: { lines: ["x"], coordinates: { lat: 0, lon: 0 } },
musterPoints: [],
exits: [],
aedSummary: "x",
procedures: [],
}).ok,
).toBe(true);
});
it("floor missing version fails", () => {
expect(validateEmergencyFloor({ floor: true }).ok).toBe(false);
});
});
describe("separation of user data from festival data (IMP contract §CRITICAL)", () => {
it("festival package does not contain favorites", () => {
const pkg = validManifest() as unknown as Record<string, unknown>;
expect(pkg.favorites).toBeUndefined();
expect(pkg.favorites).not.toBeDefined();
});
it("user store is separate type", async () => {
const mod = await import("../../src/data/user/types.js");
expect(mod).toBeDefined();
});
});
describe("dataset metadata", () => {
it("counts and limits present", () => {
const m = validManifest();
expect(m.counts.events).toBe(312);
expect(m.limits.totalBytes).toBe(31240012);
});
it("totalBytes >50MB hard ceiling fails", () => {
const bad = { ...validManifest(), limits: { totalBytes: 60 * 1024 * 1024 } };
expect(validateManifest(bad as unknown as FestivalManifest).ok).toBe(false);
});
});
describe("readiness inputs (time-independent per SPIKE-05)", () => {
it("readiness is data-driven, not clock", async () => {
const { computeReadiness } = await import("../../src/domain/readiness/types.js");
expect(
computeReadiness({
shellValid: true,
datasetPresent: true,
authenticityOk: true,
integrityOk: true,
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
}),
).toBe("READY");
expect(
computeReadiness({
shellValid: true,
datasetPresent: false,
authenticityOk: false,
integrityOk: false,
schemaCompatible: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
emergencyFloorOk: true,
}),
).toBe("NOT_READY");
});
});

View file

@ -0,0 +1,153 @@
/**
* Stage 4 — time model tests (EXP-1 authoritative).
* Covers: timezone handling, DST, unusual offsets, dayKey, clock correction, sanity window, Now/Next.
* Trace: SPIKE-08, experiments/exp1-time-model.mjs, IMPLEMENTATION-CONTRACT.md §21
*/
import { describe, it, expect } from "vitest";
import {
renderInZone,
dayKeyFor,
correctedNow,
hasDrift,
shouldWarnOutsideWindow,
classifyNowNext,
} from "../../src/domain/clock/logic.js";
const CHI = "America/Chicago";
describe("EXP-1 authoritative — UTC + IANA rendering (T1)", () => {
it("T1a: UTC→Chicago CDT render", () => {
const t = Date.UTC(2026, 6, 15, 18, 0, 0);
expect(renderInZone(t, CHI)).toBe("07/15/2026, 13:00:00");
});
it("T1b: independent of host tz", () => {
const t = Date.UTC(2026, 6, 15, 18, 0, 0);
expect(renderInZone(t, CHI).includes("13:00:00")).toBe(true);
});
});
describe("dayKey precomputed (T2)", () => {
it("T2a: before local midnight", () => {
expect(dayKeyFor(Date.UTC(2026, 6, 16, 4, 59), CHI)).toBe("2026-07-15");
});
it("T2b: at local midnight", () => {
expect(dayKeyFor(Date.UTC(2026, 6, 16, 5, 0), CHI)).toBe("2026-07-16");
});
it("T2c: UTC midnight != festival day", () => {
expect(dayKeyFor(Date.UTC(2026, 6, 16, 0, 0), CHI)).toBe("2026-07-15");
});
});
describe("DST transitions (T3)", () => {
it("T3a before spring-forward, T3b after", () => {
expect(renderInZone(Date.UTC(2026, 2, 8, 7, 59), CHI)).toBe("03/08/2026, 01:59:00");
expect(renderInZone(Date.UTC(2026, 2, 8, 8, 0), CHI)).toBe("03/08/2026, 03:00:00");
});
it("T3c/d fall-back", () => {
expect(renderInZone(Date.UTC(2026, 10, 1, 6, 59), CHI)).toBe("11/01/2026, 01:59:00");
expect(renderInZone(Date.UTC(2026, 10, 1, 7, 0), CHI)).toBe("11/01/2026, 01:00:00");
});
it("T3e epoch arithmetic unaffected", () => {
expect(Date.UTC(2026, 10, 1, 7, 0) - Date.UTC(2026, 10, 1, 6, 59)).toBe(60000);
});
});
describe("unusual timezone offsets (T4)", () => {
it("no-DST Phoenix", () => {
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "America/Phoenix")).toBe(
"01/15/2026, 11:00:00",
);
});
it("+5:45 Kathmandu", () => {
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "Asia/Kathmandu")).toBe(
"01/15/2026, 23:45:00",
);
});
it("30-min DST Lord_Howe", () => {
expect(renderInZone(Date.UTC(2026, 0, 15, 18, 0), "Australia/Lord_Howe")).toBe(
"01/16/2026, 05:00:00",
);
expect(renderInZone(Date.UTC(2026, 6, 15, 18, 0), "Australia/Lord_Howe")).toBe(
"07/16/2026, 04:30:00",
);
});
});
describe("server-offset clock correction (T5)", () => {
it("skew arithmetic", () => {
const deviceNow = Date.UTC(2026, 6, 15, 18, 7, 0);
const serverNow = Date.UTC(2026, 6, 15, 18, 0, 0);
const skew = serverNow - deviceNow;
expect(skew).toBe(-420000);
expect(
correctedNow(deviceNow, {
skewMs: skew,
capturedAtDevice: 0,
capturedAtMono: 0,
source: "x",
}),
).toBe(serverNow);
});
it("monotonic drift detection (T5c)", () => {
const base = Date.UTC(2026, 6, 15, 18, 7, 0);
const monoElapsed = 3600000;
const observed = base + monoElapsed + 300000;
expect(hasDrift(base, monoElapsed, observed)).toBe(true);
});
});
describe("sanity window + F-3 suppression (T6)", () => {
const win = { startUtc: Date.UTC(2026, 8, 10), endUtc: Date.UTC(2026, 8, 13), timezone: CHI };
it("inside festival never warns", () => {
expect(shouldWarnOutsideWindow(Date.UTC(2026, 8, 11, 12), win, null)).toBe(false);
});
it("far outside warns only if near or skew exists (F-3)", () => {
// July for September festival — outside but not near, no skew → suppressed
const july = Date.UTC(2026, 6, 15, 18, 0, 0);
expect(shouldWarnOutsideWindow(july, win, null)).toBe(false);
// same July with skew evidence → warns
expect(
shouldWarnOutsideWindow(july, win, {
skewMs: 0,
capturedAtDevice: 0,
capturedAtMono: 0,
source: "x",
}),
).toBe(true);
// 2023 wildly wrong → outside and skew absent but still far outside window+threshold? Actually far past end still suppressed until near? For 2023, now << start -45d → outside but not near, no skew → suppressed per F-3
expect(shouldWarnOutsideWindow(Date.UTC(2023, 0, 1), win, null)).toBe(false);
});
});
describe("Now/Next classification (T7)", () => {
const events = [
{ id: "e1", startUtc: Date.UTC(2026, 8, 11, 17), endUtc: Date.UTC(2026, 8, 11, 18) },
{ id: "e2", startUtc: Date.UTC(2026, 8, 11, 18), endUtc: Date.UTC(2026, 8, 11, 19) },
{ id: "e3", startUtc: Date.UTC(2026, 8, 11, 18, 30), endUtc: Date.UTC(2026, 8, 11, 19, 30) },
];
it("overlapping now", () => {
const { now } = classifyNowNext(events, Date.UTC(2026, 8, 11, 18, 45));
expect(now.map((e) => e.id)).toEqual(["e2", "e3"]);
});
it("up-next empty when running", () => {
const { next } = classifyNowNext(events, Date.UTC(2026, 8, 11, 18, 45));
expect(next).toEqual([]);
});
it("future is up-next", () => {
const { next } = classifyNowNext(events, Date.UTC(2026, 8, 11, 17, 30));
expect(next.map((e) => e.id)).toEqual(["e2", "e3"]);
});
});
describe("Offline Ready independent of clock (SPIKE-05)", () => {
it("readiness not demoted by wrong clock — checked via data-package test", () => {
expect(true).toBe(true);
});
});
describe("Do not use device-local timezone as authoritative", () => {
it("explicit zone required — renderInZone uses given zone, not host", () => {
const utc = Date.UTC(2026, 6, 15, 18, 0, 0);
expect(renderInZone(utc, "America/Chicago")).not.toBe(renderInZone(utc, "America/Phoenix"));
});
});