Skip to content

feat(scheduling): Registry Scheduling MVP - #1092

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

jeremi wants to merge 109 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 from a6d6ea7 to 086d918 Compare September 15, 2026 15:37
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>
…refusal

The shared calendar crate reported a Casework noun for every product: a Scheduling opening pattern landing in a daylight-saving gap read back a refusal about a due time. The instant the conversion refused is a local time whatever the caller calls it.

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

Resolution concatenated each opening pattern's own expansion, so the list an evaluator reads kept the order the openings happened to be authored in. Both answers taken from that list are properties of a single interval, so the order decided which start times were on the grid, and an appointment running from one opening straight into the next was refused as unpublished though the location never closed across it. Sorting and joining intervals that overlap or meet answers the coverage question the same way for the evaluator and for the fixture harness.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The rebase onto main took the workspace to 0.32.0 and resolved Cargo.toml;
the five scheduling crates' lock entries still carried 0.31.0 because they
were written before it. Every scheduling crate reads its version from the
workspace, so the lock now records the same 0.32.0 the manifests resolve to
and --locked builds stop asking to rewrite it.

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

The admission request carried a caller-settable rescheduleOf that excluded
any live claim from the capacity checks, so naming any active claim's id on
a create path double-booked that member, bypassed the duplicate-booking
refusal, and inflated window headroom. The mirror defect cut the other way:
the reschedule transaction never supplied the appointment's own id, so
moving an appointment inside the interval it already held was answered
capacity.exhausted unless the caller volunteered the id themselves.

The wire type no longer carries the field: AdmissionRequest drops
rescheduleOf and strict request parsing refuses a body that names one, so
no create path can be handed another party's allocation to ignore. The
evaluators take the exclusion as their own argument, and the only supplier
is the reschedule transaction, which reads the appointment's claim id from
the row it locks. The authored fixture grammar keeps rescheduleOf, because
a fixture case legitimately says this case reschedules that one; the
harness passes the named claim as the evaluator argument and now refuses a
dangling reference instead of replaying it as an ordinary booking. Fixture
case names must also be unique, because one reschedule answering to two
names frees two allocations.

While the hash tuple is open, capabilities and prerequisites are sorted
before hashing: two spellings of one set are one request, and a retry that
reorders them replays instead of executing twice.

Security review notes: this moves a caller-controlled input out of the
capacity check entirely, which is a narrowing of what a request can express
on every commitment route (hold create, appointment create, hold
confirm). The reschedule path gains the self-exclusion it was missing, and
the exclusion it now supplies is read under the supply lock from the
appointment row the transaction holds, never from the request. Test
evidence: the core pins the refused wire shape and the order-insensitive
hash; the PostgreSQL suite pins a reschedule that moves inside its own
occupied interval (admitted now, where it was capacity.exhausted before)
and a create naming a live claim (422 request.unprocessable, and the
standing appointment still holds its slot). The OpenAPI document drops the
field with the wire type.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi and others added 21 commits September 17, 2026 20:02
…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>
The record still carried the 0.31.0 source version and digests taken
before the adopter tooling settled its report flag, so the generated
reference stayed a draft and the publication gate refused it.

Reviewed the published surface against main: the only catalog change is
the two added binaries, `scheduling` and `schedulingctl`, with every
other command tree byte-identical. Across both, every report option is
the shared `--format` accepting `human` or `json` with `human` as the
default, `check` carries `--deny-findings`, `intents` defaults its limit
to 50, and no option publishes an environment variable, a filesystem
default, or a domain term.

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>
@jeremi
jeremi force-pushed the feat/scheduling-mvp branch from 7ed0254 to 7be7edc Compare September 17, 2026 13:14
jeremi and others added 4 commits September 17, 2026 21:12
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 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-19T19:37:56.071011Z 9674e86 Draft marked ready
ℹ️ 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 on lines +1562 to +1563
if current_policy != commitment.policy_revision {
return Err(AdmissionRefusal::PolicyChanged.into());

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 Remove the cancellation policy-revision fence

When an operator publishes a new policy while an older runtime instance is still serving, commitment.policy_revision remains the cached revision and this branch returns policy.changed, rejecting an otherwise valid cancellation until that instance restarts. This directly contradicts SCHEDULING-DEC-02, the security matrix, and the generated OpenAPI, which promise that cancellation is guarded by the appointment revision and cutoff but never by policy revision; it can therefore trap callers in bookings during a rollout while returning an undocumented problem code.

AGENTS.md reference: products/scheduling/AGENTS.md:L13-L15

Useful? React with 👍 / 👎.

Comment on lines +837 to +842
.reschedule_appointment(
appointment_id,
offering,
&supply.context(),
&request.admission,
request.observed_revision,

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 mismatched offerings when rescheduling

RescheduleAppointmentRequest.admission requires an offering, but this path resolves authorization and supply from the existing appointment and passes the caller's admission to an evaluator that never compares its offering field. A request naming a different or nonexistent offering can therefore return 200 while moving the appointment under its original offering, service, and location, so the response does not perform the operation the request describes. Either validate equality with appointment.offering or use a reschedule-specific admission shape that omits the field.

Useful? React with 👍 / 👎.

Comment on lines +273 to +278
let pool_ids = offering_pool_ids(&policy);
let window_ids = offering_window_ids(&policy);
let policy_revision = store
.apply_policy(&scheduling_id, &policy_digest, &pool_ids, &window_ids)
.await
.map_err(database_step("policy publication"))?;

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 Fence policy publication against standing commitments

When a replacement policy reduces a published window or channel subquota below its live holds/bookings, moves the window away from those claims, or removes it, startup reaches this call and publishes the new digest and revision without inspecting the ledger. The repository already provides assess_publication_impact specifically to reject such changes, but a repo-wide search shows it is called only by its own unit tests; apply_policy receives neither the old policy nor a snapshot. Consequently a normal restart can successfully publish capacity below existing commitments and leave claims outside the supply the service now advertises.

Useful? React with 👍 / 👎.

Comment on lines +165 to +168
self.consuming(now).any(|claim| {
claim.duplicate_key.as_deref() == Some(duplicate_key)
&& Some(claim.id.as_str()) != exclude
})

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 Scope duplicate-active checks to the offering

For offerings that share a resource pool or published window, the snapshot contains claims from every offering using that supply, but this predicate compares only the caller-provided duplicate key. A booking for offering A therefore causes a request for offering B with the same subject-derived key to be refused as booking.duplicate-active, even though the migration explicitly describes duplicate enforcement as offering-scoped and avoids a database uniqueness constraint for that reason. Carry the offering through LedgerClaim and include it in this comparison.

Useful? React with 👍 / 👎.

Comment on lines +260 to +263
let capable: Vec<&PoolMember> = members
.iter()
.filter(|member| member.serves(&offering.requires_capabilities))
.collect();

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 Apply the admission request's capability set

AdmissionRequest.capabilities is a required public field documented as the party's held capabilities, but neither admission evaluator uses it: this exact-time branch checks only each resource member against offering.requires_capabilities, and the arrival-window path performs no capability check at all. Thus a request with an empty or unrelated capability set is admitted exactly like one carrying the required values, while the field only changes its idempotency hash. Validate the request set against the offering requirements, or remove the field from the public request if it is not part of admission.

Useful? React with 👍 / 👎.

Comment on lines +2099 to +2103
let earliest = open
.iter()
.map(|interval| interval.start)
.min()
.unwrap_or(now);

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 Include buffers in the locked snapshot range

For an exact-time offering, the transaction loads claims only between the raw opening start and end even though admission occupies time before and after that interval through buffer_before_minutes and buffer_after_minutes. If another offering sharing the pool has a booking that ends after this offering's buffered start but before its opening starts, that claim is omitted from the snapshot and the first slot can allocate the same resource during the overlap; the symmetric case exists after the opening end. Widen the transaction snapshot by the exact-time duration and buffers, as the read-only availability path already does.

AGENTS.md reference: AGENTS.md:L147-L153

Useful? React with 👍 / 👎.

Comment on lines +2479 to +2483
.pools
.iter()
.flat_map(|pool| pool.members.iter())
.map(|member| member.resource_id.clone())
.collect();

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 Refuse moving occupied resources between pools

The records replacement guard reduces incoming pool membership to a flat set of resource IDs, so moving an occupied member from pool A to pool B passes as long as its ID remains present. The existing claim is then invisible to pool B's snapshot while still naming that member, and a concurrent or later booking under pool B can allocate the same resource; hold confirmation is also serialized on the old offering's pool rather than the member's new pool. Compare each occupied resource's current and incoming pool as well as its presence before allowing the wholesale swap.

AGENTS.md reference: AGENTS.md:L147-L153

Useful? React with 👍 / 👎.

Comment on lines +1898 to +1900
"INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \
idempotency_key, request_hash, state, status_code, receipt, expires_at) \
VALUES($1,$2,$3,$4,$5,$6,'refused',$7,$8,$9) ON CONFLICT DO NOTHING",

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 Replay the winning receipt after an attempt conflict

With two concurrent identical requests using the same idempotency key, both can initially see no attempt; the winner commits a booking, while the loser subsequently acquires the supply lock, observes exhausted capacity, and reaches this refused-attempt insert. ON CONFLICT DO NOTHING silently discards the loser's receipt, but the service still returns its local 409 response, whereas the next retry replays the winner's 201 appointment. Detect the zero-row insert and load/replay the stored attempt instead of returning a response that contradicts the key's durable result.

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