Skip to content

feat(schedule): operator-registered recurring goals with reproducible firing#1806

Merged
chernistry merged 1 commit into
mainfrom
feat/1798-recurring-goals-deterministic
May 21, 2026
Merged

feat(schedule): operator-registered recurring goals with reproducible firing#1806
chernistry merged 1 commit into
mainfrom
feat/1798-recurring-goals-deterministic

Conversation

@chernistry

@chernistry chernistry commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #1798.

Summary

Operator-registered recurring goals with a deterministic fire contract. A scheduled fire is a pure projection from (schedule_id, fire_time, last_state) onto a canonical task graph: two operators that share the same triple land on the byte-identical task graph and the same projection_hash. Each fire appends a schedule.fire entry to the existing AuditLog HMAC chain (no parallel chain) and pushes a TriggerEvent through the standard trigger pipeline.

Files touched

  • src/bernstein/core/planning/schedule_store.py (new): schedule CRUD over .sdd/runtime/schedules/<id>.json plus a self-contained 5-field cron parser. No new runtime dependency.
  • src/bernstein/core/orchestration/schedule_projection.py (new): pure deterministic projection; no wall-clock, no random, no host-dependent ordering. fire_time is pinned to int so sub-second drift cannot fork two operators.
  • src/bernstein/core/orchestration/schedule_supervisor.py (new): long-running supervisor with skip (default) and catch_up misfire policies, audit chain wiring via the existing AuditLog primitives, and counterfactual receipts for skipped windows.
  • src/bernstein/core/trigger_sources/schedule.py (new): TriggerEvent normaliser for the existing trigger pipeline.
  • src/bernstein/cli/commands/schedule_cmd.py (new): add / list / show / remove / audit / doctor / run verbs with --json output.
  • src/bernstein/cli/commands/status_cmd.py, src/bernstein/cli/commands/doctor_cmd.py: doctor integration reports supervisor liveness, last fire, next fire.
  • src/bernstein/cli/main.py: register schedule subcommand.
  • docs/operations/schedule.md (new): operator-facing reference.
  • tests/unit/test_schedule_store.py (new)
  • tests/unit/test_schedule_projection.py (new)
  • tests/unit/test_schedule_supervisor.py (new)
  • tests/integration/test_schedule_audit_chain.py (new)

Acceptance criteria coverage

  • bernstein schedule add --cron <expr> --goal <text> [--scenario <id>] persists under .sdd/runtime/schedules/<id>.json and validates the cron expression.
  • schedule list, schedule remove <id>, schedule show <id> with human and --json output.
  • Long-running supervisor (bernstein schedule run) wakes at the configured cadence, builds a TriggerEvent, and dispatches through the existing trigger pipeline via TriggerManager.evaluate.
  • Each fire is a deterministic projection of (schedule_id, fire_time, last_state) onto a canonical task graph; two operators with identical state arrive at byte-identical task graphs at fire_time T.
  • Each fire records event_type=schedule.fire with payload (schedule_id, fire_time, projection_hash, prev_chain_digest) in the existing AuditLog chain.
  • bernstein schedule audit walks the persisted receipts and verifies the recorded projection_hash matches a re-projection from the inputs.
  • Doctor confirms supervisor is reachable + reports last fire + next fire (bernstein doctor and bernstein schedule doctor).
  • Misfire handling (skip default, catch_up opt-in) is documented in docs/operations/schedule.md, configurable per schedule, and missed windows leave counterfactual lineage receipts.
  • Operator docs page under docs/operations/schedule.md.

Test plan

  • uv run pytest tests/unit/ -q --no-cov --timeout=120 -k "schedule or trigger_sources" (151 passed)
  • uv run pytest tests/integration/test_schedule_audit_chain.py (5 passed)
  • uv run ruff check clean on new files
  • uv run ruff format applied
  • uv run pyright clean on new modules
  • uv run lint-imports clean
  • Manual CLI smoke: schedule add -> schedule list -> schedule show -> schedule doctor -> schedule remove; bernstein doctor table includes the supervisor row.

Summary by CodeRabbit

  • New Features

    • Introduced operator-facing bernstein schedule command group supporting recurring cron-based scheduling with subcommands for add, list, show, remove, audit, run, and doctor health checks.
    • Added configurable misfire policies (skip and catch_up) for handling missed schedule windows.
    • Integrated schedule fire events into the trigger pipeline with deterministic projection hashing.
  • Documentation

    • Added operations guide documenting schedule registration, supervisor behavior, audit logging, and cron expression support.
  • Tests

    • Added comprehensive unit and integration tests covering schedule CRUD, cron validation, supervisor tick behavior, audit-chain determinism, and misfire policy handling.

Review Change Stack

… firing

Closes #1798

Adds an in-project recurring-goals surface so operators do not depend on
host-level systemd / cron / a cloud scheduler. A fire is a pure function
of (schedule_id, fire_time, last_state) and lands on a byte-identical
task graph across hosts; each fire chains into the existing AuditLog
with event_type schedule.fire.

Modules
- src/bernstein/core/planning/schedule_store.py: schedule CRUD over
  .sdd/runtime/schedules/<id>.json plus a self-contained 5-field cron
  parser (no new runtime dep).
- src/bernstein/core/orchestration/schedule_projection.py: pure
  deterministic projection; no wall-clock, no random, no host-dependent
  ordering. fire_time is pinned to int so sub-second drift cannot fork
  two operators.
- src/bernstein/core/orchestration/schedule_supervisor.py: long-running
  supervisor with skip (default) and catch_up misfire policies, audit
  chain wiring via the existing AuditLog primitives, and counterfactual
  receipts for skipped windows.
- src/bernstein/core/trigger_sources/schedule.py: TriggerEvent
  normaliser for the existing trigger pipeline.
- src/bernstein/cli/commands/schedule_cmd.py: add / list / show /
  remove / audit / doctor / run verbs with --json output.
- src/bernstein/cli/commands/{status_cmd,doctor_cmd}.py: doctor
  integration reports supervisor liveness, last fire, next fire.
- docs/operations/schedule.md: operator-facing reference (registration,
  restart semantics, audit walk).

Acceptance criteria coverage
- schedule add persists under .sdd/runtime/schedules/<id>.json and
  validates the cron expression.
- schedule list / remove / show with human and --json output.
- bernstein schedule run is the long-running supervisor; ticks build a
  TriggerEvent and dispatch through TriggerManager.evaluate.
- Projection is byte-identical across hosts; two operators on the same
  (schedule_id, fire_time, last_state) compute the same task graph and
  the same projection_hash.
- Each fire appends schedule.fire to the existing HMAC chain with
  (schedule_id, fire_time, projection_hash, prev_chain_digest).
- bernstein schedule doctor + bernstein doctor surface the schedule
  supervisor's liveness and the next/last fire.
- Misfire policy default = skip; catch_up is per-schedule opt-in. Both
  documented under docs/operations/schedule.md.
- A missed window emits a lineage receipt the operator can replay.

Tests
- tests/unit/test_schedule_store.py: store CRUD + cron validation
  (37 cases).
- tests/unit/test_schedule_projection.py: byte-identical determinism +
  purity (12 cases).
- tests/unit/test_schedule_supervisor.py: cron iteration, misfire
  policies, chain integration, doctor status (26 cases).
- tests/integration/test_schedule_audit_chain.py: end-to-end with the
  real AuditLog, two-operator byte-identical proof, tamper detection
  (5 cases).
@chernistry chernistry enabled auto-merge (squash) May 21, 2026 19:59

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @chernistry, you have reached your weekly rate limit of 2500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@github-actions

Copy link
Copy Markdown
Contributor

Sonar insights (advisory, no merge-block)

Snapshot of bernstein on the configured Sonar instance:

Metric Value
Coverage 13.4
Code smells 124
Bugs 10
Vulnerabilities 2
Security hotspots 87

Run bernstein doctor sonar locally for the full surface.

This comment is a soft signal. The Sonar scan runs on push to main; the PR check itself never fails on smells.

@github-actions

Copy link
Copy Markdown
Contributor

Review-bot acknowledgement summary

  • Must-address findings: 0 (0 acknowledged, 0 open)
  • Informational findings: 0

All must-address findings are resolved or acknowledged.

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be151cab-e1d3-4b96-be4e-1c4adaa4ec3f

📥 Commits

Reviewing files that changed from the base of the PR and between fe6a831 and 419cb85.

📒 Files selected for processing (13)
  • docs/operations/schedule.md
  • src/bernstein/cli/commands/doctor_cmd.py
  • src/bernstein/cli/commands/schedule_cmd.py
  • src/bernstein/cli/commands/status_cmd.py
  • src/bernstein/cli/main.py
  • src/bernstein/core/orchestration/schedule_projection.py
  • src/bernstein/core/orchestration/schedule_supervisor.py
  • src/bernstein/core/planning/schedule_store.py
  • src/bernstein/core/trigger_sources/schedule.py
  • tests/integration/test_schedule_audit_chain.py
  • tests/unit/test_schedule_projection.py
  • tests/unit/test_schedule_store.py
  • tests/unit/test_schedule_supervisor.py

📝 Walkthrough

Walkthrough

This PR implements a complete operator-registered recurring schedule system enabling deterministic, auditable goal execution on fixed cadences within a single bernstein installation. Schedule fires dispatch deterministic task graphs whose projections are cryptographically verifiable; misfire policies (skip/catch_up) control behavior across downtime windows; integration with the audit chain provides cryptographic proof of execution.

Changes

Schedule subsystem

Layer / File(s) Summary
Schedule store and cron parsing
src/bernstein/core/planning/schedule_store.py, tests/unit/test_schedule_store.py
Introduces ScheduleStore persisting per-schedule JSON under runtime/schedules/<id>.json, a deterministic 5-field cron parser expanding fields into explicit matching sets, schedule id derivation from canonical (cron, goal, scenario_id) via SHA-256 prefix, and atomic writes with tolerant JSON loading that returns None on parse errors rather than raising.
Deterministic schedule-fire projection
src/bernstein/core/orchestration/schedule_projection.py, tests/unit/test_schedule_projection.py
Defines project_schedule_fire as a pure function mapping fire events to canonical task graphs via hashed state digests, sorted node ordering, and SHA-256 of canonical JSON bytes; returns ProjectionResult with canonical_bytes, projection_hash, and rev for audit-chain determinism.
Schedule supervisor with cron iteration and misfire policies
src/bernstein/core/orchestration/schedule_supervisor.py, tests/unit/test_schedule_supervisor.py
Implements ScheduleSupervisor to tick through schedules, compute due fire times via deterministic cron math (_next_fire_after with 2-year bound, POSIX day/weekday union), dispatch trigger events, apply per-schedule skip (collapse windows) or catch_up (dispatch per window with cap) misfire policies, persist FireReceipt records with audit-chain digests and counterfactual markers, and integrate optional audit writers and trigger dispatchers.
Trigger normalization for scheduled fires
src/bernstein/core/trigger_sources/schedule.py
Adds normalize_schedule_fire to construct TriggerEvent instances from supervisor fires, populating canonical source/timestamp/message/metadata fields with schedule_id, fire_time, misfire_policy, and optionally scenario_id and projection_hash, while defensively merging extra fields.
CLI command group and subcommands
src/bernstein/cli/commands/schedule_cmd.py
Implements bernstein schedule command group: add validates cron and persists with optional --misfire-policy, list shows all schedules (human table or JSON), show fetches by id with timestamps, remove deletes by id, audit loads receipts and shows fire/skip status with truncated digests, run drives supervisor ticks once or continuously while optionally wiring AuditLog and TriggerManager, doctor reports supervisor status.
Doctor check and CLI registration
src/bernstein/cli/commands/doctor_cmd.py, src/bernstein/cli/commands/status_cmd.py, src/bernstein/cli/main.py
Adds check_schedule_supervisor health check verifying .sdd workspace and reporting schedule count and last/next fire times (or WARN on unavailability); integrates into doctor command sequence; registers schedule_group as top-level subcommand.
Audit chain determinism and receipt validation
tests/integration/test_schedule_audit_chain.py
Verifies two independent supervisor instances sharing schedule state and audit keys produce byte-identical deterministic audit payloads (schedule_id, fire_time, projection_hash, rev, misfire_policy), allows only prev_chain_digest to differ non-deterministically, validates chain integrity, confirms receipt chronological ordering, and parametrizes misfire policies in audit payloads.
Operations guide for recurring schedules
docs/operations/schedule.md
Documents operator-facing workflow: deterministic firing and audit guarantees, idempotent schedule registration, skip vs catch_up misfire semantics, audit-chain verification and cross-operator receipt comparison, lifecycle commands, counterfactual receipt storage, and cron expression support (UTC evaluation, feature constraints).

Sequence Diagram

sequenceDiagram
    actor Op as Operator
    participant CLI as bernstein schedule run
    participant Sup as ScheduleSupervisor
    participant Store as ScheduleStore
    participant Proj as project_schedule_fire
    participant AuditWriter as AuditLog
    participant TrigMgr as TriggerManager
    
    Op->>CLI: schedule run --interval 60
    CLI->>Sup: __init__(store, dispatch_fn, audit_writer)
    loop Every 60 seconds
        CLI->>Sup: tick(now=current_epoch)
        Sup->>Store: list() schedules
        loop Per schedule
            Sup->>Sup: _next_fire_after(last_fire) → fire_time
            alt fire_time <= now
                Sup->>Proj: project_schedule_fire(schedule_id, fire_time, last_state, ...)
                Proj-->>Sup: ProjectionResult(canonical_bytes, projection_hash, rev)
                Sup->>AuditWriter: append(schedule.fire, {projection_hash, prev_chain_digest, ...})
                AuditWriter-->>Sup: chain_digest
                Sup->>TrigMgr: dispatch(TriggerEvent with projection_hash, fire_time)
                TrigMgr-->>Sup: async dispatch result
                Sup->>Store: update_last_fire(schedule_id, fire_time)
                Sup->>Sup: persist FireReceipt to runtime/schedule_receipts
            end
        end
        Sup-->>CLI: list[FireReceipt]
    end
    CLI-->>Op: receipt counts + errors
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

This PR introduces a substantial new subsystem with interconnected layers: deterministic cron math, pure projection functions, stateful supervisor orchestration with misfire policy branching, audit-chain integration, and comprehensive CLI and test coverage. The review requires careful attention to cron iteration correctness, determinism properties in projection and audit chaining, and misfire semantics across skip/catch_up branches. The breadth of file changes (7 new modules, 5 modified modules, 6 new test files) and the density of scheduling logic and type conversions demand detailed inspection across each layer.

Suggested labels

size/xl, core, cli, docs, tests

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1798-recurring-goals-deterministic

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

bernstein doctor observe for PR #1806 (feat/1798-recurring-goals-deterministic): ok=0, warn=2, fail=0, error=0, skipped=2

sonar -- WARN (project bernstein)

metric value delta threshold status
coverage_pct 13.4% new 80.0% fail
code_smells 124 new 50 warn
bugs 10 new 0 fail
vulnerabilities 2 new 0 warn
security_hotspots 87 new 0 fail

code-scanning -- WARN (5 open alert(s))

metric value delta threshold status
open_alerts 5 new 0 warn
critical_alerts 0 new 0 ok
high_alerts 2 new 0 warn
medium_alerts 0 new - ok
low_alerts 0 new - ok
Skipped backends (credentials not configured)
  • glitchtip: BERNSTEIN_GLITCHTIP_TOKEN not set
  • dt: DTRACK_URL/TOKEN/PROJECT not set

See docs/observability/unified-doctor.md for backend setup notes.

@chernistry chernistry merged commit 591b00d into main May 21, 2026
60 of 63 checks passed
@chernistry chernistry deleted the feat/1798-recurring-goals-deterministic branch May 21, 2026 19:59
@github-actions

Copy link
Copy Markdown
Contributor

Contract drift detected - proposed patch

Inline autofix push failed (failure). Apply the patch below manually.

Three contract tests act as drift detectors against the public CLI / API surface:

  • tests/unit/test_readme_api_coverage.py::test_all_cli_commands_are_documented
  • tests/unit/test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart
  • tests/unit/test_cli_run_params.py::test_run_params_match_cli_call

One or more failed on this PR. scripts/regen_contract_drift.py produced the patch below (2 LOC, cap: 30).

Files changed:

tests/unit/test_readme_api_coverage.py

How to apply

Either run the regen script locally:

uv run python scripts/regen_contract_drift.py --fixture all
git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"
git push

Or apply the patch directly:

gh pr checkout 1806
git apply <<'PATCH'
diff --git a/tests/unit/test_readme_api_coverage.py b/tests/unit/test_readme_api_coverage.py
index f492845b..8151f2de 100644
--- a/tests/unit/test_readme_api_coverage.py
+++ b/tests/unit/test_readme_api_coverage.py
@@ -237,6 +237,8 @@ DOCUMENTED_COMMANDS: frozenset[str] = frozenset(
         "interop",
         # Bot-added: drift autofix (regen_contract_drift.py)
         "desktop-register",
+        # Bot-added: drift autofix (regen_contract_drift.py)
+        "schedule",
     }
 )
PATCH
git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"
git push
Full diff
diff --git a/tests/unit/test_readme_api_coverage.py b/tests/unit/test_readme_api_coverage.py
index f492845b..8151f2de 100644
--- a/tests/unit/test_readme_api_coverage.py
+++ b/tests/unit/test_readme_api_coverage.py
@@ -237,6 +237,8 @@ DOCUMENTED_COMMANDS: frozenset[str] = frozenset(
         "interop",
         # Bot-added: drift autofix (regen_contract_drift.py)
         "desktop-register",
+        # Bot-added: drift autofix (regen_contract_drift.py)
+        "schedule",
     }
 )

Source CI run: https://github.com/sipyourdrink-ltd/bernstein/actions/runs/26249816693

Refs #1273.

"""List all registered schedules."""
sdd = _sdd_dir()
store = ScheduleStore(sdd)
schedules = store.list()
store = ScheduleStore(sdd)
supervisor = ScheduleSupervisor(store, lambda _evt: None, None)

status = supervisor.status()
self._store = store
self._dispatch = dispatch
self._chain = _AuditChainAdapter(audit_writer) if audit_writer is not None else None
self._catch_up_limit = max(1, int(catch_up_limit))
misfire_policy: MisfirePolicy = "skip"
created_at: float = 0.0
last_fire_at: float = 0.0
extra: dict[str, Any] = field(default_factory=lambda: {})
continue
start, end = value, hi

for v in range(start, end + 1, step):
scenario_id=schedule.scenario_id,
misfire_policy=schedule.misfire_policy,
created_at=schedule.created_at,
last_fire_at=float(fire_time),
metadata: dict[str, Any] = {
"source_type": "schedule",
"schedule_id": schedule_id,
"fire_time": float(fire_time),

return TriggerEvent(
source="schedule",
timestamp=float(fire_time),
timestamp=float(fire_time),
raw_payload={
"schedule_id": schedule_id,
"fire_time": float(fire_time),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Operator-registered recurring goals with reproducible firing

2 participants