@kontourai/ephemeris
v0.4.1
Published
Kontour's external time actor: a deadline engine (data-derived freshness wake-ups that nudge Flow) plus a pure recurring-schedule core (cron/at/every, timezone/DST-aware, catch-up). It triggers; it never authors.
Maintainers
Readme
Ephemeris
Status: re-scoped (2026-08-05) — Ephemeris is the Kontour suite's general time actor, not a Flow-internal module. It owns two schedule axes: the deadline engine below (data-derived
expiresAt/ttlSecondswake-ups that nudge Flow) and a pure recurring-schedule core (cron/ one-shotat/ fixedevery, timezone- and DST-aware, with catch-up) for consumers such as Station. The re-scope decision is tracked in #15; Flow's own deadline-reaction fold remains scoped to the Flow-internal flow#99, and Ephemeris is the shared building block both consume via published contract. (The earlier FROZEN notice from 2026-07-02 is superseded.)
External time actor for the Kontour suite — deadline engine + recurring-schedule core. Status: v0.3 · Layer: time actor.
Ephemeris (n.) — in surveying/GNSS, a table of time-indexed positions that has an age, goes stale, and must be refreshed. The name carries the freshness/expiry meaning for free.
Ephemeris is a long-lived daemon whose entire job is to turn time into a trigger. It:
- Ingests Flow's emitted run-output TrustBundles and reads each referenced
claim's
expiresAt/ttlSeconds(Hachure freshness fields — no new schema, it only reads them, validated against the publishedhachurepackage's claim schema). - Arms a durable timer per claim deadline (data-derived instants, not cron), coalesced per claim so a flappy claim can't storm.
- Fires an idempotent, rate-limited trigger at the deadline — by default a
programmatic call to Flow's exported
evaluateRun(runId).
That is its whole job. It owns the clock and durable wake-ups; it owns no trust or process authority.
The numbered flow above is the deadline axis (Flow-emitted bundles → wake-up → nudge). Ephemeris now also owns a recurring-schedule axis — a pure evaluation core (
cron/at/every+ timezone/DST + catch-up) that consumers like Station build on instead of growing their own clock. See Recurring-schedule core below.
Why it exists
In kontourai/flow's docs/design/route-back-cascade-and-trust-recursion.md, Decision #1 resolved that
neither Surface nor Flow has a scheduler. Flow's only clock is the now
captured at an evaluateRun that some external actor triggers. A claim that
expires at 2am is only observed at the next externally-invoked evaluation.
That leaves a deliberate hole: something has to notice "claim X expires at T" and
produce the trigger at T — without putting a timer back into the two layers
that must not have one. Ephemeris is that external time actor, productized.
The layer model
| Layer | Owns |
|-------|------|
| Hachure | the shape — the TrustBundle / claim schema (incl. expiresAt, ttlSeconds). |
| Surface | the meaning — derives fresh / stale from expiresAt. |
| Flow | the reaction — re-derives at the real now on evaluateRun; emits artifacts. |
| Ephemeris | the time — two axes: arms wake-ups off data-derived deadlines and nudges Flow when they pass; and evaluates recurring wall-clock schedules (cron / at / every, tz/DST-aware) as a pure core for consumers like Station. |
Ephemeris is the same edge-adapter shape as Flow's HostedConsoleSink: both
consume Flow's neutral emitted bundle and translate it outward, while Flow stays
ignorant of both. The difference is direction — the console sink is purely
outbound (Flow → console); Ephemeris closes a feedback loop (bundle → timer →
trigger back into Flow's evaluateRun).
Invariants (non-negotiable, enforced in code)
- It triggers, it never authors. Ephemeris writes nothing to any
TrustBundle or ledger. Firing is only a nudge: Flow re-derives at the real
nowand Surface decides for real. Over-firing is therefore harmless. Enforced: theTriggercontract returnsvoidand has no authoring surface; the scheduler mutates only its own privateStore. Thetrigger writes nothingtest arms and fires a deeply-frozen bundle and asserts it is byte-for-byte unchanged. - Expiry is derived; invalidation is an event. Ephemeris reacts to
expiresAt(a field); it does not synthesizestale/revokedevents. Enforced: a claim with no freshness field arms no deadline and never fires. - Owns no trust or process authority — only timers. No copy of any authoritative state lives here; the only durable state is its own wake-up bookkeeping.
What changed in v0.2
v0.2 turns three v0 scaffolds into real behavior, within a bounded increment:
- Read-model aligned to the published Hachure schema (
hachure@^0.5.1). The freshness fields Ephemeris reads now use Hachure's exact names/types and are validated against the package's publishedclaimschema at ingest. Two schema realities are now honored in code:- The Hachure
trust-bundleschema has no bundle-levelid— it forbids one. A bundle is identified by itssourcestring. So the read-model keys onsource, not a syntheticbundle.id. - There is no
runobject on the bundle. Flow stamps the run-output bundle'ssourceasflow-run:<definitionId>:<runId>; Ephemeris derives the runId it fires against from that (runIdFromSource), or the caller supplies one.
- The Hachure
FlowEvaluateTriggeris real. It invokes Flow'sevaluateRunfor the bundle's run. The invocation is an injectableFlowRunner; the default (programmaticFlowRunner) does a programmatic dynamic import of@kontourai/flowand calls its exportedevaluateRun(runId)— confirmed exported by@kontourai/flow@^1.4.0. AcliFlowRunner(shellsflow evaluate <runId>, the confirmed subcommand) is provided for PATH-only deployments. The trigger writes nothing to any bundle/ledger — it is only a nudge; whatever the runner returns is discarded.- Backpressure / coalescing. Per-
(bundleSource, claimId)coalescing collapses redundant pending deadlines (a re-armed claim's newest deadline supersedes its older pending one), and a configurableminFireIntervalMsrate-limits fires for the same claim. A flappy claim therefore can't storm the trigger. Coalescing is harmless by design: under-firing within the window is safe because Flow re-derives at the realnowon the next allowed fire.
What changed in v0.3
v0.3 closes the two deferred TODOs from v0.2 — richer discovery and
durability-at-scale — in a bounded, well-tested increment. No invariant moved:
Ephemeris still triggers and never authors, derives expiry, owns no authority,
and stays deterministic under ManualClock (no real sleeps, no real timers in
tests).
- Discovery beyond directory-watch —
RegistrySource. Ephemeris now ships a second, event-driven source alongsideDirectoryWatcherSource: an in-process producer registry. A producer that already has an emitted bundle in hand callsregister(bundle)(arms immediately) /deregister(source)(cancels immediately); the scheduler learns about it with no polling and no clock coupling.- Why a registry over a poller: discovery here is in-process — the producer
can push directly, so a registry models the "producer notify" path exactly.
There is no interval, so there is nothing to drive from a
Clock; tests are deterministic by construction (every call is synchronous). A clock-drivenPollingSourcewould only be needed for an EXTERNAL store that can't push — that is the still-deferred shared hosted-ingest seam, not in-process discovery. - Composable: a
Sourceis anything that feeds bundles toarm()and signals removals tocancel(). Both sources (plus the rawarm()API) now target a narrowSchedulerSinkand can run against one scheduler at once;arm()/cancel()are idempotent + flap-coalescing, so overlapping sources never double-fire. (src/sources.ts)
- Why a registry over a poller: discovery here is in-process — the producer
can push directly, so a registry models the "producer notify" path exactly.
There is no interval, so there is nothing to drive from a
- Durability-at-scale —
AppendLogStore. The v0.2JsonFileStorerewrites the whole file per mutation (O(state) each time) — fine for a small daemon.AppendLogStoreinstead appends one record per mutation (arm / fire / remove → O(1)), reconstructs state by replaying the log on load, and compacts when the log grows past a threshold (rewrite a single snapshot line + truncate, bounding the file). It is a drop-in behind the sameStoreinterface.- Proven (tests): (a) reload replays to identical state; (b) fired history survives restart so a reload / re-armed bundle never double-fires; (c) compaction preserves state and bounds the log. A torn final line (a crash mid-append) is tolerated — replay skips it, losing at most the last in-flight mutation, which the scheduler re-derives on the next arm.
JsonFileStorestays the simple no-config default;InMemoryStorestays the test store. (src/store.ts)
Recurring-schedule core (the second axis)
Alongside the deadline engine, Ephemeris ships a pure recurring-schedule
evaluation core (src/schedule.ts). It computes when a schedule fires; it
does not fire anything itself — the consumer owns firing, storage, and the
job record's lifecycle. This is the deliberate counterpart to the deadline
engine's "it triggers, it never authors" invariant: the schedule core evaluates,
it never authors.
The core answers four questions for a ScheduledJob:
nextOccurrence(job, nowMs)— the next fire instant strictly afternowMs(DST-aware for cron), ornullwhen a one-shotatis spent or no cron match exists within ~one year.isOverdue(job, nowMs)—truewhen a fire was due beforenowMsand has not been recorded inlastRunMs. Disabled jobs are never overdue.missedCount(job, nowMs)— how many fires were missed in(lastRunMs, nowMs], capped at 1000 (a count at the cap means "many"; the cap also bounds work for pathologically smalleveryintervals).nextOccurrences(job, nowMs, n)— a preview of the nextnfire times, or fewer when the schedule is spent.
validateSchedule(schedule) returns null on a well-formed schedule or a
human-readable error string otherwise (malformed cron, out-of-range fields,
unknown timezone, non-positive everyMs).
Kinds. Schedule is a discriminated union:
cron— Vixie-cron 5-field expression (minute hour dom month dow), with an optional IANAtimezone. When a timezone is set, the cron fires at the given local wall-clock fields, so the UTC offset shifts across DST while the local time stays constant. DOM/DOW OR-semantics apply when both are restricted; a step expression (0-30/15) is not a wildcard for that rule.at— a one-shot at a fixed epoch instant (timeMs); inert after it fires.deleteAfterRunis an advisory hint to the consumer.every— a fixed interval anchored to the job's own run history (fromMs + everyMs), so drift across restarts is impossible.
DST and purity. Wall-clock cron is evaluated through Intl.DateTimeFormat
(no new dependencies); DST transitions are handled by walking candidate minutes
and reading local parts, with a fall-back/fall-forward probe for the ambiguous
and skipped hours. The core is pure: it takes nowMs as an argument and
contains no Date.now() and no timers in executable code (a test strips
comments and asserts this). Deterministic testing therefore needs no Clock
shim — pass any nowMs.
Catch-up. A job that falls behind (host down, long stall) reports its
missedCount so the consumer can decide whether to fire once, fire the tail, or
just record the gap. The consumer writes a durable RunRecord
(firedAtMs / scheduledForMs / missedCount / durationMs / success /
outputRef) — a type only: Ephemeris does not store these, matching how
Store is kept separate from the deadline engine. This RunRecord is the
intended receipt substrate for
station#1889.
Why a pure core, not an executor. Ephemeris's boundary is time, not
orchestration. Stations / boo-TS / Flow Agents each own their own executor,
persistence, and job lifecycle; they share this one evaluation core so no
product grows its own clock. The first consumer is
station#1940 (rebase
BuiltinScheduler).
v0.2 architecture (decided defaults)
- Stack: TypeScript, Node ESM (
>=22),node --test. Mirrors Flow's tsconfig/scripts. EphemerisSchedulercore —arm(bundle),cancel(bundleSource), restart-safestart(), deterministictick(), plusminFireIntervalMs/onCoalescedfor backpressure.- Injectable
Clock—SystemClock(default) andManualClockfor tests. All timing flows through it; withManualClockthere are no real wall-clock waits — firing is driven synchronously byclock.advance(). - Pluggable
Trigger— defaultFlowEvaluateTriggerover an injectableFlowRunner(programmaticFlowRunnerby default;cliFlowRunneravailable), plusNoopTriggerandRecordingTriggerfor tests/examples. - Pluggable
Store— defaultJsonFileStore(persists pending + fired sets to disk; reloads and re-arms on startup), plusInMemoryStorefor tests andAppendLogStorefor durability-at-scale (append-per-mutation + compaction; see "What changed in v0.3"). - Source adapters — programmatic
arm(bundle)API + two pluggable sources:DirectoryWatcherSource(watches a directory of emitted bundle JSON files, keyed onsource) andRegistrySource(event-driven in-process producer registry). Both target the narrowSchedulerSinkand compose against one scheduler. - Idempotency — deadlines deduped by
(bundleSource, claimId, fireAt), fire at most once; fired keys persisted so reload / duplicate-arm / past-due never double-fire. - Hachure binding —
src/hachure-schema.tsloads the publishedclaim/trust-bundleschemas straight from thehachurepackage and exposes a dependency-freevalidateClaimFreshnessfor the slice Ephemeris reads. (No full JSON-Schema engine is pulled in: Ephemeris reads a tiny, well-known slice, and a test asserts its constraints still match the published schema.)
Public API
import {
EphemerisScheduler, // core: arm / cancel / start / tick / stop
Clock, SystemClock, ManualClock,
Trigger, FlowEvaluateTrigger, // default trigger over a FlowRunner
FlowRunner, programmaticFlowRunner, cliFlowRunner,
NoopTrigger, RecordingTrigger,
Store, InMemoryStore, JsonFileStore, AppendLogStore,
DirectoryWatcherSource, RegistrySource, SchedulerSink,
TrustBundleReadModel, ClaimReadModel, ArmedDeadline,
deadlineKey, claimKey, runIdFromSource, HACHURE,
validateClaimFreshness, getClaimSchema, getTrustBundleSchema,
deriveFireAt,
// Recurring-schedule evaluation core (pure functions over data)
Schedule, CronSchedule, AtSchedule, EverySchedule,
ScheduledJob, RunRecord,
validateSchedule, nextOccurrence, isOverdue, missedCount, nextOccurrences,
} from "@kontourai/ephemeris";The read-model types (TrustBundleReadModel, ClaimReadModel) are a
deliberately minimal slice — Ephemeris carries only the freshness-bearing fields
— but those fields now match Hachure's published claim / trust-bundle schema
names and types exactly, and are validated against them at ingest.
Quick start
npm install
npm run build # tsc
npm test # build + node --test (fully deterministic, no sleeps)
npm run example # arm a claim that expires shortly, advance a ManualClock, fire onceRun the daemon:
ephemeris watch <bundleDir> \
[--store .ephemeris/wakeups.json] \
[--store-mode json|appendlog] [--compact-threshold <n>] \
[--min-fire-interval <ms>] \
[--flow-mode programmatic|cli] [--flow-cmd flow] [--cwd <path>]The store defaults to json (JsonFileStore, full rewrite per mutation —
simple). Pass --store-mode appendlog for AppendLogStore (append-per-mutation
- compaction) when the watched-bundle count grows;
--compact-thresholdtunes how many records accumulate before it rewrites a snapshot and truncates.
Flow invocation defaults to a programmatic import of @kontourai/flow's
evaluateRun; pass --flow-mode cli to shell out to the flow evaluate binary
instead (e.g. when Flow is only available on PATH).
Resolved in v0.3
These v0.2 open-questions are now closed in code (no TODO(...) marker left):
discovery→ resolved. Ephemeris ships two pluggable sources —DirectoryWatcherSource(filesystem) andRegistrySource(event-driven in-process producer registry) — plus the rawarm()API, all composing against oneSchedulerSink. What remains deferred is only the shared hosted-ingest transport (below), not in-process discovery. (src/sources.ts)durability→ resolved.AppendLogStoreappends one record per mutation, replays on load, and compacts past a threshold (snapshot + truncate) to bound the log.JsonFileStorestays the simple default. (src/store.ts)
Resolved in v0.2
These design open-questions are now decided in code (no TODO(...) marker left):
emit-target→ resolved. Ephemeris invokes Flow's exportedevaluateRun(runId)programmatically by default, behind the injectableFlowRunner/Triggerseam (CLI shell-out available ascliFlowRunner). It remains swappable for a producer notify / event-bus adapter. (src/trigger.ts)backpressure→ resolved. Per-claim coalescing of pending deadlines +minFireIntervalMsrate-limiting of fires. (src/scheduler.ts)schema→ resolved. Read-model aligned to and validated against the publishedhachure@^0.5.1claim/trust-bundleschemas; keyed onsource(Hachure forbids a bundleid). (src/types.ts,src/hachure-schema.ts)
Still open / deferred (clearly out of v0.3 scope)
- Shared hosted-ingest seam — the only genuinely-deferred discovery item.
The cross-process hosted-ingest contract that Flow's
HostedConsoleSinkalso needs is the same surface Ephemeris consumes; it should be designed once, for both, so it is deferred until that joint design lands. (When it does, the natural adapter is a clock-drivenPollingSourceover the external store, or a push subscription — both slot into the existingSchedulerSinkseam.) Ephemeris co-owns it.
Non-goals
- Not an orchestrator and not a trust authority. It schedules; it does not decide.
- Not a replacement for the producer / CI / person trigger paths — it is one more external trigger source, specialized for wall-clock expiry.
License
Apache-2.0.
