@motionstudies/data 0.1.0-alpha.26 → 0.1.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -194,6 +194,12 @@ Consumers supply remaining `stops` in journey order and update them with playbac
194
194
 
195
195
  The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
196
196
 
197
+ ## Feed observability
198
+
199
+ `@motionstudies/data/feed-observability` validates versioned feed registries, operational events and health reports. `@motionstudies/data/recorder-observability` projects a recorder status snapshot into that shared contract, rechecking freshness against the observer's clock and omitting private paths, PIDs and raw errors. Both are pure functions with no acquisition, storage or notification side effects. The source repository's `docs/FEED-OBSERVABILITY.md` describes the read-only CLI, example registry, states and evidence limits. Registry daily schedules remain declarative; explicit dated evidence plans and incident history are available through the Node adapters below.
200
+
201
+ `@motionstudies/data/recorder-evidence` reads explicit dated local journal/close-out and release targets, verifies their hashes and identities, and assesses missing expected outputs against deadlines. `@motionstudies/data/feed-incidents` persists debounced incident transitions with replay protection, atomic checksummed state, bounded retention and fail-closed capacity limits. These two modules require Node. See `docs/FEED-INCIDENTS.md` for CLI opt-in flags and operational limits; neither module installs a scheduler or sends notifications.
202
+
197
203
  ## Optional live airport feed
198
204
 
199
205
  `AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
@@ -0,0 +1,3 @@
1
+ export interface DailyExpectation { serviceDate: string; deadlineAt: string; nextDeadlineAt: string }
2
+ /** Repeated local minutes use their later occurrence; missing minutes use the next valid minute. */
3
+ export function dailyFeedExpectation(schedule: { timeZone: string; deadline: string }, now?: number): DailyExpectation
@@ -0,0 +1,33 @@
1
+ import { readOperationalTime } from './feed-observability.mjs'
2
+
3
+ const shift = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86400000).toISOString().slice(0, 10)
4
+ function formatter(timeZone) {
5
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
6
+ }
7
+ function wall(format, ms) {
8
+ const p = Object.fromEntries(format.formatToParts(ms).map(p => [p.type, p.value]))
9
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`
10
+ }
11
+ /** Latest occurrence of a repeated local minute; first valid minute after a DST gap. */
12
+ function deadlineAt(date, deadline, format) {
13
+ const target = `${date}T${deadline}`, nominal = Date.parse(`${target}:00Z`)
14
+ let exact = null, next = null
15
+ for (let ms = nominal - 18 * 3600000; ms <= nominal + 18 * 3600000; ms += 60000) {
16
+ const local = wall(format, ms)
17
+ if (local === target) exact = ms
18
+ if (local.slice(0, 10) === date && local > target && (!next || local < next.local)) next = { local, ms }
19
+ }
20
+ const value = exact ?? next?.ms
21
+ if (value === undefined) throw new Error('Local deadline does not exist on this date')
22
+ return value
23
+ }
24
+ /** Latest completed service date whose following-day deadline has passed. */
25
+ export function dailyFeedExpectation({ timeZone, deadline }, now = Date.now()) {
26
+ if (typeof timeZone !== 'string' || timeZone.length > 80 || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(deadline)) throw new Error('Invalid daily expectation')
27
+ readOperationalTime(new Date(now).toISOString())
28
+ const format = formatter(timeZone), today = wall(format, now).slice(0, 10)
29
+ const todayDeadline = deadlineAt(today, deadline, format), dueToday = now >= todayDeadline
30
+ const dueDate = dueToday ? today : shift(today, -1)
31
+ return { serviceDate: shift(dueDate, -1), deadlineAt: new Date(dueToday ? todayDeadline : deadlineAt(dueDate, deadline, format)).toISOString(),
32
+ nextDeadlineAt: new Date(dueToday ? deadlineAt(shift(today, 1), deadline, format) : todayDeadline).toISOString() }
33
+ }
@@ -0,0 +1,44 @@
1
+ import type { FeedHealth, OperationalState } from './feed-observability.mjs'
2
+ export interface IncidentPolicy { openAfterSeconds: number; recoverAfterSeconds: number; retentionDays: number; maxEvents: number }
3
+ export declare const INCIDENT_POLICY: Readonly<IncidentPolicy>
4
+ export interface IncidentEvent {
5
+ id: string
6
+ incidentId: string
7
+ key: string
8
+ feedId: string | null
9
+ stageId: string
10
+ firstDetectedAt: string
11
+ at: string
12
+ transition: 'opened' | 'updated' | 'recovered'
13
+ state: OperationalState
14
+ reasons: string[]
15
+ }
16
+ export interface ActiveIncident {
17
+ incidentId: string
18
+ key: string
19
+ feedId: string | null
20
+ stageId: string
21
+ firstDetectedAt: string
22
+ openedAt: string
23
+ lastObservedAt: string
24
+ state: OperationalState
25
+ reasons: string[]
26
+ recoveryStartedAt?: string
27
+ recoveryProducerAt?: string
28
+ }
29
+ export interface IncidentState {
30
+ schemaVersion: 1
31
+ kind: 'feed-incident-state'
32
+ registryId: string
33
+ producerId: string
34
+ policy: IncidentPolicy
35
+ lastObservedAt: string
36
+ lastReportId: string
37
+ report: FeedHealth
38
+ active: Record<string, ActiveIncident>
39
+ pending: Record<string, { since: string }>
40
+ events: IncidentEvent[]
41
+ prunedEvents: number
42
+ }
43
+ export function readIncidentStore(root: string): Promise<IncidentState>
44
+ export function updateIncidentStore(root: string, registry: unknown, report: unknown, options?: { policy?: Partial<IncidentPolicy>; now?: number }): Promise<{ state: IncidentState; changed: boolean; events: IncidentEvent[] }>
@@ -0,0 +1,148 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, lstat, open, rename, rm } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import { hostname } from 'node:os'
5
+ import { readFeedRegistry, readFeedHealth, readOperationalTime } from './feed-observability.mjs'
6
+ import { readOperationalFile } from './operational-files.mjs'
7
+
8
+ const MAX_BYTES = 8 * 1024 * 1024
9
+ export const INCIDENT_POLICY = Object.freeze({ openAfterSeconds: 120, recoverAfterSeconds: 60, retentionDays: 180, maxEvents: 10000 })
10
+ const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v)
11
+ const hash = value => createHash('sha256').update(value).digest('hex')
12
+ const notInstrumented = new Set(['upload-evidence-unavailable', 'stage-evidence-unavailable', 'capture-threshold-unconfigured', 'normalization-threshold-unconfigured', 'analytics-threshold-unconfigured'])
13
+ function policy(value) {
14
+ const p = { ...INCIDENT_POLICY, ...value }
15
+ if (Object.keys(p).some(k => !Object.hasOwn(INCIDENT_POLICY, k)) || Object.values(p).some(n => !Number.isSafeInteger(n) || n < 0) || p.openAfterSeconds > 86400 || p.recoverAfterSeconds > 86400 || p.retentionDays < 1 || p.retentionDays > 3650 || p.maxEvents < 1 || p.maxEvents > 20000) throw new Error('Invalid incident policy')
16
+ return p
17
+ }
18
+ function validateState(state) {
19
+ if (!state || state.schemaVersion !== 1 || state.kind !== 'feed-incident-state' || typeof state.registryId !== 'string' || typeof state.producerId !== 'string') throw new Error('Invalid incident state')
20
+ policy(state.policy)
21
+ readOperationalTime(state.lastObservedAt)
22
+ if (!/^[a-f0-9]{64}$/.test(state.lastReportId) || !Array.isArray(state.events) || state.events.length > state.policy.maxEvents) throw new Error('Invalid incident cursor/history')
23
+ for (const field of ['active', 'pending']) if (!state[field] || typeof state[field] !== 'object' || Array.isArray(state[field]) || Object.keys(state[field]).length > 4096) throw new Error('Invalid incident index')
24
+ if (!Number.isSafeInteger(state.prunedEvents) || state.prunedEvents < 0) throw new Error('Invalid retention counter')
25
+ for (const event of state.events) {
26
+ if (!/^[a-f0-9]{64}$/.test(event.id) || !/^[a-f0-9]{64}$/.test(event.incidentId) || !['opened', 'updated', 'recovered'].includes(event.transition)) throw new Error('Invalid incident event')
27
+ readOperationalTime(event.at)
28
+ }
29
+ if (new Set(state.events.map(e => e.id)).size !== state.events.length) throw new Error('Duplicate incident event')
30
+ return state
31
+ }
32
+ /** Reads the last atomically committed state; corruption never initializes a new history. */
33
+ export async function readIncidentStore(root) {
34
+ const wrapper = JSON.parse(await readOperationalFile(join(root, 'state.json'), MAX_BYTES))
35
+ if (wrapper.sha256 !== hash(canonical(wrapper.state))) throw new Error('Incident state checksum mismatch')
36
+ return validateState(wrapper.state)
37
+ }
38
+ function conditions(report, registry) {
39
+ const entries = [], add = (feedId, stageId, disposition, state, reasons) => {
40
+ entries.push({ key: JSON.stringify([report.producerId, feedId, stageId]), feedId, stageId, disposition, state, reasons: [...reasons].sort() })
41
+ }
42
+ const available = report.telemetry.state === 'current'
43
+ add(null, 'telemetry', available ? 'healthy' : 'problem', available ? 'healthy' : 'unknown', available ? [] : [`telemetry-${report.telemetry.state}`])
44
+ add(null, 'capacity', !available ? 'hold' : report.capacity.state === 'healthy' ? 'healthy' : 'problem', report.capacity.state, report.capacity.reasons)
45
+ for (const feed of report.feeds) {
46
+ const definition = registry.feeds.find(f => f.id === feed.feedId)
47
+ const actionable = stage => stage.state === 'degraded' || stage.state === 'unknown' && !stage.reasons.some(r => notInstrumented.has(r))
48
+ const blocked = id => definition.stages.find(s => s.id === id).dependsOn.some(parent => actionable(feed.stages.find(s => s.stageId === parent)) || blocked(parent))
49
+ for (const stage of feed.stages) {
50
+ let disposition = !available || feed.configuredState !== 'active' ? 'hold' : stage.state === 'healthy' ? 'healthy' : actionable(stage) ? 'problem' : 'hold'
51
+ if (disposition === 'problem' && (blocked(stage.stageId) || stage.reasons.includes('host-storage-blocked'))) disposition = 'hold'
52
+ add(feed.feedId, stage.stageId, disposition, stage.state, stage.reasons)
53
+ }
54
+ }
55
+ return entries
56
+ }
57
+ function advance(previous, report, registry, p) {
58
+ const at = report.observedAt, now = Date.parse(at), reportId = hash(canonical(report))
59
+ if (previous) {
60
+ if (previous.registryId !== registry.id || previous.producerId !== report.producerId || canonical(previous.policy) !== canonical(p)) throw new Error('Store identity/policy mismatch')
61
+ const order = now - Date.parse(previous.lastObservedAt)
62
+ if (order < 0 || order === 0 && previous.lastReportId !== reportId) throw new Error('Out-of-order or conflicting observation')
63
+ if (order === 0) return { state: previous, changed: false, events: [] }
64
+ }
65
+ const state = previous ? structuredClone(previous) : { schemaVersion: 1, kind: 'feed-incident-state', registryId: registry.id, producerId: report.producerId, policy: p, active: {}, pending: {}, events: [], prunedEvents: 0 }
66
+ const transitions = []
67
+ const emit = (incident, transition, health, reasons) => {
68
+ const event = { incidentId: incident.incidentId, key: incident.key, feedId: incident.feedId, stageId: incident.stageId, firstDetectedAt: incident.firstDetectedAt, at, transition, state: health, reasons }
69
+ transitions.push({ id: hash(canonical(event)), ...event })
70
+ }
71
+ const observedKeys = new Set()
72
+ for (const condition of conditions(report, registry)) {
73
+ const { key, disposition, state: health, reasons, feedId, stageId } = condition
74
+ observedKeys.add(key)
75
+ let incident = state.active[key]
76
+ if (disposition === 'problem') {
77
+ if (!incident) {
78
+ const candidate = state.pending[key] ?? { since: at }
79
+ state.pending[key] = candidate
80
+ if (now - Date.parse(candidate.since) < p.openAfterSeconds * 1000) continue
81
+ incident = { key, feedId, stageId, incidentId: hash(canonical([key, candidate.since])), firstDetectedAt: candidate.since, openedAt: at, lastObservedAt: at, state: health, reasons }
82
+ state.active[key] = incident; delete state.pending[key]
83
+ emit(incident, 'opened', health, reasons)
84
+ } else {
85
+ if (incident.state !== health || canonical(incident.reasons) !== canonical(reasons)) emit(incident, 'updated', health, reasons)
86
+ incident.state = health; incident.reasons = reasons; incident.lastObservedAt = at
87
+ }
88
+ delete incident.recoveryStartedAt; delete incident.recoveryProducerAt
89
+ } else {
90
+ delete state.pending[key]
91
+ if (!incident) continue
92
+ if (disposition === 'hold') { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt; continue }
93
+ incident.recoveryStartedAt ??= at
94
+ incident.recoveryProducerAt ??= report.producerGeneratedAt
95
+ // Fresh observer timestamps alone cannot make a replayed producer report confirm recovery.
96
+ const fresh = p.recoverAfterSeconds === 0 || Date.parse(report.producerGeneratedAt) > Date.parse(incident.recoveryProducerAt)
97
+ if (fresh && now - Date.parse(incident.recoveryStartedAt) >= p.recoverAfterSeconds * 1000) {
98
+ emit(incident, 'recovered', 'healthy', reasons)
99
+ delete state.active[key]
100
+ }
101
+ }
102
+ }
103
+ // Removed/paused scopes cannot silently recover existing incidents.
104
+ for (const key of Object.keys(state.pending)) if (!observedKeys.has(key)) delete state.pending[key]
105
+ for (const [key, incident] of Object.entries(state.active)) if (!observedKeys.has(key)) { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt }
106
+ state.events.push(...transitions)
107
+ const cutoff = now - p.retentionDays * 86400000
108
+ const expired = new Set(state.events.filter(e => e.transition === 'recovered' && Date.parse(e.at) < cutoff).map(e => e.incidentId))
109
+ for (const incident of Object.values(state.active)) expired.delete(incident.incidentId)
110
+ const before = state.events.length
111
+ state.events = state.events.filter(e => !expired.has(e.incidentId))
112
+ state.prunedEvents += before - state.events.length
113
+ state.lastObservedAt = at; state.lastReportId = reportId; state.report = report
114
+ if (state.events.length > p.maxEvents) throw new Error('Incident history event budget exhausted; archive before continuing')
115
+ return { state: validateState(state), changed: true, events: transitions }
116
+ }
117
+
118
+ /** Single-writer transaction: checksum + fsync + atomic rename. A leftover lock fails closed. */
119
+ export async function updateIncidentStore(root, registryValue, reportValue, { policy: requestedPolicy, now = Date.now() } = {}) {
120
+ const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry), p = policy(requestedPolicy)
121
+ if (!Number.isFinite(now) || now - Date.parse(report.observedAt) > registry.heartbeatMaxAgeSeconds * 1000 || Date.parse(report.observedAt) - now > registry.clockSkewSeconds * 1000) throw new Error('Incident observation is not current')
122
+ root = resolve(root)
123
+ // Serialize directory creation as well as updates: a second initializer must not
124
+ // take the writer lock before the directory creator has committed its first state.
125
+ const lockPath = `${root}.lock`, lock = await open(lockPath, 'wx', 0o600)
126
+ const temporary = join(root, `.state-${randomUUID()}`)
127
+ try {
128
+ await lock.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date(now).toISOString() }))
129
+ await lock.sync()
130
+ let created = false
131
+ try { await mkdir(root, { mode: 0o700 }); created = true } catch (error) { if (error.code !== 'EEXIST') throw error }
132
+ if (!(await lstat(root)).isDirectory()) throw new Error('Incident store must be a real directory')
133
+ const previous = created ? null : await readIncidentStore(root)
134
+ const update = advance(previous, report, registry, p)
135
+ if (!update.changed) return update
136
+ const content = canonical({ sha256: hash(canonical(update.state)), state: update.state }) + '\n'
137
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error('Incident history byte budget exhausted; archive before continuing')
138
+ const output = await open(temporary, 'wx', 0o600)
139
+ try { await output.writeFile(content); await output.sync() } finally { await output.close() }
140
+ await rename(temporary, join(root, 'state.json'))
141
+ const directory = await open(root, 'r')
142
+ try { await directory.sync() } finally { await directory.close() }
143
+ return update
144
+ } finally {
145
+ await lock.close()
146
+ try { await rm(temporary, { force: true }) } finally { await rm(lockPath) }
147
+ }
148
+ }
@@ -0,0 +1,68 @@
1
+ export type OperationalState = 'healthy' | 'degraded' | 'unknown' | 'waiting'
2
+ export type ConfiguredState = 'active' | 'paused' | 'setup-required'
3
+ export type FeedSchedule = { kind: 'interval'; seconds: number } | { kind: 'daily'; timeZone: string; deadline: string } | { kind: 'manual' | 'push' }
4
+ export interface FeedStage {
5
+ id: string
6
+ dependsOn: string[]
7
+ maxAgeSeconds: number | null
8
+ sourceMaxAgeSeconds?: number
9
+ }
10
+ export interface FeedDefinition {
11
+ id: string
12
+ label: string
13
+ producerId: string
14
+ producerFeed: string
15
+ sourceId: string
16
+ owner: string
17
+ runbook: string
18
+ state: ConfiguredState
19
+ schedule: FeedSchedule
20
+ stages: FeedStage[]
21
+ }
22
+ export interface FeedRegistry {
23
+ schemaVersion: 1
24
+ kind: 'feed-registry'
25
+ id: string
26
+ heartbeatMaxAgeSeconds: number
27
+ clockSkewSeconds: number
28
+ feeds: FeedDefinition[]
29
+ }
30
+ export interface FeedEvent {
31
+ schemaVersion: 1
32
+ kind: 'feed-event'
33
+ eventId: string
34
+ feedId: string
35
+ stageId: string
36
+ runId: string
37
+ parentRunId?: string
38
+ producerId: string
39
+ revision: string
40
+ occurredAt: string
41
+ recordedAt: string
42
+ type: 'started' | 'succeeded' | 'failed' | 'heartbeat'
43
+ reasons: string[]
44
+ metrics: Record<string, number>
45
+ }
46
+ export interface StageHealth {
47
+ stageId: string
48
+ state: OperationalState
49
+ reasons: string[]
50
+ timestamps: Partial<Record<'receivedAt' | 'sourceGeneratedAt' | 'latestPeriodEnd' | 'lastSuccessAt' | 'lastAttemptAt', string>>
51
+ metrics: Record<string, number>
52
+ }
53
+ export interface FeedHealth {
54
+ schemaVersion: 1
55
+ kind: 'feed-health'
56
+ registryId: string
57
+ producerId: string
58
+ observedAt: string
59
+ producerGeneratedAt: string | null
60
+ telemetry: { state: 'current' | 'stale' | 'missing' | 'invalid'; ageSeconds: number | null }
61
+ feeds: { feedId: string; configuredState: ConfiguredState; state: OperationalState | Exclude<ConfiguredState, 'active'>; stages: StageHealth[] }[]
62
+ capacity: { state: Exclude<OperationalState, 'waiting'>; reasons: string[]; metrics: Record<string, number> }
63
+ }
64
+ export function readOperationalTime(value: unknown): string
65
+ export function readFeedRegistry(value: unknown): FeedRegistry
66
+ export function readFeedEvent(value: unknown, registry: unknown): FeedEvent
67
+ export function readFeedHealth(value: unknown, registry: unknown): FeedHealth
68
+ export function summarizeFeedStages(stages: readonly Pick<StageHealth, 'state'>[]): OperationalState
@@ -0,0 +1,154 @@
1
+ /** Versioned, JSON-only contracts. No I/O, clock, Node imports or provider calls. */
2
+ const own = (o, k) => Object.hasOwn(o, k)
3
+ function object(value, keys, label) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label}`)
5
+ if (Object.keys(value).some(k => !keys.includes(k))) throw new Error(`Unknown ${label} field`)
6
+ return value
7
+ }
8
+ function text(value, label, limit = 160) {
9
+ if (typeof value !== 'string' || !value.trim() || value.length > limit || [...value].some(c => c.charCodeAt(0) < 32)) throw new Error(`Invalid ${label}`)
10
+ return value
11
+ }
12
+ function id(value) {
13
+ if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/.test(value)) throw new Error('Invalid identifier')
14
+ return value
15
+ }
16
+ function choice(value, values) {
17
+ if (!values.includes(value)) throw new Error('Invalid enum value')
18
+ return value
19
+ }
20
+ function number(value, max = Number.MAX_SAFE_INTEGER) {
21
+ if (!Number.isFinite(value) || value < 0 || value > max) throw new Error('Invalid nonnegative number')
22
+ return value
23
+ }
24
+ function seconds(value) {
25
+ if (!Number.isInteger(value) || value < 1 || value > 366 * 86400) throw new Error('Invalid duration')
26
+ return value
27
+ }
28
+ function array(value, max, min = 0) {
29
+ if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error('Invalid bounded array')
30
+ return value
31
+ }
32
+ function unique(values) {
33
+ if (new Set(values).size !== values.length) throw new Error('Duplicate identifier')
34
+ return values
35
+ }
36
+ export function readOperationalTime(value) {
37
+ if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)) throw new Error('Invalid UTC timestamp')
38
+ const ms = Date.parse(value)
39
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== (value.length === 20 ? value.replace('Z', '.000Z') : value)) throw new Error('Invalid UTC timestamp')
40
+ return new Date(ms).toISOString()
41
+ }
42
+ function header(value, kind, keys) {
43
+ object(value, ['schemaVersion', 'kind', ...keys], kind)
44
+ if (value.schemaVersion !== 1 || value.kind !== kind) throw new Error('Unsupported operational schema')
45
+ }
46
+ function schedule(value) {
47
+ object(value, ['kind', 'seconds', 'timeZone', 'deadline'], 'schedule')
48
+ if (value.kind === 'interval') {
49
+ object(value, ['kind', 'seconds'], 'interval schedule')
50
+ return { kind: value.kind, seconds: seconds(value.seconds) }
51
+ }
52
+ if (value.kind === 'daily') {
53
+ object(value, ['kind', 'timeZone', 'deadline'], 'daily schedule')
54
+ text(value.timeZone, 'timezone', 80)
55
+ new Intl.DateTimeFormat('en', { timeZone: value.timeZone })
56
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value.deadline)) throw new Error('Invalid daily deadline')
57
+ return { kind: value.kind, timeZone: value.timeZone, deadline: value.deadline }
58
+ }
59
+ object(value, ['kind'], 'manual schedule')
60
+ return { kind: choice(value.kind, ['manual', 'push']) }
61
+ }
62
+ export function readFeedRegistry(value) {
63
+ header(value, 'feed-registry', ['id', 'heartbeatMaxAgeSeconds', 'clockSkewSeconds', 'feeds'])
64
+ const feeds = array(value.feeds, 128, 1).map(f => {
65
+ object(f, ['id', 'label', 'producerId', 'producerFeed', 'sourceId', 'owner', 'runbook', 'state', 'schedule', 'stages'], 'feed')
66
+ const stages = array(f.stages, 16, 1).map(s => {
67
+ object(s, ['id', 'dependsOn', 'maxAgeSeconds', 'sourceMaxAgeSeconds'], 'stage')
68
+ return { id: id(s.id), dependsOn: unique(array(s.dependsOn, 16).map(id)),
69
+ maxAgeSeconds: s.maxAgeSeconds === null ? null : seconds(s.maxAgeSeconds),
70
+ ...(own(s, 'sourceMaxAgeSeconds') ? { sourceMaxAgeSeconds: seconds(s.sourceMaxAgeSeconds) } : {}) }
71
+ })
72
+ unique(stages.map(s => s.id))
73
+ const visiting = new Set(), visited = new Set()
74
+ const visit = stageId => {
75
+ if (visiting.has(stageId)) throw new Error('Cyclic stage dependency')
76
+ if (visited.has(stageId)) return
77
+ const stage = stages.find(s => s.id === stageId)
78
+ if (!stage) throw new Error('Unknown stage dependency')
79
+ visiting.add(stageId); stage.dependsOn.forEach(visit); visiting.delete(stageId); visited.add(stageId)
80
+ }
81
+ stages.forEach(s => visit(s.id))
82
+ return { id: id(f.id), label: text(f.label, 'label'), producerId: id(f.producerId), producerFeed: id(f.producerFeed), sourceId: id(f.sourceId),
83
+ owner: text(f.owner, 'owner'), runbook: text(f.runbook, 'runbook', 300), state: choice(f.state, ['active', 'paused', 'setup-required']), schedule: schedule(f.schedule), stages }
84
+ })
85
+ unique(feeds.map(f => f.id)); unique(feeds.map(f => `${f.producerId}/${f.producerFeed}`))
86
+ return { schemaVersion: 1, kind: 'feed-registry', id: id(value.id), heartbeatMaxAgeSeconds: seconds(value.heartbeatMaxAgeSeconds),
87
+ clockSkewSeconds: number(value.clockSkewSeconds, 300), feeds }
88
+ }
89
+ function metrics(value) {
90
+ object(value, Object.keys(value ?? {}), 'metrics')
91
+ if (Object.keys(value).length > 32) throw new Error('Too many metrics')
92
+ return Object.fromEntries(Object.entries(value).map(([key, v]) => [id(key), number(v)]))
93
+ }
94
+ function reasons(value) { return unique(array(value, 16).map(id)) }
95
+ function registered(registry, feedId, stageId) {
96
+ const feed = registry.feeds.find(f => f.id === feedId)
97
+ if (!feed?.stages.some(s => s.id === stageId)) throw new Error('Unregistered feed or stage')
98
+ }
99
+ export function readFeedEvent(value, registryValue) {
100
+ header(value, 'feed-event', ['eventId', 'feedId', 'stageId', 'runId', 'parentRunId', 'producerId', 'revision', 'occurredAt', 'recordedAt', 'type', 'reasons', 'metrics'])
101
+ const registry = readFeedRegistry(registryValue)
102
+ registered(registry, value.feedId, value.stageId)
103
+ if (registry.feeds.find(f => f.id === value.feedId).producerId !== value.producerId) throw new Error('Producer does not own feed')
104
+ return { schemaVersion: 1, kind: 'feed-event', eventId: id(value.eventId), feedId: id(value.feedId), stageId: id(value.stageId), runId: id(value.runId),
105
+ ...(own(value, 'parentRunId') ? { parentRunId: id(value.parentRunId) } : {}), producerId: id(value.producerId), revision: id(value.revision),
106
+ occurredAt: readOperationalTime(value.occurredAt), recordedAt: readOperationalTime(value.recordedAt),
107
+ type: choice(value.type, ['started', 'succeeded', 'failed', 'heartbeat']), reasons: reasons(value.reasons), metrics: metrics(value.metrics) }
108
+ }
109
+ export function summarizeFeedStages(stages) {
110
+ return ['degraded', 'unknown', 'waiting'].find(state => stages.some(s => s.state === state)) ?? 'healthy'
111
+ }
112
+ export function readFeedHealth(value, registryValue) {
113
+ header(value, 'feed-health', ['registryId', 'producerId', 'observedAt', 'producerGeneratedAt', 'telemetry', 'feeds', 'capacity'])
114
+ const registry = readFeedRegistry(registryValue)
115
+ if (value.registryId !== registry.id) throw new Error('Registry does not match health report')
116
+ const producerId = id(value.producerId), expected = registry.feeds.filter(f => f.producerId === producerId)
117
+ if (!expected.length) throw new Error('Unregistered producer')
118
+ const observedAt = readOperationalTime(value.observedAt)
119
+ const producerGeneratedAt = value.producerGeneratedAt === null ? null : readOperationalTime(value.producerGeneratedAt)
120
+ object(value.telemetry, ['state', 'ageSeconds'], 'telemetry')
121
+ const telemetry = { state: choice(value.telemetry.state, ['current', 'stale', 'missing', 'invalid']), ageSeconds: value.telemetry.ageSeconds === null ? null : number(value.telemetry.ageSeconds) }
122
+ if (producerGeneratedAt === null) {
123
+ if (!['missing', 'invalid'].includes(telemetry.state) || telemetry.ageSeconds !== null) throw new Error('Missing producer clock cannot establish freshness')
124
+ } else {
125
+ const elapsed = (Date.parse(observedAt) - Date.parse(producerGeneratedAt)) / 1000
126
+ const state = elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current'
127
+ if (telemetry.state !== state || telemetry.ageSeconds === null || Math.abs(telemetry.ageSeconds - Math.max(0, elapsed)) > 0.000001) throw new Error('Telemetry does not match report clocks')
128
+ }
129
+ const feeds = array(value.feeds, 128, 1).map(f => {
130
+ object(f, ['feedId', 'configuredState', 'state', 'stages'], 'feed health')
131
+ const entry = expected.find(e => e.id === f.feedId)
132
+ if (!entry || f.configuredState !== entry.state) throw new Error('Feed configuration does not match registry')
133
+ const stages = array(f.stages, 16, 1).map(s => {
134
+ object(s, ['stageId', 'state', 'reasons', 'timestamps', 'metrics'], 'stage health')
135
+ registered(registry, f.feedId, s.stageId)
136
+ object(s.timestamps, ['receivedAt', 'sourceGeneratedAt', 'latestPeriodEnd', 'lastSuccessAt', 'lastAttemptAt'], 'timestamps')
137
+ return { stageId: id(s.stageId), state: choice(s.state, ['healthy', 'degraded', 'unknown', 'waiting']), reasons: reasons(s.reasons),
138
+ timestamps: Object.fromEntries(Object.entries(s.timestamps).map(([k, v]) => [k, readOperationalTime(v)])), metrics: metrics(s.metrics) }
139
+ })
140
+ unique(stages.map(s => s.stageId))
141
+ if (stages.length !== entry.stages.length) throw new Error('Incomplete stage report')
142
+ const state = entry.state === 'active' ? summarizeFeedStages(stages) : entry.state
143
+ if (f.state !== state) throw new Error('Inconsistent feed summary')
144
+ if (telemetry.state !== 'current' && stages.some(s => s.state !== 'unknown')) throw new Error('Unavailable telemetry cannot assert stage health')
145
+ return { feedId: f.feedId, configuredState: entry.state, state, stages }
146
+ })
147
+ unique(feeds.map(f => f.feedId))
148
+ if (feeds.length !== expected.length) throw new Error('Incomplete producer report')
149
+ object(value.capacity, ['state', 'reasons', 'metrics'], 'capacity')
150
+ const capacity = { state: choice(value.capacity.state, ['healthy', 'degraded', 'unknown']), reasons: reasons(value.capacity.reasons), metrics: metrics(value.capacity.metrics) }
151
+ if (telemetry.state !== 'current' && capacity.state !== 'unknown') throw new Error('Unavailable telemetry cannot assert capacity health')
152
+ return { schemaVersion: 1, kind: 'feed-health', registryId: registry.id, producerId, observedAt,
153
+ producerGeneratedAt, telemetry, feeds, capacity }
154
+ }
@@ -0,0 +1,29 @@
1
+ import type { SERVICE_TIMEZONE } from './uk-service-day.mjs'
2
+ import type { FeedHealth, StageHealth } from './feed-observability.mjs'
3
+ import type { DailyExpectation } from './feed-expectations.mjs'
4
+ export interface ObserverConfig {
5
+ schemaVersion: 1
6
+ kind: 'feed-observer-config'
7
+ producerId: string
8
+ healthUrl: string
9
+ consumers: { feedId: string; manifestUrl: string; operator: string; deadline: string; timeZone: typeof SERVICE_TIMEZONE }[]
10
+ }
11
+ export interface ObserverCheck {
12
+ schemaVersion: 1
13
+ kind: 'feed-observer-check'
14
+ startedAt: string
15
+ completedAt: string
16
+ sourceError: string | null
17
+ requests: number
18
+ bytes: number
19
+ report: FeedHealth
20
+ consumers: (StageHealth & { feedId: string; expectation: DailyExpectation })[]
21
+ }
22
+ export declare const OBSERVER_LIMITS: Readonly<{ requests: number; totalBytes: number; timeoutMs: number; totalMs: number; healthBytes: number; manifestBytes: number; releaseBytes: number }>
23
+ export function observeFeeds(registry: unknown, config: unknown, options?: {
24
+ fetchImpl?: typeof fetch
25
+ clock?: () => number
26
+ tokens?: { health?: string; consumers?: Record<string, string> }
27
+ limits?: typeof OBSERVER_LIMITS
28
+ }): Promise<ObserverCheck>
29
+ export function assessObserverCheck(registry: unknown, checked: ObserverCheck | null | undefined, options?: { now?: number; maxAgeSeconds?: number }): { state: 'healthy' | 'degraded' | 'unknown'; reasons: string[] }
@@ -0,0 +1,187 @@
1
+ import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
2
+ import { recorderFeedHealth } from './recorder-observability.mjs'
3
+ import { dailyFeedExpectation } from './feed-expectations.mjs'
4
+ import { studyDay, SERVICE_TIMEZONE } from './uk-service-day.mjs'
5
+
6
+ export const OBSERVER_LIMITS = Object.freeze({ requests: 9, totalBytes: 10 * 1024 * 1024, timeoutMs: 5000, totalMs: 30000, healthBytes: 1024 * 1024, manifestBytes: 8192, releaseBytes: 2 * 1024 * 1024 })
7
+ const digest = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s)
8
+ const sha = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(b => b.toString(16).padStart(2, '0')).join('')
9
+ const stage = (state, reason, timestamps = {}, metrics = {}) => ({ stageId: 'serve', state, reasons: [reason], timestamps, metrics })
10
+ function checkUrl(value) {
11
+ const url = new URL(value)
12
+ if (url.username || url.password || url.hash || url.search || !['https:', 'http:'].includes(url.protocol) || url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Use HTTPS or loopback HTTP without embedded credentials or query strings')
13
+ return url.href
14
+ }
15
+ function config(value, registry) {
16
+ if (value?.schemaVersion !== 1 || value.kind !== 'feed-observer-config' || Object.keys(value).some(k => !['schemaVersion', 'kind', 'producerId', 'healthUrl', 'consumers'].includes(k))) throw new Error('Invalid observer config')
17
+ if (!registry.feeds.some(f => f.producerId === value.producerId) || !Array.isArray(value.consumers) || value.consumers.length > 4) throw new Error('Invalid observer targets')
18
+ const consumers = value.consumers.map(c => {
19
+ if (!c || Object.keys(c).some(k => !['feedId', 'manifestUrl', 'operator', 'deadline', 'timeZone'].includes(k))) throw new Error('Invalid consumer config')
20
+ if (!registry.feeds.some(f => f.id === c.feedId && f.producerId === value.producerId && f.stages.some(s => s.id === 'serve'))) throw new Error('Register the consumer serve stage')
21
+ if (typeof c.operator !== 'string' || !c.operator.trim() || c.operator.length > 100 || c.timeZone !== SERVICE_TIMEZONE || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(c.deadline)) throw new Error('Invalid bus consumer expectation')
22
+ return { ...c, manifestUrl: checkUrl(c.manifestUrl) }
23
+ })
24
+ if (new Set(consumers.map(c => c.feedId)).size !== consumers.length) throw new Error('Duplicate consumer target')
25
+ return { producerId: value.producerId, healthUrl: checkUrl(value.healthUrl), consumers }
26
+ }
27
+ class CheckError extends Error { constructor(reason) { super(reason); this.reason = reason } }
28
+ function client(fetchImpl, tokens, limits) {
29
+ const began = performance.now(), usage = { requests: 0, bytes: 0 }
30
+ const read = async (url, cap, token) => {
31
+ if (usage.requests >= limits.requests || usage.bytes >= limits.totalBytes || performance.now() - began >= limits.totalMs) throw new CheckError('check-budget-exhausted')
32
+ usage.requests++
33
+ const controller = new AbortController()
34
+ let reader, body, timer
35
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new CheckError('request-timeout')) }, Math.min(limits.timeoutMs, limits.totalMs - (performance.now() - began))) })
36
+ try {
37
+ return await Promise.race([timeout, (async () => {
38
+ const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache', ...(token ? { Authorization: `Bearer ${token}` } : {}) }
39
+ const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: 'error', cache: 'no-store' })
40
+ body = response.body
41
+ if (!response.ok || response.redirected) throw new CheckError('http-unavailable')
42
+ const advertised = response.headers.get('content-length')
43
+ if (advertised !== null && (!/^\d+$/.test(advertised) || Number(advertised) > cap)) throw new CheckError('response-too-large')
44
+ reader = response.body?.getReader()
45
+ if (!reader) throw new CheckError('document-invalid')
46
+ let size = 0
47
+ const chunks = []
48
+ while (true) {
49
+ const { done, value } = await reader.read()
50
+ if (done) break
51
+ size += value.byteLength; usage.bytes += value.byteLength
52
+ if (size > cap || usage.bytes > limits.totalBytes) throw new CheckError('response-too-large')
53
+ chunks.push(value)
54
+ }
55
+ const bytes = new Uint8Array(size)
56
+ let offset = 0
57
+ for (const c of chunks) { bytes.set(c, offset); offset += c.length }
58
+ let json
59
+ try { json = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) } catch { throw new CheckError('document-invalid') }
60
+ return { bytes, json }
61
+ })()])
62
+ } catch (e) { throw e instanceof CheckError ? e : new CheckError('request-failed') }
63
+ finally { clearTimeout(timer); controller.abort(); if (reader) void reader.cancel().catch(() => {}); else if (body) void body.cancel().catch(() => {}) }
64
+ }
65
+ return { usage, health: url => read(url, limits.healthBytes, tokens.health), consumer: (url, kind, token) => read(url, limits[`${kind}Bytes`], token) }
66
+ }
67
+
68
+ /** Reevaluate a mirrored producer report at the checker's clock; never renew its heartbeat. */
69
+ function refresh(registry, raw, producerId, now) {
70
+ const report = readFeedHealth(raw, registry)
71
+ if (report.producerId !== producerId || Date.parse(report.observedAt) > now + registry.clockSkewSeconds * 1000) throw new Error('Unexpected producer/observer clock')
72
+ const generated = report.producerGeneratedAt ? Date.parse(report.producerGeneratedAt) : null
73
+ const elapsed = generated === null ? null : (now - generated) / 1000
74
+ report.observedAt = new Date(now).toISOString()
75
+ report.telemetry = { state: elapsed === null ? report.telemetry.state : elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current', ageSeconds: elapsed === null ? null : Math.max(0, elapsed) }
76
+ for (const f of report.feeds) {
77
+ const entry = registry.feeds.find(e => e.id === f.feedId)
78
+ f.stages = f.stages.map(s => {
79
+ if (report.telemetry.state !== 'current') return { ...s, state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], timestamps: {}, metrics: {} }
80
+ const def = entry.stages.find(d => d.id === s.stageId)
81
+ const field = { capture: 'receivedAt', normalize: 'latestPeriodEnd', analytics: 'lastAttemptAt' }[s.stageId]
82
+ const metric = { capture: 'capture-age-seconds', normalize: 'normalization-lag-seconds', analytics: 'analytics-status-age-seconds' }[s.stageId]
83
+ if (field && s.timestamps[field]) s.metrics[metric] = Math.max(0, (now - Date.parse(s.timestamps[field])) / 1000)
84
+ if (s.stageId === 'capture' && s.timestamps.sourceGeneratedAt) s.metrics['source-age-seconds'] = Math.max(0, (now - Date.parse(s.timestamps.sourceGeneratedAt)) / 1000)
85
+ if (f.configuredState !== 'active' || !['healthy', 'waiting'].includes(s.state)) return s
86
+ if (field && def.maxAgeSeconds !== null) {
87
+ const stamp = s.timestamps[field], age = stamp ? (now - Date.parse(stamp)) / 1000 : null
88
+ if (age === null) return { ...s, state: 'unknown', reasons: ['stage-clock-unavailable'] }
89
+ if (age < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['stage-clock-invalid'] }
90
+ if (age > def.maxAgeSeconds) return { ...s, state: s.stageId === 'analytics' ? 'unknown' : 'degraded', reasons: [`${s.stageId}-stale`] }
91
+ }
92
+ if (s.stageId === 'capture' && def.sourceMaxAgeSeconds) {
93
+ const stamp = s.timestamps.sourceGeneratedAt
94
+ if (!stamp) return { ...s, state: 'unknown', reasons: ['source-time-unavailable'] }
95
+ if ((now - Date.parse(stamp)) / 1000 < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['source-clock-invalid'] }
96
+ if ((now - Date.parse(stamp)) / 1000 > def.sourceMaxAgeSeconds) return { ...s, state: 'degraded', reasons: ['source-stale'] }
97
+ }
98
+ return s
99
+ })
100
+ f.state = f.configuredState === 'active' ? summarizeFeedStages(f.stages) : f.configuredState
101
+ }
102
+ if (report.telemetry.state !== 'current') report.capacity = { state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], metrics: {} }
103
+ return readFeedHealth(report, registry)
104
+ }
105
+
106
+ async function consumer(c, expectation, api, now, skew, token) {
107
+ try {
108
+ const { json: manifest } = await api.consumer(c.manifestUrl, 'manifest', token)
109
+ const id = manifest.resultId, file = manifest.report
110
+ if (manifest.schemaVersion !== 1 || !digest(id) || file?.path !== `releases/${id}/result.json` || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > OBSERVER_LIMITS.releaseBytes || !digest(file.sha256)) throw new CheckError('manifest-invalid')
111
+ const publishedAt = readOperationalTime(manifest.observedAt)
112
+ if (Date.parse(publishedAt) > now + skew * 1000) throw new CheckError('publication-clock-invalid')
113
+ const { json: release, bytes } = await api.consumer(new URL(file.path, c.manifestUrl).href, 'release', token)
114
+ const { resultId, ...payload } = release
115
+ if (bytes.length !== file.bytes || await sha(bytes) !== file.sha256 || resultId !== id || await sha(new TextEncoder().encode(JSON.stringify(payload))) !== id) throw new CheckError('release-integrity-failed')
116
+ if (release.schemaVersion !== 2 || release.kind !== 'feed-quality-history' || release.operator !== c.operator || !Array.isArray(release.days) || release.days.length > 366) throw new CheckError('release-invalid')
117
+ readOperationalTime(`${release.from}T00:00:00Z`); readOperationalTime(`${release.to}T00:00:00Z`)
118
+ if (release.from > release.to) throw new CheckError('release-invalid')
119
+ const expected = studyDay(expectation.serviceDate)
120
+ const selected = release.days.filter(d => d.date === expectation.serviceDate)
121
+ if (release.from > expectation.serviceDate || release.to < expectation.serviceDate || selected.length === 0) return stage('degraded', 'consumer-period-overdue')
122
+ if (selected.length !== 1) throw new CheckError('release-invalid')
123
+ const day = selected[0]
124
+ if (day.status !== 'current') return stage('degraded', 'consumer-day-overdue')
125
+ if (!digest(day.revision) || !day.quality || day.day?.startUtc !== expected.startUtc || day.day?.endUtc !== expected.endUtc || day.day?.serviceDate !== expected.serviceDate || Date.parse(publishedAt) < Date.parse(expected.endUtc)) throw new CheckError('release-day-invalid')
126
+ return stage('healthy', 'consumer-release-verified', { lastSuccessAt: publishedAt, latestPeriodEnd: expected.endUtc }, { 'verified-release-bytes': bytes.length })
127
+ } catch (e) { return stage('degraded', `consumer-${e instanceof CheckError ? e.reason : 'document-invalid'}`) }
128
+ }
129
+
130
+ /** Portable Web-API checker; reads configured endpoints only, with no redirects or retries. */
131
+ export async function observeFeeds(registryValue, configValue, { fetchImpl = fetch, clock = Date.now, tokens = {}, limits = OBSERVER_LIMITS } = {}) {
132
+ const registry = readFeedRegistry(registryValue), c = config(configValue, registry)
133
+ // Tests may lower bounds, never raise them.
134
+ for (const [key, value] of Object.entries(OBSERVER_LIMITS)) if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > value) throw new Error('Invalid check limit')
135
+ const api = client(fetchImpl, tokens, limits), startedAt = new Date(clock()).toISOString()
136
+ let source = null, sourceError = null
137
+ try { source = (await api.health(c.healthUrl)).json; refresh(registry, source, c.producerId, clock()) }
138
+ catch (e) { source = null; sourceError = e instanceof CheckError ? e.reason : 'document-invalid' }
139
+ const checks = []
140
+ for (const target of c.consumers) {
141
+ const definition = registry.feeds.find(f => f.id === target.feedId)
142
+ if (definition.state !== 'active') continue
143
+ const expectation = dailyFeedExpectation(target, clock())
144
+ const health = await consumer(target, expectation, api, clock(), registry.clockSkewSeconds, tokens.consumers?.[target.feedId])
145
+ checks.push({ feedId: target.feedId, expectation, ...health })
146
+ }
147
+ const finished = clock()
148
+ for (const check of checks) {
149
+ const target = c.consumers.find(t => t.feedId === check.feedId), latest = dailyFeedExpectation(target, finished)
150
+ if (latest.serviceDate !== check.expectation.serviceDate) Object.assign(check, stage('degraded', 'consumer-expectation-advanced'), { expectation: latest })
151
+ }
152
+ let report
153
+ if (source) report = refresh(registry, source, c.producerId, finished)
154
+ else report = recorderFeedHealth(registry, sourceError === 'document-invalid' ? {} : null, { producerId: c.producerId, now: finished })
155
+ if (report.telemetry.state === 'current') for (const check of checks) {
156
+ const feed = report.feeds.find(f => f.feedId === check.feedId)
157
+ const { feedId: _feedId, expectation: _expectation, ...health } = check
158
+ feed.stages = feed.stages.map(s => s.stageId === 'serve' ? health : s)
159
+ feed.state = summarizeFeedStages(feed.stages)
160
+ }
161
+ return { schemaVersion: 1, kind: 'feed-observer-check', startedAt, completedAt: new Date(finished).toISOString(), sourceError, requests: api.usage.requests, bytes: api.usage.bytes,
162
+ report: readFeedHealth(report, registry), consumers: checks }
163
+ }
164
+
165
+ /** Assess a stored check at read time. A successful old check is historical evidence. */
166
+ export function assessObserverCheck(registryValue, checked, { now = Date.now(), maxAgeSeconds = 180 } = {}) {
167
+ const registry = readFeedRegistry(registryValue)
168
+ if (!Number.isFinite(now) || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 3600) throw new Error('Invalid observer clock/threshold')
169
+ const unknown = reason => ({ state: 'unknown', reasons: [reason] })
170
+ if (!checked) return unknown('checker-missing')
171
+ try {
172
+ if (checked.schemaVersion !== 1 || checked.kind !== 'feed-observer-check' || !Array.isArray(checked.consumers)) return unknown('checker-invalid')
173
+ const completed = Date.parse(readOperationalTime(checked.completedAt)), started = Date.parse(readOperationalTime(checked.startedAt))
174
+ if (started > completed || completed > now + registry.clockSkewSeconds * 1000) return unknown('checker-clock-invalid')
175
+ if (now - completed > maxAgeSeconds * 1000) return unknown('checker-stale')
176
+ const report = refresh(registry, checked.report, checked.report.producerId, now)
177
+ if (report.telemetry.state !== 'current') return unknown(`telemetry-${report.telemetry.state}`)
178
+ for (const c of checked.consumers) {
179
+ if (Date.parse(readOperationalTime(c.expectation.nextDeadlineAt)) <= now) return unknown('consumer-expectation-expired')
180
+ if (!['healthy', 'degraded', 'unknown', 'waiting'].includes(c.state)) return unknown('checker-invalid')
181
+ }
182
+ const states = [report.capacity.state, ...report.feeds.filter(f => f.configuredState === 'active').map(f => f.state), ...checked.consumers.map(c => c.state)]
183
+ if (states.includes('degraded')) return { state: 'degraded', reasons: ['feed-attention-required'] }
184
+ if (states.includes('unknown')) return unknown('feed-evidence-unavailable')
185
+ return { state: 'healthy', reasons: ['check-current'] }
186
+ } catch { return unknown('checker-invalid') }
187
+ }
@@ -0,0 +1,20 @@
1
+ import { open, constants } from 'node:fs/promises'
2
+
3
+ /** Read a bounded regular file without following a final symlink or blocking on a FIFO. */
4
+ export async function readOperationalFile(path, limit) {
5
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
6
+ try {
7
+ const stat = await handle.stat()
8
+ if (!stat.isFile() || stat.size > limit) throw new Error('Input must be a bounded regular file')
9
+ const bytes = Buffer.alloc(limit + 1)
10
+ let size = 0
11
+ while (size <= limit) {
12
+ const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null)
13
+ if (!bytesRead) break
14
+ size += bytesRead
15
+ }
16
+ const after = await handle.stat()
17
+ if (size > limit || size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw new Error('Input changed or exceeds read limit')
18
+ return bytes.subarray(0, size)
19
+ } finally { await handle.close() }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.26",
3
+ "version": "0.1.0-alpha.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -86,6 +86,30 @@
86
86
  "./rail-geometry": {
87
87
  "types": "./rail-geometry.d.mts",
88
88
  "import": "./rail-geometry.mjs"
89
+ },
90
+ "./feed-observability": {
91
+ "types": "./feed-observability.d.mts",
92
+ "import": "./feed-observability.mjs"
93
+ },
94
+ "./recorder-observability": {
95
+ "types": "./recorder-observability.d.mts",
96
+ "import": "./recorder-observability.mjs"
97
+ },
98
+ "./recorder-evidence": {
99
+ "types": "./recorder-evidence.d.mts",
100
+ "import": "./recorder-evidence.mjs"
101
+ },
102
+ "./feed-incidents": {
103
+ "types": "./feed-incidents.d.mts",
104
+ "import": "./feed-incidents.mjs"
105
+ },
106
+ "./feed-observer": {
107
+ "types": "./feed-observer.d.mts",
108
+ "import": "./feed-observer.mjs"
109
+ },
110
+ "./feed-expectations": {
111
+ "types": "./feed-expectations.d.mts",
112
+ "import": "./feed-expectations.mjs"
89
113
  }
90
114
  },
91
115
  "files": [
@@ -101,7 +125,7 @@
101
125
  ],
102
126
  "sideEffects": false,
103
127
  "dependencies": {
104
- "@motionstudies/core": "0.1.0-alpha.26",
128
+ "@motionstudies/core": "0.1.0-alpha.28",
105
129
  "fast-xml-parser": "5.11.1"
106
130
  }
107
131
  }
@@ -0,0 +1,14 @@
1
+ import type { FeedHealth } from './feed-observability.mjs'
2
+ export interface RecorderEvidencePlan {
3
+ schemaVersion: 1
4
+ kind: 'recorder-evidence-plan'
5
+ producerId: string
6
+ validUntil: string
7
+ feeds: {
8
+ feedId: string
9
+ archive?: { journalPath: string; closeoutPath: string; expectedEnd: string; deadlineAt: string }
10
+ publication?: { pointerPath: string; operator: string; expectedThrough: string; deadlineAt: string }
11
+ }[]
12
+ }
13
+ /** Reads explicitly selected local files, with integrity checks and a 64 MiB total read budget. */
14
+ export function recorderEvidenceHealth(registry: unknown, report: unknown, plan: unknown): Promise<FeedHealth>
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { isAbsolute, dirname, join } from 'node:path'
3
+ import { readOperationalFile } from './operational-files.mjs'
4
+ import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
5
+ import { studyDay } from './uk-service-day.mjs'
6
+
7
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex')
8
+ const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
9
+ const count = value => Number.isSafeInteger(value) && value >= 0
10
+ function keys(value, allowed) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !allowed.includes(k))) throw new Error('Invalid evidence plan')
12
+ }
13
+ function path(value) { if (typeof value !== 'string' || !isAbsolute(value) || value.length > 4096) throw new Error('Evidence paths must be absolute'); return value }
14
+ function date(value) { readOperationalTime(`${value}T00:00:00Z`); return value }
15
+ function readPlan(value, registry, producerId) {
16
+ keys(value, ['schemaVersion', 'kind', 'producerId', 'validUntil', 'feeds'])
17
+ if (value.schemaVersion !== 1 || value.kind !== 'recorder-evidence-plan' || value.producerId !== producerId || !Array.isArray(value.feeds) || value.feeds.length > 16) throw new Error('Invalid evidence plan')
18
+ const validUntil = readOperationalTime(value.validUntil)
19
+ const feeds = value.feeds.map(f => {
20
+ keys(f, ['feedId', 'archive', 'publication'])
21
+ const entry = registry.feeds.find(e => e.id === f.feedId && e.producerId === producerId)
22
+ if (!entry) throw new Error('Unregistered evidence feed')
23
+ const output = { feedId: f.feedId }
24
+ for (const id of ['archive', 'publication']) if (f[id]) {
25
+ if (!entry.stages.some(s => s.id === id)) throw new Error('Unregistered evidence stage')
26
+ if (id === 'archive') {
27
+ keys(f[id], ['journalPath', 'closeoutPath', 'expectedEnd', 'deadlineAt'])
28
+ output.archive = { journalPath: path(f[id].journalPath), closeoutPath: path(f[id].closeoutPath), expectedEnd: readOperationalTime(f[id].expectedEnd), deadlineAt: readOperationalTime(f[id].deadlineAt) }
29
+ } else {
30
+ keys(f[id], ['pointerPath', 'operator', 'expectedThrough', 'deadlineAt'])
31
+ if (typeof f[id].operator !== 'string' || !f[id].operator.trim() || f[id].operator.length > 100) throw new Error('Invalid operator')
32
+ output.publication = { pointerPath: path(f[id].pointerPath), operator: f[id].operator, expectedThrough: date(f[id].expectedThrough), deadlineAt: readOperationalTime(f[id].deadlineAt) }
33
+ }
34
+ const end = id === 'archive' ? output.archive.expectedEnd : studyDay(output.publication.expectedThrough).endUtc
35
+ if (Date.parse(output[id].deadlineAt) < Date.parse(end)) throw new Error('Evidence deadline precedes expected period end')
36
+ if (Date.parse(output[id].deadlineAt) > Date.parse(validUntil)) throw new Error('Evidence deadline exceeds plan validity')
37
+ }
38
+ return output
39
+ })
40
+ if (new Set(feeds.map(f => f.feedId)).size !== feeds.length) throw new Error('Duplicate evidence feed')
41
+ return { feeds, validUntil }
42
+ }
43
+ const result = (stageId, state, reason, timestamps = {}, metrics = {}) => ({ stageId, state, reasons: reason ? [reason] : [], timestamps, metrics })
44
+ const pending = (id, deadline, now, reason) => result(id, now > Date.parse(deadline) ? 'degraded' : 'waiting', `${reason}-${now > Date.parse(deadline) ? 'overdue' : 'pending'}`)
45
+
46
+ /** Read explicit local evidence targets; never infer paths from a status report or fetch R2. */
47
+ export async function recorderEvidenceHealth(registryValue, reportValue, planValue) {
48
+ const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry)
49
+ const plan = readPlan(planValue, registry, report.producerId), now = Date.parse(report.observedAt)
50
+ let remaining = 64 * 1024 * 1024
51
+ const read = async (path, limit) => {
52
+ if (remaining <= 0) throw new Error('Evidence byte budget exceeded')
53
+ const bytes = await readOperationalFile(path, Math.min(limit, remaining))
54
+ remaining -= bytes.length
55
+ return { bytes, json: JSON.parse(bytes) }
56
+ }
57
+ for (const f of plan.feeds) {
58
+ const feed = report.feeds.find(e => e.feedId === f.feedId)
59
+ if (report.telemetry.state !== 'current' || feed.configuredState !== 'active') continue
60
+ const entry = registry.feeds.find(e => e.id === f.feedId)
61
+ for (const stageId of ['archive', 'publication']) {
62
+ if (!f[stageId]) continue
63
+ let evidence
64
+ try {
65
+ evidence = now > Date.parse(plan.validUntil) ? result(stageId, 'unknown', 'evidence-plan-expired') : stageId === 'archive' ? await archive(f.archive, entry, read, now, registry.clockSkewSeconds) : await publication(f.publication, read, now, registry.clockSkewSeconds)
66
+ } catch (error) {
67
+ evidence = error.code === 'ENOENT' ? pending(stageId, f[stageId].deadlineAt, now, `${stageId}-evidence`) : result(stageId, 'unknown', `${stageId}-evidence-invalid`)
68
+ }
69
+ feed.stages = feed.stages.map(s => s.stageId === stageId ? evidence : s)
70
+ }
71
+ feed.state = summarizeFeedStages(feed.stages)
72
+ }
73
+ return readFeedHealth(report, registry)
74
+ }
75
+
76
+ async function archive(plan, entry, read, now, skew) {
77
+ const { bytes, json: journal } = await read(plan.journalPath, 16 * 1024 * 1024)
78
+ if (!journal.id || journal.config?.source !== entry.sourceId || !['complete', 'stopped'].includes(journal.status) || !Array.isArray(journal.attempts)) throw new Error('Invalid closed journal')
79
+ const end = readOperationalTime(journal.plannedEnd), start = readOperationalTime(journal.startedAt)
80
+ if (end !== plan.expectedEnd || Date.parse(end) < Date.parse(start) || Date.parse(end) > now || Date.parse(plan.deadlineAt) < Date.parse(end)) throw new Error('Invalid archive period/deadline')
81
+ const dates = [...new Set(journal.attempts.filter(a => a.status === 'captured').map(a => readOperationalTime(a.receivedAt).slice(0, 10)))].sort()
82
+ if (!dates.length || dates.length > 7) throw new Error('Archive has no bounded captured dates')
83
+ const { json: record } = await read(plan.closeoutPath, 1024 * 1024)
84
+ if (record.journalId !== journal.id || record.source !== entry.sourceId || record.journalSha256 !== hash(bytes)) throw new Error('Closeout provenance mismatch')
85
+ const closedAt = readOperationalTime(record.closedAt)
86
+ if (Date.parse(closedAt) > now + skew * 1000 || Date.parse(closedAt) < Date.parse(start)) throw new Error('Invalid closeout clock')
87
+ if (JSON.stringify([...record.dates].sort()) !== JSON.stringify(dates)) throw new Error('Closeout dates differ')
88
+ const n = record.steps?.normalise
89
+ if (!n || n.unsupported || !Array.isArray(n.skipped) || n.skipped.length) return result('archive', 'degraded', 'archive-normalization-incomplete')
90
+ let files = 0
91
+ for (const d of dates) {
92
+ if (entry.sourceId === 'bulk-archive' && (!record.steps.packs?.[d] || record.steps.packs[d].error)) return result('archive', 'degraded', 'archive-packs-incomplete')
93
+ const upload = record.steps.upload?.[d]
94
+ if (!upload) return pending('archive', plan.deadlineAt, now, 'archive-upload')
95
+ for (const key of ['normalized', 'publish']) {
96
+ const u = upload[key]
97
+ if (key === 'publish' && u === null && entry.sourceId !== 'bulk-archive') continue
98
+ if (!u) return pending('archive', plan.deadlineAt, now, 'archive-upload')
99
+ if (u.error || u.stopped || u.failed > 0) return result('archive', 'degraded', 'archive-upload-failed')
100
+ if (![u.files, u.uploaded, u.skipped, u.failed].every(count) || u.failed !== 0 || u.files < 1 || u.uploaded + u.skipped !== u.files) throw new Error('Invalid upload accounting')
101
+ files += u.files
102
+ }
103
+ }
104
+ return result('archive', 'healthy', 'archive-closeout-verified', { latestPeriodEnd: end, lastSuccessAt: closedAt }, { 'recorded-upload-files': files })
105
+ }
106
+
107
+ async function publication(plan, read, now, skew) {
108
+ const { bytes: pointerBytes, json: pointer } = await read(plan.pointerPath, 8192)
109
+ if (pointer.schemaVersion !== 1 || pointer.kind !== 'feed-quality-release-pointer' || !digest(pointer.resultId) || pointer.operator !== plan.operator) throw new Error('Invalid release pointer')
110
+ const observedAt = readOperationalTime(pointer.observedAt), from = date(pointer.from), to = date(pointer.to)
111
+ if (Date.parse(observedAt) > now + skew * 1000 || from > to) throw new Error('Invalid publication clock/range')
112
+ // The caller selects the pointer; only a validated digest can select its sibling immutable artifact.
113
+ const dir = join(dirname(dirname(plan.pointerPath)), 'quality-releases', pointer.resultId)
114
+ const { json: manifest } = await read(join(dir, 'manifest.json'), 4096)
115
+ const file = manifest.files?.[0]
116
+ if (manifest.schemaVersion !== 1 || manifest.resultId !== pointer.resultId || manifest.files?.length !== 1 || file.path !== 'result.json' || !count(file.bytes) || !digest(file.sha256)) throw new Error('Invalid release manifest')
117
+ const { bytes, json: release } = await read(join(dir, 'result.json'), 2 * 1024 * 1024)
118
+ const { resultId, ...payload } = release
119
+ if (file.bytes !== bytes.length || file.sha256 !== hash(bytes) || resultId !== pointer.resultId || hash(JSON.stringify(payload)) !== resultId) throw new Error('Release integrity mismatch')
120
+ if (release.schemaVersion !== 2 || release.kind !== 'feed-quality-history' || release.operator !== plan.operator || release.from !== from || release.to !== to || !Array.isArray(release.days) || release.days.length > 366) throw new Error('Release selection mismatch')
121
+ if (!(await read(plan.pointerPath, 8192)).bytes.equals(pointerBytes)) throw new Error('Release selection changed during check')
122
+ const days = new Map()
123
+ for (const d of release.days) {
124
+ date(d.date)
125
+ if (days.has(d.date) || d.date < from || d.date > to) throw new Error('Invalid release day membership')
126
+ days.set(d.date, d)
127
+ }
128
+ if (from > plan.expectedThrough || to < plan.expectedThrough || !days.has(plan.expectedThrough)) return pending('publication', plan.deadlineAt, now, 'publication-period')
129
+ const selected = days.get(plan.expectedThrough)
130
+ const expected = studyDay(plan.expectedThrough)
131
+ if (Date.parse(plan.deadlineAt) < Date.parse(expected.endUtc) || Date.parse(expected.endUtc) > now + skew * 1000 || Date.parse(observedAt) < Date.parse(expected.endUtc)) throw new Error('Invalid publication deadline/period')
132
+ if (selected.status !== 'current') return pending('publication', plan.deadlineAt, now, 'publication-day')
133
+ if (!digest(selected.revision) || !selected.quality || selected.day?.serviceDate !== plan.expectedThrough || selected.day?.startUtc !== expected.startUtc || selected.day?.endUtc !== expected.endUtc) throw new Error('Invalid selected day')
134
+ return result('publication', 'healthy', 'publication-artifact-verified', { lastSuccessAt: observedAt, latestPeriodEnd: expected.endUtc }, { 'verified-release-bytes': bytes.length })
135
+ }
@@ -0,0 +1,3 @@
1
+ import type { FeedHealth } from './feed-observability.mjs'
2
+ /** Reads only supplied JSON. `now` is the observer's Unix time in milliseconds. */
3
+ export function recorderFeedHealth(registry: unknown, status: unknown, options: { producerId: string; now?: number }): FeedHealth
@@ -0,0 +1,106 @@
1
+ import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
2
+
3
+ const finite = value => Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER
4
+ const time = value => { try { return readOperationalTime(value) } catch { return null } }
5
+ const age = (stamp, now) => (now - Date.parse(stamp)) / 1000
6
+ const stage = (stageId, state, reasons = [], timestamps = {}, metrics = {}) => ({ stageId, state, reasons, timestamps, metrics })
7
+ const metric = (key, value) => finite(value) ? { [key]: value } : {}
8
+
9
+ /** Projection of recorder status.json. Never read paths, emit raw errors or trust cached ages/state. */
10
+ export function recorderFeedHealth(registryValue, status, { producerId, now = Date.now() } = {}) {
11
+ const registry = readFeedRegistry(registryValue)
12
+ const observedAt = new Date(now).toISOString(), entries = registry.feeds.filter(f => f.producerId === producerId)
13
+ if (!entries.length) throw new Error('Unregistered producer')
14
+ if (entries.filter(f => f.stages.some(s => s.id === 'analytics')).length > 1) throw new Error('Recorder status supports one analytics feed')
15
+ const generatedAt = time(status?.generatedAt), reportAge = generatedAt ? age(generatedAt, now) : null
16
+ const telemetry = { state: status === null ? 'missing' : !generatedAt || reportAge < -registry.clockSkewSeconds ? 'invalid' : reportAge > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current', ageSeconds: reportAge === null ? null : Math.max(0, reportAge) }
17
+ const unavailable = telemetry.state !== 'current'
18
+ if (!unavailable && (!Array.isArray(status.feeds) || status.feeds.length > 128 || new Set(status.feeds.map(f => f?.name)).size !== status.feeds.length)) throw new Error('Invalid recorder feed list')
19
+ const feeds = entries.map(entry => {
20
+ const f = status?.feeds?.find?.(f => f?.name === entry.producerFeed)
21
+ const stages = entry.stages.map(def => {
22
+ if (unavailable) return stage(def.id, 'unknown', [`telemetry-${telemetry.state}`])
23
+ if (entry.state !== 'active') return stage(def.id, 'unknown', [entry.state])
24
+ if (!f) return stage(def.id, 'unknown', ['feed-missing'])
25
+ if (status.storageError) return stage(def.id, 'degraded', ['host-storage-blocked'])
26
+ if (f.error) return stage(def.id, 'unknown', ['feed-status-unavailable'])
27
+ if (f.source !== entry.sourceId) return stage(def.id, 'unknown', [f.source ? 'source-mismatch' : 'source-unavailable'])
28
+ if (def.id === 'capture') return capture(def, f, registry, now, generatedAt)
29
+ if (def.id === 'normalize') return normalize(def, f, registry, now, generatedAt)
30
+ if (def.id === 'archive') {
31
+ // A complete close-out may be local-only. This report cannot prove an R2 upload.
32
+ const count = f.processing?.incompleteCloseouts, job = f.jobs?.[`process-${entry.producerFeed}`]
33
+ if (job && job.code !== 0) return stage(def.id, 'degraded', ['processing-job-failed'], {}, metric('incomplete-closeouts', count))
34
+ return stage(def.id, finite(count) && count > 0 ? 'waiting' : 'unknown', [finite(count) && count > 0 ? 'closeouts-pending' : 'upload-evidence-unavailable'], {}, metric('incomplete-closeouts', count))
35
+ }
36
+ if (def.id === 'analytics') return analytics(def, status.analytics, registry, now, generatedAt)
37
+ // Publication/serving needs separate evidence, even when a local job succeeded.
38
+ return stage(def.id, 'unknown', ['stage-evidence-unavailable'])
39
+ })
40
+ return { feedId: entry.id, configuredState: entry.state, state: entry.state === 'active' ? summarizeFeedStages(stages) : entry.state, stages }
41
+ })
42
+ const c = status?.capacity, disk = status?.disk
43
+ const capacity = unavailable ? { state: 'unknown', reasons: [`telemetry-${telemetry.state}`], metrics: {} } : {
44
+ state: status.storageError || c?.status === 'paused' || c?.error || c?.remainingCaptureBytes === 0 ? 'degraded' : c?.status === 'open' && finite(disk?.freeBytes) ? 'healthy' : 'unknown',
45
+ reasons: status.storageError ? ['host-storage-blocked'] : c?.error ? ['ledger-unavailable'] : c?.status === 'paused' ? ['ledger-paused'] : c?.remainingCaptureBytes === 0 ? ['capture-budget-exhausted'] : c?.status === 'open' && finite(disk?.freeBytes) ? [] : ['capacity-evidence-unavailable'],
46
+ metrics: { ...metric('disk-free-bytes', disk?.freeBytes), ...metric('disk-total-bytes', disk?.totalBytes), ...metric('committed-bytes', c?.usage?.total?.committed),
47
+ ...metric('reserved-bytes', c?.usage?.total?.reserved), ...metric('remaining-capture-bytes', c?.remainingCaptureBytes), ...metric('estimated-capture-days', c?.estimatedCaptureDays) },
48
+ }
49
+ return readFeedHealth({ schemaVersion: 1, kind: 'feed-health', registryId: registry.id, producerId, observedAt, producerGeneratedAt: generatedAt, telemetry, feeds, capacity }, registry)
50
+ }
51
+
52
+ function capture(def, f, registry, now, generatedAt) {
53
+ const receivedAt = time(f.lastCaptureAt)
54
+ if (!receivedAt) return stage(def.id, 'unknown', ['capture-time-unavailable'])
55
+ const receivedAge = age(receivedAt, now), timestamps = { receivedAt }, metrics = { 'capture-age-seconds': Math.max(0, receivedAge) }
56
+ if (receivedAge < -registry.clockSkewSeconds || Date.parse(receivedAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['capture-clock-invalid'])
57
+ const reasons = []
58
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['capture-threshold-unconfigured'], timestamps, metrics)
59
+ if (receivedAge > def.maxAgeSeconds) reasons.push('capture-stale')
60
+ // Legacy host status stores source age at report time, not the original timestamp.
61
+ // Advancing it by report age prevents a fresh read from making old source data fresh.
62
+ if (finite(f.sourceAgeSeconds)) {
63
+ const sourceGeneratedAt = new Date(Date.parse(generatedAt) - f.sourceAgeSeconds * 1000)
64
+ if (!Number.isFinite(sourceGeneratedAt.getTime())) return stage(def.id, 'unknown', ['source-clock-invalid'], timestamps, metrics)
65
+ timestamps.sourceGeneratedAt = sourceGeneratedAt.toISOString()
66
+ metrics['source-age-seconds'] = Math.max(0, age(timestamps.sourceGeneratedAt, now))
67
+ if (def.sourceMaxAgeSeconds && metrics['source-age-seconds'] > def.sourceMaxAgeSeconds) reasons.push('source-stale')
68
+ } else if (def.sourceMaxAgeSeconds) {
69
+ return stage(def.id, reasons.length ? 'degraded' : 'unknown', [...reasons, 'source-time-unavailable'], timestamps, metrics)
70
+ }
71
+ if (f.plan?.action === 'blocked') reasons.push('collection-blocked')
72
+ if (reasons.length) return stage(def.id, 'degraded', reasons, timestamps, metrics)
73
+ if (['complete', 'stopped'].includes(f.state) || f.plan?.action === 'wait') return stage(def.id, 'waiting', ['collection-between-runs'], timestamps, metrics)
74
+ if (!['healthy', 'stalled'].includes(f.state)) return stage(def.id, 'unknown', ['collection-state-unrecognized'], timestamps, metrics)
75
+ return stage(def.id, 'healthy', [], timestamps, metrics)
76
+ }
77
+
78
+ function normalize(def, f, registry, now, generatedAt) {
79
+ const hour = f.processing?.latestNormalizedHour
80
+ const start = typeof hour === 'string' && /^\d{4}-\d\d-\d\dT\d\d$/.test(hour) ? time(`${hour}:00:00Z`) : null
81
+ if (!start) return stage(def.id, 'unknown', ['normalized-period-unavailable'])
82
+ const latestPeriodEnd = new Date(Date.parse(start) + 3600000).toISOString(), lag = age(latestPeriodEnd, now)
83
+ if (lag < -registry.clockSkewSeconds || Date.parse(latestPeriodEnd) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['normalized-period-in-future'])
84
+ const timestamps = { latestPeriodEnd }, metrics = { 'normalization-lag-seconds': Math.max(0, lag) }
85
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['normalization-threshold-unconfigured'], timestamps, metrics)
86
+ return stage(def.id, lag > def.maxAgeSeconds ? 'degraded' : 'healthy', lag > def.maxAgeSeconds ? ['normalization-lagging'] : [], timestamps, metrics)
87
+ }
88
+
89
+ function analytics(def, a, registry, now, generatedAt) {
90
+ const attemptedAt = time(a?.observedAt)
91
+ if (!attemptedAt) return stage(def.id, 'unknown', ['analytics-status-unavailable'])
92
+ const elapsed = age(attemptedAt, now)
93
+ if (elapsed < -registry.clockSkewSeconds || Date.parse(attemptedAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['analytics-clock-invalid'])
94
+ const timestamps = { lastAttemptAt: attemptedAt }
95
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['analytics-threshold-unconfigured'], timestamps)
96
+ if (elapsed > def.maxAgeSeconds) return stage(def.id, 'unknown', ['analytics-status-stale'], timestamps)
97
+ if (a.error || a.job && a.job.code !== 0) return stage(def.id, 'degraded', ['analytics-job-failed'], timestamps)
98
+ if (!Array.isArray(a.pendingMonths) || !Array.isArray(a.waiting) || !a.months || typeof a.months !== 'object' || Array.isArray(a.months) || Object.keys(a.months).length > 120) return stage(def.id, 'unknown', ['analytics-status-invalid'], timestamps)
99
+ const months = Object.values(a.months), successes = months.map(m => time(m?.lastSuccessAt)).filter(Boolean).sort()
100
+ if (successes.length) timestamps.lastSuccessAt = successes.at(-1)
101
+ if (timestamps.lastSuccessAt && (age(timestamps.lastSuccessAt, now) < -registry.clockSkewSeconds || Date.parse(timestamps.lastSuccessAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000)) return stage(def.id, 'unknown', ['analytics-clock-invalid'])
102
+ const metrics = { 'pending-months': a.pendingMonths.length, 'waiting-inputs': a.waiting.length }
103
+ // Open receipt hours normally remain waiting. A fresh worker check with no work is not a failed publication.
104
+ const waiting = a.pendingMonths.length > 0 || a.waiting.length > 0
105
+ return stage(def.id, waiting ? 'waiting' : successes.length ? 'healthy' : 'unknown', waiting ? ['analytics-awaiting-inputs-or-work'] : successes.length ? [] : ['analytics-success-unavailable'], timestamps, metrics)
106
+ }
@@ -1,3 +1,4 @@
1
+ export declare const SERVICE_TIMEZONE: 'Europe/London'
1
2
  /** UK civil day boundaries. GTFS service time has a separate noon-based origin. */
2
3
  export interface UkStudyDay {serviceDate:string;timezone:'Europe/London';startUtc:string;endUtc:string;durationSeconds:number}
3
4
  export function checkedDate(value:string):string
@@ -1,4 +1,4 @@
1
- const TIMEZONE = 'Europe/London'
1
+ export const SERVICE_TIMEZONE = 'Europe/London'
2
2
 
3
3
  export function checkedDate(value) {
4
4
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
@@ -11,7 +11,7 @@ export function checkedDate(value) {
11
11
  // UK midnight is unambiguous on both DST transition days. Never infer a full day as 86400 seconds.
12
12
  function londonMidnight(date) {
13
13
  const nominal = Date.parse(`${date}T00:00:00Z`)
14
- const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: TIMEZONE, timeZoneName: 'longOffset' })
14
+ const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: SERVICE_TIMEZONE, timeZoneName: 'longOffset' })
15
15
  let instant = nominal
16
16
  for (let attempt = 0; attempt < 3; attempt++) {
17
17
  const offset = formatter.formatToParts(instant).find(part => part.type === 'timeZoneName').value
@@ -28,7 +28,7 @@ export function studyDay(serviceDate) {
28
28
  const next = new Date(Date.parse(`${serviceDate}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
29
29
  const start = londonMidnight(serviceDate)
30
30
  const end = londonMidnight(next)
31
- return { serviceDate, timezone: TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
31
+ return { serviceDate, timezone: SERVICE_TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
32
32
  }
33
33
 
34
34
  export function utcDatesForDay(day) {