# History

cronstable is a fork of [yacron](https://github.com/gjcarneiro/yacron),
continuing from yacron 0.19.  The 1.0.x entries below document the fork; the
entries from 0.19.0 onward document the history of the original yacron
project, on which cronstable is based.

## 1.2.38

Reduction of code. Fixing of bugs and optimizations throughout.

- The package is about 4,600 lines smaller. None of it was meant to change
  behavior. `cron.py`, `cluster.py` and `__main__.py` account for most of
  it, the backends share their lease helpers through
  `cronstable/backends/_common.py`, and the argparse definitions moved to a
  stdlib-only leaf, so no CLI invocation pulls in the client HTTP and TLS
  graph just to build its parser. The suite follows: `test_cron.py` is
  split by subject and the fixtures every file was copying now live in one
  place.
- The README is a third shorter, each example stack shares one certificate
  generator and node entrypoint instead of carrying its own, the six
  screenshot scripts became one, and the logo engine has a single
  hand-maintained copy that the demo page and logo lab are generated from.
- Every web API failure reaches the client as the JSON error envelope. An
  unhandled error used to escape to aiohttp's own handler, which answers
  plain text (or HTML on an `Accept`), so the 500 was the one status a
  client could not parse the way it parses the rest. A store call that
  outruns its budget answers 504 rather than 500. The reason goes to the
  log, never into the body.
- The three endpoints that serve text (`/version`, `/job-set-id` without a
  JSON `Accept`, and the schedule lint's text form) ignore a `Content-Type`
  set under `web.headers`, like every other route. A deployment that set
  one saw those three come back as 500s.
- `GET /activity` takes the same clamped `limit` the other capped listings
  take. Its default still serves the whole retained window.
- A DAG catch-up offset elapsing during a graceful stop no longer creates
  runs and forks task subprocesses on the way out. The sleepers are
  interruptible and are cancelled with the rest of the launch-adjacent
  work, and the checkpoint stays open, so the next boot resumes the slots
  still owed.
- A run document recording a mapped index it holds no entry for is repaired
  instead of wedging. The claim pass materialises the missing instance and
  fails it with a reason, so the run terminalises, releases its advance
  lease and becomes eligible for retention. Under 1.2.37's absent-instance
  rule alone, such a run stayed open for the life of the daemon.
- The DAG summary memo is invalidated even when the write that should have
  busted it times out or raises. An abandoned store call can still land, so
  a failure could leave the fleet reading a stale run summary for the whole
  memo window.
- One unreadable job during boot reconciliation degrades to a logged skip.
  It used to abort the rest of the boot tail (DAG reconcile, retry re-arm,
  the job API start) while the other workers kept mutating scheduler state
  behind it.
- The mail, Sentry and webhook reporters read a `fromFile` secret on the
  executor, the rule the launch path already followed, so a hung secret
  mount no longer blocks the scheduler from the completion path. A start
  cancelled while staging those secrets hands its concurrency slot back.
- Two warnings that latched for the life of the process now re-arm per
  episode: the passthrough mirror's "backed up, dropping output" once its
  consumer drains, and the candidate-set truncation once the fleet fits the
  cap again. The truncation warning also stops re-firing every poll round
  on ordinary membership churn, and `candidates_truncated` is re-derived on
  a node nobody polls rather than reporting whatever the last build saw.
- An oversized `/peer` response that shedding the fleet view cannot bring
  under the cap is logged as an error saying peers will record the node
  oversized and drop it from their quorum. A peer advertising an empty node
  name is refused rather than counted toward quorum.
- A statsd `job_started` emission cancelled while its transport is still
  opening no longer evicts that live transport from the pool, and one that
  fails is logged the same way whichever arm catches it.
- Filesystem store housekeeping stats an idempotency document before
  reading it, so a GC pass no longer opens and parses every permanent claim
  and every live TTL on the one lane its budget shares. A JSON `true` in
  `expiresAt` reads as unclassifiable rather than as expired.
- Byte payloads decode as strict UTF-8 on hosts without orjson, matching
  orjson's own contract, so a BOM or a UTF-16 mapped-task XCom is refused
  everywhere instead of parsing on one host and failing on the next.
- The terminal dashboard's activity card retains the same 80 jobs the web
  card and its own fan-out fallback do, instead of holding a row list per
  job at fleet scale, and an approval gate parked on a decision paints as
  pending again.

## 1.2.37

- `catchupJitterSeconds` now spreads DAG catch-up replays the way it always
  spread plain-job backfills: the same deterministic per-name offset,
  slept out on a spawned task so one dag's spread never stalls the
  scheduler pass. The key was accepted and validated on dag schedules but
  silently never applied. The deferred replay also checkpoints the
  watermark it owes (a `catchup-dag/<dag>` stream, the twin of the job
  engine's `catchup/<job>`) before the sleeper spawns and closes the
  cycle after the last run document lands. A restart during the offset
  used to lose the backfill to run documents created meanwhile; now it
  resumes from the checkpoint. And after the sleep the replay re-checks
  `onMissed`, the schedule type, and cluster ownership, as the job
  backfill always has.
- Every 4xx and 5xx body the web API serves is now one JSON envelope,
  `{"error": "<reason>"}`, across the job, DAG, schedule, state, and push
  routes alike, including the auth middleware's 401 and the router's own
  405 (wrong method) and 404 (unmatched path). Previously the body was
  JSON on some handler families, plain text on others, and empty on a
  few, so a client had to sniff per endpoint. The envelope's own content
  type wins over a `web.headers` one in any spelling, the rule `/metrics`
  and the live tails already followed, and every other endpoint now keeps
  its own content type the same way, whether it serves JSON, the
  dashboard page, or a calendar feed. A deployment that set a content
  type there used to see those routes come back as 500s.
- The activity heatmap on both dashboards fills from one `GET /activity`
  request instead of a `GET /jobs/{name}/runs` per job per refresh. The
  endpoint serves every job's retained runs reduced to the three plotted
  fields, on the same shared-product machinery as `/jobs` and `/fleet`
  (ETag, gzip, busted by the local events that change run history), and
  both clients keep the per-job fan-out as a fallback against a daemon
  that predates it.
- `GET /metrics` shares one build across the scrapers that arrive while it
  is rendering, instead of only across those that arrive after it finishes.
  On a large job set the render runs on a worker thread, and every scraper
  landing during it used to start a render of its own: a Prometheus pair
  plus an agent and a federation puller hitting the same second paid four
  full builds of a body that runs to tens of megabytes. A local change
  (a run, a launch, a pause, a reload) that lands mid-render also no longer
  loses its cache invalidation to the older build finishing afterwards, on
  `/metrics` and `/jobs` alike.
- `/cluster` reports `candidates_truncated`, and the daemon logs a warning,
  when the bridge-discovered candidate set outgrows what a node may
  advertise. Past that point a `spread` job whose owner falls in the
  dropped tail can run on more than one node, which is not something an
  operator should have to infer from run history. The cap itself moved from
  256 names to 512, matching the per-node job-summary cap, which is still
  half the peer response budget in the worst case.
- `POST /jobs/{name}/start` and `/cancel` answer with the same JSON acks
  their MCP twins return (`{"started": ...}` and `{"cancelled": ...,
  "instances": N}`) instead of empty 200s, and the DAG run listing and
  trigger responses carry the subject under `name` as well as `dag`, the
  key the job routes use.
- Every capped listing takes a `limit` query parameter; the old spellings
  (`count` on the schedule preview, `per_job` on the calendars, `runs` on
  the resources series) keep working as aliases read when `limit` is
  absent. `GET /jobs/{name}/runs`, the one run listing without a cap,
  gained the same clamped `limit` its DAG and MCP twins had; its default
  still serves the whole retained window.
- The schedule describers no longer speak confidently about schedules the
  engine refuses to load. Wrap-around ranges (`fri-mon`), steps that
  escape their field (`*/12` on months, `59/1` on minutes), and the
  classic-crontab-only `@midnight` all read as a `Custom schedule:` line
  now, in the daemon's describer and the dashboard sandbox alike; every
  engine-legal quirk (`sat-sun`, `0-0`, `30/20`) keeps its prose.
- The terminal dashboard paints DAG states from an explicit port of the
  web page's map instead of a spelling-guess ladder, so `upstream_failed`
  shows fail-red (it painted neutral), `up_for_retry` amber, and
  `skipped` the neutral off ink; a parity test pins the map to the
  engine's vocabulary and the web's map both.
- An operator-configured `web.headers` Content-Type or proxy-buffering
  override no longer breaks the live log tails: the SSE protocol headers
  win over the configured map in any spelling, the same guard `/metrics`
  already had.
- A state-section reload that swaps in a healthy store clears the `@reboot`
  gate's "store timed out, stop probing" latch along with the other
  per-store verdicts; the latch used to survive for the life of the
  process, leaving boot-marker dedupe degraded on a healthy replacement
  store.
- The mail, Sentry, and webhook reporters resolve their
  value/fromFile/fromEnvVar secrets through the same shared resolver as
  everything else, so an unreadable `fromFile` is a clean logged skip
  instead of a traceback out of the completion path, and none of the
  three echoes an env var's name to the logs (mail's long-standing rule,
  now shared).
- The document-key listing applies the same round-trip check the stream
  and namespace listings use, so a foreign file in a namespace directory
  reads as "listing unavailable" instead of decoding into a key that
  addresses a different or nonexistent document; keys carrying a lone
  surrogate now list correctly instead of failing the listing.
- The two mapped fan-in reductions share one absent-instance rule: an
  entry missing for a run-recorded index holds the run open (it reads as
  pending), where the run terminaliser used to skip it and could complete
  a run whose group still read running to every downstream.
- The `dev` extra installs the same list as `requirements_dev.txt` again
  (it had drifted, missing orjson, pynacl, and zeroconf, so an
  extra-based checkout ran the suite with the optional-dependency test
  arms silently skipping); a new test pins the two lists equal.
- Reference-doc gaps closed: the configuration reference now covers
  `notify:`, `push:`, `report.push`, `web.authTokens`, and `web.bonjour`,
  and the HTTP API page documents the `retry`/`rebootPending`/
  `concurrencyScope`/`slot` job fields, run records' `ranAt`, the
  running-row `never_fires` flag, the error envelope, and the new acks.
  `docs/openapi.yaml` declares the envelope, acks, and `limit`
  parameters.
- The statsd timers (`.duration`, plus `.cpu` under `monitorResources`)
  no longer carry a literal `@0.1` sample-rate flag. Nothing was ever
  sampled (every run sends exactly one observation), so the flag made
  rate-honoring statsd servers weight each observation 10x. If your
  dashboards divided by 10 to compensate, remove that correction when
  upgrading.
- A DAG whose catch-up replay hits the 100-run cap now logs the same
  warning the job engine's cap always logged, naming
  `startingDeadlineSeconds` and `onMissed: run-once` as the escape
  hatches; the truncation used to be silent.
- A DAG task that never started reports `failReason: "launch failed"` on
  both paths that can get it there; one of the two used to say
  `"launch error"` for the same situation.
- `cronstable state` sends its error and refusal messages to stderr, the
  convention every other cronstable command already follows; success
  summaries and inventories stay on stdout, so piping them is now clean.
- A typo'd `--log-level` exits as a normal usage error naming the valid
  levels instead of an `AttributeError` traceback, and lowercase
  spellings (`--log-level debug`) now work; `--help` also explains that
  `-v` is validate-config, not verbose.
- `tox -e mypy` (the CI type gate) installs the package and drops its
  global `--ignore-missing-imports`, which had silently typed every
  third-party surface as `Any`; the deps that legitimately cannot
  resolve are now enumerated per-module in `pyproject.toml`, so a new
  unresolvable import fails the gate.
- Loading a large configuration is much faster, and the cost per job no
  longer climbs with the size of the file. Parsing scaled with the square
  of the job count: 1,000 jobs took 0.9 s, 4,000 took 12.5 s, and 8,000
  took 49.6 s. The cause sat in strictyaml's bundled copy of ruamel, whose
  sequence deep-copy re-copies the sequence's annotations once per element
  instead of once in total, and `jobs:` is normally the only long sequence
  a config has. cronstable now corrects that method at import time when it
  finds it uncorrected, and the same three files parse in 0.4 s, 1.4 s and
  2.8 s. Startup, `--validate-config`, `--job-set-id` and every config
  reload pay the lower cost.
- A peer that gossips an empty node name can no longer stand the whole
  cluster down. The empty string passed every validation the peer payload
  applies (it is printable and well under the length cap) yet sorts below
  every real name, so one buggy or hostile peer made every node that saw
  it elect `''` as leader. No node matched that name, every `Leader` job
  stopped firing cluster-wide, and `/cluster` still reported the cluster
  quorate and conflict-free. Empty names are now rejected where the peer
  payload is parsed, and all three places that fold peer names into the
  election spell the guard the same way.
- The `quorate_vouched` set a node advertises on `/peer` is capped, like
  the `@reboot` ran-set beside it. It is the one re-advertised set built
  entirely from absorbed peer data, so an inflated upstream peer could
  walk a healthy node's own response past the size cap that honest peers
  enforce, and they would then record it as an oversized failure and drop
  it from their quorum. The list is sorted before it is capped, so every
  node keeps the same prefix and the lowest names, which is what the
  election reads. As a backstop, a response that exceeds the cap anyway
  now sheds its observability-only job summaries rather than shipping a
  body peers will reject.
- `GET /metrics` is compressed for scrapers that accept gzip, which
  Prometheus always does. Exposition text is the same metric names and
  label blocks repeated once per job, so it compresses about 17x: at
  10,000 jobs a 22.0 MB scrape becomes 1.32 MB for roughly 7 ms of CPU.
  It was the largest recurring payload the daemon served and the only
  large one going out uncompressed.
- `GET /metrics` and `GET /fleet` share one built response across the
  clients that ask for it within a second, the way `GET /jobs` already
  did. Several scrapers landing together used to each rebuild the whole
  metric universe on the event loop, and every open dashboard tab paid a
  full fleet merge, which is O(nodes x jobs). `/fleet` also gained the
  ETag and gzip its sibling poll endpoints had; a local change (a run, a
  launch, a pause, a reload) still busts all three memos so it renders on
  the very next poll.
- A DAG task renamed while a run is in flight no longer wedges that run
  forever. If the renamed task was the one a mapped task fans out from,
  the run document held an entry under the old name and none under the
  new one, and the mapped placeholder waited on a task that would never
  appear. The run never reached a terminal state, so it renewed its
  advance lease for the life of the daemon, retention never collected it,
  and every advance pass copied the whole document to change nothing. The
  placeholder now fails with a reason naming the missing source, which is
  the rule the dependency and completion checks already applied.
- A job that emits a very long line without a newline (a progress bar, a
  binary blob, a stuck writer) no longer stalls the scheduler. The bytes
  carried between reads were rebuilt on every read, which is quadratic:
  at the 16 MiB `maxLineLength` default that measured 10.1 s of pure
  copying on the event loop for one line. The pieces are now carried and
  joined once, measured at 11 ms, and the length cap is applied without
  ever assembling the over-long line.
- Housekeeping is back to at most once per wall-clock minute on
  deployments that run DAGs. The gate looked only at whether a cron job
  fires at second granularity, but the DAG orchestrator also shortens the
  loop's sleep, so a config with DAGs and no second-level job re-ran the
  whole config reload, cluster, web, push, state and SLA block on every
  DAG wake.
- The DAG task log pane in the dashboard is bounded and no longer slows
  down as it fills. It appended by rebuilding the whole buffer and
  measured the buffer's height once per line, both of which cost more the
  more lines were showing, and unlike the job log pane it had no line
  cap at all. It now uses the same append path as the job log.
- Container images build their dependency layers from `pyproject.toml`
  alone, with the source tree copied only at the end, so a commit that
  touches only source reuses the cached dependency layers instead of
  recompiling every dependency for every distro and architecture. Release
  builds deliberately miss that cache so a release still resolves every
  dependency fresh. The build context also drops the documentation,
  wiki, benchmark and packaging trees it never read, about 48 MB.
- The webhook reporter shares one connection pool per daemon instead of
  opening a fresh connection, and a fresh TLS handshake, for every
  report. Each report keeps its own session, so its own timeout and
  cookie jar, while the sockets and the SSL context underneath are
  pooled and held for 90 seconds, long enough that a minutely job's next
  report rides the connection its last one opened. The pool is
  deliberately uncapped and is closed on a graceful stop.
- The pipes of a capturing job no longer take `maxLineLength` as their
  flow-control watermark. That number was there for the reader's old
  `readuntil` call; the chunked reader has enforced the line cap itself for
  a while, so all the setting still did was let asyncio buffer up to twice
  `maxLineLength` (32 MiB by default) per stream per running job before it
  paused the child. The watermark is the reader's own 64 KiB chunk size
  now, and the cap is unchanged.
- Boot-time in-flight reconciliation reads jobs sixteen at a time, like the
  run-history warm-up and the retry re-arm on either side of it. It was
  the last strictly sequential per-job store read on the startup path, so a
  large crontab on a network mount paid one full read latency per job
  before the first scheduling pass, and an unhealthy store cost one read
  timeout per job instead of one for the pass. Each job is still reconciled
  exactly once, and its own open and closed writes stay ordered.
- The terminal dashboard's activity heatmap no longer dies with a
  `KeyError` when it meets a run outcome its shading ladder does not know.
  The lookup sat inside the paint loop, so a single unrecognised outcome
  took the whole running dashboard down once per frame; an unknown outcome
  now paints in the unknown ink, and a parity test fails the build if the
  ladder falls behind the outcome vocabulary.
- The dashboard's fleet view no longer stacks overlapping `/fleet` requests
  when the daemon answers slower than the poll interval. It was the one
  secondary poll the earlier in-flight sweep missed, so it added a request
  per cycle against a per-origin connection budget the held log tails
  already draw on. A test now holds every secondary poll to the guard, not
  just this one.
- The MCP `cron_query_metrics` tool formats the metric universe on the
  default executor rather than the event loop once the resident job count
  clears the threshold `GET /metrics` already uses (the family build, which
  reads live scheduler state, stays on the loop), and it shares one built
  sample snapshot across the callers that arrive within a second, the way
  `GET /metrics` shares its response. An agent polling metrics no longer
  stalls job dispatch at fleet scale.
- The three reporting contexts (a job run, an SLA breach, a `notify:`
  event) name their shared `template_vars` key set once, and a parity test
  holds all three to it. A template written for `onFailure` is meant to
  render unchanged on `onLate` and on a notify event, and a missing name
  renders empty instead of raising, so drift between them was invisible at
  runtime. No key name or value changed.
- The cluster's "a peer declared this coordination value and it is not
  ours" check is one shared predicate now, read by the mutual-agreement
  exclude set and by both conflict detectors, and the set of declared
  fields those two halves cover is fenced by a test. A new coordination
  field can no longer reach the detector without also dropping the
  diverging peer from the agreeing set we gossip. No behaviour change.
- What the wheel ships is derived from the source tree and diffed against
  what `pyproject.toml` declares, so a new subpackage or a new runtime data
  file that never reached `packages` or `package-data` fails a test instead
  of shipping a wheel missing the dashboard page or a whole backend.
  MANIFEST.in's add-backs inside the pruned `docs/` tree are checked to
  still exist too.
- Every example config under `example/` is parsed by the real parser,
  cross-section validation included, so a schema change that leaves the
  examples teaching an old spelling is caught here instead of by whoever
  copied one. Compose files and Kubernetes manifests are classified out by
  rule rather than by a skip list, so a newly added example has to parse.
- The differential that replays the cron golden corpus through both the
  dashboard's client-side schedule engine and the daemon's now runs in
  CI, where playwright is a dev dependency and one matrix cell installs
  the browser. It had been self-skipping everywhere, including on the
  machines that changed either engine, so that cell now also proves the
  test passed rather than skipped (a browser-revision skew between the
  two installers would otherwise restore the silent skip). The
  differential compares validity verdicts as well as the previews of
  mutually-valid expressions: the sandbox's step parsing is strict the
  way the engine's is, where it used to salvage numeric prefixes and
  confidently describe `*/2.5`-style schedules the daemon refuses to
  load.
- A second YAML-parse benchmark, `config.parse_yaml_3k`, gates the config
  parse at a job count where a complexity regression is visible. The
  quadratic `Seq` validation fixed above shows up at 300 jobs, the size
  `config.parse_yaml_300` measures, as a 38% bulge that a loaded runner can
  argue with; at 3,000 jobs it is 5.3x.
- The logo engine now has one hand-maintained copy instead of four. The
  dashboard keeps its inline copy (that page must stay a single
  self-contained file), the demo's is generated from it by
  `scripts/build_demo.py`, and the logo lab and comparison page share an
  extracted `docs/logo-engine.js`. A test pins the extract to the
  dashboard's copy and fails either page for regrowing an inline one,
  where only the dashboard and demo pair had a drift guard before.
- A finished run's live-log ring buffer is released once a newer run
  supersedes it. Only the newest run's logs are replayable, but every
  retained history entry kept its full ring in memory anyway, so a
  long-running daemon with chatty jobs slowly pinned unservable log
  buffers, up to fifty rings per job.
- Lease heartbeats skip the directory flush. A renew or release only moves
  the expiry of a lease whose fence it keeps, and losing such a write to a
  crash just means the lease expires a little earlier, so an idle HA pair
  no longer spends tens of thousands of directory flushes a day
  maintaining its election. Writes that issue or bump a fence keep the
  full crash-durability barrier, and every lease write still flushes its
  own bytes before the rename.
- `GET /dags` answers an unchanged conditional poll with `304 Not
  Modified` and compresses large bodies for clients that accept gzip;
  `GET /cluster` compresses too. The per-dag run listing behind the
  `/dags` rollup and the run drawer is also served from a short
  memo between local changes, so dashboard polling of a quiet store no
  longer performs one store listing per dag per poll per viewer.
- A mapped task's fan-out list is parsed from the artifact's bytes
  directly (no decoded second copy) and, past 64 KiB, on a worker thread.
  Parsing a multi-MB XCom inline used to stall the scheduler loop for
  tens of milliseconds per expansion.
- `fromFile` secrets are read on a worker thread when a run is staged.
  The read sits inside the launch chain, so a slow or hung secret mount
  (a Kubernetes secret volume, NFS) used to stall the event loop itself
  rather than just that launch.
- Boot rehydration reads the ledger sixteen jobs at a time instead of one
  by one. Warm-up ran before the first scheduling pass, so its wall clock
  was pure boot delay: seconds at fleet scale on local disk, minutes on a
  network mount, and a hung mount now costs one read timeout, not one per
  job.
- Subprocess spawns are gated sixteen at a time when one slot launches
  many jobs. The fork/exec setup is synchronous work on the event loop,
  and an ungated 500-job burst occupied it for up to a second each minute
  boundary while web requests and cluster heartbeats waited.
- One built `GET /jobs` response (payload, ETag, body, gzip) is shared
  across every poller for up to a second, and locally recorded runs,
  launches, pauses and reloads rebuild it immediately. A wallboard plus N
  dashboard tabs used to cost N identical builds per poll cycle.
- The state garbage collector sweeps idempotency documents whose TTL
  lapsed a whole grace window ago. An expired claim is already
  re-winnable, but its document stayed on disk forever, so the documented
  per-event dedupe pattern grew a flat directory without bound.
- Job output mirrored to the daemon's own stdout/stderr is written by a
  dedicated thread with a bounded queue. The write used to run on the
  event loop, so a consumer that stopped reading (a stopped
  `docker logs`, a suspended console) froze the entire daemon; now it
  wedges only the mirror, which sheds its oldest batches (the queue is
  bounded in both count and bytes) until the consumer drains.
- Dashboard: the header mark's glow is applied per drawn primitive
  instead of on the whole svg, whose animated geometry forced a
  near-half-viewport filter re-raster every frame; the per-second
  relative-time sweep skips cells whose text did not change; the
  secondary poll fetches (`/cluster`, `/node`, `/dags`, `/state`) skip a
  cycle while their previous request is still in flight instead of
  stacking connections against the browser's per-origin cap; and the
  balance-recovery planner skips a frame after any pass that overran its
  budget, so weak machines drop to half-rate planning instead of dropped
  frames (validated by a knockover Monte Carlo: 24 of 24 seeds caught
  and held at forced half rate; `planBudgetMs: 0` pins the budget off
  for deterministic captures).
- A live SSE log tail delivers a burst of lines as one joined write per
  wake instead of one timer task, one frame build and one transport
  write per line per viewer, which cost the scheduler's loop thousands
  of coroutine steps a second under a chatty job. The framing benchmark
  moved with the code: `webapi.sse_burst_20k` times the burst path and
  retires `webapi.sse_frame_20k`, whose per-line seam no longer exists.
  The smoke test's never-skip net is now derived from
  `benchmarks/expected_gated.txt` instead of hand-copied from it, which
  is the gap that had let the old metric die silently.
- TUI: the heat overlay's per-job run fetches run as a background task
  with a few requests in flight, instead of strictly one at a time
  inside the poll loop, which froze every panel for the sum of up to 40
  round trips each refresh; entries for jobs removed by a reload are
  pruned.

## 1.2.36 (2026-08-03)

Defect fixes from a full-application review.

- Windows: cancelling a job (executionTimeout, Replace, shutdown, or the
  cancel API) now kills the process tree while its root is still alive.
  Before, the real workload of a shell-form command could survive as an
  orphan, and a Forbid job could double-run after a timeout.
- The Forbid/Replace launch gate is serialised per job. A manual start
  racing the scheduled fire (or a double-click on Run) could previously
  launch two instances of a Forbid job.
- DAG catch-up after a suspend or a forward clock jump honours `onMissed`
  and the same catch-up limit plain jobs use, instead of replaying every
  missed occurrence.
- Duplicate job names are refused at config load; previously all but the
  last definition silently never ran.
- A web listener whose every bind fails (port still held by a draining
  predecessor, say) is retried on the next housekeeping pass instead of
  staying down for the life of the process.
- SSE log tails end at web teardown instead of waiting out aiohttp's
  60-second shutdown timeout, so a web, TLS, or MCP config change no
  longer stalls scheduling while open dashboard tails drain.
- CORS preflights reach the `/mcp` OPTIONS route with authentication on, so
  browser MCP clients on `mcp.allowedOrigins` can connect to a
  token-protected daemon.
- The MCP `cron_decide_gate` tool requires the `approve` token scope, like
  the REST decision route it fronts.
- Bearer-token redaction covers the full RFC 6750 charset: tokens carrying
  `+`, `/` or `~` no longer escape redaction, whole or in part. The Basic
  pattern learned the base64url alphabet too.
- The durable store keeps record names monotonic across a backward clock
  step. Pruning could otherwise delete a just-written record while keeping
  stale future-dated ones, and the missed-run/retry watermarks could skip
  new history.
- Stream names carrying lone surrogates (from non-UTF-8 crontab filenames)
  round-trip the store audit exactly; the orphan-blob sweep no longer
  deletes artifact payloads such streams still reference.
- Removing `expand:` from a task while a run is mid-flight no longer wedges
  that run: the recorded fan-out keeps dispatching and the run reaches a
  terminal state.
- Boot reconciliation of DAG runs isolates a stalled or unreadable run
  document. One bad document used to abort state rehydration entirely,
  which also skipped starting the job API.
- Custom `web.metrics.durationBuckets` no longer discard the persisted
  duration histogram on every restart.
- Dashboard: the drawer Logs pane re-attaches when a new run starts
  producing output; a poll that never settles can no longer freeze all data
  loading behind a still-green "live" indicator; the live CPU/RSS chip and
  the command cell update when their rendered values change.
- TUI: a run finishing in the local future (clock skew against a remote
  daemon) no longer crashes the heatmap, and 8-bit C1 escape sequences from
  job output are stripped like their 7-bit forms.
- The MCP stdio bridge pins its stdio to UTF-8, so non-ASCII tool results
  no longer kill it on Windows consoles.
- CI: a fork pull request from a branch named `main` can no longer cancel
  an in-flight release, and the pyinstaller Docker build works from the
  repo-root context again.

## 1.2.35 (2026-07-27)

- Code cleanup - removal of dead and unused code
- Author email update

## 1.2.34 (2026-07-27)

- Logo changes: simplification
- Address GitHub security findings

## 1.2.33 (2026-07-25)

A performance-audit release, the follow-up to 1.2.32's engine pass. That
release took the hot frames a profiler shows; this one takes the distributed
costs only end-to-end measurement shows, across the durable state store, the
run loop's between-fires bookkeeping, the web and metrics endpoints, config
parsing, the cluster paths, and both UIs. It adds no configuration surface.
Where a rewrite touches observable output, the old and new paths were pinned
against each other during review: the cron-expression work by a 3,000-case
differential against 1.2.32's engine (the golden vectors are untouched),
redaction byte-for-byte over a hostile corpus, and the Prometheus value
formatter over 9,000 fuzz cases. The benchmark gate that will hold these wins
grows from 8 to 24 armed metrics, and the audit surfaced and fixed a
long-standing Windows locking race (see Fixes).

### The state store pools its worker threads

- **Blocking store calls dispatch to pooled daemon threads** instead of
  spawning a thread per operation, which cost about 130 microseconds of pure
  dispatch per call, several times the store work itself on a read from a
  warm page cache. Abandonability is unchanged: a worker rejoins the pool
  only once its operation returns, so one wedged in a dead-mount syscall is
  never handed more work and the next call spawns a replacement.
- **Lease and coordination traffic gets its own lane**: a dedicated slot
  pool, and an exemption from the write rate limiter, so a burst of bulk
  record writes (or bulk workers wedged on a hung mount) can never queue a
  lease renew past its TTL and expire a live holder's fence.
- **Re-reads of unchanged record files are served from a validated
  in-memory cache**, capped by entry count and total bytes. Entries are
  admitted only after full schema validation, every hit hands the caller a
  private copy, and strict reads bypass the cache in both directions.
- **Documents a caller has already validated can be dumped as trusted**,
  skipping the portability pre-walk; the dump's own failure path re-walks
  and raises the same `UnsupportedValue` it always did.
- **Per-operation store statistics** (count, errors, seconds, measured on
  the worker thread around the store work itself) accumulate per label and
  surface through the backend's stats.

### The run loop does less between fires

- **The due-fire heap tolerates stale entries** and compacts in one pass
  only when they outnumber live jobs, instead of rebuilding eagerly.
- **Pause refresh and cross-node retry-claim scans run on their own
  intervals**, re-anchored when the state backend generation changes,
  rather than riding every housekeeping tick; the claim scan enumerates the
  store's retry streams once instead of probing one stream per configured
  job.
- **Reload reparse runs with the garbage collector held**: the parse's
  temporary object storm no longer triggers collections over the live job
  set mid-reload, and the config signature that decides whether anything
  changed is computed off the event loop. Two new gated metrics
  (`config.reload_gc_100k`, `mem.gc_pause_100k`) watch exactly this.
- **`ResourceMonitor.stop()` reuses the ticker's last process-tree
  snapshot** for the final sample instead of a fresh table walk; per-member
  readings are taken live, and members spawned inside the final interval
  are absent from that sample, the same blind spot the periodic samples
  already carry.

### Web responses render off-loop and revalidate

- **`GET /` and `GET /jobs` answer with a strong validator and gzip**: a
  repeat dashboard load revalidates into an empty 304 instead of resending
  the document, and clients that accept gzip get the compressed body.
- **`GET /metrics` renders in two phases**, snapshotting the metric
  families and building the exposition text on the executor; the per-label
  block memo introduced in 1.2.32 now persists across scrapes (label values
  are part of its key, so it can never serve a stale block).
- **JSON responses are written from pre-encoded bytes**: the Content-Type
  is bare `application/json` (bodies are UTF-8) and non-ASCII values are
  sent raw rather than `\uXXXX`-escaped.
- **SSE log-tail replay is one write** of the retained buffer instead of a
  write per retained line, and per-line frames are encoded through the same
  trusted-dump fast path.
- **statsd endpoints are opened once and reused** (refreshed after 60
  seconds, never cached on a failed open, queued bytes bounded) instead of
  an open-send-close per emit.

### The dashboard and the TUI redraw only what changed

- **The jobs table diffs per-row signatures** and rebuilds exactly the rows
  whose rendered inputs moved; countdown cells keep ticking client-side.
  The pendulum logo's LQR gain seed is computed once per parameter set and
  cached.
- **The TUI's line clipping and padding take an ASCII fast path**, SGR
  color runs dispatch on a single token scan, heat-strip and ANSI-scrub
  results are cached with pruning, wrapped-log windows are sliced by
  arithmetic instead of wrapping the whole buffer, and the log search
  carries its match state incrementally across appends instead of
  rescanning the tail.

### Config parses less and remembers job identity

- **Included files ride the content-hash parse cache** that already served
  config directories, so an unchanged include is not reparsed; cycle and
  overlap detection are unchanged.
- **A crontab parsed once is carried through the defaults merge** instead
  of being reparsed per job, schedule lint results are cached per parse,
  and a job whose `onLate` block is the untouched default settles that
  validation with one identity test.
- **Job digests share a per-reload memo** for the config blocks jobs share
  (the digest bytes are unchanged, so persisted fingerprints and the golden
  are unaffected), and SLA payload views are precomputed at parse time.
- **The cron-expression engine got a further pass** over its search
  internals and its equality check (resolved-text comparison first), pinned
  by the differential above; parse error messages are unchanged.

### Cluster and backend paths stop re-deriving

- **Rendezvous-hash ownership streams its candidate bytes** and memoizes
  answers against the peer-state mutation generation, the gossiped fleet
  summary body is cached as bytes and replayed for 304 responses, and
  `@reboot` advertising reuses the same gates as the membership test.
- **The etcd campaign tries a plain linearizable range read first** and
  falls to the create-if-absent transaction only when the key is empty; the
  Kubernetes backend gets its own client pool. Lease fencing semantics are
  unchanged in both.
- **Redaction pre-screens each line with a casefolded gate** for the secret
  keywords before running the full pattern pass; import-time checks pin the
  gate letters to the keyword set, and output is byte-identical to 1.2.32.

### Startup and the thin clients

- **The daemon no longer imports PyNaCl to learn it exists**: the startup
  probe asks importlib for the module spec (the real import cost about 10
  ms and 1 MB of RSS on every start, for a reporter most installs never
  configure). The import happens at the first sealed-box use; a PyNaCl
  installation that is findable but broken now surfaces there, as a
  per-send `PushError`, instead of at config load.
- **`--validate-config` and `--job-set-id` answer without building a
  scheduler**, and the `jobcli` / `mcp` / `tui` entry stubs register their
  flags without importing asyncio or the daemon; a parity suite asserts the
  stubs match the real registrations flag for flag.
- **The push log-tail trim bisects** to the drop count instead of removing
  one line at a time (verified equivalent over 400 randomized payloads).

### The benchmark gate doubles its coverage

- **16 more metrics are release-gated** (state mutation, reload GC, idle
  wake rate, SSE fan-out, resource-monitor stop, DAG advance, cluster
  ownership, web UI render and append, push sealing among them), bringing
  the expected-gated ledger to 24; `push.seal_500` arms now that the
  baseline release ships the module.
- **Five new absolute ceilings wait in a `proposed` block** (not read by
  the comparer) until one release publishes a CI-observed value to size
  them against; the local measurements recorded there (for example
  `state.mutate_document_1k` at 1.161 s, `webui.render_term_5k` at 69 ms)
  exist to make that later sizing auditable.
- **About 790 lines of new tests pin the rewrites**: the worker pool's
  wedge and spawn-failure paths, record-cache isolation, the Prometheus
  two-phase seam, resource-monitor sampling, CLI stub parity, and fast
  path versus slow path agreement in the portable JSON codec.

### Fixes

- **Two schedulers sharing a Windows state directory could crash a claim
  pass on first contact with a fresh lock file.** The lock bootstrap
  (msvcrt needs a byte present before it can lock one) raced: both sides
  could observe the empty file, and the loser's own bootstrap write then
  landed on the winner's just-locked byte range, which Windows rejects, so
  a `PermissionError` escaped the lease acquire instead of the loser
  waiting its turn. Present since the durable store's lock files were
  introduced; this release's pooled lease dispatch made rivals arrive
  close enough together to hit it. The loser now falls through and
  contends on the lock; two regression tests cover the simulated loss and
  the real byte-range collision.

## 1.2.32 (2026-07-25)

An alerting-reach release, with a round of hot-path tuning alongside it. A
fifth reporter delivers end-to-end encrypted push alerts to paired devices
through a hosted relay that never sees plaintext; a device-pairing API and a
dashboard QR panel manage those pairings; a new `GET /whoami` tells any client
what its bearer token may do; and an opt-in Bonjour/mDNS advert lets a
companion app on the same network find the daemon without a typed URL. Both
capabilities are optional extras (`push`, `discovery`) and both fail closed at
config load: a config that asks for either on an install without the library
refuses to start instead of silently not alerting or not advertising.

A full CPU-efficiency review then took the remaining cost out of the cron
engine, the Prometheus scrape and the run loop's per-tick bookkeeping. Every
figure in those sections is from an A/B benchmark of this release against the
previous one on the same machine, and every change is output-identical:
the engine's answers are pinned by the golden vectors plus a 104,244-case
differential against the previous implementation, and a scrape renders
byte-for-byte the same exposition text.

### End-to-end encrypted push alerts (the `push` reporter)

- **A fifth reporter, `push`, seals every alert to each paired device's public
  key** (a libsodium sealed box: X25519 + XSalsa20-Poly1305) and hands the
  ciphertext to a hosted relay that forwards it to the platform push service
  (APNs). The relay sees only a device token, a ciphertext, an opaque
  collapse-id hash, a priority, and an event flag, never job names, hostnames,
  or log lines, so a self-hosted daemon can use a shared relay without
  trusting it. See the
  [Push Notifications wiki page](wiki/Push-Notifications.md); the versioned
  wire contract is [`docs/relay-protocol.md`](docs/relay-protocol.md).
- **`report: push:` rides the existing report schema** on `onFailure` /
  `onPermanentFailure` / `onSuccess` / `onLate` and under `notify.report`, so
  DAG failures, approval gates, and leader/quorum events can push too. Its
  keys: `enabled` (default `false`), `priority` (`time-sensitive` or
  `passive`, relayed as the APNs interruption level), and `includeLogTail`
  (default `true`).
- **A daemon-global `push:` section says where alerts go and where pairings
  live**: `push.relay.url` (required, http(s)) plus an optional
  `push.relay.timeout`, and registry storage that rides the durable `state:`
  store when one is configured (one document per device, cluster-visible,
  never swept by state GC) or a local `push.devicesFile` otherwise.
- **The sealed payload always fits the platform cap**: the ciphertext is sized
  so the relay's final APNs JSON stays under APNs' 4096-byte limit, trimming
  the log tail oldest-first (the newest lines carry the failure) and keeping
  the alert's identity (name, kind, host) intact.
- **Push is a new optional extra (`push`, PyNaCl) and fails closed**: a
  `push:` section without PyNaCl installed, `report.push.enabled` anywhere
  without a `push:` section, a `push:` section with neither a `state:`
  section nor `devicesFile`, and a `push:` section on a daemon whose web API
  listens on a routable address with no `web.authToken`/`web.authTokens`
  (the `/push/devices` pairing endpoints would answer anyone who can reach
  the listener; `push.allowUnauthenticated: true` is the override) are all
  `ConfigError`s at parse time. The release
  binaries bundle PyNaCl per architecture behind a sealed-box verification
  step (a broken build is dropped); a lane that cannot build it ships without
  the extra, and the config error then says so.

### Device pairing API and dashboard QR

- **Four new endpoints manage the paired-device registry**:
  `GET /push/devices` lists pairings (push tokens redacted to their trailing
  characters), `POST /push/devices` pairs a device
  (`{name, platform, publicKey, pushToken}`; the same public key pairing
  again updates its record in place and keeps its id, so revocation
  references stay stable), `DELETE /push/devices/{id}` revokes one, and
  `POST /push/devices/{id}/test` round-trips a test alert through the relay
  so a silent phone is debuggable from the dashboard. All ride the existing
  listeners, bearer tokens, and scopes (`view` to list, `control` to mutate)
  and answer `404` until a `push:` section is configured, so a reload that
  adds the section needs no web-app restart.
- **The dashboard gains a "Pair a device" panel** (command palette and
  settings): a QR code of `{v: 1, name, url, token}` plus the same JSON as a
  copyable string, with a warning when the stored token is the all-scopes
  one, pointing at a scoped
  [`web.authTokens`](wiki/HTTP-API.md#scoped-tokens-webauthtokens) entry for
  phones instead.

### `GET /whoami`

- **A new introspection endpoint describes the presented bearer token**:
  `{authenticated, label, scopes, allScopes}`, so a companion app can show
  what it is allowed to do and the dashboard can warn before its pairing QR
  hands a phone the all-scopes token. With no token configured there is no
  auth middleware to match against: `authenticated` is `false`, `allScopes`
  is `true`, and every scope is effectively granted.

### Bonjour/mDNS LAN discovery (`web.bonjour`)

- **`web.bonjour: true` advertises the web API as a `_cronstable._tcp` mDNS
  service**, so a companion app (or `dns-sd -B _cronstable._tcp`) finds the
  daemon without a typed URL. The advert names one LAN-reachable listener
  (the first bound https listen entry another machine can dial, else the
  first such http one; loopback and unix listeners are never advertised) and
  carries the instance name (the hostname, or the map form's `name:`
  override), that listener's actually bound TCP port (correct even for an
  ephemeral `:0` listen) and scheme, and a TXT record `v` (the daemon
  version); its SRV target is a dedicated `<name>-cronstable.local.`
  hostname, never the machine's own `.local` name. No secrets are
  advertised; a discovered client still needs a bearer token to read
  anything. See the [LAN Discovery wiki page](wiki/LAN-Discovery.md).
- **It requires the new `discovery` extra (python-zeroconf) and a TCP
  listener**, both enforced at parse time: `web.bonjour` without the library,
  or with every `web.listen` entry a unix socket, is a `ConfigError`. Unlike
  push, a *runtime* mDNS failure only logs and leaves the advert off until
  the next config apply: discovery is a convenience and must never take down
  a scheduler.

### The cron engine tests fewer candidates per search

- **The day walk stops calling a method per candidate day.** Both day columns
  are plain value sets in all but the L / W / `#` schedules, and the
  unrestricted spelling of either column has already expanded to that
  column's full range, so the both-restricted AND rule needs no special case:
  such a schedule now tests a day with two set lookups inline, carrying the
  weekday forward instead of building a fresh `datetime.date` for it. The
  `_day_matches` / `_dom_matches` call pair it replaces was about 28% of a
  `next()` on the profile, because the loop runs once per candidate day and a
  sparse schedule (`0 0 29 2 *`) walks most of a month to find its answer.
  Schedules that do use the L / W / `#` families take the old path unchanged.
- **The month length is computed, not looked up.** `calendar.monthrange`
  validates its argument through an `enum.Month` lookup and computes the
  month's first weekday, the half of its result pair this engine never reads;
  the arithmetic that replaces it measured about 12x cheaper, and it runs
  once per candidate month and once per `test()`.
- **The time-of-day columns are binary-searched.** Locating the first
  matching hour, minute and second used to scan the sorted columns one value
  at a time, so a plain `* * * * *` seeded at 10:30 stepped through roughly
  40 values; `bisect` gets there in a couple of probes.
- **A per-call copy is gone from every search entry point.** `next`, `prev`
  and `occurrences` each opened by stripping a tzinfo to get a naive civil
  label, which for an already-naive argument only bought a copy of it; the
  aware paths of `next` and `occurrences` then overwrote that label with a
  properly seeded one without ever reading it.
- Together: `next()` over 20k plain tabs is 37% faster, `test()` over 200k
  matches 38%, occurrence enumeration 36%, `next()` over complex tabs 18%,
  and zoned `next()` across real DST transitions 15%. The scheduler inherits
  it: reseeding a 100k-job index is 39% faster, a cold 100k-job build 12%,
  and a due-fire pass 16%. The iCal feed (17%) and the schedule preview
  endpoints (21%) ride the same searches.

### Prometheus scrapes build each label block once

- **A sample's `{k="v",...}` block is memoized for the pass.** One job's
  label set recurs across about nine families in a scrape (each counter,
  each gauge), and the block was reassembled from a generator expression, a
  `str.join` and a `format` per label every single time; only the escaping
  was cached. Keying the memo on the label items collapses that to one
  assembly per distinct label set, and the one- and two-label shapes that
  are nearly every sample skip the generator machinery entirely on a miss.
  The memo lives and dies with the one render pass, so nothing is served
  stale across scrapes.
- **The per-sample line is built inline** in the renderer instead of through
  a helper whose call frame and `(name, block)` tuple cost more than the two
  lines they saved. Rendering a 500-job scrape is 29% faster; on a 3,000-job
  fleet the exposition step measured 85ms before and 71ms now, for the same
  72,006 samples and byte-identical output.

### The run loop and the payload builders stop repeating themselves

- **The sub-minute check is memoized.** `run()` asks whether any enabled job
  fires at second granularity on *every* loop iteration, which a second-level
  job makes once a second, and the underlying scan is O(all jobs). It was
  worst-cased exactly where it hurts most, since the scan only short-circuits
  early when a second-level job happens to sit near the front of the job set.
  The answer can only change on a reload, so it now rides the same cache
  lifecycle as the job-set fingerprint and is cleared wherever the job set is
  swapped.
- **`/jobs`, `/status`, `/summary` and the gossiped fleet summaries read the
  clock once per request** instead of once per job. Besides the redundant
  work at fleet scale, this makes each snapshot internally consistent: every
  countdown is now measured from one instant rather than from instants
  drifting apart across the loop. The `/jobs` payload for 500 jobs is 8%
  faster, and the MCP tool dispatch that shares these builders 24%.

## 1.2.30 (2026-07-23)

A reporting, orchestration-visibility, and API-contract release. Report
payloads now carry the run's host, schedule, start time and ledger id; a global
`defaults:` block finally reaches DAG tasks; a new `notify:` block reports on
DAG failures, approval gates, and leadership changes (not just job runs); the
REST API gains a single-job and a batched-summary endpoint; the web API can now
issue **scoped, per-device bearer tokens** so a phone or wallboard need not
carry an all-powerful token; and the whole HTTP control API is now described by
a CI-checked OpenAPI spec. Every change is useful to existing webhook/ntfy/CLI
users, not just to API clients. (Rename this `Unreleased` heading to the cut
version at release time; see [Contributing](CONTRIBUTING.md#releasing).)

### Richer report payloads

- **`template_vars` gains `host`, `schedule`, `started_at` and `run_id`**, so a
  webhook, ntfy, mail or Sentry report can identify the run (which node ran it,
  its crontab line (object schedules rendered), the ISO-8601 start instant, and
  its [durable-ledger](wiki/Durable-State.md) id) without the template digging
  through `environment`. `started_at`/`run_id` are `None` before a run starts
  and on an `onLate` breach (which describes a run that did not happen); `host`
  and `schedule` are always populated. Existing templates are unaffected.
- **The shell reporter exports `CRONSTABLE_HOST`, `CRONSTABLE_RUN_ID` and
  `CRONSTABLE_STARTED_AT`** alongside the existing `CRONSTABLE_*` variables, so
  a notify script sees the same run context.

### The global `defaults:` block now covers DAG tasks

- **A DAG task inherits the file's `defaults:` block just as a job does**:
  global `shell`, `environment`, `env_file`, capture, `monitorResources`,
  run-scoped `secrets`, and reporter (`onFailure`/`onSuccess`) config now
  reach DAG tasks, with the task's own value winning on any key it sets.
- **DAG task runs now fire their `onFailure`/`onSuccess` reporters**, set
  per-task (a new report-only task key; there is no `onFailure.retry` on a
  task, attempts stay graph-driven) or inherited from `defaults:`. Every
  failed attempt reports via `onFailure`; cancelled and replaced instances do
  not report, matching job semantics. Reports are spawned off the reaper, so
  a slow reporter never stalls completion handling or the graph advance.
  Previously a task template was built over the built-in defaults only, so a
  global reporter or environment silently skipped DAG tasks. Graph-shape fields
  (`dependsOn`, `triggerRule`, `retries`, `expand`, `onReject`, the poke
  settings) are never touched by `defaults:`, and the DAG's synthetic
  schedule-trigger job stays on the built-in defaults so a global reporter fires
  per DAG **run**, not on every tick.

### Daemon event notifications (`notify:`)

- **A new top-level `notify:` block reports on daemon and orchestration
  events**, over the same four reporters (sentry / mail / shell / webhook) a job
  uses. It fires on `dag_failure` (a DAG run ended failed), `approval_waiting`
  (an approval gate began awaiting a decision, once per gate), `leader_change`
  (this node acquired or lost scheduled-job leadership), and `quorum_loss` (this
  node left quorum). An optional `events` allow-list filters which fire; the
  default is all. Its default templates key on the event (`event` / `subject` /
  `message`) rather than the completed/failed job wording, and it has its own
  Sentry grouping. Until now only job runs could report; DAG failures, gate
  waits, and cluster events never fired a notification. Notifications are
  fire-and-forget and never block the scheduler, cluster loop, or a DAG advance.

### New REST endpoints

- **`GET /jobs/{name}`** returns one job's detail in the identical shape as an
  entry of `GET /jobs`, so a client can refresh a single job without pulling and
  filtering the whole fleet. (The same detail was already an MCP tool,
  `cron_get_job`; this puts it on REST too.)
- **`GET /summary`** returns one batched fleet overview for an at-a-glance
  client (a widget, a status tile): fleet job counts (total / enabled /
  disabled / running / paused / failing / never-fires), the soonest upcoming
  fire, this node's identity, and its compact cluster role: one small poll
  instead of folding the whole `/jobs` array.

### OpenAPI specification

- **The HTTP control API is now described by an OpenAPI 3.0 spec at
  [`docs/openapi.yaml`](docs/openapi.yaml)**, kept honest by two CI checks:
  `.github/scripts/check_openapi.py` (`tox -e openapi`) validates the document
  itself (schema, refs, duplicated keys), and `tests/test_openapi.py` diffs the
  spec's paths and methods against the served route table
  (`cronstable.cron.WEB_ROUTES`, now the single source the aiohttp app builds
  its routes from) in both directions, so a route added or renamed without a
  spec edit fails the suite. It is the contract a generated client is built
  from; the [HTTP-API wiki page](wiki/HTTP-API.md) remains the field-by-field
  reference.

### Scoped web bearer tokens (`web.authTokens`)

- **A new `web.authTokens` list issues per-device bearer tokens, each with its
  own scopes** (`view` / `control` / `approve`), so a phone, a wallboard, or a
  CI trigger need not carry the all-powerful `web.authToken`. `view` covers
  every read-only `GET`; `control` covers the mutating `POST`s (start / cancel
  / pause / resume, DAG trigger / backfill) and `POST /mcp`; `approve` covers
  only the DAG approval-gate decision. `control` and `approve` each imply
  `view`. A recognised token that lacks a route's scope is now **`403
  Forbidden`** (naming the token and the missing scope), distinct from the
  `401` for an unknown token. New routes get a safe default (a `GET` needs
  `view`, any other method needs `control`), so nothing is ever unguarded by
  omission.
- **The scalar `web.authToken` is unchanged**: it remains an all-scopes token,
  every configured token is accepted, and both keys compose. Each scoped entry
  resolves its secret from the same `value`/`fromFile`/`fromEnvVar` sources and
  **fails closed** the same way (a configured-but-empty entry refuses to start
  the web API); two tokens resolving to the same secret are refused at startup,
  since matching is by secret and only one entry's scopes could apply.
  `mcp.enabled`'s fail-closed gate is satisfied by either key.
- **Revoke a device** by dropping its entry and reloading; its optional `label`
  identifies it in logs and 403 bodies. These transport scopes are unrelated to
  the loopback job-state API's key-value `scope`. See the
  [HTTP-API wiki page](wiki/HTTP-API.md#scoped-tokens-webauthtokens).

## 1.2.29 (2026-07-21)

A configuration release.  A YAML config can now pull its own values from the
environment.  `${VAR}` and `${VAR:-default}` in any string value are expanded
from cronstable's process environment when the file loads, so one config file
serves many environments without a wrapper entrypoint templating the YAML from
env first.  A listen address, a state path, a timezone, an `include` path or a
webhook URL can come from an environment variable while the config itself stays
declarative.  It is opt-in by syntax: a config that never writes `${...}`
behaves exactly as before, and expansion introduces no new keys.

### Environment-variable interpolation

- **`${VAR}` expands to an environment variable and `${VAR:-default}` supplies
  a fallback** when the variable is unset or set-but-empty (the shell's `:-`).
  `$$` is a literal `$`, so `$${VAR}` stays `${VAR}`.  Only these braced forms
  are recognised: a lone `$`, a bare `$VAR`, or a malformed `${...}` is left
  exactly as written, so a value that never used the syntax passes through
  untouched.
- **An unset variable with no default is a hard `ConfigError`** that names the
  variable, the config value it appeared in (e.g. `web.listen[0]`), and the
  file.  A missing deploy-time variable therefore fails at load, and
  `cronstable --validate-config` catches it in CI or a deploy check before the
  scheduler ever starts.
- **Expansion runs after the document is validated**, over the parsed values,
  so it reaches every string-typed field.  A consequence is that a numeric key
  cannot itself be a bare `${VAR}` (its value fails schema validation before
  expansion is reached); put the variable inside a string, as in
  `listen: ["0.0.0.0:${PORT}"]`.  The section builders then validate the
  expanded value, so an interpolated `state.path` that resolves to empty is
  rejected the usual way.
- **Each file is expanded against the environment as it is parsed.**  An
  `include` path is an ordinary string value, so it too may be built from a
  variable (`- ${ENVIRONMENT:-prod}.yaml`), and an included file resolves its
  own references.  An environment value is never rescanned for further
  `${...}`, so a variable cannot expand into another expansion.
- **Job, DAG-task and shell-reporter `command`/`shell` are left untouched**:
  their `${VAR}` belongs to the runtime shell, which expands it against the
  job's own environment (`env_file`, per-job `environment`, staged secrets) at
  execution time, not the daemon's.  The `logging` section is likewise left for
  Python's `logging.config`, whose `$`-style formatters legitimately write
  `${asctime}` in a `format` string; interpolating it would fail an otherwise
  valid logging config to load.
- **The job-set id is taken over the expanded config**, so a job set that
  interpolates per-environment values gets a different id per environment by
  design (the deployments really are running different job sets).  Keep
  interpolated variables out of fingerprinted job fields if one id must compare
  across environments.

## 1.2.28 (2026-07-21)

A performance and hardening release.  The cron engine, the config loader, the
web and metrics endpoints, the cluster mesh and the durable store each do the
same work with fewer CPU cycles, fewer allocations and fewer redundant reads,
and two guards bound memory on the durable write and mapped read paths.  It
introduces no new configuration.

### The cron engine and schedule analytics

- **The fire walk stops recomputing the month length for every candidate
  day.**  `CronTab._day_matches` now receives the month's last day from the
  caller that already holds it (`test`, and the forward and backward civil
  walks), rather than calling `calendar.monthrange` again per day.  On a
  schedule-pressure scan over a thousand per-minute jobs that removed on the
  order of a million redundant calls per request.  The result is byte for byte
  the same, checked by a differential fuzz against the previous engine across
  221k cases spanning next, prev, test and occurrences in naive and DST-aware
  frames.
- **The DST linter scans each zone once instead of once per job.**  The
  schedule linter used to walk 366 days of UTC offsets for every zoned job on
  every config load.  The transition days in a year are now computed once per
  (zone, year) and cached, so a fleet sharing a zone pays the scan a single
  time and every later reload reuses it.  The findings are unchanged.
- **The collision heatmap walks each distinct schedule once.**  A fleet
  duplicates schedules heavily, and the fire enumeration depends only on the
  schedule and the zone, never on the job name, so `schedule_pressure` now
  walks each distinct (schedule, zone) once and replays the resulting cells
  for the jobs that share it.

### Config loading

- **A one-file edit no longer reparses the whole config directory.**  When a
  watched file changes, the reload used to rebuild every job in the directory
  through strictyaml.  A per-file cache now returns the unchanged files'
  already-parsed configs and reruns the parser only for the file that changed.
  An entry is validated by hashing each input's bytes (the file, its
  transitive `include`s and its jobs' `env_file`s), so a size-preserving edit
  whose modification time is pinned back (a coarse-granularity network
  filesystem, or tooling such as `rsync -a`, `cp --preserve=timestamps` or a
  backup restore) is still picked up.  Reading the bytes is work the reload
  already did; only the parse is skipped.
- **A job's schedule is compiled once per load.**  The linter reads the
  `CronTab` the scheduler already built instead of parsing the expression a
  second time.
- **A shared `env_file` is read once per document** rather than once per job
  that names it.

### The web API

- **JSON responses are encoded with orjson.**  A shared response helper
  serializes the data endpoints with orjson and compact separators, falling
  back to the standard library for any value orjson rejects, and every web
  route, the cluster `/peer` body and the server-sent-events line encoder go
  through it.
- **`GET /jobs` answers conditional requests.**  The response carries a content
  `ETag` and honors `If-None-Match` with a `304`, so a poll that finds nothing
  changed does not re-encode or re-send the body.  The tag is computed over the
  payload with each job's relative countdown swapped for its absolute next-fire
  instant, so it holds steady while the countdown ticks and moves the moment a
  fire lands or any other field changes.  For a large fleet the encode runs off
  the event loop.
- **The SLA trends drawer stops rescanning the ledger on every poll.**
  `GET /jobs/{name}/trends` serves its built payload for a few seconds and
  drops that cache the instant a run for the job finishes, so a drawer several
  clients are watching reads up to `TREND_SCAN_LIMIT` ledger records at most
  once per window instead of once per poll.

### Metrics

- **Label escaping is skipped when there is nothing to escape,** and each
  distinct label value is escaped once per scrape rather than once per sample,
  so a job name that appears across dozens of series is processed a single
  time.
- **The MCP metrics query filters structured samples directly.**
  `cron_query_metrics` reads the metric families in place rather than rendering
  the full exposition text and parsing it back with a regular expression.

### The cluster mesh

- **Ownership is derived once per pass, not once per job.**  The spread-owner
  and available-member sets are memoized and the member names are pre-encoded,
  so `job_owner` and `available_job_owner` no longer rebuild the member list
  for every job; the per-job, per-peer encoding collapses to one derivation a
  pass.

### The durable store

- **The newest-record lookup stops at the first match.**
  `artifact_get_record` scans the stream once and stops early instead of
  paging the whole listing twice, cutting a representative newest-record read
  by close to a third.
- **`_derive_max` no longer sorts the whole listing;** it anchors on the
  maximum and sorts only the small set of newer records the fold needs for its
  tie-break.
- **Durable writes are bounded in time and in number.**  Every run-record,
  inflight, counter and archive write runs under the state-operation timeout,
  and the pending-write set is capped: past the cap a write is shed and counted
  as `cronstable_state_dropped_writes{kind="overflow"}` rather than queued
  without limit.
- **Mapped-XCom reads are bounded in size.**  A mapped fan-in checks a
  record's declared size and refuses an oversized blob before fetching it, and
  a list past the item cap is returned unwalked, so one runaway upstream cannot
  exhaust a downstream task's memory.

## 1.2.27 (2026-07-21)

A transport release.  Every HTTP surface cronstable serves could previously
be encrypted only by putting a reverse proxy in front of it; now the daemon
can do it itself.  `web.listen` accepts `https://` addresses, the job-facing
state API accepts one too, the certificates behind both are declared in
config rather than in a sidecar, a web certificate replaced in place is
picked up while the daemon runs, and every client that dials a web listener
gained the same four options for saying which CA to trust and what
certificate to present.  The listener can also be told to require a client
certificate, so it authenticates its callers at the transport instead of
only encrypting them.  Each block below is optional and empty by default.

The machinery is the cluster mesh's, which has spoken mutual TLS since
1.1.x: its context builders, its on-disk certificate fingerprint and its
"does the new material load yet?" dry run moved into a shared
`cronstable/tlsutil.py`, a standard-library-only leaf module, and the
cluster's own functions delegate to it under their existing names.
`cluster.tls` is unchanged.

### Web listeners speak TLS, optionally mutual

- **`web.listen` accepts `https://` entries**, served from a new `web.tls`
  block whose `cert` and `key` are required together.  The context is built
  once per app start and applied per listener, not per runner, so a listen
  list can mix `http://` and `https://` entries serving the same app on one
  process, each with its own transport.  `unix://` listeners stay plaintext:
  they are confined to the host's filesystem, where the socket's own
  permissions (`web.socketMode`) are the access control.
- **`web.tls.clientCa` requires a client certificate signed by that CA**
  (mutual TLS).  It is required, never merely requested: a client that
  presents nothing is refused at the handshake rather than completing it and
  being sorted out later.  The CA file is consequently the caller allowlist,
  because a server does no hostname verification and accepts any certificate
  that CA ever signed, so it wants to be a CA minted for this purpose rather
  than a shared organisational one.
- **`mcp.enabled` is now allowed on a routable listener with no
  `web.authToken` when `web.tls.clientCa` is set**, since mTLS authenticates
  the caller.  Plain `https://` does not qualify and still raises the
  original configuration error: encryption is not authentication.
- **Misconfigurations fail at parse time**, so `--validate-config` catches
  them: a cert without its key or a key without its cert, a `clientCa` with
  no certificate of the listener's own, TLS material with no `https://`
  listener to use it (it would be silently ignored), and an `https://`
  listener with no material to serve.  Whether the files exist or load is
  deliberately not checked there; config parsing touches no filesystem,
  `--validate-config` may run somewhere that is not the deployment target,
  and a mounted Kubernetes secret need not exist at first boot.  That check
  happens at the listener, which logs and declines to start rather than
  falling back to cleartext on a port an operator asked to encrypt.

### The job state API no longer has to be plaintext

- **`state.jobApi.listen` accepts an `https://` URL** served from
  `state.jobApi.tls.cert` and `.key`, with the same paired validation.  The
  endpoint hands every run a bearer token and stages that job's secrets, so
  it is the surface where cleartext cost the most.
- **Jobs are handed a trust anchor.**  `state.jobApi.tls.ca` is injected into
  every run as `CRONSTABLE_STATE_CACERT` and read by the in-job CLI, so
  `cronstable state|cursor|lock|artifact|idempotent|secret` can verify a
  certificate no public root signed, which is the normal case for an
  internally-issued one.  Note the asymmetry with `web.tls.clientCa`: this
  `ca` is the client-side anchor the daemon gives its jobs, not a CA that
  authenticates callers.  The in-job CLI has no TLS flags at all and no way
  to switch verification off, by design: nothing running inside a job should
  be able to downgrade the channel carrying that job's own secrets.
- **An in-place rotation of the endpoint's certificate is picked up while
  the daemon runs**, the same as a web certificate.  The `cert`/`key` files
  are fingerprinted as the listener loaded them; a change (same paths, new
  bytes, which is how cert-manager, Vault and Kubernetes secret refreshes
  renew) rebuilds just this listener on the next housekeeping pass, without
  disturbing the store backend or its leases, and gated on the new material
  loading first so a half-written refresh keeps the old certificate up.
  `state.jobApi.tls.ca` is exempt, because jobs read it fresh by path.
- **A wildcard host over `https://` is a configuration error.**  Jobs dial
  the address they are handed and no certificate carries a SAN for every
  interface, so `https://0.0.0.0:9000` could only ever fail verification.
  Name the interface explicitly.
- **The advertised URL now prefers the configured host over the bound one**,
  which also fixes a latent bug: a plaintext `listen: 0.0.0.0:9000` used to
  advertise `CRONSTABLE_STATE_URL=http://0.0.0.0:9000`, which happened to
  work on Linux and failed on Windows.  The bound address is still used when
  nothing was configured, where the ephemeral loopback default makes it the
  right answer.
- **Mutual TLS is deliberately not offered on this endpoint.**  The per-run
  bearer token already authenticates the caller, and requiring client
  certificates would mean injecting key material into every job's
  environment.
- **`allowNonLoopbackBind` changed its wording, and gained a warning.**  Its
  configuration error no longer asserts that the endpoint is plaintext or
  tells the reader to add a reverse proxy, because TLS is now available in
  process.  Enabling it alongside a plaintext off-host listen logs a warning
  naming the exposure at every boot.  It is a warning and not an error: the
  documented pairing was a reverse proxy, and that remains a valid answer.

### Certificates rotate in place

- **An in-place rotation restarts the web listener.**  The SSL context is
  built once and never reloaded, so new bytes at the same paths, which is
  exactly how cert-manager, Vault and a Kubernetes secret refresh renew,
  would otherwise be invisible until the old certificate expired.  An
  `(mtime, size)` fingerprint of the configured files is compared on the
  ordinary housekeeping reload and a change restarts the listener.
- **The restart is gated on the new material loading.**  Make before break is
  impossible here, because the new runner binds the port the old one still
  holds, so a half-written rotation (none of those refreshes is atomic across
  the files) would otherwise tear down a working listener and then fail to
  rebuild it.  When the new material does not load, the running listener is
  kept, a warning says why, and the next reload retries.
- **The restart drops connections open at that moment**, including the SSE
  log streams the dashboard and the terminal dashboard hold open.  They
  reconnect on their own; a live log tail blips.
- **The job state API listener does not do any of this.**  It builds its
  context once at startup, so rotating its certificate needs a daemon
  restart.

### The clients gained a consistent verification surface

- **`--cacert`, `--client-cert`, `--client-key` and `--insecure`**, with the
  identical names and meanings in `cronstable tui` and the `cronstable mcp`
  stdio bridge (and in the thin `__main__` stubs that advertise them without
  importing either).  Each falls back to an environment variable
  (`CRONSTABLE_WEB_CACERT`, `CRONSTABLE_WEB_CLIENT_CERT`,
  `CRONSTABLE_WEB_CLIENT_KEY`, `CRONSTABLE_WEB_INSECURE`), the same flag then
  env precedence the bearer token already used, so one exported set of
  variables serves every client.  With none of them set the clients build no
  context at all and keep exactly the transport they had.
- **`--insecure` warns on stderr every time.**  Verification is off but the
  `Authorization` header is still sent, so the bearer token goes to whoever
  answers the connection.
- **`--client-key` without `--client-cert` is refused.**  A key alone cannot
  present an identity, so the only alternative to failing is accepting the
  flag and ignoring it, which would leave the caller believing it had
  authenticated to a listener that had in fact refused it.
- **A failed handshake reads as a failed handshake.**  `urllib` delivers one
  wrapped as a generic connection error, which would have sent an operator
  hunting a firewall or a wrong port while the socket connected fine; both
  the MCP bridge and the in-job CLI now tell the two apart and name the CA
  variable or flag involved.
- **Client hostname verification is on**, so a certificate must cover the
  name actually dialled: `https://127.0.0.1:8443` needs an IP SAN for
  `127.0.0.1` and `https://localhost:8443` needs a DNS SAN.  This is the most
  likely first-run failure.
- **The webhook reporter has no CA option yet.**  `report.webhook` still
  verifies against the system trust store only, so an endpoint holding an
  internally-issued or self-signed certificate cannot be reported to.

See [Listener TLS](https://github.com/ptweezy/cronstable/wiki/Listener-TLS)
in the wiki for the full configuration, certificate requirements and the
trust models.

## 1.2.26 (2026-07-20)

Two operator features land this release: a runtime job pause and per-job SLA
(late-run) monitoring, each wired through the HTTP API, the web and terminal
dashboards, Prometheus, and MCP.  Both are control-and-alerting layers only:
neither changes what a job runs or when, and both stay off the scheduling hot
path.  A pause is runtime-only state, and the `sla`/`onLate` config keys are
excluded from the job-set-id fingerprint, so replicas never read either as
drift.  The rest of the branch hardens their edge cases; one internal change
stands apart from both.

### A job can be paused at runtime

- **`POST /jobs/{name}/pause` holds a job's scheduled fires; `/resume` ends the
  hold.**  The body is optional: `durationSeconds` (default `3600`, range `1`
  to `2592000`) or, exclusively, an absolute `until` (future, at most 30 days
  out, naive timestamps read as UTC), plus a `note` (at most 500 chars) and
  `by` (at most 100 chars).  An unknown job is `404`; both time keys at once, an
  out-of-range or past deadline, a wrong type, or an oversized field is `400`.
  Re-pausing overwrites the window, which is how a pause is extended.  Both
  routes sit behind `web.authToken` and the cross-site request defense, like
  `start` and `cancel`.
- **A pause is always bounded.**  Every pause carries an `until`; there is no
  indefinite pause (edit `enabled: false` for that).  Expiry takes no timer: an
  elapsed window reads as absent everywhere at once, and the once-a-minute
  housekeeping pass sweeps the record and logs the auto-resume, with nothing to
  reconcile if the daemon restarts across the deadline.
- **Skipped slots are recorded, not silent.**  Each due slot inside the window
  writes a synthetic run-ledger row with `outcome: "skipped"` and
  `skip_reason: "paused"` (no `started_at`, no `exit_code`), so history shows a
  deliberate skip rather than a gap, and stamps neither success nor failure.
- **Interactions are conservative.**  Catch-up owes nothing for a paused
  window, including slots the daemon slept through.  Pending retries defer
  rather than cancel.  Manual `start` still launches a paused job (a disabled
  one is refused `409`), and `cancel` and running instances are untouched.  A
  paused `@reboot` job defers its once-per-boot run instead of forfeiting it,
  firing when the pause lifts (still exactly once per OS boot).  SLA checks are
  suppressed while paused, and any active OVERDUE clears on the pause itself.
- **Durable and fleet-wide with a state store.**  Without a `state:` store a
  pause is in-memory and forgotten on restart.  With one, each pause and resume
  appends to a durable `paused/<job>` stream (newest wins): boot rehydrates
  active windows before the first fire, every node sharing the store honours the
  pause, and a resume revokes it fleet-wide even on a node that had not yet seen
  it.  Propagation rides the housekeeping pass (up to about a minute; the
  accepting node applies it immediately), and the fire-time check is
  memory-only, so an unreadable store never blocks firing.
- **Every surface shows it.**  `paused` is always present on `GET /jobs`
  (`{since, until, note, by, channel}` or `null`); `/schedule/why` adds a
  `paused` note naming expiry, actor, and note; the dashboards add a **Paused**
  status, a `⏸` chip, a summary pill, and a `p` toggle; Prometheus adds
  `cronstable_job_paused{job_name}` and counts skips under
  `cronstable_job_runs_total{status="skipped"}`; MCP gains `cron_pause_job` and
  `cron_resume_job` in the `act` toolset.  `channel` records the acting surface
  (`api` or `mcp`).

### Per-job SLA monitoring with an onLate hook

- **A new `sla:` block declares three independent thresholds (seconds).**
  `maxTimeSinceSuccessSeconds` (no successful finish in the window: the
  dead-man check for a wedged or silently dead job), `lateAfterSeconds` (a due
  slot has not started within the window), and `maxRuntimeSeconds` (a run has
  been going longer than the window; observes only, never kills, use
  `executionTimeout` to enforce).  Each is off (`null`) until set and must be
  `> 0`.
- **A new `onLate` reporting hook, the fourth alongside onFailure /
  onPermanentFailure / onSuccess.**  It fires once per breach through the same
  mail, sentry, shell, and webhook reporters, with defaults reworded for an
  overdue condition (an "is overdue" mail subject and body naming check,
  threshold, observed value, and last success, a Slack-compatible webhook body,
  and the sentry fingerprint `["cronstable", "sla", "{{ name }}"]` so breaches
  group apart from run failures).  Configuring an `onLate` reporter with no
  `sla` threshold set is a load-time `ConfigError` (`onLate requires sla`).
- **Evaluated in-process, once per minute, memory-only.**  A state store is not
  required (it does improve the staleness reference across restarts).  With no
  success on record the check references when the monitor first saw the job, so
  a fresh boot ages into the breach rather than paging instantly.  Disabled and
  paused jobs are skipped, and time a job spends paused or disabled is credited
  to the staleness check, so a resumed or re-enabled job gets a fresh window
  instead of paging the instant it comes back.  Under leader election only the
  owning node evaluates, so one breach pages once and an ownership handoff does
  not false-page.
- **Breaches latch per `(job, check)`.**  Entry fires `onLate` once, sets
  `cronstable_job_late{job_name, check}` to `1`, increments
  `cronstable_job_sla_breaches_total`, and logs a warning; nothing re-fires
  while the breach holds; recovery clears the gauge and logs, with no recovery
  report.  The latch is in-memory, so a still-breached check reports once more
  after a restart.  Reports dispatch off the scheduler loop, ordered after the
  same job's in-flight completion reports.
- **Breach variables.**  `sla_check`, `threshold_seconds`, `observed_seconds`,
  and `last_success_at` join the standard template set (run-shaped fields
  empty), and the shell reporter also receives them as `CRONSTABLE_SLA_CHECK`,
  `CRONSTABLE_SLA_THRESHOLD_SECONDS`, `CRONSTABLE_SLA_OBSERVED_SECONDS`, and
  `CRONSTABLE_LAST_SUCCESS_AT`.
- **Every surface shows it.**  `GET /jobs` carries an `sla` object for
  configured jobs (`{thresholds, state, breaches}`, `observed_seconds`
  re-measured at payload time); the dashboards add an **OVERDUE** badge (row,
  drawer, wallboard) independent of run status; Prometheus adds
  `cronstable_job_late` and `cronstable_job_sla_breaches_total`; MCP observe
  tools `cron_list_jobs` and `cron_get_job` return the same object.
- **The monitor cannot report its own death.**  An in-process check dies with
  the daemon; pair it with the external Prometheus staleness alert on
  `cronstable_job_last_success_timestamp_seconds` as the outside backstop.

### An unrelated internal change

- **The classic-crontab control-character guard is rebuilt from explicit code
  points.**  The refused set (C0 controls minus TAB and LF, DEL and the C1
  range, and the Unicode line and paragraph separators) is unchanged, so every
  crontab that parsed before parses the same; enumerating the points instead of
  a literal range keeps the TAB/LF carve-out visible and clears a
  static-analysis false positive (CodeQL `py/overly-large-range`).

## 1.2.25 (2026-07-19)

A bounding and hardening release.  The previous two passes made the hot
paths cheap; this one puts ceilings on the paths that had none, so a
fleet's worst minute is bounded rather than merely fast on a good one.
The batching work lands where a fan-out finishes all at once, the
ceilings land where a queue or a stream could previously grow with the
workload, and the benchmark harness learns to tell a real regression
from runner noise.  All figures below are from an A/B benchmark of this
release against 1.2.24 on the same machine, interleaved rounds, with the
scenario sizes noted per figure.

The hardening half comes from a property-based fuzzing campaign that
drove the parsers, encoders and state machines with several million
generated inputs across 38 surfaces, then re-argued every candidate
against the module's own documented contract.  The four critical and
seventeen high-severity defects it confirmed are fixed in the sections
below ("A fuzzing campaign..." onward), each with a regression test
pinning the fuzzer's repro; the medium- and low-severity findings are
catalogued for a follow-up pass.

### DAG completions land in one write per flush

- **A mapped fan-in used to pay a full run-document rewrite per finished
  task**: each completion took its own locked read-modify-write and fsync
  of the whole document and its own graph advance, so a thousand
  instances finishing together cost a thousand of each.  Completions are
  now buffered while the reaper drains a batch and applied through one
  batched transform per run, followed by a single advance for the whole
  batch.  Every mark keeps its own proc-token, attempt and poke fences
  and applies against only its own task entry, so a superseded or
  duplicate completion is still dropped on its own while the rest of the
  batch lands.  Finishing a 1,000-instance fan-in dropped from 13.3s to
  12.7ms.
- **A completion is now durable at the next reaper flush rather than
  the instant the task is reaped.**  A crash inside that window leaves
  the task's entry reading `RUNNING`; boot reconciliation recovers it by
  pid liveness exactly as it recovers a task the daemon died while
  running, so no outcome is lost, but the recovery path is
  reconciliation rather than the record itself.
- **One job's failure to finish no longer strands the rest of its
  batch.**  Buffering made the reaper's per-job work shared: a job that
  raised while being finished (a state backend answering 503 during its
  finish step is enough) abandoned the remaining jobs in that batch and
  skipped the flush, leaving completions that earlier jobs in the same
  batch had already buffered sitting in memory until some unrelated job
  happened to complete.  Finishing a job is now guarded per job, and
  the flush runs whether or not one of them raised.
- **`/dags` stops re-parsing finished runs.**  The rollup listed and
  fully parsed every retained run document of every dag on every call,
  which a three-second dashboard poll repeated forever.  Run terminality
  is monotonic, so each terminal run's summary is now cached and the
  rollup reads only keys plus the documents that are new or still
  running, falling back to a single bulk sweep past
  `DAG_ROLLUP_BULK_THRESHOLD` (8) unread documents or on a backend with
  no keys-only listing.  A warm rollup dropped from 11.1ms to 1.7ms.
  Switching state backends drops that cache along with the rest of the
  per-store bookkeeping: run keys are derived from the dag name and
  logical date, so the new store's live run reuses the key of whatever
  the old store had finished under it, and nothing rebuilds this cache
  on a timer to correct it later.

### Queues and streams gain ceilings

- **A live-log subscriber can no longer pin a run's whole output.**  The
  1,000-line ring buffer bounded what the dashboard showed but not what
  a subscriber's delivery queue held, so one stalled SSE viewer (a
  backgrounded tab, a full TCP window) accumulated every line the job
  ever wrote.  Subscriber queues are now bounded at
  `LIVE_LOG_SUBSCRIBER_QUEUE_LIMIT` (8,192 lines, ample headroom over
  the ring) and overflow drops the oldest queued line, so a viewer that
  falls behind keeps receiving current output instead of a growing
  backlog.  A viewer more than 8,192 lines behind now misses lines in
  its live tail; captured output, archived runs and failure reports are
  unaffected, and a reconnect re-snapshots the ring.  The number of
  lines dropped this way is counted per stream.
- **Artifact and XCom streams bound to their distinct names.**  A put
  appended a record and superseded versions accumulated until the job
  was garbage collected, so a job republishing one name every minute
  grew its stream without limit.  Appends now carry a name-keyed prune
  that keeps the newest record per name and drops the superseded ones,
  amortised on the same one-in-eight cadence as the existing bounded
  prune, with the orphaned blobs reclaimed by the next sweep.  Only the
  newest record per name was ever readable, so nothing that was
  reachable becomes unreachable, but the older records are now deleted
  rather than retained, and the first put per stream after upgrade
  prunes the history already accumulated.  Listing a 1,000-put stream
  of ten names dropped from 820ms to 9.2ms.
- **An over-range cron field costs O(1) instead of an allocation.**
  Enumerating a field materialised its whole range before checking any
  value, so `1-2000000000` allocated billions of integers before the
  first bound check could reject it, which also defeated the promise
  that an unparseable schedule degrades to prose rather than failing.
  Enumeration is lazy and raises on the first out-of-range value; every
  schedule that parsed before yields identical values.

### A dependency gate reads a probe page

- **`onlyIfLastSucceeded` materialised the full 50-record history
  window on every scheduled fire** to find an outcome that is almost
  always in the newest few records.  It now probes the newest
  `DEPENDS_GATE_PROBE` (8) records and widens to the full window only
  when the probe came back full and held nothing but cancellations and
  skips, so the skip window is preserved exactly.  Evaluating the gate
  dropped from 64.8ms to 33.3ms.

### The CLI stops importing what it will not run

- **Every job-spawned thin client paid for the terminal dashboard.**
  Registering `cronstable mcp` and `cronstable tui` imported their
  modules, and importing the TUI runs a 7,000-line module body and
  pulls in `unicodedata`'s C table.  Every `cronstable state get`,
  `lock` and `xcom pull` a job runs builds that parser first, so two
  commands almost never invoked taxed the ones invoked constantly.
  Both subcommands now register as stubs and import their real modules
  only when dispatched, with a parity test holding the stub flags
  in lockstep with the real definitions.  `cronstable --version` fell
  from 128ms to 113ms, and against the interpreter's own floor
  cronstable's share of it fell from 108ms to 93ms.

### The dashboard paints only what changed

- **The fleet matrix rebuilt its nodes-by-jobs grid on every poll.**
  Relative ages moved out of the built markup into spans the
  once-a-second tick refreshes, which lets the render skip the whole
  `innerHTML` rebuild and reflow when the payload, the failures-only
  filter and the owner set are all unchanged.
- **Log search match counts update incrementally** as lines arrive
  instead of rescanning the buffer, with a full rescan only when the
  query itself changes.
- **A hidden tab drops to a 30-second poll, or stops.**  A backgrounded
  viewer kept the daemon serving its full jobs, cluster and dags fan-out
  every interval forever.  A hidden tab now stops polling entirely and
  resyncs once immediately on return rather than waiting out an interval,
  unless one of the three opt-in features that read the poll is on:
  desktop notifications, the audible alarm, or the run ledger.  With any
  of those armed it keeps polling at 30 seconds.  All three refresh only
  off the poll and all three exist for the tab nobody is watching, so
  pausing outright silenced new-failure notifications, left the alarm
  sounding on whatever the last poll saw, and punched a hole in the
  ledger for as long as the tab was backgrounded.

### Untrusted text stops reaching wire formats verbatim

- **A failed job launch no longer logs the child's environment.**  The
  spawn arguments carry a full copy of the daemon's own environment
  plus the loopback state-API token, and both the debug line on every
  launch and the error path when a spawn fails formatted that whole
  structure into the log record.  Any secret the operator exported to
  cronstable, and a live credential for its own state API, reached
  journald or syslog and anything shipping from them, at a level no
  production configuration filters out.  The environment is now
  summarised as a count; the argv, the failure and the encoding that
  make the message useful are unchanged.  Output redaction covers
  archived job output only and never applied here.
- **DAG task ids reject control characters.**  An id reaches log sinks
  and durable keys verbatim, so an embedded CR or LF could forge or
  split daemon log lines.  The C0 range and DEL are now refused at
  config load; the printable set is unchanged.
- **A statsd prefix cannot inject samples.**  CR, LF, `:` and `|` are
  stripped from the configured prefix, none of which is legal in a
  statsd metric name, so a working prefix is unchanged.
- **Prometheus label values escape CR** as well as LF, backslash and
  quote.  LF remains the only line delimiter, but a raw CR inside a
  quoted value is not valid in the exposition grammar and can confuse
  strict scrapers.
- **Blob digests are validated before they become paths.**  A digest is
  content-addressed lowercase sha256 hex by construction; anything else
  is now refused rather than joined into a filesystem path, so a
  crafted `sha256` field in a restored archive cannot escape the blob
  directory.

### A fuzzing campaign: shared state survives hostile values

- **A job can no longer brick a named lock for the entire fleet.**
  `lock acquire` coerced its caller-supplied TTL with a bare `float()`,
  so `--ttl inf` (which argparse accepts without complaint) flowed into
  `expires_at = now + inf`; orjson persists that as `expiresAt: null`,
  every later read then failed as unreadable, acquire failed closed,
  release could not repair it and the sweeper refused to reclaim it --
  one request left the lock denied to everyone, forever.  A non-finite
  `ttl` or `blockSeconds` is now a 400, a finite TTL is clamped into
  [5s, `MAX_LOCK_TTL`] (30 days), and the idempotency-claim TTL gets the
  same finiteness check.
- **Lone surrogates are stopped at the portability gate.**
  `ensure_portable` had no string branch at all, so a value holding an
  unpaired surrogate (surrogateescape-decoded OS data is enough) passed
  the gate on every host -- and a stdlib node then persisted a record no
  orjson node could ever write or parse, splitting the fleet.  Strings
  and object keys are now walked and a codepoint in U+D800..U+DFFF is
  rejected at write time, identically on both backends.
- **Durable JSON gains a nesting-depth ceiling.**  No depth bound was
  defined anywhere, so the accepted-document set was whatever each
  backend's encoder happened to tolerate: orjson's encoder hard-fails
  at 256 while its parser reads to 1024 and the stdlib reaches ~1000
  both ways, so a stdlib node could persist a 256..1023-deep document
  every orjson node could read but never write back -- permanently
  wedging any read-modify-write path, a DAG run advance included.  The
  gate itself recursed unboundedly and blew the stack (a ~2KB body is
  1,000 levels deep).  One `MAX_DEPTH` (128) is now enforced with an
  explicit counter on both backends, so a too-deep value is a clean
  rejection -- never a RecursionError, and never a document only half
  the fleet can rewrite.
- **An integer wider than 64 bits is a parse error on every host.**
  The stdlib parser preserves `18446744073709551616` as an exact int
  the portability gate rejects, while orjson silently narrowed it to a
  lossy *float* the gate happily accepted -- so the same upstream bytes
  produced a mapped fan-out on one node and an empty one on another,
  and the very pre-check built to catch the value could not see it.
  `loads()` now rejects out-of-window integer literals uniformly (a
  cheap 19-digit prescan gates a verified parse); every boundary value,
  19-digit timestamp and big float literal parses exactly as before.
- **A state scope is matched exactly, never normalised.**  The scope --
  the isolation boundary between jobs' durable state -- was `strip()`ed
  after authorization checked the raw string and before the store named
  the on-disk path, so `report`, `report␠` and `report\xa0` silently
  collapsed onto one namespace (a job could read and overwrite another
  job's private state without appearing in any `stateAllowedScopes`),
  and a whitespace-padded job's on-disk scope diverged from the GC
  keep-set built from its exact config name, letting collection delete
  a live job's artifacts.  A scope that is not equal to its own
  `strip()` is now refused with a clear 400; distinct scope strings can
  never share a namespace.  A job whose YAML name carries a quoted
  leading/trailing space loses state access until renamed -- previously
  it was silently sharing another job's.

### A fuzzing campaign: the cron engine tells one story at a DST edge

- **`prev()` no longer starves the event loop for a far-future query.**
  For an aware anchor past the 2099 horizon the backward scan stepped
  one fire instant at a time from the anchor down to the horizon --
  measured ~1.8s of CPU per year for a per-minute schedule, roughly
  four hours for `at: 9999-01-01` -- and `cron_why_no_run` runs it
  synchronously on the scheduler's event loop, so one schema-valid MCP
  argument (an LLM's hallucinated year is enough) froze dispatch, the
  web server and cluster heartbeats until the node lost leadership.
  The scan now starts at the horizon's edge, where the answer lives.
- **`occurrences()` honours the fold of its start.**  `datetime.now()`
  in a DST zone returns `fold=1` for one hour every autumn, and the
  iterator dropped it, re-offering the repeated hour's first-leg fires
  -- instants up to a full DST shift in the *past*, while `next()`
  correctly named the future one.  The schedule preview, the job-explain
  payload and the TUI all read `occurrences()`, so the dashboard showed
  a "next" fire that had already happened and missed-run detection
  reported a run the scheduler never performed.  Candidates are now
  compared through UTC against the fold-aware start, exactly as
  `next()` compares them.
- **A spring-forward no longer swallows the shifted fire.**  A fire
  whose civil label sits inside the gap (`59 2 * * *` on changeover day)
  really happens one shift later -- but once `now`'s wall label passed
  the schedule's label, `next()` abandoned the day even though the real
  instant was still ahead, silently dropping that day's run for every
  daemon start, reload or re-seed landing in the window; 252 of 598
  IANA zones were affected, by up to three hours.  The scan now rewinds
  its seed by the offset jump when a recent transition makes that
  necessary (one extra offset probe otherwise), and discards candidates
  through the same resolved-UTC guard, so an already-past instant is
  never returned.  `next()`, `occurrences()` and `prev()` now agree on
  one fire set in absolute time, and a regression test holds that
  identity across both transition days, both folds and five zones.

### A fuzzing campaign: the redactor closes its own gaps

- **The credential guard redacts what the detector detects.**  The two
  URL-userinfo helpers -- written specifically to keep passwords out of
  logs -- located the authority by splitting on a literal `://`, with
  two subtly different fallbacks: a protocol-relative
  `//user:pass@host` endpoint was invisible to both (the password went
  verbatim into a ConfigError the reload loop re-logs every cycle), and
  a trailing-`://` shape made the detector and redactor disagree, so
  the "always redacted" error path echoed the cleartext secret.  Both
  helpers now share one RFC 3986 authority locator, so nothing the
  detector flags can reach a log unredacted; the campaign's enumeration
  of 81 credentialed endpoint shapes had found 31 leaking.
- **Output redaction is linear again -- and off the event loop.**  The
  URL-password pattern backtracked quadratically on a long
  scheme-character run followed by `://` and an `@`-less tail: a clean
  4.0x runtime per input doubling on job-controlled stdout, blocking
  the entire event loop from inside the archive path (extrapolating to
  days at the default 16MiB line cap).  The scheme is now anchored and
  bounded, restoring amortised-linear scans (2.0x per doubling, 1.35s
  to 2.5ms at 32KB), and the archive scrub runs in a thread executor so
  a future pattern regression degrades that one write, not job
  dispatch.
- **`PGPASSWORD=` and friends are recognised as the credentials they
  are.**  The key pattern's word-boundary lookbehind rejected an
  alphanumeric prefix, so the *separated* compound forms
  (`MY_PASSWORD=`) redacted while the unseparated vendor forms libpq
  and friends actually use -- `PGPASSWORD=`, `DBPASSWORD=`,
  `MYSQLPWD=`, exactly what `set -x` echoes -- passed through verbatim
  into an archive stamped `redacted: true`.  The key may now carry a
  compound prefix (with the scan still provably linear), and
  `REDISCLI_AUTH` joins the list explicitly.

### A fuzzing campaign: classic crontabs parse as the file reads

- **Crontab lines are physical, LF-delimited lines.**  Parsing split on
  `str.splitlines()`, whose Unicode line-boundary set (VT, FF, FS/GS/RS,
  NEL, U+2028/U+2029) is wider than cron's LF-only model -- so text
  sitting inside a `#` comment (one line in every editor, `cat`, `git
  diff` and code review) became a live job, a mid-line FF silently
  truncated a command, and error line numbers drifted from the file's.
  FF is the classic Emacs page separator, so this needed no malice --
  and doubled as a review-evasion vector.  Both the parser and the
  content sniffer now split on LF alone (one trailing CR tolerated), so
  a comment is a comment for its whole length and every job name and
  error names the physical line.
- **Control characters in a live crontab line are refused, with
  file:line.**  A NUL in a command, environment value or `SHELL=` built
  a job the OS can never spawn, and the `ValueError` out of the spawn
  call escaped every guard on the launch path and killed the whole
  scheduler -- re-fired on every tick.  The crontab front end (the sole
  route to that shape; the YAML reader already refuses control
  characters) now rejects C0/C1 controls (tab excepted) in live lines
  with a clear per-line error, and the spawn guard additionally treats
  `ValueError` as an ordinary start failure so any future unspawnable
  argv is retried by the reaper rather than fatal.  A command with a
  mid-line FF, previously truncated silently, is now refused rather
  than half-honoured.

### A fuzzing campaign: configuration mistakes fail closed

- **A schedule-object value must be exactly one cron field.**  The
  object form rendered its values into a whitespace-joined line that
  the engine re-splits, so a *blank* value deleted its column and
  shifted every later field left -- `second: "0"` plus a leftover
  `year:` turned "every second at :00" into "hourly at minute 0", a
  silent 60x rate change with no error, no warning and no lint finding
  -- and an embedded space injected a column and shifted fields right.
  Every provided value must now render as one non-empty,
  whitespace-free token or the parse fails naming the offending key.
- **Secret resolution cannot crash-loop the daemon.**  `_resolve_secret`
  caught `OSError` and `UnicodeDecodeError`, but a NUL in a `fromFile`
  path raises `ValueError` and a lone-surrogate path or env-var name
  raises `UnicodeEncodeError` (reachable from a pure-ASCII file via
  YAML `\u` escapes) -- and on the per-fire job-secret staging path
  anything but ConfigError escapes the scheduler loop and recurs at
  every fire of that job.  Both source branches now catch broadly and
  re-raise as ConfigError naming the key.
- **strictyaml's own validators can no longer escape config parsing.**
  Its numeric prefilters accept `''`, `'.'`, `'-'` and `'_'`, which
  `float()`/`int()` then reject with a bare `ValueError` -- so an
  `executionTimeout:` left with no value (an ordinary typo, reaching
  every one of ~26 Float/Int keys) surfaced as a raw traceback at
  startup and as "please report this as a bug" in the reload log, and
  a control character anywhere in a file aborted a whole
  config-directory load via a strictyaml `AttributeError`.  Both are
  now translated to the ConfigError every caller already handles.

### A fuzzing campaign: gates and dispatchers stop wedging

- **A destructive MCP gate tests identity, not truthiness.**
  `cron_backfill_dag` read `args.get("dry_run", True)`, which applies
  the default only when the key is *absent* -- and `dry_run: null` is
  precisely how an MCP client or LLM encodes "unspecified", so the two
  encodings of the same intent diverged into opposite outcomes and a
  present-but-falsy value started a real backfill on a tool the
  registry itself marks destructive.  Only the literal `false` now
  takes the executing branch, mirroring the existing confirm gate.
- **Non-finite numeric arguments clamp instead of erroring.**  `1e999`
  is a well-formed JSON number the stdlib parser reads as infinity, and
  `int(inf)` raises `OverflowError` -- which none of the MCP numeric
  coercion helpers caught, turning a schema-valid `limit`/`offset`/
  `tail` on fifteen tool/argument pairs into an opaque -32603 protocol
  fault the model cannot self-correct from.  All three helpers now fall
  back to their documented clamp or default.
- **A task retyped to mapped mid-flight fails cleanly instead of
  wedging its run forever.**  Adding `expand:` to a task while an
  entry sat parked `up_for_retry` routed it exclusively through the
  mapped placeholder path, which only handles fresh pending entries --
  so the retry never fired, the run never terminalised, its advance
  lease was held for the life of the daemon and every advance paid a
  full document copy to do nothing.  Such a stale-shaped entry is now
  failed with an explanatory reason so the run finishes and the next
  run expands normally; genuinely in-flight attempts and parked
  approval gates keep their own recovery paths.
- **The object-form `schedule:` reaches the shell reporter.**  The
  reporter's child environment carried `schedule_unparsed` verbatim,
  and for the object form that is a dict -- the spawn died in
  `os.fsencode`, so `onFailure`/`onSuccess` shell reports silently
  never executed for any job whose schedule was spelled as an object.
  The environment now carries the rendered crontab line, as the status
  payload and Prometheus already do, and the reporter's spawn guard
  catches the type errors the "never propagated" contract promises to
  absorb.
- **The TUI sorts like the web dashboard.**  The terminal port compared
  names by code point -- every uppercase-initial name first -- where
  the web uses `localeCompare`'s root collation, so the two frontends'
  *default* first screen disagreed for any mixed-case fleet, on the
  name column and on every column's tie-break.  The TUI now sorts on a
  collation-equivalent key (case-insensitive primary, lowercase first
  on a pure case tie), verified against the browser's collation for
  mixed-case fleets.

### The benchmark gate learns what noise looks like

- **A regression must now clear the measurement's own scatter to gate.**
  Each side's round-to-round coefficient of variation is estimated (from
  three rounds up, by a median absolute deviation, so one throttled
  round cannot inflate the band and hide a real regression behind it),
  the two sides are combined in quadrature, and a change over its
  declared limit gates only if it also exceeds two of those bands.
  Unknown scatter never suppresses, so the guard can only make the gate
  more conservative.  Suppressed changes are reported rather than
  silently dropped, and every metric's band is shown alongside its
  delta.
- **Benchmarks split into two tiers with their own round counts.**  The
  cheap deterministic in-process metrics run five rounds per side, which
  tightens the noise estimate they are judged against; the nine
  subprocess metrics (cold start, imports, peak RSS) run two, since each
  round spawns processes.
- **Startup metrics are gated on cronstable's own share.**  Each side's
  measured interpreter floor is subtracted before the delta is computed,
  so a change is judged against the part cronstable controls rather than
  diluted by process spawn and interpreter init.  Displayed values stay
  the raw totals.  The absolute floor a change must also clear is capped
  on that same subtracted scale: the 10ms default is sized for the ~40ms
  raw totals, and applied to a single-digit-millisecond own share it
  swamped the percentage limit entirely, so `startup.import_cronexpr`
  could double cronstable's own import cost and still pass.  A startup
  metric now gates where its declared limit says it does, at 25% of the
  own share, with a 2ms floor below which the own share's scatter is too
  large to judge.
- **A metric that ran on neither side is reported as ungated.**  A
  metric skipped on both the baseline and the current side produced no
  row, no violation and no mention anywhere in the summary, so a gate
  that had compared nothing still printed an unqualified pass.  Both
  sides' skips are now listed in the report and warned about in the job
  log, and the pass line carries the count of metrics actually compared.
- **The web-dashboard renders are gated rather than recorded.**
  Playwright was installed only into the current side's environment, so
  every `webui.*` metric skipped on the baseline, and a metric with no
  baseline is reported without a gate; the three render benchmarks could
  not have failed a release at any magnitude.  Both sides now install
  it.  Releases predating the `?perf=1` hook still skip on the baseline,
  so these gate from the next release onward rather than immediately.
- **Nine benchmarks were added and three rescaled**, covering the DAG
  fan-in and claim paths, the artifact stream, the dependency gate, the
  TUI's log restyle and search, and the schedule duplicate scan.  Three
  more time the web dashboard's row, fleet and log-count renders in a
  headless browser through a `?perf=1` hook that is inert without it.
- **Regression labels no longer fall outside the chart.**  A large
  improvement clamped to the axis put its percentage label on top of the
  metric-name gutter; a label that will not fit outside its bar is now
  drawn inside the bar's end, in a near-black that clears contrast
  against both fills in light and dark themes.
- **The release chart draws every compared metric.**  The diverging bar
  chart cut the suite to a top-N of movers, so the release image showed
  a slice rather than the run; it now grows to fit all compared rows,
  with an alternate-row wash to carry a name across the gutter, tick
  labels at both ends of the (now taller) plot, and a footnote counting
  metrics measured on only one side, whose numbers stay in the
  release-notes table.

### Build, test and release plumbing

- **The repository is LF-only and CI enforces it.**  `.gitattributes`
  now stores and checks out every text file with LF on all platforms
  including Windows under `core.autocrlf=true`, images are declared
  binary, and a static-analysis step fails the build if a CRLF or
  mixed-ending blob ever reaches the index.
- **The coverage floor rises from 85% to 90%**, against a measured
  91.9% on the lowest matrix cell, and the suite grows by roughly 336
  tests (about 7,300 lines) covering the filesystem state backend's
  garbage collection, migration, blob and lease internals, the DAG
  state machine and scheduler, the web payload builders and state
  inspector, cluster gossip and leadership renewal, resource sampling,
  the CLI entry point and cron parsing edge cases.
- **Images and binaries shed dead weight.**  The eight container builds
  uninstall pip from the virtualenv after the last step that needs it,
  removing 11.4MB per image, and the frozen binary excludes sixteen
  stdlib modules it never loads (GUI toolkits, curses, readline,
  sqlite3 and the developer stdlib), removing a further 172KB.
- **`winget` manifest updates are temporarily warning-only**, since
  `wingetcreate update` bumps an existing manifest and cannot succeed
  until the initial submission merges into `microsoft/winget-pkgs`.
- Build tooling moves to uv 0.11.29 and
  `pypa/gh-action-pypi-publish` 1.14.1.

## 1.2.24 (2026-07-18)

A second performance pass, aimed at the work a fleet repeats continuously:
terminal painting, cluster gossip and election gates, durable-cursor reads,
DAG document traffic, and the trends endpoint.  All figures below are from
an A/B benchmark of this release against 1.2.23 on the same machine, with
the scenario sizes noted per figure; medians of repeated runs, variance
within a few percent.

### The terminal dashboard paints what it shows

- **The job drawer's log pane** used to re-run its ANSI rewrite over every
  buffered line on every paint (a full 5,000-line tail is 10,000 regex
  passes per frame, repeated once a second while idle and up to 30 times a
  second under output floods), then slice out the ~40 visible rows.  It now
  computes the visible window first and transforms only those rows, through
  a bounded per-line cache that a theme change empties: a steady-state
  paint over a full tail dropped from 8.6ms to 0.02ms.  The DAG logs tab
  gets the same treatment.
- **The multi-tail console** merged and sorted every buffered line of every
  tail per paint (20,000 tuples for four full tails); it now merges only
  the slice of each tail that can reach the visible window, which is exact
  because each tail is already time-ordered: 1.8ms to 0.33ms per paint.
- **Log search match counts** recompute only when the query or the buffer
  changed, instead of rescanning the whole tail once per frame while the
  search box is open.

### Election gates stop re-deriving the same answer

- **The election-derived peer sets** (mutual agreement, eligibility, bridge
  and vouched contenders, the conflict scans) are pure functions of the
  last poll round's observations, yet every owner and leader gate check
  re-derived the whole cascade, about six times per `job_owner` call.  Each
  derived set is now memoized against a mutation generation that every
  peer-state write rolls, so the cascade runs once per observation change:
  a `job_owner` check against 50 agreed peers dropped from 185us to 36us,
  and the saving repeats per due job at every fire, per job in the
  once-a-minute claim scan, and per dashboard poll.
- **Serving `/peer`** rebuilt the full payload and its ETag before it could
  answer a conditional poll with a 304, so a converged round was only cheap
  for the network, never the CPU.  The handler now reuses the last built
  payload and tag while the election state is unchanged and the build is
  under a second old (a bound indistinguishable from the poller having
  polled a second earlier): 256us to 1.2us per steady-state conditional
  response, once per configured peer per poll interval, with the node-stats
  header still sampled fresh on every request.

### Durable cursors fold instead of rescanning

- **`derive_max`** (the cursor behind catch-up and cross-node retry
  claims) parsed every retained run record on every call to recompute a
  maximum that is monotonic by contract.  The filesystem backend now keeps
  a per-stream watermark and parses only records appended since the last
  call; a wholesale stream wipe (unlike a bounded prune, which always
  leaves the newest record standing) is detected and drops the memo, so a
  recreated stream defines its cursor alone.  A repeat derive over 1,000
  ten-KB records: 64ms to 1.0ms, paid per catch-up job per service pass at
  boot.

### DAG advances touch the run document once

- **Reconcile and claim share one read-modify-write**: an advance used to
  pay one locked full-document pass to recover crash-interrupted tasks and
  a second to expand, propagate, and claim, even when nothing had changed.
  The two halves now compose into a single transform in the common case
  (mapped tasks awaiting expansion still pre-read their upstream lists
  between two RMWs, the old shape), and a read-only pre-scan skips the
  transform's full-document copy when it can prove no task is claimable,
  due, recoverable, or terminalisable, erring toward the full pass on any
  doubt.  Advancing a quiescent 1,000-instance fan-out dropped from 4.4ms
  (two locked parses, two deep copies) to 1.8ms (one locked parse, no
  copy, no rewrite), at least once a minute per active run plus after
  every task completion.
- **Launch batches stamp their pids in one write**: recording each launched
  task's OS pid cost a full-document rewrite per subprocess, up to 32 per
  pass against a document holding up to 1,000 task entries.  The whole
  batch now lands in one RMW under the same per-entry proc-token and
  attempt fences (a stale entry is dropped alone while the rest applies):
  stamping 32 launches into a 1,000-entry document fell from 364ms to 11ms
  of store traffic per pass.

### The trends endpoint leaves the event loop alone

- **`GET /jobs/{name}/trends`** (and MCP `cron_get_job_trends`) parsed up
  to 5,000 ledger records and made four full window-filter passes on the
  scheduler's event loop, stalling launches, gossip, and output pumping
  for the duration: a 13.8ms stall per request against a 5,000-record
  ledger.  The parse and aggregation now run on the executor like the
  pressure and calendar payloads, each record's age is computed once for
  all nested windows in a single pass, and the per-record output-stream
  allocation is gone.  End-to-end latency edged down (244ms to 221ms, the
  read itself dominating); the loop now spends only thread-switch
  granularity on the request instead of the whole aggregation.

## 1.2.23 (2026-07-18)

A performance release: the hot paths flagged by a full efficiency review now
do their work once instead of per poll, per map instance, or per stored
record.  All figures below are from an A/B benchmark of this release against
the previous one on the same machine.

### Status payloads read the scheduler's own index

- **`/jobs`, `/status`, and the gossiped fleet summaries** no longer re-run
  a cron-engine search per job per request: they read the next-fire index
  the run loop already maintains (the same source the Prometheus next-run
  gauge reads), keeping the engine search only for the startup window before
  the index is seeded.  Builds 4-7x faster on a 300-job fleet, and the
  saving repeats on every dashboard poll and every cluster gossip exchange.
- **A running job with a dead schedule** used to earn its `never_fires`
  flag by searching the remaining calendar horizon on every poll (about a
  millisecond each time for `0 0 31 2 *`); the answer now comes from the
  dead-schedules latch in constant time.  A schedule that dies while the
  daemon runs (a fixed year slipping into the past) now joins that latch
  with the same warning a seed-time-dead schedule gets, instead of leaving
  the fire index silently.

### DAG runs advance with less store traffic

- **One document read fewer per advance**: the crash-reconcile step already
  observes the run document inside its locked read-modify-write, so the
  advance reuses that body instead of re-reading the same document before
  the claim.
- **Completion bursts coalesce**: when many task completions land at once
  (a mapped fan-in), the spawned advances collapse into at most the pass
  already running plus one follow-up that observes the whole burst, instead
  of one full pass per completion.  A burst of 20 completions measured 20
  advance passes and 60 durable document operations before, 2 passes and 4
  operations now.
- **The claim transform is cheaper on large fan-outs**: the dependency
  verdict is resolved once per task instead of once per map instance, and
  the transform's working copy of the run document is cloned through the
  orjson fast path when the `speedups` extra is installed.  A claim pass
  over a 1000-instance fan-out dropped from 3.2ms to 1.5ms, all of it time
  spent holding the document lock.

### Artifact lookups stop reading whole streams

- **Reading an artifact by name** probes the newest 64 records first and
  only then falls back to the full scan, so the common lookup (a recently
  published name) no longer reads the scope's entire publish history: 65ms
  to 5ms against a 1500-record stream, for every `cronstable artifact get`
  and every mapped-task expansion.  Looking up a name that was never
  published costs one probe page on top of the old full scan.
- **The dashboard's XCom tab** fetches each value directly by the blob
  digest its own listing already holds, instead of re-listing the run's
  whole artifact stream per entry: at 200 entries that is one stream read
  instead of 201, and the tab renders 2.5x faster.

### Job completion leaves the reaper's hot loop

- **Reporters no longer run inline on the reaper**: each finished run's
  report and retry-arm sequence is spawned as its own tracked task, chained
  per job so overlapping instances of one job are still handled in finish
  order, and drained in full on graceful shutdown.  One slow SMTP server or
  webhook used to delay every other job's completion, slot release, and
  retry arming daemon-wide: with a reporter that takes half a second, the
  next job's completion waited 524ms before and 0.1ms now.  Mail reports
  also gained an overall 60-second bound (aiosmtplib's default only bounds
  each individual protocol step).
- **The reaper is fully event-driven while jobs run**: the job-launch
  signal joins its wait set, replacing the 1-second poll that re-counted
  the running set.  A daemon babysitting one long-running job used to wake
  86,400 times a day for that; now it wakes only when a job starts or
  finishes.
- **Job output passthrough batches per pipe drain**: mirroring a job's
  stdout/stderr to the daemon's own used to cost a blocking write and flush
  on the event loop for every line; lines are now coalesced and written
  once per drained read.  A 20,000-line job drains in 55ms instead of
  132ms, and a stalled daemon-stdout pipe no longer freezes the event loop
  once per line (a broken pipe is logged and skipped; capture and the live
  web tail are unaffected).

### Durable writes carry their own housekeeping

- **The bounded-stream prune rides inside the append**: every run, retry,
  in-flight, manifest, counter, and archive append used to be paired with a
  separate re-list-and-sort prune call that usually deleted nothing.  The
  bound now travels with the append (one store dispatch instead of two) and
  the actual re-list runs on one append in eight per stream, so a stream
  may briefly hold up to seven records beyond its bound before the next
  due prune trims it.  Recording one bounded run record: 6.0ms to 4.9ms,
  half the worker dispatches.
- **Serialization sheds its pre-flight walk where it can**: with the
  `speedups` extra installed, the fleet-portability check now walks
  payloads only for the one hazard orjson cannot catch itself (a NaN or
  infinite float); oversized integers and non-string keys are refused by
  the serializer and reported as the same error as before.  A run record
  with a full resource series serializes in 69us instead of 93us, and the
  lease record written on every ~10s renew, built entirely from the
  daemon's own values, skips the walk outright: 0.18us instead of 0.89us.

### Cluster and DAG background chatter thins out

- **The etcd backend re-reads the `@reboot-ran` key every 60 seconds**
  instead of on every ~5-second renew round once it is synced and has no
  marks to persist, matching the filesystem backend's cadence: roughly
  17,000 fewer etcd reads per node per day.  A leadership gain or a known
  lease loss still forces an immediate unthrottled read-back, so the
  failover double-fire guard is unchanged.
- **The DAG adopt scan stops re-reading finished runs**: run terminality is
  monotonic, so each node remembers which run documents it has seen finish
  and the 30-second orphan scan reads only the runs it does not know yet,
  discovering the rest from a single key listing (a new backend capability
  that lists document keys without opening any body).  With `retainRuns:
  50` that is one directory listing instead of 50 document reads and
  parses per dag per cycle: 5.8ms to 2.6ms per scan on local disk, with
  the gap growing on networked stores where each read is a round trip.  A
  periodic full pass and the hourly GC rebuild the cache from actual
  bodies.

### Frames and samples get cheaper

- **The terminal dashboard's string machinery gained ASCII fast paths**:
  width measurement and row cutting now short-circuit for escape-free
  ASCII (nearly every character of every frame), the ANSI matcher runs
  only at escape characters instead of at every position, and theme
  colours are compiled to SGR fragments once per theme instead of
  re-parsing hex per span.  Assembling a 60-row frame's strings dropped
  from 6.2ms to under 0.1ms.
- **Concurrently monitored jobs share one process-table snapshot**: per-run
  resource monitors used to each walk the entire process table every
  sampling tick; a shared ticker now takes one snapshot per tick and each
  due monitor derives its own process tree from it, keeping per-monitor
  intervals and the per-member CPU/memory reads unchanged.  Eight
  monitored jobs sample in 2.8ms per tick instead of 27.3ms.

## 1.2.22 (2026-07-18)

The schedule dialect learns to speak business days, and the scheduler's own
fire enumeration becomes something you can put on a calendar.

### Business-day schedule forms

- **Four additive day forms**, straight from the Quartz family the dialect
  already borrows `?` from: `L-<n>` in day-of-month counts back from the
  month's final day (`L-3` = three days before it), `<n>W` fires on the
  weekday nearest day `n` (a Saturday 15th resolves to Friday the 14th, a
  Sunday 15th to Monday the 16th, flipped inward at the month's edges so the
  fire never leaves the month), `LW` is the month's last weekday, and
  `<d>#<n>` in day-of-week is the month's n-th such weekday (`5#3` = third
  Friday, the sibling `L5` always wanted).  The golden compatibility
  corpus pins the new forms from the engine's own recorded vectors
  (flagged `"extension": true`) alongside the legacy ones.
- **Every schedule surface understands them from parsed ground truth**: the
  plain-English describers (server and dashboard), the linter (month
  reachability generalizes: `31W` warns about April, `L-28` in February
  earns the leap-day-only note), the no-run explainer, semantic duplicate
  grouping (`fri#3` equals `5#3`), pressure, previews, and the MCP schedule
  authoring tools.  Wrong-field uses keep the hint machinery: `#` outside
  day-of-week and `W` outside day-of-month name the right field, and
  Quartz's trailing-L (`5L`) points at the `L5` spelling.
- **The dashboard's client engine reaches full parity with the daemon's**:
  it now parses the whole day-form family (including the legacy `L` and
  `L<n>` forms it never previewed before), and a differential test replays
  the entire golden corpus through both engines instant-for-instant.  Two
  degenerate-quirk gaps fixed on the way: a day-of-week range ending in 0
  now wraps like the engine (`sun-sun` fires daily), and bare-start steps
  in day-of-week expand over 0-6.

### Calendar export and the week calendar

- **`GET /calendar.ics` and `GET /jobs/{name}/calendar.ics`** serve upcoming
  fires as standard iCalendar feeds: one event per fire from the scheduler's
  own occurrence walk in each job's timezone, emitted as UTC instants with
  stable UIDs (subscribed clients update in place), block lengths from run
  history (5-minute floor), `TRANSP:TRANSPARENT` so maintenance windows do
  not mark on-call busy, and descriptions that carry the schedule and its
  plain-English reading but never the command line.  `?days=` (default 14,
  max 60) and `?per_job=` bound the feed; truncation is announced in-band.
- **Calendar clients cannot send bearer headers**, so with `web.authToken`
  set the `.ics` paths (only) accept the token as a `token` query
  parameter, the secret-address model calendar services use; every other
  path still refuses query tokens.
- **A week calendar in the dashboard** (the `◫ week` toolbar button): seven
  browser-local day columns of hue-keyed fire chips with a live now-line,
  quarter-hour collision splitting, and click-through to each job's
  Schedule tab, which now links the per-job `.ics` feed.  Jobs firing more
  than ~8x/day summarize into a "background hum" strip under the grid
  instead of flooding it.  The demo mirror carries the full view, and its
  synthetic fleet gained business-day showcase jobs.
- **The terminal dashboard keeps feature parity**: the same panel under
  the same palette command ("Toggle week calendar"), terminal-shaped: a
  seven-day by 24-hour shaded fire grid, a scrollable UTC agenda of the
  calendar-worthy fires, and the identical background-hum rule, computed
  locally from the `/jobs` snapshot like the pressure panel, so it works
  against older daemons too.

## 1.2.21 (2026-07-18)

The in-house cron engine grows a safety net and a toolbox.  A schedule that
can never fire again (a fixed past year, `0 0 30 2 *`) used to vanish
silently: it simply never entered the fire index.  Now it is loud everywhere:
config load logs a `never-fires` warning, the scheduler warns once when it
drops the job, `/status` and `/jobs` report `never_fires`, and the dashboards
badge it.  It stays a warning rather than an error on purpose (a past year is
also the working idiom for parking a job).

### The schedule linter

- **Advisory findings for legal-but-suspect schedules**, computed by the new
  shared `cronstable/croninfo.py` and reported identically by config-load
  logging, `GET /jobs` (`schedule_findings`), the TUI cron sandbox, and the
  TUI job drawer's schedule tab (which renders the daemon-computed findings
  straight from the payload): `never-fires`, whose message tells an
  exhausted year column apart from a date that never exists;
  `day-fields-both-restricted` (this dialect's AND rule vs. Vixie's OR, the
  classic crontab-import surprise); `uneven-step` (`*/7` minutes fires at
  :56 then :00 four minutes later); `skipped-months` (day 31 never occurs in
  April); `leap-day-only`; and DST notes (`dst-skipped-time`,
  `dst-repeated-time`) with the actual transition dates, computed in the
  job's own timezone.

### One engine behind every preview

- **`GET /schedule/preview`** parses, describes, previews and lints any
  expression with the daemon's own engine, so tooling can ask the daemon
  itself instead of re-implementing cron.  `describe_cron`/`next_fires`
  moved from the TUI into `cronstable/croninfo.py` (the TUI re-exports
  them), so the terminal sandboxes compute with literally the scheduler's
  code.  The web page's client-side preview stays a convenience, but it
  now applies the engine's AND day rule when day-of-month and day-of-week
  are both restricted (it used to preview Vixie's OR: fire days the
  daemon would never run) and phrases its description the way the daemon
  does.

### Engine additions (`cronstable/cronexpr.py`)

- **`CronTab.prev()`**: the backward mirror of `next()`: seconds since the
  most recent occurrence strictly before now, for missed-run and late-run
  reasoning without replaying the schedule forward.  Both `prev()` and the
  timezone-aware `next()` resolve DST edges through real instants (the
  `occurrences()` policy below), so neither reports a negative delay
  across a spring-forward gap nor mishandles the second leg of a
  fall-back hour.  The naive golden vectors against the replaced
  parse-crontab library still pass byte-for-byte; the aware DST-edge
  vectors are deliberately corrected, because the legacy library answers
  those with civil arithmetic (in the worst case, a negative delay).
- **`CronTab.occurrences()`**: iterate the exact instants the scheduler
  would fire.  Steps through real instants across DST: a spring-forward
  wall time is yielded once at its shifted label, a fall-back repeat fires
  its first occurrence only.  `next_fires` previews now ride this iterator.
- **Read-only field-set properties** (`minutes`, `hours`, `days_of_month`,
  `last_day_of_month`, `months`, `days_of_week`, `last_days_of_week`,
  `years`, `seconds`), so tooling works from the engine's ground truth
  instead of re-parsing expression text.
- **`?` accepted** standing alone in the day fields (the Quartz spelling of
  "unrestricted"; a 7-field Quartz expression now parses verbatim), and
  parse errors that smell of Quartz (`#`, `W`, the seconds-first 6-field
  layout) carry a hint naming the dialect and how to convert.

### Hashed schedules: `H * * * *`

- **Jenkins-style `H` fields** (`H`, `H(a-b)`, `H/n`, `H(a-b)/n`, any field
  but the year) hash the job's name to a stable slot, killing the `:00`
  thundering herd without random jitter, which would break the "was this
  run late?" question a monitoring product must keep answerable.  The hash
  is a per-field-salted SHA-256 of the job name: identical across
  restarts, reloads, replicas and versions (the concrete slots are pinned
  by tests), so `H H * * *` picks an uncorrelated minute and hour, and a
  job's bare `H` minute agrees with its `H/15` phase.  In day-of-month,
  every rangeless `H` form (bare `H` and `H/n` alike) hashes over 1 to
  28, never skipping short months; `H(1-31)` opts back in.  Renaming a
  job re-hashes its slots.  Resolution happens at parse time, so
  matching, the next-fire search and semantic equality see plain values;
  the linter attaches a `hashed-slot` note naming the resolved
  expression, `GET /jobs` serves it as `schedule_resolved`, and
  `GET /schedule/preview` grew a `seed` parameter so sandboxes can
  resolve prospective `H` schedules.  Classic crontab files accept `H`
  lines too (seeded by their line-derived names).  Both sandboxes know
  the form: the web page explains a valid `H` schedule while still
  flagging an invalid one, the TUI does the same instead of claiming the
  daemon would reject it, and the TUI job drawer analyzes the resolved
  spelling from the payload, so an `H` job gets the same description,
  preview and lint as any other.

### Fleet-level schedule analysis

- **`GET /schedule/pressure`**, the collision heatmap: every enabled
  schedule's fires over the next 24h (up to 168h), enumerated with the
  scheduler's own engine (timezone- and DST-exact, sub-minute schedules
  weighted, DAG schedules included) and bucketed into an hour-by-minute
  grid with per-minute histograms, the busiest-minute headline, the empty
  minutes, and the heaviest cells with their jobs.  Disabled and `@reboot`
  jobs are excluded and counted.  The walk runs on a worker thread over
  an immutable snapshot, so a big fleet's enumeration cannot stall the
  scheduler loop.
- **`GET /schedule/duplicates`**: groups of jobs whose schedules fire on
  the identical instants, by the engine's own semantic equality (`*/5`
  equals `0-59/5`, `@hourly` equals `0 * * * *`) and the resolved
  timezone, so two midnight jobs in different zones are not called
  duplicates.
- **`GET /schedule/suggest`**: the least-loaded minute (`period=hourly`)
  or minute and hour (`period=daily`) for a new job, scored on the same
  24h fire walk, deterministic ties breaking circularly away from the
  busiest slot (an idle fleet is told `:30`, not `:00`), with runners-up
  and the `H` spelling that keeps future jobs spreading themselves.
- **The web dashboard** grew a schedule-pressure card (grid heatmap,
  minute histogram, duplicate groups as clickable chips, suggest-a-slot
  buttons, UTC/local display toggle) plus a compact pressure strip on the
  wallboard; the job drawer shows what an `H` schedule resolved to, and
  every client-side preview computes from the resolved form.  **The TUI**
  has the same panel as an overlay, computed on a worker thread from its
  `/jobs` and `/dags` snapshots with the identical shared analyzers (one
  fire walk feeds the heatmap and both suggestions), so it works against
  older daemons.  **Three read-only MCP tools** (`cron_schedule_pressure`,
  `cron_schedule_duplicates`, `cron_suggest_slot`) serve the same
  payloads to agents.

### Schedule authoring and debugging for agents

- **`GET /schedule/why`** answers "why didn't this job run at 09:00?"
  from ground truth: given a job and a timestamp, the new
  `croninfo.why_no_run` decomposes the engine's own match test field by
  field ("minute matched; day-of-week Tuesday is not in Monday and
  Friday"), renders each field's accepted values in prose (ranges
  collapse, weekday and month names, the `L` forms spelled out), and
  brackets the probe with the nearest real fire on each side.  Notes
  call out the two semantics that make a miss genuinely confusing: the
  dialect's day-field AND rule (when exactly one restricted day field
  matched, classic Vixie cron would have fired) and DST transitions (a
  skipped wall time fired at its shifted label instead; a repeated one
  fired once).  Aware timestamps convert into the job's own timezone,
  naive ones read as wall time there; `@reboot` and disabled jobs
  answer honestly, and a DAG's `dag:<name>` schedule job resolves too.
- **Three more read-only MCP tools** make an agent a schedule author,
  not just a reader: `cron_validate_schedule` (parse and lint an
  expression before it becomes a job: the engine's exact error with its
  Quartz dialect hints, advisory lint findings, the first upcoming
  fire, and prospective `H` resolution via `seed`),
  `cron_explain_schedule` (plain-English description plus the next N
  fires in a chosen zone plus lint, for round-tripping a proposed
  schedule to a human before it ships), and `cron_why_no_run` (the
  explainer above, with a one-line verdict that points at
  `cron_list_runs` when the schedule DID select the instant).  All
  three ride the `observe` toolset; the server's `initialize`
  instructions steer agents to validate before proposing.

### Packaging

- **winget**: `winget install ptweezy.cronstable` installs the self-contained
  Windows release binary (`amd64` or `arm64`), no Python required. A new
  `winget` release job updates the manifest in `microsoft/winget-pkgs`
  automatically on every release, the same way the existing `homebrew` job
  keeps the Homebrew tap current.

## 1.2.20 (2026-07-17)

This release gives the web dashboard a **terminal twin**: `cronstable tui`
opens the same board in a terminal -- an SSH session, a tmux pane, a box where
a browser is one window too many.  It is a pure client of the daemon's
existing HTTP control API (the daemon itself is untouched by this release),
it adds **zero new dependencies** (the standard library plus the already-core
`aiohttp`), and it keeps the web page's muscle memory: the shortcut table is
the same sixteen keys, now enforced by a test that parses the rows out of the
web page's own source.  The final cut also reflects an adversarial review of
the whole surface -- the notable outcomes are documented below because they
are load-bearing guarantees, not incidental polish.

### The terminal dashboard (`cronstable tui`)

- **The whole board makes the trip.** The jobs table (status glyphs,
  next-fire countdowns, duration sparklines, live CPU/memory chips, the owner
  column under a spread cluster, filter/sort/status segments); the job drawer
  with the live **SSE log tail** (search with `n`/`N`, follow/wrap/timestamps
  toggles, save-to-file), run **history** with success rate and per-run bars,
  **resources** for monitored jobs, and the **schedule tab**; the fuzzy
  **command palette**; the **verdict bar**, **incident timeline**, and
  **mitigate console** with its Markdown writeup; the **multi-tail** merging
  up to four live logs; the **DAG drawer** (runs, an ASCII task graph,
  per-task states, approval gates decided with `a`/`R`, XCom, task logs,
  trigger and backfill); the **cluster panel**, **fleet matrix**, node
  resources, activity **heatmap**, and next-fire radar; the durable-state
  inspector; the **cron sandbox**; the **wallboard** with its `NO SIGNAL`
  banner and zen screensaver; and the BIOS-style **boot self-test**, probing
  the daemon for real (at most every 12 hours; any key, `--no-boot`, or a
  settings toggle skips it).

- **The schedule tab cannot disagree with the scheduler.** The next-fire
  preview runs the daemon's own `CronTab` engine, stepping in absolute time
  so a window that crosses a DST transition shows the true fire instants (no
  phantom or hour-shifted entries), and the plain-English description states
  the engine's deliberate rule that a day must satisfy **both** day-of-month
  and day-of-week when both are restricted ("on the 13th, and only on
  Friday") -- the daemon's documented parse-crontab semantics, not standard
  cron's OR.  An expression the engine would reject (month `13`, weekday `8`)
  degrades to prose in the sandbox instead of raising.

- **Hostile bytes die before the frame.** A TUI paints raw bytes into the
  operator's terminal, so everything remote is scrubbed in layers: log
  content keeps only SGR colour (re-inked per theme) while every other
  escape family -- CSI with private parameters, OSC (title, clipboard),
  DCS/SOS/PM/APC strings, single-character escapes like a hard reset, bare
  trailing ESC -- is stripped; every *other* API-derived string (job and DAG
  names, node/peer names arriving over cluster gossip, XCom keys, server
  error text in toasts) passes the same scrub in the text helpers; and the
  final row assembly drops any non-SGR escape as a last line of defense.  A
  malicious peer advertising an OSC 52 node name cannot write the operator's
  clipboard; a job that runs `reset` cannot tear down the board from inside
  its own log pane.

- **Tails do not repeat themselves.** The daemon replays a finished run's
  retained buffer on every SSE re-attach, and the stream carries no run
  identity -- so the tail holds a replay aside until it diverges from what is
  already on screen: an identical replay that simply ends again is the old
  run repeated and is dropped whole, while divergence is the next run's
  output and flushes through (runs stack up behind their end markers, like
  the page).  Idle re-attaches back off geometrically, so an open drawer on
  a finished job stops re-downloading its log every five seconds.

- **Responsive from the first frame.** The input and paint loops start
  before the first data load, boot-probe API calls race the skip key, and
  every HTTP call carries a bounded connect timeout (with a read timeout
  above the daemon's SSE keep-alive cadence on streams) -- so against an
  unreachable daemon the header says "disconnected" and `q`/Ctrl-C work,
  rather than a blank, un-quittable screen while probes time out.  Manual
  refresh (`g`, and the refresh after every action or token entry) performs
  a fetch even with polling paused (`--poll 0`), and Ctrl-K on the wallboard
  stays inert rather than opening an invisible palette that could fire
  unseen actions.

- **Same themes, same accessibility, same keys.** The five hues in phosphor
  (dark) and paper (light) variants, `t`/`T` cycling, the colour-vision-safe
  remaps, and an `--ascii` glyph mode for limited fonts; preferences persist
  in a small JSON file (`%APPDATA%\cronstable\tui.json` on Windows,
  `$XDG_CONFIG_HOME/cronstable/tui.json` elsewhere).  Flags mirror the
  page's hash routes: `--tv` (the wallboard), `--job NAME` (deep-link a
  drawer), plus `--url`, `--token`/`--token-env` (default
  `CRONSTABLE_WEB_TOKEN`; a `401` opens the token prompt, and the token is
  kept for the session only, never written to the prefs file), `--theme`,
  `--poll`, `--boot`/`--no-boot`.  Works on Linux, macOS, and Windows (a
  msvcrt reader thread and VT-mode enablement stand in for termios), and
  ships in the same package and binaries as the daemon.

- **The CLI stays light.** `cronstable.tui` defers its `aiohttp` import
  until the app actually starts, so registering the subcommand costs every
  other `cronstable` invocation nothing.

### Documentation and tests

- **A README section and a Terminal-Dashboard wiki page** (options, every
  key, the panel tour), with screenshots captured from the real TUI driven
  against the running grand-tour fleet by a new
  `docs/screenshots/capture_tui.py`, alongside the web dashboard's existing
  pipeline.

- **A headless, tty-free test harness** boots the real app against a fake
  daemon on a loopback port, drives it with a scripted key queue, and
  asserts on painted frames -- the same suite runs on POSIX CI and a Windows
  checkout.  The keyboard-parity test extracts the shortcut table from
  `fillHelp()` in the web page's source (failing loudly if the parse finds
  nothing), so the two frontends cannot drift apart silently.

## 1.2.19 (2026-07-17)

A docs-website-and-CI release: no functional changes -- the package, the CLI,
and every shipped binary behave exactly as in 1.2.18.  The feature-comparison
chart grows to cover the orchestration and fault-tolerance surface the daemon
already ships, and the wiki stops drifting by hand -- CI now publishes it.

- **The comparison chart expands from 24 capabilities to 35.** The matrix now
  scores the rows that were cronstable's strongest differentiators and simply
  went unlisted: sub-minute schedules, the extended cron dialect, `@reboot`,
  configurable failure conditions, and the depends-on-past gate under
  scheduling; dynamic task mapping and poll-until-true sensors under
  orchestration; cluster-wide concurrency scope and crash-resume of in-flight
  runs under fault-tolerance; archived-output secret redaction; and state-store
  backup/restore/migrate.  cronstable is native on all 35; the runner-up
  (Airflow) is at 18.  The `— not available` legend entry is dropped -- a blank
  cell already reads as "no" -- and both renders (`docs/comparison.md` and the
  `docs/comparison.html` page) carry the identical 35-row matrix.

- **The wiki is published by CI instead of by hand.** A new ungated `wiki` job
  in the pipeline mirrors `wiki/*.md` onto the project's GitHub wiki (a
  separate `.wiki.git` repo) on **every push to `develop`**, making `wiki/` in
  this repo the single source of truth.  The previous manual clone-copy-push
  had drifted -- pages a week stale -- exactly the way an unautomated step
  always does.  The mirror is authoritative, so it deletes: a page edited from
  the wiki's web UI is reverted on the next `develop` push, and the job prints
  every add/modify/delete to the run log.  It publishes from `develop` (never
  `main`, whose merges would race the per-branch `concurrency` key), is guarded
  to the canonical repo so forks don't redden, and needs no PAT -- a
  `GITHUB_TOKEN` with `contents: write` can push a repo's own wiki.
  `CONTRIBUTING.md` and the releasing wiki page gain an **Editing the wiki**
  section documenting the flow and the deliberately-dead bare `[Page](Page)`
  links.

- **The pendulum-logo tooltip drops its interaction hints.** The header
  wordmark's hover title no longer spells out "sweep your cursor through it to
  nudge it, right-click to knock it over"; it now just names the mark ("the
  cronstable logo, the l is a self-balancing double pendulum").  The physics is
  unchanged -- only the tooltip copy -- and the edit is applied identically to
  the dashboard (`cronstable/web/index.html`) and its demo mirror
  (`docs/demo/index.html`).

- **CI housekeeping.** Dependabot bumped `softprops/action-gh-release` from
  `v3.0.1` to `v3.0.2` in the release job.

## 1.2.18 (2026-07-15)

A reliability release: correctness and hardening fixes across the job runner,
the cluster backends, the DAG scheduler, and the web API, with no config
migrations and no behavior change for a healthy single-node install.  Two
scheduler-crash paths are closed, a clustered `@reboot` failover double-fire is
sealed on etcd, the control API gains an always-on cross-site defense, and a
family of unbounded or undecodable inputs can no longer wedge the daemon.

- **Jobs run in their own process group, and cancellation takes the whole tree
  down.** A job that leaves a helper behind (`sh -c 'helper & main'`) used to
  strand its run forever: terminating only the process cronstable spawned left
  the helper holding the job's stdout/stderr write-ends, so the pipe never
  reached EOF, `wait()` never returned, the slot was never released, and under
  `concurrencyPolicy: Forbid` the job never ran again.  Jobs now spawn in a
  fresh session/process group (`start_new_session` on POSIX; the process tree
  is walked by `taskkill /T` on Windows), and `cancel()` signals the **group** --
  `SIGTERM`, then an unconditional `SIGKILL` after `killTimeout` -- so
  descendants that outlive a killed shell go down with it and `executionTimeout`
  bounds the run's work rather than just its root process.  As defense in depth
  for a descendant that escaped the group (it called `setsid` itself, or Windows
  lost the orphan from the tree), the post-kill stream drain is now bounded, so
  the run always leaves `running_jobs` -- at the cost only of output already
  lost -- and cronstable closes its end of the job's stdout/stderr pipe once
  that drain returns, so such a run does not leak the pipe's read-end file
  descriptor until garbage collection.

- **Cancelling a never-spawned run is a no-op, not a scheduler crash.** A job
  whose command failed to spawn registers with `proc=None`; the next fire's
  `Replace` branch -- and the cluster slot-renewer -- then cancel whatever
  `running_jobs` holds, and both run *outside* the scheduler loop's
  `try/except`.  `cancel()` raising `RuntimeError("process is not running")`
  there could take down the whole daemon on the second fire after a bad deploy;
  it now logs and returns, and the reaper still completes the run through its
  `start_failed` path.

- **A clustered `@reboot` can no longer double-fire across an etcd failover.**
  Leadership and the persisted `@reboot-ran` record live at separate etcd keys
  read by separate requests, so a failover leader whose ran-set read blipped
  could answer "not run yet" from a stale cache and re-run a one-shot the
  previous leader had already marked.  The etcd backend now applies the same
  conservative read-side gate the filesystem backend uses: between gaining
  leadership (or a known lease loss) and the first completed read-back, it
  answers `@reboot-ran` queries by deferring -- the one-shot stays pending and
  is re-asked next wakeup -- rather than risking a second run.  The shared
  `RebootRanUnknownError` moves to `cronstable.leadership` (re-exported from
  `backends.filesystem` for compatibility); Kubernetes needs no gate, since its
  ran-set rides the very Lease read that wins leadership.

- **A transient store blip no longer freezes a mapped DAG task into an empty
  fan-out.** A mapped task's expansion is recorded once and never recomputed, so
  reading its upstream list at an instant the store could not answer (an
  `ESTALE`/`EIO` on a shared NFS/EFS mount) used to be indistinguishable from
  "published nothing" -- silently skipping the task's entire fan-out while
  reporting success downstream.  `artifact_get`/`artifact_get_record` gain a
  `strict=` mode that propagates an unreadable record instead of skipping it,
  and the expansion read now maps a store error to **unknown** (stay unexpanded,
  retry next pass), reserving the empty fan-out for a definitively absent,
  non-list, or blob-gone (`410`) result.

- **Cross-site request defense for the control API.** An always-on middleware
  refuses cross-site browser requests to the mutating endpoints
  (`POST /jobs/{name}/start`, `/cancel`, `/dags/{name}/trigger`, `/backfill`,
  task decisions).  Those POSTs are CORS "simple requests" -- sent without a
  preflight -- so without this any web page an operator happened to visit could
  fire them at a localhost-bound daemon (classic CSRF, and the DNS-rebinding
  variant).  Same-origin requests, and clients that send no `Origin` (curl,
  monitoring), always pass; a foreign `Origin` is refused `403`.  A new
  `web.allowedOrigins` allow-lists trusted cross-origin dashboards, a specific
  `Access-Control-Allow-Origin` response header is folded in automatically, and
  `Access-Control-Allow-Origin: *` disables the gate (logged loudly).  `/mcp`
  keeps enforcing its own `mcp.allowedOrigins`.  The gate complements, not
  replaces, `web.authToken`, and is the default posture when no token is set.

- **The shell reporter is bounded.** Reports run inline on the reaper -- the
  daemon's single job-completion loop -- so a notify command that never exits
  (`curl` with no `--max-time`, a script that reads stdin) would freeze
  completion handling for *every* job daemon-wide.  A new `report.shell.timeout`
  (default 60 s) kills the reporter's whole process group on expiry and lets
  completion proceed.

- **Undecodable secret and token files fail cleanly instead of crash-looping.**
  A `fromFile` secret pointing at binary data (a `.p12` bundle, a gzip, a key
  with a stray high byte) raised `UnicodeDecodeError` from the read, which only
  `ConfigError` callers handle -- so it escaped the scheduler loop and
  crash-looped the daemon at every fire of that job, or 500'd web startup.
  `config._resolve_secret` and `web.authToken.fromFile` now surface a clean
  `ConfigError`.

- **Smaller hardening across the surface.**  Bearer-token comparison now runs on
  bytes, so a non-ASCII `Authorization` header is a clean `401` rather than a
  `500`; secret redaction now catches the space-less `Authorization:Basic <b64>`
  form (still requiring a separator, so ordinary prose is untouched); the
  job-state base URL brackets IPv6 literals so `CRONSTABLE_STATE_URL` is
  parseable (`http://[::1]:8080`); the semaphore `acquire` endpoint caps
  `permits` at 1024, rejecting an absurd count up front instead of launching up
  to a billion sequential store probes; the state CLI's binary artifact/xcom
  verbs tolerate a non-JSON error body (a bare plaintext `401`) instead of
  printing a `JSONDecodeError` traceback.

- **Two in-memory leaks pruned.**  A reload now drops the `last_run` /
  `run_history` display data of removed jobs -- unreachable once the job is gone,
  and worst under classic crontabs, whose `<file>:<line>` job names are reminted
  by every line added or removed above them -- and the DAG scheduler sweeps the
  per-run advance locks it accumulated for every run it ever touched, keeping
  only those still owned, held, or awaited.

## 1.2.17 (2026-07-14)

A docs-and-examples release: no functional changes -- the package, the CLI,
and every shipped binary behave exactly as in 1.2.16.

- **Each example owns its compose file.** The eight `docker-compose-*.yml`
  files that lived in the repo root now sit in the example they belong to, as
  `example/<name>/docker-compose.yml`, next to that example's config and
  README.  Commands change accordingly -- `docker compose -f
  example/cluster/docker-compose.yml up` instead of `docker compose -f
  docker-compose-cluster.yml up` -- and the READMEs, wiki, and in-file comments
  are updated to match.  The root `docker-compose.yml` is untouched: `docker
  compose up` still boots the `demo` quickstart from a fresh clone.

- **The MCP example joins the gallery.** `example/mcp` -- an agent driving the
  scheduler over `POST /mcp` -- was missing from the README's example table.

## 1.2.16 (2026-07-14)

The dashboard's public-face release.  The wallboard is redesigned around what
a wall viewer actually glances for, the header wordmark becomes a live
control system, and the project gains a feature-comparison chart plus a
zero-install live demo of the dashboard.  Every product change is contained
to the dashboard page (`cronstable/web/index.html`); the daemon, scheduler,
CLI, configuration, and APIs are untouched.

- **Wallboard tiles lead with the glanceable fact.** Instead of showing every
  job the same next-fire countdown, each tile now leads with the datum its
  state makes urgent: a failing tile shows **when it failed and its exit
  code**, with the age counting up live between polls; a running tile shows
  **elapsed time**, turning amber once the run exceeds twice its longest
  recent duration (with 60 s of slack for short jobs); healthy tiles keep the
  countdown.  Elapsed times are an honest observed-since lower bound (stamped
  when this browser first saw the run -- the `/jobs` payload has no start
  field), never an invented start time.  The status hue now fills the whole
  tile rather than just a glyph and a 1 px border, so state reads at TV
  distance, and the footer tally represents every health bucket -- pending,
  unknown, and cancelled jobs are counted instead of silently dropped.

- **A verdict headline at TV scale.** The dashboard's correlation verdict
  (single failing job + exit code, correlated fleet event, or cluster alert)
  used to vanish on the wallboard along with the header that carries it.  It
  now renders as a full-width headline strip above the grid, distilling scope
  and likely cause into one sentence instead of "count the red tiles".  The
  strip is rewritten only on a real change, so its screen-reader status
  region announces each verdict once, not once per poll, and its age ticks
  live between polls (the main verdict bar's age now ticks too).

- **The grid fits itself to the glass.** A TV has no one to scroll it, so a
  new fit governor re-decides the layout on every paint, resize, and zoom
  change: a handful of jobs on a big screen grows its tiles and type
  proportionally to use the glass; a crowded fleet first scales full tiles
  down -- name, glance line, and run-history sparkline all kept -- before
  conceding anything; only past the readability floor does it step down to
  compact tiles, and even those keep the sparkline whenever their stretched
  rows leave it room; and if even compact overflows, the healthiest tail is
  cut behind an explicit footer chip computed from what was actually cut
  (`+22 offscreen . none failing`, turning red with exact counts if failing
  or unknown tiles ever overflow a whole screen).  The board never silently
  clips a failure behind a scrollbar nobody can reach.

- **Wallboard correctness fixes.** The `unknown` state was missing from the
  worst-first sort order, which left the entire tile ordering unspecified
  whenever an interrupted job was on the board -- fixed.  Grid rebuilds are
  now skipped when the structure is unchanged, so tile animations no longer
  restart on every poll and the exit button keeps keyboard focus.  The
  escalation INCIDENT stamp moved from an absolutely-positioned overlay --
  which could cover the worst (first) tile or hide beneath the NO SIGNAL
  banner -- to an in-flow strip that can do neither, and leaving the
  wallboard now tears escalation state down symmetrically, so re-entering
  after a recovery no longer flashes a stale stamp and vignette over a
  healthy grid.  Navigating away from `#tv` by URL (back button, address-bar
  edit, kiosk script) actually exits the wallboard and preserves the target
  deep link, and a DAG drawer left open can no longer desync the `#tv` hash.
  Keyboard shortcuts ignore modifier chords -- Ctrl+A is select-all again,
  not a silent alarm acknowledgement.

- **The l balances itself now.** The block glyph that used to spin beside the
  wordmark is retired: the `l` in the header's "cronstable" is a **live
  cart-and-double-pendulum simulation** -- the full nonlinear dynamics
  integrated at 240 Hz (RK4) and balanced by an LQR controller whose gains
  are computed in your browser at page load from a numerical linearization.
  Not a canned animation: while the daemon is live the letter stands upright,
  riding out little gusts; sweep your cursor through the header to brush it
  aside, right-click to knock it clean over.  Lose the daemon and its motor
  cuts -- the letter collapses out of the word and swings.  When the signal
  returns, an energy-shaping swing-up threaded by a receding-horizon
  cross-entropy planner carries it back into the balance controller's basin,
  and every catch is verified by a two-second closed-loop rollout before it
  is committed; a hard recovery gets running room (the right end of the track
  swings open toward mid-page, then eases home once the letter stands).
  Reduced motion parks a still pose that stays honest about daemon state --
  upright when live, hanging when not; without JavaScript the span prints a
  plain roman l; the SVG is absolutely positioned, so none of this moves the
  layout.  Tuned and Monte-Carlo-tested headlessly: recovery median ~15 s,
  ~100% by 90 s, zero unverified catches in a 10-minute soak.

- **How cronstable compares.** A new `docs/comparison.md` scores 24
  capabilities -- AI and agent control, scheduling core, orchestration,
  distribution and fault tolerance, observability, platform -- against
  yacron, supercronic, Ofelia, dkron, Cronicle, Kubernetes CronJob, and
  Apache Airflow.  Every competitor cell was checked against that project's
  official docs and adversarially re-verified; the MCP server remains the row
  nothing else in the field ships natively, Airflow included.
  `docs/comparison.html` is the same chart as a standalone styled page ("As
  simple as cron, as capable as Airflow, in one daemon") with an MCP
  spotlight and a path into the live demo.

- **A live demo with nothing to install.** `docs/demo/index.html` is the real
  dashboard page with a synthetic backend injected ahead of it: it patches
  `window.fetch`, so the untouched SPA runs against a deterministic nine-node
  "meridian" fleet derived from the wall clock -- countdowns tick,
  long-runners stay running, a staged incident flares and self-clears, and
  every view works (jobs, drawers, DAGs, cluster, fleet, wallboard, state,
  boot self-test).  No network request ever leaves the page, and it serves
  from GitHub Pages or any static host.

- **Assets, docs, and capture tooling.** The README wears the new mark:
  `logo-balance.gif` replaces `logo-spin.gif`, recorded by stepping the real
  simulation deterministically at 50 fps, so the loop plays the product story
  with the real controller -- theme glitches physically knock the pendulum,
  the big one cuts the signal, and the word heals through a verified catch.
  The social card lifts the logo engine straight out of the dashboard page so
  it can never drift from the shipped mark, the still-capture scripts park
  the pendulum at exact upright so screenshots stay pixel-identical across
  themes, and a new `docs/logo-lab.html` preserves the standalone harness the
  mark was tuned in.  The wiki's wallboard section is rewritten for the new
  tiles, the failure glyph across the dashboard and docs swaps the heavy
  ballot cross for a symmetric one that sits better next to the check mark,
  and the full screenshot set is regenerated.

## 1.2.15 (2026-07-14)

A maintenance release: no functional changes to cronstable itself -- the
package, the CLI, and every shipped binary behave exactly as in 1.2.14.  It
consolidates the project's separate CI workflows into one gated pipeline,
hardens the release automation and updates the contributor
docs to match.

- **One CI/CD pipeline.** The former `build.yml`, `docker.yml`, and `tox.yml`
  are folded into a single `release.yml` that builds and tests the whole product
  on every push and pull request -- the tox lint/mypy/pytest matrix, the wheel +
  sdist, every self-contained binary (Linux glibc and musl across the full arch
  set, macOS arm64/amd64, Windows amd64/arm64), and all eight Docker images --
  and gates a release on all of it.  Nothing publishes on an ordinary commit; a
  release still ships the PyPI upload, the GitHub Release with every binary and a
  single `SHA256SUMS`, the container images, and the Homebrew tap update, but
  only after the entire build + test matrix is green.

- **Reliable release publishing.** The consolidated pipeline attaches every
  release asset in one shot as the GitHub Release is created (no separate attach
  step that could fail against an already-published, immutable release), and the
  Homebrew tap update is no longer best-effort -- if the tap cannot be updated
  (for example, a missing token) the release now fails loudly instead of
  finishing green with a stale formula.

- **Hardened release trigger.** A `1.3.0` was published in error before this
  release: the trigger substring-matched whole commit messages, so a commit
  *body* that merely discussed the bare `[release]` marker out-bumped the
  intended `[release:patch]`. `1.3.0` is withdrawn (yanked on PyPI, its GitHub
  release and container tags removed) and contains exactly what ships here as
  `1.2.15`. The trigger now scans only commit subject lines, only honors a
  marker at the very start of the subject, and when several commits carry one,
  the latest commit's marker wins.

- **Contributor docs.** `CONTRIBUTING.md` and the "Contributing and Releasing"
  wiki page are rewritten for the single-pipeline flow.

## 1.2.13 (2026-07-08)

cronstable becomes drivable by an AI agent.  A new, opt-in **MCP server**
exposes the scheduler over the [Model Context
Protocol](https://modelcontextprotocol.io), so Claude, Cursor, VS Code
Copilot, or any MCP client can **observe** every job, DAG, the cluster/fleet,
metrics, and the durable state store the way an operator reads the dashboard
-- and, when you opt in, **act** (run or cancel a job, trigger / backfill /
approve a DAG).  It is read-only by default, served two ways from one
implementation -- a `POST /mcp` endpoint on the existing web listeners and a
`cronstable mcp` stdio bridge for desktop clients -- and, like the rest of
cronstable, hand-rolled in pure Python with **no new dependencies**.  It stays
off unless an `mcp:` section sets `enabled: true`, so a plain install pays
nothing and behavior is unchanged without it.

- **The `mcp:` section and its two transports.** Configuration lives under a
  new optional top-level `mcp:` block that rides the `web:` listeners -- a
  `web` section is required, because there is nowhere else to serve it.
  `enabled: true` turns on a **stateless Streamable-HTTP** JSON-RPC 2.0
  endpoint at `POST /mcp`, pinned to MCP revision `2025-11-25` (no
  `Mcp-Session-Id`; `GET /mcp` is `405`), and exposes the `cronstable mcp`
  **stdio bridge** that desktop clients launch as a subprocess.  Both paths
  run the same server code.

- **Tools, grouped into opt-in toolsets.** The default `observe` toolset is
  read-only -- status, jobs, per-job runs / trends / resources, cluster,
  fleet, node load, a metrics query, version, and live log tails (twelve
  tools).  `dags` adds DAG, run, and XCom reads plus task-log tails; `state`
  adds a redacted durable-state inspector; `act` adds mutating job control
  (`cron_run_job`, `cron_cancel_job`), and `dags` gains DAG control
  (`cron_trigger_dag`, `cron_backfill_dag`, `cron_decide_gate`) once writes are
  enabled -- 23 tools with every toolset on and `readOnly: false`.  Mutating
  tools require an explicit `confirm: true`, carry honest `destructiveHint`
  annotations, re-check the same authorization as the REST route, and
  `cron_backfill_dag` previews as a dry run unless it is called with both
  `dry_run: false` **and** `confirm: true`.

- **Resources and prompts, both read-only and on by default.** Resources are
  URI-addressable read-only snapshots a client can attach as context --
  `cronstable://status`, `cronstable://cluster`, `cronstable://fleet`,
  `cronstable://version`, and templates for jobs, runs, DAGs, and state
  namespaces -- and every critical read is *also* a tool, because client
  support for resources is uneven.  Prompts are canned triage playbooks that
  chain the read tools: `triage_job_failure`, `why_did_dag_run_fail`,
  `blast_radius`, `fleet_health_summary`, and `backfill_plan`.  Both are scoped
  by the enabled toolsets (a DAG resource appears only with the `dags` toolset)
  and can be turned off (`resources: false`, `prompts: false`) for a
  tools-only client.

- **Safe by default.** `readOnly: true` is the default and strips every
  mutating tool regardless of toolset, so an agent gets look-but-don't-touch
  until you opt in.  `/mcp` inherits `web.authToken` exactly like the data
  routes and is never in the public set; enable it with no token on a routable
  (non-loopback, non-socket) listener and cronstable **fails closed at config
  load** (without a token the web app installs no auth middleware at all, so
  `/mcp` would be wide open) -- restrict `web.listen` to loopback/sockets, set
  a token, or set `mcp.allowUnauthenticated: true` when a proxy terminates
  auth.  A present, non-allow-listed `Origin` is refused `403` (a DNS-rebinding
  defense; browser clients go on `mcp.allowedOrigins`), an oversized body is
  refused `413` (`maxBodyBytes`, 1 MiB default), and any list tool's `limit` is
  capped at `maxRows` (200) with an opaque cursor for the remainder.
  `cron_inspect_state` mirrors the dashboard's metadata-only stance: KV values
  collapse to a size/type summary and secret *names* appear without their
  values.

- **One source of truth for REST and MCP.** The web layer's read handlers are
  refactored so each endpoint's data is produced by a reusable `*_payload`
  method -- `status_payload`, `jobs_payload`, `cluster_payload`,
  `fleet_payload`, `node_payload`, the per-job runs / resources / trends
  projections, `dags_payload`, the `state_*_payload` family, and new
  poll/cursor log-tail projections -- and both the REST routes and the MCP
  tools call those same methods, and the same `start_job_by_name` /
  `cancel_job_by_name` action paths, so `GET /jobs` and `cron_list_jobs` can
  never drift apart.  The web app now also rebuilds when only the `mcp` config
  changes, so flipping `readOnly` or adding a toolset takes effect on the next
  reload.

- **The `cronstable mcp` stdio bridge.** A featherweight stdlib client --
  imported lazily so it never pulls the daemon graph into CLI start-up --
  reads newline-delimited JSON-RPC on stdin and forwards each frame to a
  running daemon's `/mcp`, writing replies to stdout and logs to stderr.
  `--url`, `--token` / `--token-env` (defaulting to the `CRONSTABLE_WEB_TOKEN`
  env var), and a `--check` handshake that runs `initialize` + `tools/list`,
  prints the negotiated protocol and tool count, and exits.  It needs a
  reachable running daemon -- the right model for an ops tool.

- **Docs, an example, and a rebuilt README.** New wiki pages (MCP and
  MCP-Server-Design), an `mcp` row and full option table on
  Configuration-Reference, and a `POST /mcp` section on HTTP-API.
  `example/mcp` (with `docker-compose-mcp.yml`) boots a single node with the
  server on and every toolset enabled -- a steady `heartbeat`, an intentionally
  failing `flaky-export`, a long `slow-report`, and an on-demand
  `on-demand-sync` -- and walks through wiring Claude Code / Desktop, Cursor,
  and VS Code to it.  The README now leads with an animated dashboard reel
  (every frame a real running fleet, WebP with a GIF fallback) and a
  ten-theme / two-font showcase, adds a written tour of the dashboard's
  accessibility options (interface font, UI scale, color-vision-safe palettes,
  reduced motion), lists the MCP server among the features, moves the yacron
  fork attribution to the foot of the page, and -- at last -- says how to
  pronounce the name: *kraahn-stuh-bl, like constable*.

- Internal: the MCP server ships with a dedicated `tests/test_mcp.py` suite --
  the `initialize`/capability handshake, toolset and `readOnly` gating, the
  `confirm` and dry-run write guards, `maxRows` clamping and pagination, the
  fail-closed no-token check and the Origin / body-size / batching HTTP
  defenses, resource and prompt scoping, and the featherweight-bridge import
  cost -- and the release workflow's finalize job, which has no
  `actions/checkout` (so `gh release download/upload` could not infer the
  repository and died with "not a git repository"), now sets `GH_REPO`
  explicitly.

## 1.2.12 (2026-07-08)

The package finishes becoming **cronstable**.  Everything that was still
named `yacron2` -- the import package, the `python -m` entry point, the
console-script command, and the PyPI distribution -- is now `cronstable`, so
the name is consistent all the way from `pip install` through `import` to the
CLI.  The fork has shipped as cronstable in its repo and docs for a while;
this release moves the code's own identifiers to match, so nothing user-facing
still answers to the old name.

- **The `yacron2` package is renamed to `cronstable`.** The source tree moves
  from `yacron2/` to `cronstable/` and every intra-package import follows, so
  `import yacron2...` becomes `import cronstable...` and `python -m yacron2`
  becomes `python -m cronstable`.  The web assets, backends, and cluster
  modules move with it; no module contents change, only their home.

- **The CLI command and PyPI distribution are renamed.** The console script the
  wheel installs is now `cronstable` (it was `yacron2`), and the project
  publishes to PyPI as `cronstable` -- `pip install cronstable`.  The repo,
  Docker image, and container registry are `ptweezy/cronstable`.  Update any
  scripts, service/unit files, or `pip`/import references that still say
  `yacron2`; there is no compatibility shim, so the old names stop resolving.

- **Release-pipeline hardening.** The release workflow now pushes the version
  tag with a scoped `RELEASE_TOKEN` rather than the default `GITHUB_TOKEN` --
  GitHub refuses to let the Actions app push a tag whose commit touches
  `.github/workflows/` without the `workflows` scope it cannot be granted -- and
  the PyPI publish runs with `skip-existing`, so a release interrupted after the
  upload (before the tag and GitHub Release) can be retried without burning the
  version.

## 1.2.10 (2026-07-07)

cronstable now parses cron expressions itself.  This release retires the
third-party `crontab` (parse-crontab) library in favor of a small,
stdlib-only engine that lives in the tree -- so the scheduler owns the one
piece of syntax every job depends on, the dialect is documented and tested
where it is implemented, and the install carries one dependency fewer.
Compatibility is not aspirational: it is pinned by golden vectors recorded
from the old library across 180+ expressions and fixed instants (DST
transitions, leap days, month/year boundaries, the year cap, ambiguous
fall-back folds).  Nothing changes for existing configs -- the dialect, the
timezone model, and the strictly-future fire semantics are all preserved
vector-by-vector.

- **`cronstable/cronexpr.py`: the built-in cron engine.** A new `CronTab` class
  parses the crontab dialect cronstable has always accepted (5/6/7 fields,
  ranges, steps including bare-start `5/15`, lists, case-insensitive
  `jan`/`mon` names, `0`-`7` weekdays with `6-0` wrap, `L`-last-day and
  `L5`-last-Friday forms, `@`-nicknames) and answers the scheduler's two
  questions: `next()` (strictly future, DST-correct across UTC-offset
  changes, capped at the 2099 horizon so dead schedules drop from the index)
  and `test()`.  A stdlib-only leaf module -- `calendar` and `datetime`, no
  third-party imports.

- **The `crontab` dependency is dropped.** Removed from `pyproject.toml` and
  every import site (`config.py`, `cron.py`, `crontabs.py`, `dagrun.py`,
  `prometheus.py`); classic crontab-file loading and YAML `schedule` strings
  now share the same in-house engine, so both formats still accept identical
  expressions.

- **Golden-vector compatibility harness.** `tests/gen_cron_golden.py`
  records `next()`/`test()` answers from the original parse-crontab library
  into `tests/data/cron_golden.json`; `tests/test_cronexpr.py` replays them
  against the new engine so any behavioral drift fails the suite.  This is
  the proof that "behavior-compatible" is a fact, not a hope.

- **`mergedicts` reimplemented.** The config defaults-merge helper is
  rewritten with the identical semantics -- dicts merge recursively, an empty
  YAML section (`None`) never wipes a populated default, `environment` and
  `secrets` lists merge by key/name, sentry `fingerprint` replaces rather
  than appends, and all other lists concatenate -- and now returns a `dict`
  directly, retiring the `dict(mergedicts(...))` wrappers at every call site.

- **Dashboard accessibility pass.** The web UI gains an **interface font**
  toggle (the terminal monospace, or a proportional sans "reader mode" for
  easier reading, with logs and cron strings kept monospace either way),
  app-level **color-vision** palettes (deutan / tritan status-color remaps,
  status glyphs always distinct too), whole-UI **zoom**, and a
  **reduce-motion** switch, each reachable from Settings and the command
  palette and persisted across sessions.

- **Docs.** The "Schedules and Timezones" wiki page now documents the full
  dialect the engine owns -- the day-of-month-AND-day-of-week rule (Friday
  the 13th, deliberately kept over Vixie's OR), the `L` forms, the
  1970-2099 year horizon -- and Installation, Migration-from-yacron,
  Classic-Crontabs, Architecture-and-Internals, and the README are updated to
  point at the built-in engine instead of the removed library.

## 1.2.9 (2026-07-07)

Resource monitoring grows a time axis. 1.2.8 answered "what did that run
use?" with two numbers per run; this release records **how the run used it**
-- a per-run CPU/memory chart series with a user-tunable sampling cadence --
and puts proper charts in the dashboard: a live view of the running
instance, the recorded profile of any recent run, per-run trend strips, and
a whole-node history chart behind the header meter. Everything rides the
existing HTTP+JSON surface and the durable run ledger; nothing changes for
existing configs (`monitorResources: true` still means what it meant, with
the same 1s cadence).

- **`monitorResources` map form.** Alongside the bool, the option now takes
  a map: `enabled` (default true -- writing the map at all opts in),
  `interval` (seconds between process-tree samples, default `1.0`, floor
  `0.1`), and `history` (chart points kept per run, default `240`, `0` for
  summary-only, ceiling `2000`). Validated at load time with the other
  numeric ranges, merged normally under `defaults:`, and accepted by DAG
  tasks. Not fingerprinted, like the bool before it.

- **Per-run chart series.** Each monitored run now records a
  `[t, cpu%, rss]` point per sample, downsampled in place once it exceeds
  the `history` cap: adjacent buckets merge with *mean* CPU but *peak* RSS,
  so the memory spikes people monitor for survive downsampling, and a run
  of any length stays a few KB with uniform bucket widths. The series is
  embedded in the durable run record's `resources.series` -- charts survive
  restarts and are bounded by the existing `state.maxRunsPerJob` pruning --
  and is deliberately **excluded** from the polled `/jobs` and
  `/jobs/{name}/runs` payloads, which keep their summary-only shape.

- **`GET /jobs/{name}/resources`.** A lazy, chart-grade endpoint: the
  run-so-far series of every currently-running monitored instance (plus its
  live instantaneous readings) and the recorded series of recent finished
  monitored runs, capped by a `runs` query parameter. `monitored: false`
  with empty lists distinguishes "never opted in" from "no data yet".

- **`GET /node/history` + `web.nodeHistory`.** A background sampler records
  the node's CPU/memory (the same cgroup-aware percentages `GET /node`
  reports) into an in-memory ring -- every 5s, keeping the last hour, by
  default. It follows the web app's lifecycle, is on whenever the web API
  is on, and is tuned or disabled via `web.nodeHistory`
  (`interval`/`points`, or `false`). A gap wider than the cadence in the
  returned points means the daemon was down, not idle.

- **Dashboard: the Resources tab and the node card.** The job drawer gains
  a **Resources** tab: CPU and memory drawn as separate small-multiple
  charts (one honest 0-based axis each, never dual-axis) with a synced
  crosshair and tooltip, chips to flip between the live instance and recent
  runs, a refresh-cadence selector for the live view (1s-10s, persisted),
  and clickable per-run CPU-time / peak-memory trend strips. Clicking the
  header node meter opens a **node resources** card charting the retained
  node history. Chart inks are the theme-aware blue/amber pair
  (colorblind-safe and contrast-checked against every theme surface, light
  and dark), gaps in sampling break the line rather than lying across it,
  and unmonitored jobs get a pointer at the config instead of an empty
  chart.

- **Examples.** The grand tour (`_defaults.yaml` + `platform.yaml`) and the
  large-cluster demo now use the map forms -- a 0.5s sampling cadence with
  the default 240-point series, and a 2s x 1800-point node history ring --
  so their resource-heavy jobs light the new charts up out of the box.

## 1.2.8 (2026-07-07)

This release answers "what is this actually *using*?" Opt-in per-job
**resource monitoring** records every run's CPU time and peak memory and
carries the numbers everywhere a run already reports -- the dashboard, the
HTTP API, Prometheus, statsd, and failure reports -- while a new `GET /node`
endpoint and a `cluster.observability` block put live whole-node load beside
the jobs, on every node in the fleet. One footprint note: **psutil joins the
core dependencies**, the fork's first addition to the core install (it ships
wheels for the mainstream targets and builds from source elsewhere).
Behavior is unchanged without the new config: `monitorResources` is off by
default and the observability overlay is opt-in.

- **Per-job resource monitoring (`monitorResources: true`).** A
  psutil-backed sampler polls the run's whole process tree and records its
  total CPU time (user and system) and its sampled peak resident memory.
  Accounting is best-effort by design: a process that exits mid-sample, a
  platform that denies the read, or psutil failing outright simply yields
  whatever was captured so far -- monitoring never crashes a job, never
  delays it, and never changes its success/failure verdict. Peak RSS is a
  sampled high-water mark and per-member CPU is banked as the tree shrinks,
  so the long, heavy runs that matter are measured well; only a child that
  spawns *and* exits within a single sampling gap escapes entirely.

- **The numbers surface everywhere a run does.** The dashboard overview
  shows live CPU/memory chips on a running job, and the history tab adds
  per-run CPU and peak-memory columns and stats. The HTTP API carries
  `resources` on each run in the history, live `running_resources` on a
  running job, and windowed CPU/RSS aggregates in the job stats. Prometheus
  grows `cronstable_job_cpu_seconds_total{job_name, mode}` (user/system),
  `cronstable_job_peak_rss_bytes`, and last-run CPU/RSS gauges -- emitted only
  once a job has a monitored run, and persisted across restarts by the
  durable metrics snapshot. A monitored run's statsd stop datagram gains a
  `cpu` timer and a `max_rss` gauge (an unmonitored job's datagram is
  unchanged), and failure reports get `cpu_seconds` / `max_rss_bytes` (and
  friends) plus `CRONSTABLE_CPU_SECONDS` / `CRONSTABLE_MAX_RSS_BYTES` template
  variables.

- **`GET /node`: the node's own live load.** A new endpoint samples the
  serving host's CPU and memory fresh per request -- whole-host utilisation
  plus the daemon's own footprint -- and drives a node meter in the
  dashboard header. It is container-aware: under a cgroup v2 limit
  (Docker/Kubernetes limits, systemd slices) the numbers describe the
  daemon's *slice* -- the effective memory limit with reclaimable page cache
  excluded (the same accounting `docker stats` shows) and utilisation of the
  CPU quota -- with memory and CPU switching over independently. Unlimited
  cgroups, cgroup v1 hosts, and non-Linux platforms report whole-host
  numbers, and the response shape never changes.

- **`cluster.observability`: gossip as a secondary data plane.** Opt in and
  every node shares its whole-node CPU/memory across the cluster. Under
  `backend: gossip` the reading rides the election mesh as a small
  `X-Cronstable-Node-Stats` response header on full and `304` responses alike,
  so a sharing cluster's steady-state round still costs headers only. The
  lease backends (`kubernetes`/`etcd`/`filesystem`), which have no
  node-to-node channel of their own, can stand up a *second,
  election-inert* gossip mesh purely for observability data -- which also
  brings the fleet view to lease-backed clusters. The dashboard's cluster
  panel gains per-peer load meters and the fleet view puts each node's live
  load in its column header. Like the run summaries, node stats are
  best-effort display data: a malformed peer payload degrades to "no data",
  never poisoning the view or any decision.

- **Dashboard themes.** A new `carolina-light` theme joins the palette, and
  `carolina` replaces `amber` as the default.

- **Docs and examples.** The README is rebuilt around a sixty-second quick
  start, four tutorials (alerting and retries, durable restarts, a first
  DAG, two-replica leader election), and a screenshot tour of the
  dashboard; the screenshots themselves are now reproducible via a scripted
  pipeline under `docs/screenshots/` that captures a live grand-tour fleet.
  The grand tour gains resource-monitored CPU- and memory-heavy demo jobs
  and the observability overlay, and the new features are documented on the
  wiki's Configuration-Reference, HTTP-API, Clustering, Metrics, and
  Reporting pages.

## 1.2.7 (2026-07-06)

This release makes cronstable **stateful**. An opt-in durable state store lets
the scheduler remember across restarts -- retries that survive a daemon
restart, `@reboot` that really means once per boot, Prometheus counters that
do not reset -- and turns a shared directory into fleet-wide coordination:
cluster-scoped concurrency, cross-node retry takeover, and leader election
with nothing but a mount both nodes can reach. On top of the store sit a
state API handed to every job (key-value, cursors, fleet locks, idempotency
claims, artifacts, run-scoped secrets) and durable **DAG orchestration**
(dependencies, XCom, fan-out, sensors, approval gates, backfills) with
crash-resume. All of it is opt-in: without a `state:` block (and a `dags:`
block for pipelines) nothing changes -- no new behavior, no new files on
disk, and the zero-new-dependency, architecture-portable core install is
untouched.

- **A durable state store behind a single `state:` block.** `state.path`
  names a directory -- a local disk or a shared NFS/EFS-style mount -- and
  the daemon keeps everything under `<path>/<deploymentId>`: append-only
  JSON record streams, mutable documents, content-addressed blobs, and
  `flock`-guarded TTL leases with monotonic fence counters. The write
  discipline is crash-safe on POSIX and Windows alike (atomic
  temp-plus-rename with directory fsyncs; a record that cannot be parsed is
  quarantined, never trusted and never fatal), files are owner-only
  (`0o700`/`0o600` -- archived job output is exactly where secrets live),
  and archived output additionally passes through a conservative
  best-effort secret redactor before it is written. A store outage degrades
  the stateful features, never scheduling: durable writes are
  fire-and-forget, reads on scheduling paths are bounded and fall back,
  store calls run on abandonable worker threads so a hung hard mount cannot
  wedge the daemon or its shutdown, and `state.maxOpsPerSecond` throttles
  everything except lease renewals, which must never queue behind bulk
  work. The full model is documented on the wiki's Durable-State page.

- **Restart-surviving scheduling.** With a store configured, a pending
  retry re-arms after a daemon restart instead of vanishing; `@reboot`
  distinguishes a real boot from a mere restart (and, under election, runs
  once per fleet); Prometheus counters persist across restarts; the run
  ledger, optionally archived output (`archiveOutput`), and catch-up
  checkpoints are durable; and in-flight run records let a restarted daemon
  settle the runs that died with it, so failure handlers and retries fire
  for work a crash orphaned.

- **Garbage collection that can prove absence.** Every node periodically
  writes a manifest of the jobs, scopes, and dags it carries; GC deletes a
  stream only when no recent manifest references it *and* its newest record
  is older than `state.gcGraceSeconds` (default seven days), and it defers
  wholesale until the retained manifest history spans a full grace window
  -- "nobody has manifested yet" never reads as "nobody wants this".
  Artifact streams and payload blobs age out with their scope, run
  documents of removed dags are collected by the daemon that owned them,
  and of the lease files only the per-run DAG advance class is ever
  reclaimed: every other lease carries fences that persist in durable
  records, so it is never deleted at any age. A node that is merely down
  loses nothing.

- **Fleet HA through a shared directory.** A new `cluster.backend:
  filesystem` runs leader election over the same flock-and-lease machinery
  -- no gossip ports, no Kubernetes, no etcd -- and composes with
  everything clustering shipped in 1.2.1. `concurrencyScope: cluster` makes
  `concurrencyPolicy: Forbid`/`Replace` hold fleet-wide through per-job
  slot leases (a `Replace` fired anywhere cancels the run wherever it
  lives; a crashed holder's slot frees by TTL), a pending retry left by a
  dead node can be claimed and resumed by a survivor -- serialized on a
  claim lease and re-checked under it; the contract is at-least-once,
  honestly -- and `@reboot` under election survives leader failover in the
  safe direction: a takeover can delay a one-shot, never double-run it.

- **Every job gets a state API.** With `state.jobApi` (on by default once a
  store is configured) the daemon serves a loopback-only HTTP endpoint and
  injects its address and a per-run bearer token into each job's
  environment; the `cronstable` binary doubles as the client. `cronstable state
  get/set/delete/keys` is durable KV; `cronstable cursor` keeps resumable
  positions; `cronstable lock` gives fleet-wide mutexes and semaphores backed
  by the same TTL leases the cluster uses, with fencing tokens and a
  `lock run --` wrapper; `cronstable idempotent` makes run-once guards honest
  (exit `0` fresh, `5` duplicate, `1` transport or store error); `cronstable
  artifact` stores content-addressed payloads under configurable size caps.
  A job's `secrets:` block stages secrets over the endpoint for exactly one
  run -- resolved fresh, served only to that run, never in the environment
  and never in the durable store -- read back with `cronstable secret get`.
  Scopes default to the job's own name; `stateAllowedScopes` opens shared
  ones.

- **DAG orchestration.** A new `dags:` section defines multi-step pipelines
  on the job grammar: tasks with `dependsOn` and per-task retries, XCom
  hand-off between tasks (`cronstable xcom push/pull`), mapped fan-out over a
  pushed list (capped, launched in bounded batches), sensors that poke on
  an interval, and approval gates a human resolves from the dashboard or
  API. Dags run on cron schedules (the job schedule grammar, minus
  `@reboot`), manual triggers, and date-range backfills. A per-run advance
  lease makes exactly one node drive each run; when a driver dies, the
  lease lapses and a peer adopts the run mid-flight, reconciling exactly
  what was and was not still running. Run history is retained per-dag and
  collected under the same grace rules. See the wiki's
  Orchestration-and-DAGs page.

- **Dashboard and HTTP API.** The dashboard gains DAG cards with a run
  drawer and task graph (trigger, backfill, and approve/reject inline),
  cluster/HA chips, per-job durable run history, and a metadata-only state
  inspector -- streams, documents, leases, and blob inventory by name and
  size, never values. The HTTP API adds the matching `/dags/...` routes
  (runs, XCom, live task logs) and `/state` inventory routes, and the
  Prometheus endpoint grows state-store and DAG metric families. All routes
  are documented on the wiki's HTTP-API page.

- **Store administration.** `cronstable state backup` writes an owner-only
  `.tar.gz` of the whole store, safe against a live daemon; `state restore`
  merges it back atomically (fence-aware, refuses a non-empty store without
  `--force`, and is not safe while a daemon uses the store); `state
  migrate` copies a store across paths or mounts without a reader ever
  seeing a torn record; `state gc [--dry-run]` runs or previews a
  collection pass; `state check` verifies the store is usable and prints an
  inventory; `state migrate-schema` rewrites records of older known
  schemes.

- **Packaging and examples.** orjson joins uvloop in the `speedups` extra,
  accelerating the durable-state and cluster-gossip JSON paths through
  `cronstable._json`, whose stdlib fallback is behavior-identical -- the core
  install stays zero-new-dependency and architecture-portable, and the
  prebuilt binaries bundle orjson wherever a real wheel or verified source
  build exists, with a verify-or-strip step mirroring uvloop's. New
  examples: `example/job-state` (the CLI primitives), `example/dag` and
  `example/dag-cluster` (pipelines, single-node and fleet), and
  `example/grand-tour` (a docker-compose fleet exercising the whole
  feature set end to end).

## 1.2.6 (2026-07-03)

A toolchain and packaging release. There are **no behavior, API, or
configuration changes** and **no change to the core dependency footprint**: the
published wheel/sdist and the release binaries are built from the same sources
as before and install exactly the same runtime dependencies. The work adopts
[uv](https://docs.astral.sh/uv/) across the paths where it pays off -- CI, the
build/release pipeline, and the local dev loop -- refreshes the container base
images, and pins the CI action versions.

- **uv on the runner-native CI, build, and dev paths.** The dist build
  (`uv build`), the runner-native PyInstaller binaries (macOS, Windows, and the
  all-wheels Linux arches, each installed into a throwaway `uv venv` and frozen
  with `uv run`), the version probe (`uv run --no-project --with
  setuptools-scm`), and `twine check` (`uvx twine`) all run through uv now:
  parallel downloads and a shared global wheel cache make them markedly faster,
  and the results are behavior-identical. `UV_PYTHON_DOWNLOADS=never` keeps uv on
  the exact interpreter `setup-python` pinned rather than fetching a managed one,
  and `UV_HTTP_TIMEOUT` carries the same transient-network hardening the pip
  paths had.

- **The emulated foreign-arch binary legs stay on pip, on purpose.** The
  musl and glibc-extra binary jobs (armv7/armv6, ppc64le, s390x, riscv64, i686)
  build inside `docker run` containers under QEMU, where uv is not a fit: its
  official image is amd64/arm64 only and it has no musl ppc64le/s390x wheels, so
  pip remains the arch-portable choice there and keeps `PIP_RETRIES`/`PIP_TIMEOUT`
  hardening. The uvloop bundling and per-arch `--version` smoke test are
  unchanged on every leg.

- **uv in the local dev loop.** `tox.ini` now declares `requires = tox-uv`, so a
  plain `tox` auto-provisions its environments and installs dependencies with uv
  (much faster, behavior-identical); `tox-uv` is added to the `dev` extra and
  `requirements_dev.txt`. `CONTRIBUTING.md` documents the uv quickstart
  (`uv venv`, `uv pip install -e ".[dev]"`) alongside the unchanged stock
  `venv`+`pip` path, and notes the `tox --runner virtualenv` escape hatch for
  anyone who wants the legacy runner.

- **Refreshed container base images.** The Docker variant matrix moves to
  current bases: `ubuntu` 24.04 -> 26.04 (Python 3.12 -> 3.14), `rhel` UBI9 ->
  UBI10, `fedora` 41 -> 44 (3.13 -> 3.14), `opensuse` Leap 15.6 -> 16.0 (3.11 ->
  3.13), and `distroless` to Python 3.13. The Debian/Alpine images and every
  image tag stay as they were.

- Internal: every CI-consumed action is pinned to an exact version
  (`checkout@v7.0.0`, `setup-python@v6.3.0`, `setup-uv@v8.2.0`, the docker/*
  actions, etc.), and a `dependabot.yml` is added to keep those pins and the
  Python dev dependencies current.

## 1.2.5 (2026-07-03)

A performance and footprint release. There are **no behavior or configuration
changes**: every schedule fires exactly as before, the metrics endpoint renders
byte-for-byte identically, and the core install stays zero-new-dependency. The
work trims CPU on the daemon's hottest repeating paths -- the once-a-minute
config reload, every Prometheus scrape, and each cluster poll / gossip round /
lease renew -- lowers steady-state memory, and adds an optional faster event
loop.

- **Optional uvloop event loop (`speedups` extra).** `pip install
  cronstable[speedups]` swaps asyncio's selector loop for uvloop's faster
  libuv-based one, speeding every I/O path cronstable drives: cluster gossip and
  lease HTTP, the web dashboard, and the Prometheus scrape. It is entirely
  opt-in and best-effort -- `__main__` selects uvloop lazily on POSIX and falls
  back to stock asyncio, behavior unchanged, whenever it is absent or
  unimportable -- so it stays off the core install to keep the baseline
  architecture-portable. Windows always uses its Proactor loop (there is no
  uvloop build there, and the Proactor loop is required for subprocess support
  anyway). The prebuilt POSIX binaries now bundle uvloop wherever it builds: a
  wheel where one exists, an otherwise verified source build, with a start-up
  self-test (`verify_uvloop.py`) that uninstalls a miscompiled build (a real
  risk under QEMU emulation) before freezing so the binary cleanly runs on
  asyncio instead. An arch where uvloop cannot build ships the asyncio binary
  exactly as before.

- **The once-a-minute reload no longer reparses an unchanged config.** The
  scheduler rereads and reparses the config every minute so an on-disk edit is
  picked up promptly, but strictyaml is a slow pure-Python parser and reparsing
  an unchanged file was pure wasted work (in a worker thread, but still real CPU
  plus thread-pool churn). `reload_config` now compares a cheap `os.stat`
  fingerprint -- `(path, mtime_ns, size)` per file, plus the config directory's
  own mtime -- of exactly the files the last parse read (the top-level config,
  every transitively `include`d file, and each job's `env_file`) and skips the
  reparse entirely when nothing has changed, returning the already-loaded
  config. A genuine edit, a vanished file, or a new entry dropped into a config
  directory still reparses on the next pass.

- **Cheaper Prometheus scrapes.** The job-set fingerprint (`job_set_id`, queried
  on every scrape and every cluster poll / gossip round / lease renew) is a pure
  function of the loaded jobs, so it is now computed once per reload and
  memoized rather than re-deriving its per-job deepcopy / JSON / SHA-256 each
  time. The `job_next_run_timestamp` gauge reads the scheduler's authoritative
  next-fire index instead of re-walking every crontab and building two aware
  datetimes per job per scrape (falling back to a direct computation only in the
  brief start-up window before the index is seeded). The histogram `le` label
  strings are precomputed once from the bucket bounds rather than re-rendered for
  every bucket of every job on every scrape.

- **Lower steady-state memory and faster attribute access.** `JobConfig` -- one
  instance per configured job for the life of the process -- now declares
  `__slots__`, trimming its per-instance `__dict__` and speeding the attribute
  reads on the scheduling hot path. Fingerprint redaction is now copy-on-write
  instead of `deepcopy`, so the long immutable report templates (the sentry body,
  the webhook body) are shared by reference rather than duplicated on each
  fingerprint. The PyInstaller binaries are now built with `optimize=2`, which
  strips docstrings and (side-effect-free, internal-invariant) asserts from the
  frozen bytecode -- cronstable's modules are deliberately docstring-dense, so this
  shrinks the binary and lowers resident memory for the life of the daemon.

- **The idle scheduler no longer polls once a second.** The job reaper waited on
  its "any job running?" event with a one-second timeout, waking every second
  even when nothing was running. It now blocks on the event outright -- the wait
  condition can only change when a job launches or shutdown is signalled, both of
  which set the event (shutdown now does so explicitly, so the reaper exits
  promptly) -- so a fully idle daemon does no per-second work. A related fix reads
  the running-jobs map with `.get()` so a concurrency check can no longer leave a
  phantom empty entry that would spin the reaper hot at shutdown.

- Internal: the reload skip cache (change detection, the failed-parse and
  worker-thread paths), the memoized fingerprint, and the scrape reading the
  seeded next-fire index are covered by new tests; the uvloop bundling is gated
  behind a build-time verification step and the per-arch `--version` smoke test.

## 1.2.4 (2026-07-03)

This release re-implements the scheduler core added in 1.2.3 without changing
what it does: every schedule fires exactly when it did before, and there are no
configuration changes. The daemon no longer wakes on a fixed cadence and tests
every job against the clock; it keeps each job's next fire time in an index and
**sleeps until the soonest one is due**, servicing only the jobs whose moment
has arrived.

- **Per-wake cost scales with jobs *due*, not jobs *configured*.** The previous
  loop matched every enabled job against the clock on every tick -- and with a
  second-level job present that tick was once a second, so the whole job set was
  scanned every second. The scheduler now maintains a next-fire index (each
  job's next-fire instant, mirrored in a min-heap), sleeps until the earliest
  entry, and touches only the jobs actually due. An idle wake over a large fleet
  is an O(1) heap peek that runs zero cron matching; a wake with a cohort due
  matches only that cohort. A deployment running thousands of sparsely-scheduled
  jobs pays dramatically less per wake, and adding a second-level job no longer
  imposes a per-second scan of everything else.

- **Robust across wall-clock and NTP steps.** The sleep is realized against the
  event loop's monotonic clock, and firing compares the wall clock against
  fixed, forward-only next-fire instants. A clock step **backward** (an NTP
  correction or a manual set) now defers the pending fire instead of re-running
  a slot that already fired; a large step **forward**, a resume from suspend, or
  an RTC-less boot corrected far ahead resumes at the current slot in O(1)
  instead of enumerating and replaying every occurrence in the skipped span. The
  bounded catch-up for a genuinely overrun pass -- a slow config reload, a burst
  of simultaneous launches -- is retained, still capped at the ten-second
  `CATCHUP_LIMIT`, and now covers minute- and second-level jobs by the same
  path.

- **De-duplication is now structural.** A fired slot cannot fire twice because
  advancing the index moves a job's next fire strictly past the slot it just
  fired; the old per-slot "did this already run?" gate is gone (`_last_run_slot`
  is kept only for status/introspection). All the surrounding guarantees are
  preserved: a job fires exactly once per matching slot, a mid-period restart
  skips the period already under way (the index is seeded strictly-future at
  start-up), `@reboot` jobs run once at boot, a config reload landing on a job's
  own boundary minute does not skip that fire, and housekeeping (config reload,
  cluster and web upkeep, logging) still runs at most once a wall-clock minute.

- Internal: the next-fire index, monotonic sleep, clock-step handling, reload
  reconciliation, and a fleet-scale performance demonstration are covered by a
  new batch of scheduler tests; the wiki's "How the scheduler ticks" section is
  rewritten to describe the index.

## 1.2.3 (2026-07-02)

This release brings **second-level (sub-minute) scheduling**: a job can now
fire at second granularity, either through a new `second` field on the schedule
object or a full seven-field crontab string. The scheduler keeps its historical
once-a-minute cadence -- and its zero overhead -- until some enabled job
actually asks for seconds, at which point it ticks once a second, firing
second-level jobs on time while every minute-level job still fires exactly once
in its minute. The release also starts honoring the schedule object's `year`
key (previously accepted but silently dropped, a behavior change for the few
configs that set it), surfaces a malformed schedule as a named `ConfigError` at
reload instead of an anonymous traceback, teaches the web dashboard to parse,
describe, and preview five-, six-, and seven-field expressions, and ships two
runnable examples (`pulse-monitor` and its clustered sibling `pulse-cluster`)
built around second-level probing. Sub-minute scheduling is entirely opt-in;
see the upgrade notes below for the one behavior change that can affect an
existing deployment.

### Second-level (sub-minute) scheduling

- **New `second` field and seven-field crontab strings.** parse-crontab reads
  extra columns from the *ends* of a crontab line, so the field count selects
  the dialect: a five-field line has an implicit second of `0` and any year, a
  six-field line adds a trailing **year** column, and a seven-field line adds a
  leading **second** column too (`second minute hour dayOfMonth month dayOfWeek
  year`). So the object `second: "*/15"` and the seven-field string
  `"*/15 * * * * * *"` both fire every 15 seconds, while a six-field string pins
  a year and stays minute-granular. The `second` field takes the same syntax as
  any other (`*`, `*/5`, `0,30`, `10-20`); `second: "*"` fires every second.
  Second-level scheduling is a YAML feature: classic crontab files stay
  five-field and minute-granular.

- **Adaptive cadence, zero cost when unused.** The scheduler ticks once a second
  only while some *enabled* job pins a second (`Cron._needs_subminute()`);
  otherwise it keeps the historical once-a-minute cadence, aligned to the top of
  each UTC minute, byte-for-byte as before. A disabled second-level job never
  forces the per-second cadence. The cadence is re-evaluated every tick, so a
  reload that adds or removes a second-level job switches modes on that same
  tick.

- **Exactly once per slot; mixed cadences.** Each pass reads the clock once and
  tests every job against a single scheduling "slot" truncated to that job's own
  resolution -- the whole second for a second-level job, the top of the minute
  otherwise. Launches are de-duplicated per slot (`_last_run_slot`), so a
  minute-level job now tested up to 60 times in its due minute still fires
  exactly once, and a second-level job fires once per matching second even if two
  ticks land in the same second. A leader-gated job is evaluated once per slot
  regardless of which node runs it. Sub-minute and per-minute jobs mix freely in
  one config; `concurrencyPolicy` still governs overlap as before.

- **Catch-up for overrun seconds, bounded.** In sub-minute mode, if a pass runs
  long -- many simultaneous launches, or the once-a-minute config reload -- and
  the clock crosses one or more whole seconds before the next pass, the skipped
  seconds are serviced after the fact, so a second-level job due in the gap still
  fires (once) rather than being silently dropped. The replay is bounded by a
  ten-second `CATCHUP_LIMIT`: a larger gap is treated as a stall, suspend, or
  clock jump and resumed past with a warning, never replayed as a burst of
  backdated launches (matching cron's no-catch-up-after-an-outage behavior).
  Minute-level jobs need no catch-up: their minute-truncated slot already
  absorbs any sub-minute overrun.

- **No spurious run at a mid-period restart.** On startup the de-dup map is
  seeded with the in-progress slot for every scheduled job, so a job whose
  minute (or second) is already under way does not fire immediately on the first
  tick; it first fires at the next matching boundary, exactly as in minute-only
  mode. Without this, merely having any second-level job present would have made
  every minute-level job fire about a second after a mid-minute restart.
  `@reboot` jobs are unaffected and still fire once at startup.

- **Concurrent launches within a slot.** When several jobs are due in the same
  slot, `spawn_jobs` now launches them concurrently instead of one at a time.
  With N jobs sharing a slot the old serial form cost N times a subprocess spawn
  -- the dominant source of same-second overrun -- which now collapses to about
  a single spawn. The single-job case (the norm) still takes a direct await and
  is byte-identical to before, and the de-dup and cluster-gate decisions are
  still made sequentially, so only the per-job "Starting"/"spawned" log lines may
  now interleave.

- **Config reload moved off the event loop.** The once-a-minute reload now runs
  its disk read and full reparse in a worker thread (`reload_config`), so a slow
  parse no longer freezes the event loop -- web API, cluster gossip, job-output
  pumping -- for its whole duration. The parsed job set is still applied on the
  loop thread and *before* jobs are serviced, so the cluster leader-gate is
  always current for the tick. Housekeeping (config reload, cluster and web
  (re)start, logging config) is gated to run at most once per wall-clock minute
  even while the loop ticks per second; in pure minute-tick mode it runs every
  iteration, exactly as before.

### The `year` schedule key is now honored

- **`year` restricts the schedule to specific years.** Earlier releases accepted
  a `year` key on the schedule object but built only a five-field crontab string
  from it, silently dropping `year` so it had no effect -- a job with an
  object-form `year` ran every year. It is now emitted as parse-crontab's
  trailing year column and honored, so `year: "2017"` really does pin the
  schedule to 2017. (String schedules were always passed to parse-crontab
  verbatim, so a six-field string already honored its year; only the object form
  changes.) This is a behavior change -- see the upgrade notes below.

- Honoring `year` changes that job's job-set fingerprint, so during a rolling
  upgrade of a cluster the old and new binaries compute different `job_set_id`s
  for the identical config and will not treat each other as agreed peers until
  every node is upgraded -- the same transient, self-healing drift as any config
  rollout, and leader election stays at-most-once throughout. Jobs that do *not*
  use object-form `year` are unaffected: their fingerprint is byte-for-byte
  identical to before.

### Schedule parsing, errors, and fingerprints

- **A malformed schedule now fails the reload with a named error.**
  parse-crontab's `ValueError` on a bad field (an out-of-range value, the wrong
  field count) is caught and re-raised as `ConfigError("invalid schedule
  '...': ...")`, naming the offending expression, so a bad schedule fails config
  load or reload cleanly with a message the reload loop can log, rather than
  surfacing as an anonymous traceback.

- **One object-to-crontab builder, shared everywhere.** A single
  `schedule_object_to_crontab` helper now renders the object form to a crontab
  line -- five fields normally, six or seven when `year`/`second` are used -- and
  is shared by parsing, the fingerprint, and the dashboard's schedule label, so
  those three can never disagree on the mapping. The object form still collapses
  to the exact five-field line as before when neither `second` nor `year` is set,
  so its fingerprint is unchanged. Whether a schedule counts as second-level is
  derived from the *actual rendered field count* (seven), not mere key presence,
  so a blank `second:` value that renders an empty column does not force the
  whole scheduler onto the per-second cadence.

### Web dashboard

- **Cron parsing, description, and preview understand five-, six-, and
  seven-field expressions.** The client-side cron engine normalizes any of the
  three widths (implicit second `0` and any year for five fields, a trailing year
  for six, a leading second for seven), computes next-fire times at second
  resolution with year restriction (and parse-crontab's 2099 year ceiling), and
  renders wall-clock times with a seconds component where the schedule has one.
  Plain-English descriptions gain "Every second", "Every N seconds", "At
  second(s) ...", and an "in {year}" clause -- and deliberately do *not* lead
  with a per-second cadence phrase when a coarser field is restricted, so a
  schedule like `* 30 * * * * *` is not described as firing every second.

- **Cron sandbox covers the new widths.** The palette's schedule sandbox
  validates 5-, 6-, and 7-field expressions (its error copy and field-breakout
  labels updated to match, labelling the leading second and trailing year
  columns correctly), and its next-fire preview shows seconds.

- **Clicking the wordmark spins the logo.** The "cronstable" wordmark now triggers
  the same mark-spin animation as clicking the mark glyph.

### Examples and documentation

- **`example/pulse-monitor`** -- a small, runnable real-time uptime / SLA monitor
  built entirely on second-level scheduling: it probes a latency-critical service
  every few seconds, heartbeats every ten, and rolls up a summary once a minute
  (which still fires exactly once per minute alongside the per-second probes). It
  watches cronstable's own `/status` endpoint, so `docker compose -f
  docker-compose-pulse.yml up` needs nothing else running.

- **`example/pulse-cluster`** -- the clustered sibling: a three-node,
  mutual-TLS, leader-electing cluster that splits the monitoring work the way a
  real fleet should -- `liveness-probe` runs on every node (independent vantage
  points catch a partition outage), while `latency-slo` and the summary run on
  the leader only. A one-shot service mints throwaway certs, and an optional
  `distribution: spread` fans the leader jobs across nodes by rendezvous hashing.
  `docker compose -f docker-compose-pulse-cluster.yml up`.

- The wiki and README are updated throughout: a new "Second-level schedules"
  reference and field-count table in Schedules and Timezones, the `year` key
  documented as honored (with an upgrade note), a Troubleshooting entry on the
  common "six fields is a year, not seconds" mistake, the Configuration Reference
  `schedule` row, and the Web Dashboard sandbox notes.

- Internal: second-level scheduling ships with a matching batch of tests
  (`test_cron.py`, `test_config.py`, `test_fingerprint.py`) covering the object
  and string spellings, the adaptive cadence and its zero-overhead minute path,
  per-slot de-duplication, bounded catch-up, the mid-period-restart seeding, the
  `year` fingerprint change, and the malformed-schedule error path.

### Upgrade notes

- **Object-form `year` is now honored (breaking).** A schedule object that sets
  `year` previously had no effect and now restricts the job to that year, so a
  past year stops the job firing. This is the only change that can affect an
  existing deployment, and only one that uses object-form `year`; to keep the old
  "runs every year" behavior, remove the `year` key. During a rolling cluster
  upgrade such a job's fingerprint changes, so mixed-version nodes will not agree
  on its `job_set_id` until all are upgraded (transient and self-healing; leader
  election stays at-most-once). All other schedules -- crontab strings, and
  object schedules without `year`/`second` -- behave and fingerprint exactly as
  before.

- **Seven-field crontab strings now fire at second granularity.** A seven-field
  string earlier fired at most once a minute, because the scheduler zeroed the
  seconds column and only woke per minute; such schedules were effectively
  meaningless. They now fire on the seconds they specify. This is unlikely to
  surprise, but audit any seven-field strings already in your configs.

## 1.2.2 (2026-07-02)

- **New webhook reporter: native Slack/Discord/Teams/ntfy notifications.** A
  fourth reporter joins sentry/mail/shell in every `report` block: `webhook`
  sends an HTTP request (POST by default) to a configured URL with a
  jinja2-templated body. The default body is a `{"text": ...}` JSON payload
  carrying the same subject-plus-body text as the default mail/sentry
  templates, JSON-encoded with jinja2's `tojson` filter so quotes, newlines,
  and non-ASCII job output always produce valid JSON -- point `url` at a
  Slack, Mattermost, or Teams incoming webhook and it works with no further
  configuration. `method`, `contentType`, `headers`, `body`, and `timeout`
  cover everything else (Discord's `{"content": ...}` shape, ntfy's
  plain-text body and header-driven priority, or your own endpoint). The URL
  resolves like the sentry DSN (`value` / `fromFile` / `fromEnvVar`) and is
  treated as a secret throughout: it is never logged, and the job-set
  fingerprint redacts the inline URL value and all header values (which
  commonly carry `Authorization` tokens). No new dependency -- outbound
  delivery rides the core aiohttp. Note: because every job's effective
  config gains the new default block, job-set ids change on upgrade;
  replicas must be on the same version to compare ids, as before.

- **Unchanged peers now answer gossip polls with a bodyless `304`.** Every
  `/peer` response carries a strong `ETag` (a content hash of the payload),
  and each polling node echoes the tag of the last full body a peer served
  it back as `If-None-Match`, so a peer whose state has not changed since
  then skips re-sending the full O(members + jobs) JSON: a converged, idle
  cluster's steady-state round costs headers rather than bodies. This is a
  transport optimization, not a protocol change. A `304` is still a fresh,
  mutually-authenticated round trip, and because the tag is content-derived,
  a match proves the peer's payload is exactly the one the poller already
  holds, so the poller replays its cached observation and every gate (mutual
  agreement, conflict detection, the `cluster.driftAfter` debounce) advances
  exactly as if the identical body had been re-sent. The one live field, a
  job's seconds-to-next-fire countdown, is hashed as the absolute next-fire
  time instead, so the tag stays stable between fires and rolls exactly when
  a schedule fires. Mixed fleets degrade safely during a rolling upgrade: an
  older peer ignores `If-None-Match` and keeps serving full bodies, a
  tagless response stops the poller from sending the header at all, an
  unsolicited `304` is recorded as a failed poll, and an over-long or
  non-printable tag is never stored or echoed.

- **Fleet-view countdowns are aged, not frozen.** With `304` rounds
  refreshing a peer's liveness without re-shipping its job summaries, a
  stored snapshot can now legitimately outlive many polling rounds, so
  `GET /fleet` re-derives each peer job's advertised `scheduled_in`
  countdown from the snapshot's age (an elapsed duration measured on the
  local clock alone, so peer clock offsets never leak in) instead of serving
  the value the snapshot arrived with, clamping at zero. The fire itself
  rolls the peer's `ETag`, so the next poll ships a full body carrying the
  real successor value.

- **`/peer` bodies that do go out are gzip-compressed** once they reach
  1 KiB (below that, the per-request CPU spend outweighs the few bytes
  saved). The polling side already advertised gzip support, and the existing
  response-size cap applies to the *decompressed* payload, so compression
  does not weaken it.

- **Dashboard: the version and job-set id header chips copy their value on
  click.** Header text is chrome and is no longer text-selectable; the two
  values worth grabbing hand themselves out instead. Clicking the version
  chip copies the version, and clicking the job-set chip copies the full
  job-set id even though the header shows only a short prefix; both
  tooltips say so. The command palette carries the same two copies, so both
  values stay reachable from the keyboard.

- **Dashboard: the quick "power-on sweep" flash between boots is gone.** The
  full POST boot screen still replays once its cooldown elapses, but the
  visits in between now start the app directly instead of playing a
  full-screen power-on animation first.

- Internal: the conditional exchange ships with a matching batch of cluster
  tests (tag stability across countdown ticks and rollover on a fire, the
  `304` replay path, unsolicited-`304` and unusable-tag rejection, countdown
  aging, and an end-to-end mutual-TLS `304`-plus-gzip round), and the wiki's
  Architecture and Internals page documents the exchange.

## 1.2.1 (2026-07-02)

This is the largest cronstable release since the fork. Its headline feature is
clustering: several replicas can now verify they hold the same job set, elect
a leader so each scheduled job runs on one node instead of every node, spread
jobs across the fleet, and fail over when a node dies, coordinating either
peer-to-peer over mutual TLS or through a Kubernetes or etcd lease. The
release also adds native Prometheus metrics, accepts classic crontab files as
configuration, and grows the web dashboard into a small operations console.
Clustering is entirely opt-in; see the upgrade notes below for the few
behavior changes that apply to existing deployments.

### Clustering and leader election

- **New optional top-level `cluster:` section.** Give every replica the same
  static peer list and a dedicated mutual-TLS listener (`cluster.listen`,
  `cluster.tls.{ca,cert,key}`, `cluster.peers`), and each node polls every
  peer once per round (`cluster.interval`, default 30 seconds), comparing
  job-set ids (see 1.1.8) to attest that all replicas hold an identical job
  set. Peers are reported as `agreed`, `syncing`, `drifted` (a mismatch that
  persists for `cluster.driftAfter` consecutive rounds, default 3),
  `unreachable`, `untrusted` (TLS verification failed), or `conflict`. On its
  own this is observe-only (every node still runs every job), which makes it
  a safe first rollout step.
- **Leader election (`cluster.electLeader: true`).** The leader is the lowest
  `cluster.nodeName` (default: the hostname) among the agreeing nodes this
  node can see, and only when they form a strict majority of the declared
  cluster size; below quorum a node stands down and runs nothing. Two
  disjoint majorities cannot exist, so a clean partition produces at most one
  leader within about one polling interval. Conflicts fail closed: a
  duplicate `nodeName`, a cluster-size disagreement (say, a rolling resize
  from 3 to 5 nodes), or a coordination-policy divergence parks `Leader` jobs
  until the fleet reconverges, and each is logged loudly and shown on the
  dashboard. A freshly started or reconfigured node holds its jobs until it
  has polled every configured peer at least once, so a blank-view node cannot
  elect itself. Two-node election is refused at config load (it is strictly
  worse than a single replica); even cluster sizes log a warning.
- **A per-job `clusterPolicy`** (also settable under `defaults:`) decides
  what election means for each job: `Leader` (the default: only the elected
  leader runs it, and it is skipped when in doubt), `PreferLeader` (never
  skip: the lowest reachable agreeing node runs it even without quorum,
  accepting a rare double-run, so reserve it for idempotent jobs), and
  `EveryNode` (all nodes run it, for node-local housekeeping). Manual runs
  via `POST /jobs/{name}/start` are never gated. Automatic retries re-check
  the gate before every relaunch and abandon a pending retry when ownership
  has demonstrably moved to another node; `@reboot` jobs under election are
  deferred until the cluster converges and then run once, on the owner.
- **Load-balanced jobs: `cluster.distribution: spread`.** Instead of one
  leader running every `Leader`/`PreferLeader` job, each such job is
  assigned an owning node by rendezvous (highest-random-weight) hashing over
  the quorate agreeing members, spreading work across the fleet. A
  membership change moves only the departing or joining node's share of
  jobs. The same quorum gating applies, and `GET /jobs` reports each job's
  current `clusterOwner`.
- **Mutual TLS is the entire trust boundary.** A peer must present a
  certificate chaining to the configured CA and matching the host it was
  reached at, so the CA should be dedicated to cronstable nodes and nothing
  else. Certificates rotated in place are detected and reloaded without a
  restart. The peer endpoint is served only on the cluster listener, never
  on the web API listener.
- **The failure semantics are documented, not hand-waved.** The built-in
  backend is best-effort by design: there are narrow, self-healing windows
  where a `Leader` job can be skipped or a `PreferLeader` job can
  double-run, and the new wiki page enumerates them. When you need fenced
  exactly-once execution, use one of the lease backends below.
- `cronstable --validate-config` validates the entire `cluster` section (peer
  list, TLS material, lease timing invariants) without starting anything.

### Kubernetes and etcd lease backends

- **`cluster.backend: gossip | kubernetes | etcd`** picks the coordination
  mechanism (default `gossip`, the peer-to-peer mode above). The lease
  backends replace the peer quorum with a fenced, expiring lease in an
  external store: exactly one node holds the lease at a time, so `Leader`
  jobs are exactly-once while the store is reachable. With a lease backend,
  election is always on, there is no peer list or cluster mTLS to manage,
  and `distribution: spread` is rejected at config load (a single lease
  cannot express per-job ownership).
- **Kubernetes** (`cluster.kubernetes.*`): replicas campaign for a
  `coordination.k8s.io/v1` Lease object using the client-go leader-election
  algorithm (`leaseName` defaults to `cronstable-leader`;
  `leaseDurationSeconds`/`renewDeadlineSeconds`/`retryPeriodSeconds` default
  to 15/10/2, with the client-go timing invariants enforced at config load).
  In-cluster ServiceAccount token, CA, namespace, and API server are
  detected automatically; a `kubeconfig` and an explicit `apiServer` (https
  only) are supported. The backend talks to the API server over the bundled
  aiohttp by default; installing the new optional extra
  (`pip install cronstable[kubernetes]`) switches to the official Kubernetes
  client (`clientLibrary: auto|http|library`). The stored holder identity
  embeds a per-process token, so two pods that accidentally share a name
  cannot both hold the lease. `example/kubernetes/` ships a ready-to-apply
  Deployment with the minimal ServiceAccount, Role, and RoleBinding.
- **etcd** (`cluster.etcd.*`): replicas campaign for a lease-bound key
  (`electionName` defaults to `cronstable/leader`) through etcd's v3 JSON/HTTP
  gRPC gateway, using a create-if-absent transaction fenced by the lease id
  (`ttl` defaults to 15 seconds, minimum 3). Multiple `endpoints` fail over
  in order; optional `username`/`password` (literal, `fromFile`, or
  `fromEnvVar`) and client TLS are supported, and credentials are refused
  unless every endpoint is `https://`. `example/etcd/` ships a compose demo
  with an etcd and two cronstable replicas.
- **Failover is fast and clock-safe on both.** All fencing runs on the
  monotonic clock with a one-second skew margin (no wall-clock or cross-node
  clock-sync assumptions): a holder whose renewals stall demotes itself
  locally before the store-side lease can expire, without a network call. A
  graceful shutdown releases the lease explicitly (Kubernetes clears
  `holderIdentity`, etcd revokes its lease) so a survivor takes over
  immediately rather than waiting out the TTL. If the store becomes
  unreachable, `Leader` jobs fail closed and `PreferLeader` jobs keep
  running. `@reboot` bookkeeping is persisted in the store, scoped to the
  job-set id, so a `Leader` `@reboot` job runs once per job configuration
  rather than once per fleet restart.
- **No new runtime dependencies.** Both lease backends are plain HTTP over
  the existing aiohttp core (no etcd or gRPC client; the Kubernetes client
  is optional), so all packaged architectures keep working.

### Prometheus metrics

- **Native Prometheus metrics at `GET /metrics`**, served by the existing
  web listener whenever the web API is enabled; there is no exporter
  sidecar and no new dependency (the exposition is generated in-process).
  Both the classic text format and OpenMetrics 1.0 are served, negotiated
  via the `Accept` header.
- Per-job series cover run outcomes (`cronstable_job_runs_total` labeled by
  `job_name` and `status`), retries, permanent failures, failures to start,
  a duration histogram with configurable buckets
  (`web.metrics.durationBuckets`), last success/failure/run timestamps and
  exit code, and live state (enabled, running, next run). Daemon series
  cover the version, start time, job-set id, job counts, and config-reload
  health. Cluster series cover size, quorum, leadership, per-peer status
  counts, conflicts, and leader/quorum transition counters; the wiki
  suggests alerting on `sum(cronstable_cluster_is_leader) > 1` and on losing
  `cronstable_cluster_quorate`. Metrics are recorded at the same point as the
  run history, so `/metrics` and `/jobs/{name}/runs` never disagree.
- `web.authToken`, when configured, protects `/metrics` like every other
  endpoint; `web.metrics.public: true` exempts just this endpoint for a
  scraper, and `web.metrics: false` removes it entirely.

### Classic crontab files

- **Classic (Vixie) crontabs are now accepted as configuration.** A file
  with a `.crontab` or `.cron` extension, or named exactly `crontab`, is
  parsed as a crontab wherever configuration is loaded: passed to `-c`,
  dropped into a config directory alongside `*.yaml` files, or pulled in
  via `include:`; a neutral-named file given to `-c` or `include:` is
  content-sniffed. Supported syntax: five-field entries (the same field
  dialect as YAML `schedule` strings), `@keywords` (including `@reboot` and
  `@midnight`), position-sensitive `VAR=value` environment lines, comments,
  and the `\%` escape. `SHELL` and `CRON_TZ` assignments are honored as the
  job's `shell` and `timezone`.
- Each entry becomes a normal cronstable job named `<file>:<line>`,
  indistinguishable downstream: it shows up on the dashboard and HTTP API,
  participates in the job-set id and clustering, and can be run or
  cancelled on demand. cronstable's defaults apply rather than cron's (UTC
  unless `CRON_TZ` says otherwise; any stderr output marks the run as
  failed). Deviations are deliberate and loud: an unescaped `%` is a
  load-time error instead of silently not feeding stdin, `MAILTO` is
  exported to the job but sends no mail, and the six-column system-crontab
  user field is not supported. Per-entry knobs (retries, reporting,
  timeouts) still require migrating that line to YAML; the new wiki page
  shows how.

### Web dashboard

- **A cluster operations view.** A cluster panel shows this node's role
  (leader, follower, or standing down and why), quorum state, and a
  per-peer table with status dots and a per-peer history timeline; lease
  backends show the lease holder and expiry instead. A page-level alert bar
  calls out a conflict or lost quorum without scrolling, and a fleet view
  (backed by the new `GET /fleet`) renders a jobs-by-nodes matrix of every
  node's last outcome per job, with a failing-only filter.
- **Incident tooling.** A verdict bar summarizes active failures and, when
  several jobs fail together, says whether they look like one cause or
  independent ones; an incident timeline (press `i`) orders every job's
  most recent run; a mitigate console bulk-starts or bulk-cancels jobs and
  copies a Markdown incident summary; and a merged multi-tail console
  streams up to four jobs' logs into one color-coded view.
- **Wallboard and zen mode.** A full-screen TV mode (press `w`, or open
  `#tv`) shows worst-first job tiles with a staleness watchdog that shows
  `NO SIGNAL` rather than a false all-green; when everything is healthy and
  idle for a while it drifts into a zen screensaver in which every job
  pulses at its real next fire time.
- **More ways to read the schedule.** An activity heatmap (a 6h/24h/7d
  punchcard per job), a cron sandbox that validates a cron expression and
  previews its next 12 fire times alongside live jobs sharing it, a column
  picker adding Owner/Policy/TZ/Next-at/Rate columns, and an opt-in,
  browser-local run ledger (IndexedDB) that keeps run history beyond the
  daemon's in-memory cap and flags unusually slow runs against each job's
  own duration baseline.
- **Finishing touches.** A fourth theme, `carolina` (a Carolina-Blue CRT
  phosphor), joins amber/green/modern; optional audible cues with a volume
  setting and an escalating failure alarm (press `a` to acknowledge); and
  an optional boot self-test splash on load.

### HTTP API

- New endpoints: `GET /cluster` (this node's cluster view: backend, quorum,
  leader, conflicts, per-peer detail) and `GET /fleet` (fleet-wide per-job
  run summaries carried on the gossip round; observability only), plus
  `GET /metrics` above. `GET /jobs` gains `clusterPolicy` and, under
  `distribution: spread`, each job's `clusterOwner`. The HTTP API wiki page
  now documents every endpoint with full response shapes.
- Configured `web.headers` are now applied to every successful response,
  including the new endpoints, and to the `409 Conflict` bodies of
  `start`/`cancel`.
- A web-section configuration error found during a reload now leaves the
  web API down with a clear log line while the rest of the reload (jobs,
  cluster, logging) still applies.

### Reliability

- **A failed job launch can no longer crash the scheduler.** Spawning a job
  now guards against any `OSError` (file-descriptor exhaustion, fork or
  memory limits, permission errors), not just a missing executable: the run
  is recorded as an ordinary start failure with exit code 127 (and counted
  in `cronstable_job_start_failures_total`) instead of the error propagating
  out of the scheduling loop and taking the daemon down.

### Upgrade notes

- `/metrics` is served by default wherever the web API is enabled. It sits
  behind `web.authToken` like every other endpoint when a token is
  configured; set `web.metrics: false` to remove it.
- The job-set id changes once on upgrade: `clusterPolicy` is now part of
  every job's fingerprint, so an unchanged configuration hashes to a
  different `v1:` id than 1.1.8 through 1.1.11 reported. Compare ids only
  between nodes running the same cronstable version; a mixed-version fleet
  reads as drift until the rollout completes.
- Config directories now load crontab-named files (`*.crontab`, `*.cron`,
  `crontab`) that earlier releases silently ignored.

### Packaging

- New `cronstable.backends` subpackage, and a new optional extra:
  `pip install cronstable[kubernetes]` installs the official Kubernetes client
  for `clientLibrary: library`. A core install gains no new runtime
  dependency and the supported Python range is unchanged. The PyPI metadata
  gains prometheus/metrics/monitoring keywords and an updated description.

### Documentation and examples

- Three new wiki pages: Clustering and Leader Election (a full operations
  guide: both backend families, every failure window, sizing, rollout, and
  troubleshooting), Metrics with Prometheus, and Classic Crontabs, plus
  major updates to the HTTP API, Web Dashboard, Production Deployment,
  Architecture and Internals, and Troubleshooting pages. The README gains
  matching sections.
- New runnable demos: `docker-compose-cluster.yml` (a three-node mutual-TLS
  cluster with scripted try-it failover scenarios),
  `docker-compose-cluster-large.yml` (ten nodes with `distribution: spread`
  and CPU-heavy jobs, for watching work fan out), `docker-compose-acme.yml`
  (a five-node simulated data platform with a mail sink, a statsd exporter,
  and deterministic scripted incidents that exercise the dashboard's
  incident tooling), `docker-compose-zen.yml` (one calm node for the zen
  screensaver), `example/etcd/` and `example/kubernetes/` for the lease
  backends, and `example/crontab/` for classic crontabs.

### Internal

- The test suite roughly doubles, with new suites for the cluster manager,
  both lease backends, the leadership abstraction, the Prometheus
  registry/exposition, crontab parsing, and backend config validation, plus
  large additions to the scheduler tests. The `dev` extra adds
  `cryptography` for the cluster mTLS tests (skipped on Windows ARM64,
  which has no wheel). A new `.gitattributes` pins `*.sh` to LF line
  endings so the bind-mounted cluster demos work from a Windows clone.

## 1.1.11 (2026-06-29)

- **Coverage is now published to [Codecov](https://codecov.io/gh/ptweezy/cronstable).**
  Every CI matrix cell uploads its own `coverage.xml` under an
  `<os>-py<version>` flag, and Codecov merges them into one combined number, so
  POSIX-only paths that Windows skips (privilege drop, `user`/`group`
  resolution) still count toward the published total instead of dragging it down
  to the lowest single row. tox now also writes the report it consumes
  (`pytest --cov-report=xml`). The hard pass/fail gate stays with tox's
  `--cov-fail-under=82` (see 1.1.10): Codecov's own project and patch status
  checks are configured as *informational* only, so they annotate pull requests
  without ever blocking them. The upload runs even on failed or cancelled jobs
  and keeps `fail_ci_if_error: false`, so a Codecov outage never reds the build,
  and flag `carryforward` keeps the combined number stable when a matrix row is
  skipped on a given run. The README gains a matching coverage badge.

## 1.1.10 (2026-06-24)

- **Numeric `user`/`group` is read as a uid/gid, not a login name.** In the
  config schema the `user`/`group` type was a `Str() | Int()` union, and
  strictyaml matched the always-accepting `Str()` first, so `user: 1000`
  arrived as the *string* `"1000"` and was looked up as a login *name*
  (`getpwnam("1000")`) rather than used as uid 1000. The union is now
  `Int() | Str()`, so a bare number is treated as the uid/gid it looks like; a
  non-numeric name (`user: www-data`) still falls through to `Str()`. (POSIX
  only; per-job `user`/`group` remains rejected with a configuration error on
  Windows.)

- **More resilient container builds.** Every image build, across the default
  Debian image and all seven distro variants (`-alpine`, `-ubuntu`, `-rhel`,
  `-fedora`, `-opensuse`, `-amazonlinux`, `-distroless`), now wraps its
  package-manager and `pip` network steps in a retry-with-backoff helper,
  alongside each manager's native knobs (`apt`'s `Acquire::Retries`, `dnf`'s
  `--setopt=retries`, an explicit `zypper refresh` retry, and a longer
  `pip --timeout`), so a transient mirror or package-index hiccup retries
  instead of failing the whole build. The build and test CI workflows get the
  same hardening via `PIP_RETRIES`/`PIP_TIMEOUT`, with `build.yml` forwarding
  them into its emulated cross-architecture binary builds via `docker run -e`.

- **The `-distroless` image now builds for `amd64`/`arm64` only.** The
  `gcr.io/distroless/python3-debian12` base publishes no `ppc64le` or `s390x`
  manifest, so requesting those arches aborted the distroless release with
  "no match for platform in manifest". The RPM-based variants (`-rhel`,
  `-fedora`, `-opensuse`) still cover the wider arch set.

- The README status badges also gain brand new colors
  (and logos on the PyPI/Python badges). yay

- Internal: branch coverage is now measured and gated in CI (tox runs
  `pytest --cov-fail-under=82`), backed by substantially expanded unit tests for
  config and user/group validation, config reload and graceful shutdown, the
  job runner, and the job-set-id fingerprint.

## 1.1.9 (2026-06-23)

- **More prebuilt container images.** Alongside the default Debian-based image,
  every release now also publishes the same build on seven more bases, each
  tagged with a `-<distro>` suffix: `-alpine`, `-ubuntu`, `-rhel` (Red Hat
  UBI 9), `-fedora`, `-opensuse` (Leap), `-amazonlinux` (2023) and
  `-distroless`, plus an explicit `-debian` alias for the default. Pick the base
  that matches your host userland or image-provenance policy; behavior is
  identical, since cronstable is a pure-Python app (Python >= 3.10) and each image
  uses its distro's native interpreter. The Debian image still owns the bare
  `latest`/`<version>` tags and the widest architecture coverage. See
  [Distro variants](README.md#distro-variants).

## 1.1.8 (2026-06-23)

- **Job-set id.** cronstable can now emit a *job-set id*: an order-independent
  hash of every job's effective configuration. Two instances deployed from the
  same configuration produce the same id, so replicas can confirm they hold an
  identical set of jobs. It is taken over the merged, effective config (so
  reordering jobs, or moving
  a setting into `defaults`, doesn't change it), normalizes equivalent schedule
  spellings, fingerprints `user`/`group` as configured rather than as a
  host-specific resolved uid/gid, and embeds no secret material (inline
  reporting secrets are redacted, and only `environment` variable names are
  hashed, not their values). Get it from
  the CLI (`cronstable --job-set-id`, prints and exits), the web API
  (`GET /job-set-id`, also `application/json`), and the dashboard header; it is
  logged once at startup and again whenever a config reload changes it. The
  scheme is versioned (a `v1:` prefix) so ids are only compared within a scheme.

## 1.1.7 (2026-06-23)

- **Windows support.** cronstable now runs natively on Windows, in addition to
  Linux and macOS. The core was made portable: the POSIX-only `grp`/`pwd`
  imports are now lazy and guarded, Ctrl-C / Ctrl-Break shutdown is wired up
  without the POSIX-only event-loop signal handlers, and subprocess argv is
  encoded per platform. `pip install cronstable` works on Windows, and every
  release now also ships self-contained binaries `cronstable-windows-amd64.exe`
  and `cronstable-windows-arm64.exe` (Python not required on the target).
  - On Windows a string `command` with no explicit `shell` runs through the
    native command processor (`%ComSpec%`, i.e. `cmd.exe`), mirroring the
    `/bin/sh` default on POSIX. Set `shell:` or pass `command` as a list for
    anything else.
  - The default config location (`-c`) is `%APPDATA%\cronstable` on Windows
    (`/etc/cronstable.d` is unchanged on POSIX).
  - Two features remain POSIX-only and are reported clearly on Windows: per-job
    `user`/`group` switching (rejected with a configuration error) and
    `unix://` web listeners (skipped with a warning; use an `http://` listener).
- CI now runs the test suite on Windows (x64 and ARM64) as well as Linux, and
  the per-commit build plus every release build the Windows binaries.
- Update the README `Platforms` badge to include Windows.

## 1.1.6 (2026-06-22)

- Add self-contained binaries for two more Linux architectures, bringing
  every release to eight Linux architectures plus macOS: 64-bit RISC-V
  (`riscv64`) in both glibc and musl flavors (`cronstable-linux-riscv64` and
  `cronstable-linux-riscv64-musl`) and 32-bit ARMv6 (`armv6`, e.g. Raspberry
  Pi Zero / Pi 1) in musl only (`cronstable-linux-armv6-musl`). As with the
  other binaries, Python is not required on the target system. Neither arch
  has a native GitHub runner, so they build inside a container via
  `docker run --platform` under QEMU emulation.
  - `armv6` is musl-only because the Debian/glibc base image ships no
    32-bit ARMv6 variant (only ARMv5/ARMv7), so there is no glibc `armv6`
    binary and the container image does not cover it.
  - Some dependencies ship no prebuilt wheel for these arches
    (`multidict`/`frozenlist`/`ruamel.yaml.clib` on `riscv64`; the entire
    C-extension stack on `armv6`), so they compile from source during the
    build.
- The published container image now also covers `linux/riscv64` (alongside
  `linux/amd64`, `linux/arm64`, `linux/386`, `linux/arm/v7`, `linux/ppc64le`
  and `linux/s390x`), and is build-checked at that full arch set on every
  commit.
- Update the README `Architectures` badge to list the new targets
  (`amd64`, `arm64`, `armv7`, `armv6`, `i686`, `ppc64le`, `s390x`,
  `riscv64`).


## 1.1.5 (2026-06-22)

This is a documentation release; there are no changes to the `cronstable`
package itself.

### Documentation

- README changes
- Add an `Architectures` badge to the README summarizing the binary and
  container targets (`amd64`, `arm64`, `i686`, `armv7`, `ppc64le`,
  `s390x`).

### Release automation

- Default the manual (`workflow_dispatch`) release to a `patch` bump and
  list `patch` first in the bump options, since patch releases are the
  common case.


## 1.1.4 (2026-06-22)

- Add self-contained binaries for two more Linux architectures to every
  release, in both glibc and musl flavors: little-endian POWER (`ppc64le`)
  and IBM Z (`s390x`) (`cronstable-linux-ppc64le`, `cronstable-linux-s390x`, and
  their `-musl` variants) alongside the existing `amd64`, `arm64`, `i686`
  and `armv7` builds. As with the other binaries, Python is not required on
  the target system. Neither arch has a native GitHub runner, so they build
  inside a container via `docker run --platform` under QEMU emulation; both
  have prebuilt manylinux and musllinux wheels for the aiohttp dependency
  stack, so nothing compiles from source.
- The published container image now covers them too: the multi-arch image
  adds `linux/ppc64le` and `linux/s390x` (to `linux/amd64`, `linux/arm64`,
  `linux/386` and `linux/arm/v7`), and is build-checked at that full arch
  coverage on every commit.


## 1.1.3 (2026-06-22)

- Add self-contained binaries for two more Linux architectures to every
  release, in both glibc and musl flavors: 32-bit x86 (`cronstable-linux-i686`
  and `cronstable-linux-i686-musl`) and 32-bit ARM (`cronstable-linux-armv7` and
  `cronstable-linux-armv7-musl`), alongside the existing 64-bit `amd64` and
  `arm64` builds. As with the other binaries, Python is not required on the
  target system. The 32-bit binaries are built inside a 32-bit container
  (`i686` natively on the x86-64 runner, `armv7` under QEMU emulation).
- The published container image now covers those architectures too: the
  multi-arch image is built for `linux/amd64`, `linux/arm64`, `linux/386`
  and `linux/arm/v7`, and is build-checked at that full arch coverage on
  every commit.


## 1.1.2 (2026-06-21)

This is a documentation release; there are no changes to the `cronstable`
package itself.

### Documentation

- Add a project wiki (under `wiki/`) covering installation, the
  configuration reference, the HTTP API, the web dashboard, schedules and
  timezones, reporting, statsd metrics, output capturing, concurrency and
  timeouts, failure detection and retries, includes and defaults, logging,
  the CLI, architecture and internals, production deployment, migration
  from yacron, contributing/releasing, and troubleshooting.
- Showcase the web dashboard near the top of the README with annotated
  screenshots of the overview, live log tail, run history, schedule
  preview, command palette, keyboard-shortcut reference, and the
  green-phosphor and flat modern themes, linking the dashboard tour in the
  wiki.
- Slim the README's web-server section to an "Enabling the web dashboard"
  pointer to that showcase and the wiki, removing the duplicated feature
  list.


## 1.1.1 (2026-06-21)

### Features

- Add a built-in web dashboard, served at the root path (`/`) of any
  `http://` listener. It shows each job's latest status with a live
  countdown to the next run and a trend sparkline, tails job logs live
  (with in-log search, ANSI-color rendering, optional timestamps, a
  line-wrap toggle, and a download button), runs or cancels jobs on
  demand, and reports each job's run history, success rate, and a
  plain-English schedule with a preview of upcoming run times. It is
  keyboard-first (`?` for shortcuts, `Ctrl-K`/`⌘K` command palette, `/`
  to filter), with configurable themes, a compact density mode, polling
  interval, and optional desktop failure notifications, all remembered
  in the browser.
- Cancel running jobs over the REST API with `POST /jobs/{name}/cancel`,
  using the same graceful SIGTERM-then-SIGKILL sequence (honoring
  `killTimeout`) as elsewhere. A cancelled run is recorded with a
  `cancelled` outcome and is neither reported nor retried; the endpoint
  returns `409 Conflict` if the job is not running and `404 Not Found`
  for an unknown job.
- `GET /jobs` now returns detailed per-job information: schedule,
  timezone, enabled/running state, time until the next run, a summary of
  the most recent finished run, and a compact recent-outcome history.
- Read a job's retained run history and aggregate statistics (success
  rate and average/min/max duration) with `GET /jobs/{name}/runs`.
- Tail a job's captured output live over Server-Sent Events with
  `GET /jobs/{name}/logs`, replaying the most recent run's buffered
  output before streaming new lines.
- Add a `web.ui` option; set `ui: false` to expose only the REST API and
  disable the dashboard.
- Keep run history and live logs in memory only, so the dashboard does
  not change cronstable's read-only-filesystem deployment story; history
  resets when cronstable restarts.
- Ship a `docker-compose.yml` and a demo crontab for trying the
  dashboard against a set of varied example jobs.

### Security

- Serve the dashboard with a strict `Content-Security-Policy` and
  additional hardening headers (`X-Content-Type-Options`,
  `X-Frame-Options`, `Referrer-Policy`); each can be overridden via
  `web.headers` while unset defaults are still applied.
- When bearer-token authentication (`web.authToken`) is enabled, the
  dashboard page loads without a token and then prompts for one, storing
  it only in that browser tab; every data request it makes is
  authenticated with that token.


## 1.0.16 (2026-06-21)

- Publish container images to Docker Hub as `docker.io/ptweezy/cronstable`
  on every release, in addition to GHCR. The two registries carry the
  same multi-arch (`linux/amd64` + `linux/arm64`) image, so you can
  pull from whichever you prefer.
- Document the Docker Hub images in the README and add a quick-start
  `docker run` example and a Docker Hub badge.
- Harden the release workflow so Docker Hub publishing is enabled only
  when both `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured.


## 1.0.15 (2026-06-21)

- Lower the minimum required Python version from 3.13 to 3.10;
  cronstable now supports Python 3.10, 3.11, 3.12, 3.13, and 3.14.
- Add PyPI trove classifiers for Python 3.10, 3.11, and 3.12 so the
  expanded support is reflected on the package page.
- Expand the test matrix (`tox` and CI) to run across all five
  supported interpreters (3.10â€“3.14).
- Type-check with `mypy` against Python 3.10 so stdlib APIs that are
  unavailable on the lowest supported interpreter are caught at lint
  time rather than at runtime.


## 1.0.14 (2026-06-21)

Since 1.0.13, the net changes are entirely build/CI hardening (a new `build.yml`, an `arm64` addition to `docker.yml`). Here's the changelog body:

- Add a per-commit build-verification workflow that builds the wheel,
  `sdist`, and self-contained PyInstaller binaries for Linux (both
  glibc and musl/Alpine, on `amd64` and `arm64`) and macOS (`amd64`
  and `arm64`) on every push, without publishing, so a broken build or
  bundle is caught at commit time instead of only at release.
- Build-verify the Docker image for both `linux/amd64` and
  `linux/arm64` on every commit, catching arm64-only breakage (such as
  a dependency with no arm64 wheel) that the previous amd64-only check
  would miss.

## 1.0.13 (2026-06-20)

### Improvements

- Update the bundled Python runtime in the standalone binaries to
  `3.13.14` (from `3.13.5`), picking up the latest upstream bug and
  security fixes.
- Expand the PyPI package metadata with additional keywords, trove
  classifiers, and project links (`Documentation`, `Source`,
  `Changelog`, `Issues`, and `Container`) for easier discovery.

### Documentation

- Tidy up `README.md`, trimming redundant badges and condensing the
  macOS code-signing notes.


## 1.0.12 (2026-06-20)

- Update the GitHub Actions used to build and publish Docker images
  (`docker/metadata-action`, `docker/login-action`,
  `docker/setup-qemu-action`, `docker/setup-buildx-action`, and
  `docker/build-push-action`) to their latest major versions.
- Update the release workflow's `actions/upload-artifact`,
  `actions/download-artifact`, and `softprops/action-gh-release`
  actions to their latest major versions.


## 1.0.11 (2026-06-20)

- The macOS binaries are now Developer ID code-signed and notarized by
  Apple, so Gatekeeper accepts them and they run without first clearing
  the quarantine attribute (`xattr -d com.apple.quarantine` is no longer
  needed).


## 1.0.10 (2026-06-20)

- Release binaries now include macOS builds for both Apple Silicon
  (`cronstable-macos-arm64`) and Intel (`cronstable-macos-amd64`),
  alongside the existing Linux glibc and musl binaries. As with the
  Linux binaries, Python is not required on the target machine.
- Document clearing the macOS Gatekeeper quarantine with
  `xattr -d com.apple.quarantine` before first running the macOS
  binaries, which are unsigned and unnotarized.
- Fix a typo in the README fork attribution.


## 1.0.9 (2026-06-20)

### Documentation

- Document that the standalone binary is self-extracting: on each
  start it unpacks its embedded Python runtime into a temporary
  directory, so it requires a temp directory that is both writable
  and executable.
- Add guidance for running the binary under a read-only root
  filesystem â€” mount a small `rw,exec` tmpfs at `/tmp` (Docker's
  `--tmpfs` defaults to `noexec`, which fails), use a Kubernetes
  `emptyDir`, or point `TMPDIR` at a writable, executable directory.
- Clarify that this temp-directory requirement is unique to the
  standalone binary: the published container image and `pip`/`pipx`
  installs run cronstable as a normal Python package and need no
  writable temp directory.

### Container image

- The official multi-arch (`linux/amd64` + `linux/arm64`) container
  image is now built and published to GHCR automatically as part of
  every release, and is build-checked on every commit so a broken
  `Dockerfile` fails fast.


## 1.0.8 (2026-06-20)

- Add self-contained musl binaries to every release for Alpine and
  other musl-based systems: `cronstable-linux-amd64-musl` and
  `cronstable-linux-arm64-musl`, alongside the existing glibc
  `cronstable-linux-amd64` and `cronstable-linux-arm64` builds. Python is
  not required on the target system.
- Build the release binaries with Python 3.14.


## 1.0.7 (2026-06-20)

- GitHub Releases now use the curated `HISTORY.md` section for the
  release as the body of the release notes. The matching `## X.Y.Z`
  entry is extracted and shown above GitHub's auto-generated "What's
  Changed" list and changelog compare link, so each release page leads
  with the human-written changelog instead of only auto-generated
  notes.


## 1.0.6 (2026-06-20)

- Release binaries are now published for both `linux/amd64` and
  `linux/arm64`. Every GitHub Release attaches a self-contained
  `cronstable-linux-amd64` and `cronstable-linux-arm64` executable, each built
  natively on its target architecture (previously only a single binary
  was provided).
- The downloadable binaries embed Python, so none is required on the
  target system, and run on any Linux host with glibc 2.39 or newer
  (e.g. Ubuntu 24.04) matching the CPU architecture.
- Each binary is smoke-tested with `--version` and built before
  publishing


## 1.0.5 (2026-06-20)

- docker builds

## 1.0.4 (2026-06-19)

### Reliability fixes

- Config reload failures no longer risk crashing the scheduler: if
  re-reading the configuration fails (for example, a YAML error introduced
  while cronstable is running), the previously-loaded jobs keep running
  instead of the main loop failing on an unset `config` reference.
- A job whose command cannot be launched (for example, the executable does
  not exist) is now reported as an ordinary failure with exit code `127`,
  instead of raising `RuntimeError("process is not running")` and being
  logged as an internal "please report this as a bug" error.
- statsd reporting is now strictly best-effort: a failure to send the
  `job_started`/`job_stopped` metrics (for example, an unresolvable statsd
  host) is logged as a warning instead of propagating out of job
  start/stop.
- The mail reporter now always closes its SMTP connection, even when
  `STARTTLS`, login, or sending fails, so a misbehaving mail server can no
  longer leak one connection per report.
- Sentry and e-mail reporting no longer raise `KeyError` when the DSN or
  password is configured with `fromEnvVar` but the environment variable is
  unset; cronstable logs an error and skips that report instead.

### Configuration

- The Sentry `fingerprint` setting now replaces rather than appends when
  merging `defaults`: a job (or `defaults` block) that defines its own
  `fingerprint` overrides the default entirely, so custom Sentry issue
  grouping works as configured (previously the three default entries were
  silently prepended).
- `include` cycles are now detected and rejected with a clear `ConfigError`
  ("include cycle detected") instead of recursing until a `RecursionError`.
- Jobs loaded from a configuration directory are now processed in sorted
  filename order, so job ordering and "first config found" messages are
  deterministic rather than dependent on the filesystem's directory order.
- Environment files (`env_file`) are now read as UTF-8.

### Security

- The web API's `Authorization` check now treats the `Bearer` auth scheme
  as case-insensitive (per RFC 7235), while still comparing the token
  itself in constant time.
- The mail reporter no longer logs the name of the configured password
  environment variable.

### Internal

- Refactored `JobConfig` construction into focused helper methods and
  switched `send_to_statsd` to `asyncio.get_running_loop()`; no behavioral
  change.
- Added a `.github/CODEOWNERS` file.

## 1.0.3 (2026-06-19)

This is a tooling and documentation release; there are no changes to the
`cronstable` package itself.

### Release automation & CI

- Added an opt-in, marker-driven `release` GitHub Actions workflow: a push to
  `main` whose commit message carries a release marker on its own line
  (`[release]` / `[release:major|minor|patch]`), or a manual run, gates on
  `tox`, builds at the next version, publishes to PyPI via Trusted Publishing
  (OIDC), and only after a successful publish tags the commit and cuts a GitHub
  Release.
- Hardened the release trigger to match a whole-line marker, so a `[release]`
  mention inside prose never triggers a publish, and added a local `commit-msg`
  hook (`scripts/gen_changelog_entry.py`) that drafts a changelog entry for
  release commits.
- Set least-privilege `permissions: contents: read` defaults on the `tox` and
  `release` workflows.

### Docs

- Added `CONTRIBUTING.md` documenting the development setup, the
  test/lint/type-check workflow, and the release process, and linked it from the
  README.
- Converted the changelog from reStructuredText to Markdown (`HISTORY.rst` ->
  `HISTORY.md`) and pointed the changelog generator, the `commit-msg` hook, and
  `CONTRIBUTING.md` at the Markdown changelog.

### Packaging

- Promote the PyPI `Development Status` classifier from `4 - Beta` to
  `5 - Production/Stable` to reflect the stable 1.0 release series. No code
  changes.

## 1.0.1 (2026-06-19)

### Security & behavior fixes

- The web API now fails closed when `web.authToken` is configured but
  resolves to an empty token (an unset `fromEnvVar`, or an empty/missing
  `fromFile`): cronstable raises a `ConfigError` and refuses to start the
  HTTP server, instead of silently serving the control API without
  authentication.
- The web API now honors `enabled: false`. `POST /jobs/<name>/start`
  returns `409 Conflict` for a disabled job rather than launching it, and
  `GET /status` reports such jobs as `disabled` instead of an
  inapplicable `scheduled (in N seconds)`.
- Invalid `web.listen` URLs (an unsupported scheme, or an `http` url
  missing host/port) are now logged as a warning and skipped, instead of being
  surfaced as an internal "please report this as a bug" error; a bind failure
  (`OSError`) on one address likewise no longer aborts the whole config
  update. The "started listening" message is logged only after the bind
  actually succeeds.
- `concurrencyPolicy: Replace` no longer reports the replaced (cancelled)
  job instance as a failure and no longer schedules retries for it; the forced
  termination is treated as a replacement, not a job failure.

### Cleanups

- Removed a dead Windows event-loop branch from `main()` (cronstable is
  POSIX-only because it imports `grp`/`pwd` at load time).
- `naturaltime` no longer relies on an `assert` for control flow (which
  would be stripped under `python -O`).
- The concurrency-policy test was rewritten to be deterministic (it was
  previously an `xfail` that could never exercise a second job instance).

## 1.0.0 (2026-06-19)

### About this release

- cronstable 1.0.0 is the first release of the cronstable fork, based on
  gjcarneiro/yacron 0.19. It carries forward all of upstream yacron's
  functionality and adds modernized packaging, a Python 3.13+ runtime, new
  web-API authentication, and a set of security and correctness fixes.
- The project, package, command, config directory, and reporter environment
  variables have all been renamed from `yacron` to `cronstable` (see Breaking
  changes for migration steps).

### Breaking changes

- The installed command and PyPI distribution are renamed `yacron` ->
  `cronstable` (install with `pip install cronstable`; run `cronstable`). The
  Python import package is now `cronstable` and the entry point is
  `cronstable.__main__:main`.
- The default config directory changed from `/etc/yacron.d` to
  `/etc/cronstable.d`; operators relying on the default path must move their
  config directory.
- Minimum Python is now 3.13 (`requires-python >=3.13`); only Python 3.13 and
  3.14 are supported. Python 3.7 through 3.12 are no longer supported.
- Reporter shell environment variables were renamed `YACRON_*` ->
  `CRONSTABLE_*` (e.g. `CRONSTABLE_JOB_NAME`, `CRONSTABLE_RETCODE`). Existing
  `onFailure`/`onSuccess` shell scripts must be updated.
- mail `validate_certs` now defaults to `True`, so SMTP TLS certificate
  validation is enabled unless explicitly disabled. Delivery to servers with
  self-signed/invalid certificates that previously worked silently will now
  fail unless `validate_certs: false` is set.
- Privilege drop now drops/sets supplementary groups (`os.initgroups` /
  `os.setgroups`) before `setuid`, fixing a privilege-escalation bug where
  root's supplementary group memberships leaked into the child. A numeric
  `user` without an explicit `group` now derives its primary gid from the
  passwd database instead of silently keeping yacron's gid 0.
- `defaults.environment` now merges by key instead of concatenating: a job
  overriding a default variable yields a single entry. Configs relying on the
  old duplicate-key concatenation behave differently.
- Dependency pins changed: `crontab` jumped from `==0.22.8` to `>=1,<2`
  (major version change), `strictyaml` to `>=1.7,<2`, `aiohttp` to
  `>=3.10,<4`, `aiosmtplib` to `>=3,<6` (v2+ login API), `sentry-sdk`
  to `>=2,<3`. `pytz` and the direct `ruamel.yaml` pin were dropped;
  `tzdata>=2024.1` was added.

### Features & behavior

- New `web.authToken` option adds opt-in bearer-token authentication to the
  HTTP API (literal `value`, `fromFile`, or `fromEnvVar`); when set, an
  aiohttp middleware requires `Authorization: Bearer <token>` on every route,
  compares it in constant time (`hmac.compare_digest`), and returns 401
  otherwise.
- New `web.socketMode` option sets octal permissions on `unix://` listen
  sockets, logging a warning rather than failing on invalid values; non-unix
  schemes are ignored.
- Job stderr is now written to the process's stderr instead of stdout, so
  operators separating cronstable's own stdout/stderr streams get correctly routed
  output.
- Config now validates numeric ranges at load time and raises a clear
  `ConfigError` for invalid values (`saveLimit>=0`, `maxLineLength>0`,
  `killTimeout>=0`, `executionTimeout>0`, and `onFailure.retry`
  constraints) instead of failing obscurely at runtime.
- Multi-file config directories now aggregate jobs, defaults, and logging
  across all files instead of using only the last file's settings. Duplicate
  `web` or `logging` blocks across the directory raise a `ConfigError`,
  an empty/all-skipped directory yields an empty config (no
  `UnboundLocalError`), and a missing/unreadable single config file now
  raises a clear `ConfigError`.
- Logging configuration is now re-applied on reload when it changes and is only
  marked applied on success, so a logging section fixed after an error or
  changed at runtime is picked up without a restart.
- Scheduling a retry for a job that was removed from the configuration
  mid-retry no longer crashes; the stale retry state is cleared and the retry
  is skipped.
- Job stop metrics (statsd `job_stopped`) are now emitted exactly once per
  run; a guard makes `_on_stop` idempotent, preventing duplicate metrics when
  `cancel` races `wait` (e.g. `concurrencyPolicy=Replace`).
- Non-UTF-8 job output no longer crashes the stream reader (output is decoded
  with `errors='replace'`).
- A job with an empty environment list now gets its environment assigned
  correctly (previously left `None`).
- Email reports now set an RFC 5322 `Date` header
  (`email.utils.format_datetime`), encode HTML bodies with the correct
  charset/transfer-encoding (`set_content` subtype `html`), and call
  `smtp.login` positionally for aiosmtplib v2+ compatibility.
- The Sentry client is now initialized once per `(dsn, environment)` and
  cached instead of on every report, and uses `sentry_sdk.new_scope()`
  (replacing the deprecated `push_scope()`).
- Report templates (sentry/mail body, subject, fingerprint) are now compiled
  and cached via an `lru_cache`, and the three report blocks (`onFailure`,
  `onPermanentFailure`, `onSuccess`) deep-copy their defaults so they no
  longer alias one shared mutable object.
- The shell reporter now logs a nonzero reporter exit code via `logger.error`
  (clean message) instead of `logger.exception` (which logged a bogus
  `NoneType: None` traceback).
- statsd UDP errors are now logged with their detail (`UDP error received:
  %s`) instead of being dropped due to a missing format placeholder.

### Python & runtime

- Timezone handling migrated from third-party `pytz` to the standard-library
  `zoneinfo`; invalid timezones now raise `ConfigError`.
- Added `tzdata>=2024.1` so `zoneinfo` can resolve timezones on
  slim/minimal container images that don't ship the system tz database.
- The asyncio event loop is now created with `asyncio.new_event_loop()`
  instead of the deprecated `asyncio.get_event_loop()` (carried from
  upstream).
- Internal logger and argparse program name updated to `cronstable`; CLI
  error/version output now reads `cronstable`.

### Packaging & build

- Migrated from legacy `setup.py`/`setup.cfg` to a PEP 621
  `pyproject.toml` using the setuptools build backend (`setuptools>=77`,
  `setuptools_scm>=8`); `setup.py` and `setup.cfg` were removed.
- Versioning continues via setuptools_scm, now configured under
  `[tool.setuptools_scm]` writing `cronstable/version.py`.
- Adopted a PEP 639 SPDX license expression (`license = "MIT"`) with
  `license-files`, and updated the LICENSE with a `Copyright (c) 2026, the
  cronstable developers` line alongside the original 2019 copyright.
- Added a `[project.optional-dependencies]` `dev` extra (mypy,
  mypy-extensions, pytest, pytest-asyncio, pytest-cov, ruff, tox) and trimmed
  `requirements_dev.txt` to match (dropped flake8, types-pytz, and stale
  pins; added ruff).
- Consolidated mypy and pytest configuration into `pyproject.toml` and bumped
  the black/ruff target-version to `py313`.
- `MANIFEST.in` and packaging metadata updated for the `README.rst` ->
  `README.md` switch.

### CI & tooling

- Removed Travis CI configuration (`.travis.yml`).
- Switched linting from black + flake8 to ruff (`ruff check` + `ruff
  format`) with bugbear/mccabe/pycodestyle/pyflakes/import-sorting rules and a
  mccabe complexity limit; added a bandit config and a
  `.pre-commit-config.yaml` running bandit and ruff hooks (carried from
  upstream).
- Modernized the GitHub Actions tox workflow: bumped `actions/checkout` (v3
  -> v7) and `actions/setup-python` (v3 -> v6.2.0), renamed the lint job, and
  trimmed the test matrix to Python 3.13 and 3.14.
- Modernized `tox.ini` (envlist `py313, py314, lint, mypy`), removed the
  Travis mapping section, added `skip_install` to the lint/mypy envs, dropped
  `types-pytz` from the mypy env, and pointed lint/mypy commands at the
  `cronstable` package.
- Bumped pre-commit hook revisions (ruff-pre-commit and bandit).

### Docs & examples

- Converted the README from reStructuredText to Markdown (`README.rst` ->
  `README.md`) and rebranded it to cronstable, with a new intro noting it is a
  fork of gjcarneiro/yacron continuing from 0.19. The content is otherwise the
  same as upstream 0.19, not a rewrite; install docs now require Python >=
  3.13, the prebuilt binary targets glibc 2.39 / Ubuntu 24.04, and releases
  come from github.com/ptweezy/cronstable.
- `HISTORY.rst` gained a fork-attribution preamble; older entries are
  retained as upstream yacron history.
- Modernized the Docker example: base image `python:3.14-slim` with `pip
  install cronstable` (replacing ubuntu:xenial + virtualenv), config copied into
  `/etc/cronstable.d`, and `ENTRYPOINT ['cronstable']`.
- Updated the Kubernetes example to the `apps/v1` Deployment API with the
  now-required selector, rebranded `yacrondemo` -> `cronstabledemo`.
- Rebranded the ad-hoc example config directory, example tab file, PyInstaller
  spec/launcher, and listen socket paths (`/tmp/yacron.sock` ->
  `/tmp/cronstable.sock`) to cronstable.

### Credits (trailing upstream changes)

- `web.headers` option to control HTTP response headers on all web
  endpoints, by Gustavo Carneiro (gjcarneiro), commit bde0f0b; merged upstream
  but never released in yacron 0.19.0.
- Python 3.14 compatibility, including `asyncio.new_event_loop()` and
  modern-Python lint/format fixes, by Gustavo J. A. M. Carneiro (gjcarneiro),
  commit 27a32bc (#100).
- Switch from black/flake8 to ruff, plus bandit and pre-commit configuration,
  by Gustavo Carneiro (gjcarneiro), commits c656fa6 and 4f7936a.
- Removal of Travis CI and modernization of the Python/PyInstaller version
  matrices, by upstream yacron (gjcarneiro), commits d9b1ca6, 8d28816, 4e6892a,
  2941dcf.
- README logging example fix adding `datefmt: '%Y-%m-%d %H:%M:%S'` to the
  custom-logging formatter, by andreas-wittig, commit 931b186.

## 0.19.0 (2023-03-11)

- Add ability to configure yacron's own logging (#81 #82 #83, gjcarneiro, bdamian)
- Add config value for SMTP(validate_certs=False) (David Batley)

## 0.18.0 (2023-01-01)

- fixes "Job is always executed immediately on yacron start" (#67)
- add an `enabled` option in jobs (#73)
- give a better error message when no configuration file is provided or exists (#72)

## 0.17.0 (2022-06-26)

- Support Additional Shell Report Vars (RJ Garcia)
- Shell reporter: handle long lines truncatation (Hannes Hergeth)
- exe: undo pyinstaller LD_LIBRARY_PATH changes in subprocesses (#68, Gustavo Carneiro)

## 0.16.0 (2021-12-05)

- make the capture max line length configurable and change the default
  from 64K to 16M (#56)
- Add config option to change prefix of subprocess stream lines (#58, eelkeh)

## 0.15.1 (2021-11-19)

- Fix a bug in the --validate option (#57, Leonid Repin)

## 0.15.0 (2021-11-10)

- Allow emails to be html formatted
- Fix an error when reading cmd output with huge lines (#56)

## 0.14.0 (2021-10-04)

- Sentry: increase the size of messages before getting truncated #54
- Sentry: allow specifying the environment option #53
- Minor fixes

## 0.13.1 (2021-08-10)

- unicode fixes for the exe binary version

## 0.13.0 (2021-06-28)

- Add ability for one config file to include another one #38
- Add shell command reporting ability (Hannes Hergeth, #50)

## 0.12.2 (2021-05-31)

- constrain ruamel.yaml to version 0.17.4 or below, later versions are buggy

## 0.12.1 (2021-05-30)

- blacklist ruamel.yaml version 0.17.5 in requirements #47

## 0.12.0 (2021-04-22)

- web: don't crash when receiving a web request without Accept header (#45)
- add env_file configuration option (Alessandro Romani, #43)
- email: add missing Date header (#39)

## 0.11.2 (2020-11-29)

- Add back a self contained binary, this time based on PyInstaller

## 0.11.1 (2020-07-29)

- Fix email reporting when multiple recipients given

## 0.11.0 (2020-07-20)

- reporting: add a failure reason line at the top of sentry/email (#36)
- mail: new tls, startls, username, and password options (#21)
- allow jobs to run as a different user (#18)
- Support timezone schedule (#26)

## 0.10.1 (2020-06-02)

- Minor bugfixes

## 0.10.0 (2019-11-03)

- HTTP remote interface, allowing to get job status and start jobs on demand
- Simple Linux binary including all dependencies (built using PyOxidizer)

## 0.10.0b2 (2019-10-26)

- Build Linux binary inside Docker Ubuntu 16.04, so that it is compatible with
  older glibc systems

## 0.10.0b1 (2019-10-13)

- Build a standalone Linux binary, using PyOxidizer
- Switch from raven to sentry-sdk

## 0.9.0 (2019-04-03)

- Added an option to just check if the yaml file is valid without running the scheduler.
- Fix missing `body` in the schema for sentry config

## 0.8.1 (2018-10-16)

- Fix a bug handling `@reboot` in schedule (#22)

## 0.8.0 (2018-05-14)

- Sentry: add new `extra` and `level` options.

## 0.7.0 (2018-03-21)

- Added the `utc` option and document that times are utc by default (#17);
- If an email body is empty, skip sending it;
- Added docker and k8s example.

## 0.6.0 (2017-11-24)

- Add custom Sentry fingerprint support
- Ability to send job metrics to statsd (thanks bofm)
- `always` flag to consider any cron job that exits to be failed
  (thanks evanjardineskinner)
- `maximumRetries` can now be `-1` to never stop retrying (evanjardineskinner)
- `schedule` can be the string `@reboot` to always run that cron job on startup
  (evanjardineskinner)
- `saveLimit` can be set to zero (evanjardineskinner)

## 0.5.0

- Templating support for reports
- Remove deprecated smtp_host/smtp_port

## 0.4.3 (2017-09-13)

- Bug fixes

## 0.4.2 (2017-09-07)

- Bug fixes

## 0.4.1 (2017-08-03)

- More polished handling of configuration errors;
- Unit tests;
- Bug fixes.

## 0.4.0 (2017-07-24)

- New option `executionTimeout`, to terminate jobs that get stuck;
- If a job doesn't terminate gracefully kill it.  New option `killTimeout`
  controls how much time to wait for graceful termination before killing it;
- Switch parsing to strictyaml, for more user friendly parsing validation error
  messages.
