# KeiSeiKit Architectural Decisions

> ADR-style log. Each entry: context → decision → consequences. New entries
> at the top. Cross-link from `_primitives/_rust/<crate>/README.md` when a
> decision is crate-local.

---

## 2026-04-28 — Three scheduling abstractions in workspace

### Context

After Hermes import (P4.2 `kei-cron-scheduler`) the KeiSeiKit workspace
contains **three** scheduler-like primitives. A naive audit reads this as
duplication; in practice each occupies a distinct layer of the stack and
removing any one would break a downstream consumer. This ADR documents the
boundary so a future reader does not consolidate them by mistake.

### The three primitives

| Crate | Storage | Concurrency | Owns runner? | Canonical use |
|---|---|---|---|---|
| `kei-scheduler` | `rusqlite` (sync, metadata-only) | sync | **no** | per-call queryable schedule index |
| `kei-cron-scheduler` | JSON-on-disk + `fcntl` advisory lock | `tokio` async | **yes** | Hermes parity (`/schedule` parser + cron loop) |
| `kei-pipe` cron triggers | embedded in pipe TOML | driven by pipe runtime | depends on pipe | pipeline-level cron embedded in a pipe definition |

### Decision

**Keep all three. Do not consolidate.** Each abstraction encodes a
different ownership contract and a different blast radius on failure.

### Rationale, primitive by primitive

#### `kei-scheduler` — synchronous metadata-only store

Synchronous `rusqlite` schedule store. Stores cron expression, next-run
timestamp, owner, payload pointer. Does **not** dispatch — the caller asks
"what should I run between t and t+Δ" and the caller is responsible for
execution.

This separation matters because two callers want exactly that contract:

- `kei-pipe` queries the schedule from the pipe-runtime loop (already its
  own scheduler) — it must not have a competing async runner inside the
  store.
- `cron-wrapper-agent` test harness wants deterministic, blocking lookups
  with no background tasks. A `tokio` runtime would fight the harness.

A SQLite-backed metadata store is the smallest abstraction that satisfies
both callers. Any `tokio` infrastructure inside this crate would force its
contract on the harness and break determinism.

#### `kei-cron-scheduler` — async runner for Hermes parity

Async `tokio`-based runner. JSON-on-disk persistence (one file per job),
`fcntl` advisory lock to keep multiple binaries from racing the same job
file, owns its own loop, supports interval + standard 5-field cron. This
is the surface imported from Hermes (HERMES-MIGRATION-PLAN P4.2) — the
contract is "set-and-forget recurring scheduler with the runner inside the
crate."

A SQLite-only store like `kei-scheduler` cannot satisfy this contract:

- File-per-job is the unit of `fcntl` locking; a single SQLite file would
  serialise all locks through the SQLite write mutex.
- The runner is part of the public surface — Hermes callers expect to
  hand the crate a job and walk away. Splitting the runner into a
  separate crate would re-litigate the contract on every consumer.

#### `kei-pipe` cron triggers — pipeline-level cron embedded in a pipe

Pipes (KeiSei pipeline definitions, TOML) can declare a cron trigger
inline. The pipe runtime evaluates the trigger as part of the pipe's own
state machine, alongside event triggers, file-watch triggers, and HTTP
triggers. The cron trigger is **not** a separate scheduler — it is a
trigger source within the pipe runtime, which is itself the scheduler.

Re-implementing this on top of `kei-cron-scheduler` would either (a)
duplicate the pipe runtime's lifecycle into the cron crate, or (b) split
a single pipe's triggers across two runtimes, which loses the atomic
"trigger-fired-and-pipe-started" guarantee the pipe runtime provides.

### Consequences

- **Choosing the right primitive for a new caller.** Decision tree:
  - Need a recurring background runner with `fcntl` durability and
    minimal blast radius if a single binary crashes? → `kei-cron-scheduler`.
  - Need a queryable index of "what should I run", with execution owned
    elsewhere? → `kei-scheduler`.
  - Trigger is one of many in a pipe definition, lives next to the data
    flow, dies with the pipe? → `kei-pipe` cron trigger.
- **Fail-loud overlap.** If you find yourself porting a feature from one
  to another (e.g. "let `kei-scheduler` also dispatch"), STOP — that is
  the No-Patching/No-Overlay smell from the umbrella rules. Add the
  feature to the right primitive instead, or write a new one.
- **Audit signal.** A future audit may flag "three schedulers" as a code
  smell. This ADR is the canonical answer; link here from any review
  comment that surfaces the question again.

### References

- `_primitives/_rust/kei-scheduler/`
- `_primitives/_rust/kei-cron-scheduler/`
- `_primitives/_rust/kei-pipe/` (cron trigger source)
- `HERMES-MIGRATION-PLAN.md` §P4.2 — Hermes parity import
