Skip to content

feat(scheduling): Registry Scheduling MVP - #1092

Open
jeremi wants to merge 108 commits into
mainfrom
feat/scheduling-mvp
Open

jeremi wants to merge 108 commits into
mainfrom
feat/scheduling-mvp

Conversation

@jeremi

@jeremi jeremi commented Sep 15, 2026

Copy link
Copy Markdown
Member

Draft, opened to get CI running against the branch and to give the work a home for review. Not ready to merge.

Size

101 commits, 122 files, 42717 insertions, 145 deletions. 81 files added, 39 modified, one rename, one copy.

What it adds

Five new workspace crates, all wired into the root Cargo.toml at 0.32.0:

  • registry-scheduling-core, the source-neutral model, admission rules, policy, diagnostics, problem catalog, and fixtures
  • registry-scheduling, the runtime and the scheduling binary
  • registry-schedulingctl, adopter and operator tooling and the schedulingctl binary
  • registry-scheduling-client, the Rust client
  • registry-platform-calendar, shared calendar primitives

AGENTS.md is updated to describe Scheduling as a fifth independent runtime product, with the boundary stated explicitly: Scheduling owns its capacity ledger absolutely, a hold or appointment is created, moved, or released only inside the Scheduling runtime's own capacity transaction, and no other product may write to that ledger directly or through a shared database.

Commit scopes, complete: 30 fix(scheduling), 20 feat(scheduling), 11 docs(scheduling), 6 test(scheduling), 6 fix(schedulingctl), 4 docs, 3 feat(schedulingctl), 2 feat(platform), 2 chore(scheduling), 1 each of test(release), test(breg), refactor(casework), fix(scheduling-client), fix(platform), fix(platform-calendar), fix(ci), feat(scheduling-core), feat(auth), docs(site), docs(schedulingctl), ci(scheduling), ci, chore(schedulingctl), chore(identifiers), chore, test(schedulingctl).

Beyond the new crates it touches products/scheduling (29 files), docs (18), release (7), .github (4), products/identifiers (2).

Worth a reviewer's attention

Three things reach outside the Scheduling crates and are the parts most worth arguing about:

  1. crates/registry-casework-core/src/calendar.rs moves to crates/registry-platform-calendar/src/working_day.rs (85% similarity). Calendar logic is lifted out of Casework into a shared platform crate so Scheduling can use it without depending on Casework. The Casework public API is preserved by re-exporting the moved items from registry-casework-core. That is the right direction given the product boundaries, but it changes a Casework-owned file and deserves a second opinion on the seam.
  2. crates/registry-platform-oidc gains a Scheduling variant on GrantBounds plus its bounded permission type (3 files). The change is additive, but the crate is shared by every product, so it is worth checking nothing narrows or widens for the existing callers.
  3. crates/registry-breg/src/task_grant/tests.rs and crates/registry-cli-docs are each touched lightly. Scheduling verifies a task grant, so the BReg test change is expected, but it is a cross-product edit.

State

  • Rebased onto main and force-pushed. The rebase was clean: the branch and the three commits it was behind touch disjoint file sets.
  • docs/site/src/data/cli-reference.yaml carries a fresh v3 publication record for 0.32.0, reviewed against a generated catalog diff rather than stamped blind. The only catalog delta against main is the two added binaries scheduling and schedulingctl; every other command tree is byte-identical.
  • Cargo.lock gains only the five new workspace crates. No third-party version moved and nothing was removed, so every other product builds against an identical dependency graph.

What the first CI run found

One real defect, now fixed on the branch: intents_postgres seeded its base time straight from the clock and compared the value PostgreSQL returned against the nanosecond-precision value still held in memory. timestamptz resolves to microseconds, so the tail was dropped on write. This failed on every Linux run and passed on every macOS one, because the two platforms' realtime clocks differ in resolution, which is why it reached CI at all. The fix truncates the seeded instant to microseconds.

One unrelated gate flaked: Base Registry Engine product contracts (postgres) refused a caseworkctl dev session with a generic runtime_dependency diagnostic, then passed on a re-run of the same commit. That test starts a dev session with a stock issuer, a database, and four ports, so it is load sensitive. Not caused by this branch: the branch touches neither the Casework runtime, nor caseworkctl, nor that test, nor that job.

@jeremi
jeremi force-pushed the feat/scheduling-mvp branch 2 times, most recently from 7ed0254 to 7be7edc Compare September 17, 2026 13:14
@jeremi
jeremi marked this pull request as ready for review September 19, 2026 19:25
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-20T03:19:16.991944Z 8491a37 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9674e86e93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-scheduling/src/store.rs Outdated
Comment thread crates/registry-scheduling/src/service.rs
Comment thread crates/registry-scheduling/src/runtime.rs
Comment thread crates/registry-scheduling-core/src/model.rs
Comment thread crates/registry-scheduling-core/src/admission.rs
Comment thread crates/registry-scheduling/src/store.rs
Comment thread crates/registry-scheduling/src/store.rs
Comment thread crates/registry-scheduling/src/store.rs
Casework clocks and Scheduling openings evaluate the same mathematical
object: working days, holiday revisions, and timezone-correct local
instants. Move the evaluator into a new registry-platform-calendar crate
so both products share one implementation, and add the weekly-opening
expansion Scheduling needs: bounded patterns become concrete half-open
UTC intervals, daylight-saving gaps and folds are explicit errors rather
than silent shifts, and exception layering makes a blocking closure win
over an opening while an authorized reopening adds time back only inside
the closure it names.

registry-casework-core re-exports the platform crate through its flat
glob, so its public surface is unchanged. CalendarEvaluationError keeps
every original variant verbatim and gains the weekly diagnostics; it
loses Copy because the new variants carry identifiers.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
GrantBounds gains a closed `scheduling` tag whose permissions bind one
service and location pair to a bounded action vocabulary, mirroring the
existing BREG bounds shape. Existing evidence and breg accessors return
None for it, and a scheduling_permissions accessor joins them.

Validation:
- Threat: grant bounds carry the cross-product authorization payload
  inside a signed task grant, so a new variant is where semantics could
  leak from one product's runtime into another's. The variant stays a
  closed union member: a verifier that does not know the tag rejects it
  as a malformed bound at authentication and never interprets it, and
  the Evidence and BREG consumers read bounds only through their
  accessors, which return None for scheduling grants.
- Wire format: inside the signed registry_grant_bounds claim,
  {"type":"scheduling","permissions":[{"service":...,"location":...,
  "actions":[...]}]}. No existing claim changes shape; the union gains a
  tag and nothing else.
- Compatibility: issuers on older code cannot mint the tag and verifiers
  on older code refuse it as malformed, which is the intended fail-closed
  behavior; no token signed before this change carries scheduling bounds.
- Limits: 64 permissions of 32 actions, service and location values
  <=512 bytes with no whitespace or wildcard, actions in the existing
  lowercase operation grammar, (service, location) pairs unique; Debug
  redacts service, location, and action values.
- Tests: registry-platform-oidc 81 passed (variant distinctness,
  cardinality at and over the limits, wildcard, whitespace, and duplicate
  refusal, scheduling Debug redaction); cargo fmt/check/clippy
  --workspace --all-targets -D warnings clean; cargo test --workspace
  green with BREG_TEST_DATABASE_URL set on a disposable PostGIS instance
  (registry-breg: 53 suites, 373 passed, 0 failed; the casework clock
  PostgreSQL journey also passes against the extracted calendar);
  cargo deny check ok for advisories, bans, licenses, and sources.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A reopening restored the closure it named even when that closure reached
outside the pattern's own time, so an operator could extend published
hours through the exception layer rather than through the reviewed
pattern. A reopening now restores only pattern time its closure removed:
it must sit inside the pattern's hours on its date, on a pattern weekday,
inside the effective range, and on a non-holiday date, or the evaluation
refuses with the reopening's identifier.

Dated local intervals gain their own conversion with dedicated error
variants: local_interval turns one wall-clock interval on a date into a
half-open UTC interval in a named timezone, refusing gap and fold
instants explicitly, and InvalidIntervalDate and InvalidIntervalTimes
replace the exception-scoped variants a placeholder id used to force.

Review notes: the reopening tightening changes what an authorized
exception may publish, so the tests pin every refused shape (outside
pattern hours, reaching past pattern end, holiday date, non-pattern
weekday, out of effective range) beside the restored ones (identity
reopening of a whole closure, adjacency to an unrelated closure), plus
the fold-spanning interval that keeps real elapsed time.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
GrantBounds::Scheduling shipped with SchedulingPermission private to the
crate, so no relying product could name the permission it verifies.
Export it through the crate root and pin the wire form: the scheduling
tag round-trips through serde, refuses unknown members at the union and
the permission-value level alike, and enforces its value boundaries.

The review record for the scheduling bounds moves from the M0b commit
message into the crate README, where the BREG and Evidence bounds
records already live, so a reviewer of a later change finds the limits
and redaction decisions beside the code.

Review notes: authorization-relevant but behavior-neutral on the wire.
No claim shape changes and no verifier changes; the export makes an
existing closed type nameable, and the round-trip test holds the tag
spelling so a write-side regression fails before validation runs.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The scheduling bounds are a closed union member the BREG binding must
never interpret: a task grant carrying them is refused as a malformed
bound at authentication, the same fail-closed path as every bound the
verifier does not know. Pin that refusal so a future accessor addition
cannot silently admit scheduling grants to BREG actions.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Registry Scheduling Milestone 1, the offline checkpoint: everything that
decides an admission and everything that proves it, with no runtime yet.

registry-scheduling-core holds the source-neutral model and the pure
evaluators. The authored policy declares services, offerings, opening
patterns, holiday sets, published windows, and the hold policy; the
environment facts (locations, pools, dated exceptions) stay runtime
records the policy references but never embeds, so one published policy
governs every environment that resolves its identifiers. The evaluators
are total functions of resolved policy, facts, ledger snapshot, request,
and observed instant: no clock, no socket, no database. The runtime will
call them inside its capacity transaction, the explain endpoint will
call them again for an authorized caller, and fixture replay calls them
with synthetic facts, so the three cannot disagree about what admits.

Every refusal carries two problem codes from the closed 26-code
vocabulary: the public one every caller may see and the detailed one
only the authorized explain path may see. Exactly one code,
resource.unavailable, is detailed-only, because a specific resource's
absence can disclose another person's booking or a private staff
reason. authorization.refused is an audit reason, never a problem code.
Interval arithmetic that leaves representable time refuses as a horizon
answer, never a mislabeled capacity refusal and never a clamped buffer
that undercounts occupancy; window allocation saturates instead of
wrapping past u32.

Fixtures replay offline: synthetic facts, a starting ledger, ordered
cases with expected outcomes. Replay runs the same evaluators, records
admitted cases into the ledger later cases contend against, and refuses
a fixture naming a code outside the vocabulary or a detailed-only code
before any case runs. Structural accesses are total: a policy whose
check did not pass fails the fixture with a typed error naming the case,
never a panic.

schedulingctl authors and proves: init writes a complete starter project
from one of two templates, check validates offline, test replays every
fixture, explain publishes what the runtime would serve. check exits
zero with findings by default and gains --deny-findings for CI, matching
caseworkctl; a fixture run with failing cases always exits nonzero.
Reports render in text or JSON with path-addressed diagnostics.

products/scheduling carries the dependency-direction gate holding the
boundary: the core never references runtime, protocol, or storage types,
and no product term leaks into a platform crate.

Tests: registry-scheduling-core 71 passed, registry-schedulingctl 22
passed, the template projects init/check/test/explain green end to end;
cargo fmt, check, and clippy -D warnings clean across the workspace;
cargo test --workspace green with the disposable PostGIS databases set;
cargo deny check ok; the scheduling dependency-direction gate passes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
INT-02 makes reminders configurable at the service-design layer and binds
each intent to an appointment revision, and AT-08 asserts a reschedule
leaves no active reminder for the old revision. Both need an authored
reminder schedule to exist, or the invariant is only vacuously true, so the
policy gains one reminder offset type on each offering: minutes_before and
a review because, validated like every other authored choice (zero,
duplicate distances, and blank reasons are findings). Message transport
stays external: the runtime mints revision-bound intents from these
offsets, and an adopter with no consumer reads them back.

The standalone-exact-time template authors two offsets on the counter
offering so the runtime demo can show a real intent being suppressed.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The HTTP documents both the runtime and every client speak: the catalogue
(service, offering, window, reminder), availability entries as a kind-tagged
union of grid slots and windows, pages with cursors, holds, appointments and
their requests, history entries, and the explain document with its public and
detailed problem codes side by side.

They live in the source-neutral core beside the model they project, the way
the Casework DTOs live in registry-casework-core, so the client crate depends
on core and never on the runtime. Every document is camelCase and refuses
unknown fields. The projections are deliberate: because reasons never
publish, callers see displayed times rather than occupied intervals, and the
detailed code that names unavailability appears only on the separately
authorized explain path.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Every code in the closed vocabulary now carries its HTTP status, title, and
value-free remediation detail beside the code string itself, so the runtime
emits and every client validates one pinned mapping rather than each
re-deriving its own. The map is part of the wire contract: a caller can
enumerate every problem this product can return and know the status it
arrives with.

Statuses follow the acceptance reasoning: recoverable conflicts answer 409,
holds and idempotency receipts that no longer exist answer 410, stale
revisions answer 412, a missing precondition answer 428, requests the
published policy could never serve answer 422, and checks that could not run
answer 503. Transport statuses (404, 405, 413, 415) are deliberately absent:
edge rejections belong to the platform's own problem namespace, never to the
domain vocabulary.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The route paths, the idempotency-key header, the cursor and limit query
parameters, and the idempotency key bound are contract the runtime and every
client share, so they live in core beside the artifact names and are pinned
by the same style of test. The resource and location listings gain their
documents: a resource is a concrete pool member, never an independent
counter, and a location carries the IANA timezone its openings expand in.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The relying-party client for Registry Scheduling: fourteen methods over
the pinned exact-time HTTP contract, each taking a borrowed bearer token
by value and answering one completed call with the response document and
its validated trace identifier.

Every failure lands in one of four matchable shapes: a caller-side
configuration or request defect validated before any network input or
output, a transport failure, a protocol failure naming the stage where
the wire stopped matching the pinned contract, and an exactly validated
product problem surfaced as the core's closed ProblemCode. A problem
document only becomes a domain problem when its status, type URI, title,
detail, code, and trace identifier all equal the pinned definition; any
mismatch, and any platform-owned transport problem, is edge talk.

Mutating calls carry a caller-supplied idempotency key validated against
the pinned bound. Route identifiers are validated as one path segment
before interpolation, so an opaque id can never silently detour to a
different route. Bodies are read under a bounded byte ceiling and the
core documents refuse unknown fields, so a response is exactly the
contract or a protocol failure. The client never retains a token, never
follows a redirect, and never retries a mutation.

Boundary: the client depends on registry-scheduling-core and the shared
registry-platform-httpsec and registry-platform-httputil primitives
only, never on the Scheduling runtime or its operator tooling, and adds
no scheduling semantics of its own.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The service owns the three decisions the store cannot. Authorization:
every mutating call must carry a task grant whose scheduling permissions
name the offering's service, location, and the operation, and the store
re-checks the grant's expiry inside the capacity transaction. Disclosure:
every refusal projects to its public problem code, and only the
separately authorized explain path sees the detailed one; an admitted
start carries no codes at all, because there is no refusal to explain.
Attributability: the commitment's audit record carries pseudonymized
principal, client, grant, and approver references, and a refused
commitment records its receipt under the caller's idempotency key so a
replay answers exactly as the first attempt did.

Availability is bounded: exact-time offerings walk their authored grid
inside the published openings, clear of closures, past lead time, inside
the horizon, and only list a slot at least one available member can
still serve; arrival windows list their remaining units. A stored
success receipt carries the minted claim itself, so a replayed response
is the original response through the same projection, not a
re-derivation from live state.

The policy-to-context resolution moves from the replay fixtures into the
core beside the evaluators, so the offline replay and the live runtime
expand the same openings and closures and cannot disagree about what a
location published.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
records apply performs the one attributable operator write of a
deployment's live environment records: the document is parsed and
validated offline in its own terms (duplicates, timezone, undeclared
exception locations, strict dates and times) before any connection
opens, then lands through the store's single replace transaction with
its operator audit row. A PostgreSQL integration test behind the
postgres-test feature proves the wholesale swap and both audit rows
on a disposable schema.

package writes the deployment identity the runtime verifies: the
manifest lands beside the authored policy, an existing manifest is
refused rather than replaced, and the written manifest is proven to
verify through the runtime's own verifier before success is reported.

Runtime-configuration and store failures now map to their own
diagnostics instead of the generic authoring refusal, and the store
binds exception dates as text cast to date, the pattern the rest of
the workspace uses for typed columns.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The request-edge rejections (400/404/405/413/415/422) join the closed
problem vocabulary under the product's own prefix, exactly as Casework
carries them: no shared platform problem prefix exists in the Registry
Stack identifier catalog. The client now types the product's own
request-edge documents and treats only foreign dialects as edge talk.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The database-free checkpoint runs the dependency-direction gate, both
starter templates through the whole offline authoring journey, the
package manifest write, and a drift check holding the committed example
to exactly what init writes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Six defects found reviewing the committed M2 half against the milestone
contracts, each with its own verification:

- Exact-time capacity locked member ids as supply anchors, but the
  publication anchors pool ids only: every commitment met a missing
  anchor row and answered corrupt instead of booking. The transaction
  now locks the pool and snapshots the members (store.rs).
- The eagerly constructed internal errors on four lookup paths logged a
  phantom error line on every successful call. They now construct only
  on failure (service.rs).
- Holding an intent local stamped a delivery instant its own comment
  said never happened. No instant is stamped (store.rs).
- A reminder destination URL carrying a query or fragment passed the
  configuration check and was then refused by the frozen transport at
  startup. The check refuses it first (config.rs).
- The postgres-test feature compiles the plaintext-database refusal out
  of the binary and compiled the pinning test's only use out with it.
  The use is now gated to match the refusal (config.rs).
- An opening spanning more days than the weekly calendar expansion
  serves passed authoring and failed only at evaluation. The policy
  check now refuses the span at exactly the runtime boundary
  (registry-scheduling-core).

The store also gains adopt(), the provisioning verb that claims the
empty deployment identity the migration seeds, so a fresh database can
reach a servable state; the runtime command wiring follows in the next
commit.

Review notes for the security-adjacent changes: the pool-anchor fix
changes what the capacity transaction locks, not what it authorizes;
the adopt verb writes the deployment identity under FOR UPDATE and
refuses a mismatched claim, never overwriting one.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The scheduling binary becomes a service: migrate and serve.

serve assembles the deployment the contracts describe and refuses to
start half-provisioned. It verifies the policy package identity,
refuses an empty or mismatched deployment identity before writing
anything, keys the audit journal from one master secret through
independently HKDF-derived chain and identifier sub-keys (AUDIT-03),
and publishes the policy only when the digest moved. Four supervised
workers run beside the listener — hold expiry, reminder dispatch,
retention sweeps, and audit publication — and a worker that stops
stops the process, so the runtime never keeps selling capacity its
clocks no longer guard.

The HTTP edge (http.rs) serves the fifteen pinned routes over the
naming constants, types every refusal as a problem document with a
trace id, and replays stored attempt receipts verbatim under their
original status. Authentication (auth.rs) has no unauthenticated
mode: a read requires the reads scope, explain its own scope, and
every mutation a task grant whose scheduling bounds cover the
offering's service, location, and action, re-checked inside the
capacity transaction.

Reminder dispatch is the one outbound hop: each due intent renders as
one canonical CloudEvents 1.0 event and is POSTed to the frozen
operator-configured destination, with no destination meaning intents
stay recorded and local. The audit outbox publishes to the keyed chain
with a crash-gap reconciliation that never duplicates an envelope.

The operator configuration schema is generated from the strict types
(schema.rs) and committed with a drift test, the same contract the
other products hold. The PostgreSQL suite (tests/) drives the six
commitments, availability, cursors, replay, the edge refusals, the
unanchored-supply refusal, and deployment-identity adoption through
the served router against a real database.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Scheduling joins the Registry Stack identifier catalog: the 32 problem
codes publish under
https://id.registrystack.org/problems/registry-scheduling/, exported by
a core example beside the casework and BReg exporters, and the runtime
configuration schema registers as an active v1alpha1 schema source
generated from crates/registry-scheduling/src/schema.rs.

The generated catalog is reproduced by
products/identifiers/scripts/generate.py --write and verified by its
check.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Scheduling becomes one of the five named runtime products, with its
four crates on the repository map, its boundary paragraph beside the
other products', and its gates on the verify-your-change list: the
checkpoint script, the PostgreSQL suite under postgres-test, the
identifier reference closure, and the schema generator command.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…erence

The scheduling and schedulingctl command trees join the generated CLI
reference: the catalog crate registers both, the generator knows their
group, and the sidebar builder seats their pages. The review record
moves to the catalog that includes them.

The calendar extraction had also left two Casework evidence anchors
citing the removed casework-core calendar module; both now cite the
platform calendar file the evaluation moved into.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
run.sh builds the real scheduling and schedulingctl binaries, stands up a
disposable TLS-enabled PostgreSQL (its own throwaway PKI, since the released
runtime refuses plaintext database links), publishes the standalone-exact-time
policy with demo environment records, and drives four acceptance scenarios
against the served contract under real authentication: the two-caller race
for the last station (AT-01), hold expiry (AT-05), lost-confirmation replay
(AT-06), and the daylight-saving fold grid (AT-19).

Tokens are real RFC 9068 access tokens signed by a throwaway RSA key the
runtime verifies through its static JWKS source; mutations carry complete
task grants the runtime re-checks inside the capacity transaction. The
support module's pure pieces (JWK derivation, token minting, grant claims,
records, runtime config, pinned scenario facts) carry unit tests.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…hecks

generate_openapi.py writes the deterministic OpenAPI 3.1 document for the
sixteen public routes and verifies it against the Rust truth beside it: the
route inventory, handlers, authority profiles, bodies, parameters, and
success statuses are parsed out of http.rs; the problem vocabulary, pinned
titles, details, and statuses are read from the problem-catalog example's
export, never copied; and every wire schema is compared field by field with
the struct it projects. The per-operation problem mappings are the one
hand-shaped table (the catalog exports no operations contract yet) and are
verified for existence, pinned status, production reachability, and
rule-derived minimums. check-checkpoint.sh runs the generator's --check
first, so a drifted document fails the product gate.

reserved-problems lists eligibility.unavailable and hook.unavailable, which
the vocabulary defines and pins but no milestone path produces yet.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The development docset gains the Registry Scheduling product's API
reference lane: the narrative contract page (authority profiles, task
grants, idempotency, revision rules, problem codes, route families),
the generated operations pages rendered from the product OpenAPI
artifact, and the problem-code table generated from scheduling-api.yaml
and cross-checked against the product problem catalogue by the new
scheduling-api-reference test.

Registration follows the Casework pattern: repo-docs and the latest
docset name the product, fetch-openapi pulls its committed artifact,
redocly and the starlight-openapi plugin render it, and docsets without
the product (every archived set predating it) redirect the page to the
current docset instead of serving it. The information-architecture test
pins the product order, the docset gating, and the fetch rule.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Workspace.__init__ in ci_changes.py asserts the SHARDS inventory against
the Cargo workspace and raises when a crate is unlisted. ci_event_routing.py
constructs a Workspace unconditionally in the changes job, so the raise
failed the entire PR gate closed with no test run.

Add registry-platform-calendar to the platform shard: it is a shared,
domain-neutral primitive (chrono/chrono-tz/thiserror only) consumed by
registry-casework-core alongside the runtime and core crates, the same
role every other registry-platform-* crate plays.

Add a new scheduling shard mirroring casework's shape (core, runtime,
ctl, client; Scheduling has no breg-adapter or node/py binding crates
yet) for registry-scheduling-core, registry-scheduling,
registry-schedulingctl, and registry-scheduling-client.

python3 -m unittest discover -s .github/scripts -p 'test_ci_changes.py'
goes from one error (raises before any test body runs) to 95 passing.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A suite that passes with no database proves nothing. The fixture now
expects SCHEDULING_TEST_DATABASE_URL the way the sibling records-apply
suite already does, so an absent database fails the binary instead of
reporting ten skipped successes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi and others added 25 commits September 20, 2026 08:51
…t admits

The runtime now refuses to start when a deployment behind an
operator-controlled terminator names no clients, and refuses an exchanged
token whose assertion authority no entry declares. The configuration
reference still told an operator that an empty client list admits every
client the issuer verifies, which would now be a startup failure, and said
nothing about the authority map at all.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…rence

precondition.failed no longer has a producer in the Scheduling runtime: an
offering the deployed policy does not publish answers request.not-found, and
a stale revision answers policy.changed or revision.mismatch. The published
reference still told a caller to reload and retry something that no reload
produces.

The site cannot be run end to end until the CLI publication record is
reviewed, so this change is verified against the data file's own shape: 32
rows matching ProblemCode::ALL, sorted, every code present in the Rust
vocabulary.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
DX-16 asked for a dev verb over the runtime with the demo's provisioning
shape. The demo's shape depends on Docker container lifecycle, TLS
certificate generation, and JWT signing infrastructure
(products/scheduling/demo/support/demo.py), so a resident process
supervisor over it is a project-sized effort of its own, comparable to
crates/registry-evidencectl/src/dev.rs. Record the deferral and point at
the demo script as the current local development path.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…uers

allowedClients being empty by default no longer admits every client
unconditionally: behind listener.tlsTermination:
operator-controlled-upstream an empty or absent list is a startup
refusal (config.rs, a_production_deployment_must_name_the_clients_it_admits),
and only development loopback keeps the old convenience. The runtime
example still said otherwise. Also document the assertionIssuers key,
added alongside that change, in the same commented style.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The claim ledger keeps a single supply column: an exact-time claim occupies
the pool member it was assigned, a window claim occupies the window itself.
The published documents decided which of the two they were holding from the
ledger kind, which only ever says booking or hold, so the guard was always
true and an arrival-window appointment reported the window id through the
field documented as the pool member. Decide it from the offering's mode
instead, and cover arrival windows end to end: unit allocation, the channel
subquota ceiling, exhaustion, and a stale window revision.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The branch's prose convention is commas, colons, semicolons, periods or
parentheses. Comment text only, no behaviour change.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ts grammar

A clock that is not `HH:MM`, or a date that is not `YYYY-MM-DD`, was reported
as an invalid bound. That reads as a number out of range, so adopter tooling
treated it as unfinished authoring and let the check pass. Nothing an author
adds turns `9:00 AM` into a clock: only a correction does, and the reason now
says so.

The check also names the field that actually carries the malformed text
rather than always naming `startTime`, and leaves the range such a value
belongs to unjudged, because comparing text that is not a clock answers a
question the author never asked. `PolicyCheckReason::is_malformed_value` is
what a caller asks to refuse the document rather than report it incomplete.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
registry-scheduling-core now raises PolicyCheckReason::MalformedValue,
distinct from InvalidBound, for a clock or date whose text is outside its
grammar. Nothing an author adds turns "9:00 AM" into a clock, only a
correction does, so check and test must not treat it like ordinary
incomplete authoring that --deny-findings alone gates.

check now reports status: "invalid" for a malformed value and refuses
regardless of --deny-findings; test reports authoringStatus: "invalid" the
same way and skips fixture replay entirely, since a malformed value has no
business being replayed against. The human-readable renderer names both new
states in the product's voice.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
… deferrals

The runtime now audits a permission refused before the capacity transaction,
publishes the journal in the order it was written, bounds caller strings at
the edge, admits only the RFC 9068 access-token type, requires a production
deployment to name its clients, binds an exchanged credential to a declared
authority, and fails closed on an unreadable clock.

Promote SCHEDULING-SEC-14 to enforced and retire SCHEDULING-DEF-02 and
SCHEDULING-DEF-03, whose compensating controls describe behaviour the runtime
no longer has. Add five invariant rows with the tests that earn them, the
matching traceability entries, the review notes the changes are
security-sensitive enough to require, and the recorded decision behind a
records swap that refuses to strand a live claim.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The runtime now writes an authorization record when the service refuses a
permission before the capacity transaction opens, so the sentence that named
only the commitment was true but incomplete.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Finishes the pass that cleaned the store and runtime prose. The two
occurrences in registry-schedulingctl/src/records.rs stay for the lane that
owns that crate.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The last em-dash holdout in the scheduling family, inside schedulingctl's own
ownership boundary.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…table

Add a second standalone-arrival-window offering, opening, and window: a
Saturday afternoon household block sized by a banded units table instead of
a flat per-recipient rate. Its aboveHighestBand policy refuses a party larger
than the table's highest band rather than costing it by guess, so an adopter
starting from the template has a working example of both units policies the
grammar supports.

The accompanying fixture proves a household in the first band, a household
above the highest band refused with party.capacity-inadequate, and a
household in the second band, all replaying clean offline.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ree builds

Cargo unifies the features a dependency entry names across one invocation, so
asking for `registry-scheduling/postgres-test` in this crate's dev-dependencies
turned the feature on for every build that holds both crates. That satisfied
the runtime's own `required-features` gate, and the shared test shard, which
has no PostgreSQL server, compiled and ran `postgres_commitments`.

The database targets here already declare `required-features = ["postgres-test"]`
against this crate's own feature, which chains to the runtime's, so the gates
that mean to run them still get the plaintext test connection and nothing else
does.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A dependency entry naming a sibling's feature is the same as passing that
feature on the command line for every build that selects both crates, so one
crate can silently select another's database-only test targets and hand them to
a job with no server. The checker reads `cargo metadata`, maps each Scheduling
test target to the feature that gates it, and reports any Scheduling dependency
entry that turns such a gate on. The checkpoint runs it beside the
dependency-direction guard.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`intents_postgres` needs a PostgreSQL server and was executed by no job, so the
undelivered delivery intent read had no proof in CI. It runs in the Scheduling
PostgreSQL job on its own database, the way the authoring record application
suite does, because both are destructive.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The Casework image-pin gate named a bare digest, and the Scheduling PostgreSQL
job pins the same image, so removing Casework's pin left the gate satisfied by
Scheduling's copy and its own probe stopped failing. Each pin now names the job
it belongs to, Scheduling's pin is declared beside Casework's, and the
Scheduling workflow and checkpoint gates get the removal probes the Casework
ones already have, including the delivery intent suite and the database test
isolation guard.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…reSQL keeps

The seeded base time came straight from the clock, so it carried a
nanosecond tail that `timestamptz` drops on write. The assertion then
compared the microsecond value the command read back against the
nanosecond value still held in memory, and the two never matched on a
platform whose realtime clock resolves finer than a microsecond.
Truncating the base time to microseconds makes the seeded instant and the
reported `dueAt` the same value on any clock.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…vailability truth

Close the review findings that admitted concurrent lifecycle writers,
stamped cached policy evaluations, resolved stale supply facts, leased
no dispatch, suppressed nothing already loaded, and disagreed between
availability and admission about the published schedule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…lease

The authorization clock a mutation answers under is observed once more
after its tentative writes, immediately before the commit that lands
them, so a grant that lapses while a write waits for a lock commits
nothing; the six mutation paths and the regressive test that crosses
the boundary per operation pin it. Cancellation now guards the policy
its cutoff is evaluated under, the same guard admission answers to.
The dispatch liveness read refuses an intent whose remaining lease
cannot cover one more send deadline, so an overrunning pass hands the
send over instead of racing the reclaiming one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The database-test isolation gate now reports a maintained database suite
whose required-features gate disappeared, and a dependency entry that
asks for a feature whose expansion enables a sibling's gate, not only
the gate's own name. The known suites are named so losing a gate fails
the checkpoint rather than passing silently.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The contracts validator now asks each cited test's own runner for its
inventory instead of matching names in source text: cargo lists the
selected target's tests under the citation's features, unittest
discovers the Python module, and neither executes a test body. The one
citation living behind a feature gate names the feature in the matrix
and the traceability file. The database-suite gate reads Cargo's
resolved default features, so aliasing or forwarding postgres-test into
a default build fails the checkpoint, and an ordinary ungated
integration test is no longer mistaken for a database suite.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi force-pushed the feat/scheduling-mvp branch from 4bf86bc to 34deb3e Compare September 20, 2026 01:57
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e590e5cc66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-scheduling/src/service.rs
Comment thread crates/registry-scheduling/src/store.rs Outdated
Comment thread crates/registry-scheduling/src/service.rs Outdated
Comment thread crates/registry-scheduling/src/service.rs Outdated
Comment thread crates/registry-scheduling/src/service.rs Outdated
Comment thread crates/registry-schedulingctl/src/records.rs
Comment thread crates/registry-scheduling-core/src/resolve.rs Outdated
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8491a37f4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2290 to +2292
let rows = transaction
.query(CONSUMING_CLAUSES, &[&member_ids, &earliest, &latest, &now])
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check duplicate keys beyond the current pool

When an exact-time offering with an active booking is republished to use a different pool, which apply_policy currently permits, this snapshot contains only members of the new pool. The old claim therefore never reaches check_duplicate, so a request carrying the same offering-scoped duplicate key can create a second active booking. Either query active duplicate keys independently of the current supply snapshot or prevent pool changes while such claims remain active.

Useful? React with 👍 / 👎.

Comment on lines +34 to +36
let text = crate::project::read_authoring_input(&records_path)?;
let facts = parse_records(&text)?;
validate(&facts)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate exception layers before replacing records

When a records document contains a semantically invalid exception layer—such as an opening without reopens/authority, an opening naming an unknown closure, or a closure carrying reopening fields—this validation accepts it and the transaction commits it. Subsequent exact-time availability and commitment requests at that location fail during location_open_intervals and return service.unavailable until the operator repairs the records. Run the calendar layer validation against the loaded policy before replacing the live facts.

Useful? React with 👍 / 👎.

Comment on lines +585 to +589
if let (Some(start), Some(end)) = (start, end) {
if end <= start {
return Err(SchedulingClientError::invalid_request(
"the availability interval ends before it starts",
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve availability range normalization in the client

When callers provide end <= start, the HTTP contract explicitly accepts the request and raises the effective end to one minute after start, but the maintained Rust client rejects it locally and never sends it. This makes documented server behavior inaccessible through the canonical SDK; remove this stricter check or align the published contract and runtime to reject the range too.

Useful? React with 👍 / 👎.

Comment on lines +338 to +343
if request.window_revision != Some(window.revision) {
return Err(AdmissionRefusal::RevisionMismatch {
observed: window.revision,
});
}
check_horizon(window.start, *now, *lead_time_minutes, *horizon_days)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match arrival requests to the requested start

For an arrival-window offering, the evaluator never compares request.start with the published window start: it checks the horizon using window.start and returns an admission at that same window start. A caller can therefore request any timestamp and receive a successful booking for a different time, while that ignored timestamp still changes the idempotency hash. Require the requested start to identify the selected window, or remove it from the arrival-window request shape.

Useful? React with 👍 / 👎.

Comment on lines +200 to +203
if request.party.recipients == 0 || request.party.recipients > exact.max_recipients {
return Err(AdmissionRefusal::PartyCapacityInadequate);
}
check_capabilities(offering, request)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject impossible party counts

When attendees is zero or smaller than recipients, both admission evaluators still accept the request as long as the recipient count satisfies capacity. This contradicts PartyCounts' definition that attendees include every recipient, so inputs such as two recipients and zero attendees can produce a committed hold or appointment. Validate attendees >= recipients (and therefore nonzero for an admitted party) before evaluating capacity.

Useful? React with 👍 / 👎.

Comment on lines +1519 to +1521
let window = policy
.window(&arrival.window)
.ok_or_else(|| operator_gap(&arrival.window))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve arrival windows from operator records

Every arrival-window commitment obtains its dates, revision, and capacity directly from the authored policy, while SchedulingFacts contains no window records and startup automatically creates the corresponding lock row. Consequently publishing policy is sufficient to invent bookable window supply without the independent operator-anchored runtime record required by the product boundary. Move published-window facts into the operator record layer and resolve them there before opening the capacity transaction.

AGENTS.md reference: AGENTS.md:L147-L150

Useful? React with 👍 / 👎.

Comment on lines +1522 to +1526
Ok(Self::Window {
window: window.clone(),
lead_time_minutes: arrival.lead_time_minutes,
horizon_days: arrival.horizon_days,
channels: policy.channels.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply location closures to arrival windows

When an operator applies a valid closure covering an arrival window, this branch builds the window context without the location's effective openings or closures, so availability continues advertising the window and holds or appointments still commit. The offline fixture checker explicitly requires each window to be covered by location_open_intervals, but production admission does not preserve that rule; resolve the calendar here and refuse or hide windows no longer covered by the effective schedule.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant