Skip to content

fix(scheduling): close seven ledger and authoring follow-ups from the MVP review - #1255

Merged
jeremi merged 10 commits into
mainfrom
fix/scheduling-ledger-followups
Sep 21, 2026
Merged

jeremi merged 10 commits into
mainfrom
fix/scheduling-ledger-followups

Conversation

@jeremi

@jeremi jeremi commented Sep 21, 2026

Copy link
Copy Markdown
Member

What

Seven follow-ups from the Scheduling MVP review, one commit per ticket, in
the order they build on each other. Every one of them was filed rather than
fixed during that review because none of them was claimed by an enforced
row of the security invariant matrix; each is closed here with the test that
holds it and, where it adds an invariant, the matrix row that now claims it.

Commit Ticket
bound offering capability and prerequisite lists at authoring #1253
bound the offering selector before availability I/O #1254
advance the claim revision when hold expiry closes it #1252
release the duplicate key once the booking has passed #1251
answer a close retry from the policy that governed the claim #1250
re-check the policy against its window records at the database #1249
keep one supply identifier to one kind of supply #1242

The changes

#1253, offering capability and prerequisite lists. An offering's
requiresCapabilities and prerequisites are satisfied from the admission
request, and the request edge refuses either list past
MAXIMUM_COLLECTION_ENTRIES. The authoring check bounded every other
collection but not these two, so an offering declaring more requirements than
a request may carry passed authoring and startup, published, and then refused
every admissible request as request.invalid: permanently unbookable, with no
authoring signal. The authoring check now bounds both at the edge's own limit.

#1254, the client's offering selector. availability and explain
checked the selector for emptiness alone, while every hold and appointment
route validated its identifier against the runtime's bounded grammar. A caller
could spend a round trip on a selector the runtime refuses and receive a
transport failure rather than the caller-side InvalidRequest the client
promises for that class of input. Both now share one validate_offering. The
selector travels as a query parameter, not a path segment, so no route detour
was reachable through it.

#1252, hold expiry and the claim revision. close_claim, which release
and cancellation both go through, advances the revision, and its callers write
the history event at the new value. The expiry sweeper ran its own update and
omitted the revision, so the expired event landed at the same revision as the
held event that opened the claim, and nothing caught it: scheduling_history
has no unique constraint on (claim_id, revision). Capacity was never
affected, because the snapshot query discounts expired holds by
hold_expires_at without waiting for the sweep.

#1251, duplicate keys. The duplicate-active guard counted every active
booking regardless of its time, and no transition the party can reach closes
one: there is no completion route, and cancellation is refused at the
offering's cutoff. A party who kept an appointment was refused that offering
forever. Both places that answer the guard are now bounded by time, so the key
means one live booking per party per offering. A booking still consumes
capacity for overlap after it has passed; only its hold on the key is released.

#1250, close retries. Release and cancel resolved the offering from the
current policy before anything reached the stored receipt, so once publication
retired an offering whose claims were all closed, the retry each receipt
exists to serve answered service.unavailable for the rest of the retention
window, which a caller cannot tell from an outage. The offering is now read
from the policy revision the claim names when the current policy no longer
carries it. The receipt is still reached through an authorized request: the
grant is matched against the offering as it stood when the claim was
committed, rather than the check being skipped to reach the replay.

#1249, the combined invariants at the database. check_window_records is
the canonical validation of a policy against the window records it governs,
and it refuses the consequential case: a pool that staffs a published window
while also backing an exact-time offering, whose two modes anchor on different
supply rows and therefore never serialize. It ran only in schedulingctl,
against the files on an operator's disk. Both database writes now re-run it
under the locks they already hold, excluding the two findings that name an
absence rather than a contradiction so that ordinary operator sequencing in
either order stays possible. New matrix row SCHEDULING-SEC-26; the deferral
SCHEDULING-DEF-07 that recorded the gap is removed.

#1242, supply identifiers. A resource pool and a published window anchor
their capacity transactions on one row of scheduling_supply keyed by the
identifier, and the two namespaces are authored separately. A collision
aborted the records write on the primary key and was silently skipped by the
policy write, leaving a pool anchored on a row marked window that the next
records replacement deleted, which refused every commitment against that pool
until the policy was republished. Authoring refuses the collision by name from
both sides, and the two anchor writes share one insert that returns the kind
the row settled on. lock_supply names the identifiers it could not find
rather than reporting a corrupt ledger, which is what SCHEDULING-SEC-02
already promised the operator's diagnostics would say. New matrix row
SCHEDULING-SEC-27.

The last two are sequenced: #1249 puts the canonical combined check behind
both database writes, and #1242's window-side collision rule reaches those
writes through it.

Review fixes

Three commits answer the automated review, each correcting a commit above
rather than rewriting it.

fix(scheduling-client): hold the offering selector to the policy grammar
corrects #1254. The bound it added reused the route-identifier check, which
admits the alphabet a minted document id travels in: uppercase letters,
underscores, dots, 128 bytes. The runtime validates the offering query
parameter with the policy grammar instead, so Registry_Update or a 65-byte
lowercase name still spent the round trip the bound exists to save. The
selector now uses valid_identifier, the same predicate the edge applies.
Route identifiers keep the wider alphabet, because they carry ids the runtime
minted rather than identifiers a policy author wrote.

fix(scheduling): read a close retry's terms from the claim, not the identifier corrects #1250. That commit read the retained revision only when
the current policy no longer carried the offering, but retirement is not the
only change a closed claim outlives: publication pins an offering's service,
location, mode and supply only while a claim on it is live, so the identifier
may also stay and be sold under different terms. The retry was then authorized
against terms its claim never had, in both directions: the caller holding the
grant that committed the claim lost the receipt it had earned, and a grant over
whatever the identifier sells today could reach a receipt written under
something else. The offering now comes from the revision the claim names unless
the claim still pins it, which is the sentence apply_policy itself selects on
(an active booking, or a hold not yet past its expiry) and which also covers
the expired hold a state check alone would miss. New Postgres test, traced
under SCHEDULING-SEC-09, whose promise to replay the stored first answer for
the same key and body is what the gap was breaking.

fix(scheduling): index the whole predicate the duplicate guard filters
corrects #1251. The bound that commit put on the duplicate guard, counting only
a booking whose time has not passed, filters on three columns: the key, the
offering, and the booking's end. The version 1 index carried the key alone, and
nothing in the ledger closes an elapsed booking (no completion transition,
appointment retention deferred), so the active claims behind one party's key
accumulate for the life of the deployment and the guard rechecked all of them.
Schema version 7 indexes (duplicate_key, offering, occupied_end) under the
same partial predicate and drops the narrower index. 0001_scheduling.sql
stays as written, so a migrated deployment and a fresh one converge on the same
schema. New Postgres test asserting the index the guard needs.

The review's other finding, a missing DCO sign-off, names a commit SHA that
resolves in neither this repository nor the GitHub API; all ten commits carry
the trailer.

Not in this PR

#1240 and #1241 stay open. #1240 wires Scheduling binaries and installers
through the release pipeline, which touches release provenance and wants its
own reviewed change. #1241 adds the client to the three unified facades: it is
purely additive, spans the Node.js and Python packaging, and belongs with a
facade change rather than here.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings,
    cargo test --locked --workspace --no-fail-fast (the failures in that run
    are the registry-breg targets that require BREG_TEST_DATABASE_URL, plus
    one registry-platform-httputil deadline test that passes on its own under
    no load; neither depends on any crate this branch touches)
  • products/scheduling/scripts/check-checkpoint.sh
  • products/scheduling/scripts/check-contracts.sh
  • python3 products/identifiers/scripts/generate.py --check-references
  • cargo test --locked -p registry-scheduling --features postgres-test --test postgres_commitments against a disposable PostgreSQL 17 database

…authoring

An offering's requiresCapabilities and prerequisites are satisfied from the
admission request, and the request edge refuses either list past
MAXIMUM_COLLECTION_ENTRIES. The authoring check bounded reminders, services,
offerings, holiday sets, openings, hooks, and windows, but not these two, so an
offering declaring more requirements than a request may carry passed authoring
and startup, published, and then refused every admissible request as
request.invalid: a permanently unbookable offering with no authoring signal.

Closes #1253

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ty I/O

availability and explain each checked the offering selector for emptiness
alone, while every hold and appointment route validated its identifier against
the runtime's own bounded grammar. A caller could spend a round trip sending a
URL the runtime would refuse, and receive a transport or intermediary failure
rather than the caller-side InvalidRequest the client promises for that class
of input.

Both now share one validate_offering, so explain no longer carries its own copy
of the check and there is a single definition of a valid selector. The selector
travels as a query parameter, not a path segment, so no route detour was
reachable through it.

Closes #1254

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
close_claim, which release and cancellation both go through, sets
revision=$4 + 1 and its callers write the history event at that new value. The
expiry sweeper ran its own update and omitted revision from the SET list, so
the RETURNING value was the revision the row already carried and the expired
event landed at the same revision as the held event that opened the claim.
Nothing caught it at write time: scheduling_history has no unique constraint on
(claim_id, revision).

Capacity was never affected, because the snapshot query discounts expired holds
by hold_expires_at without waiting for the sweep.

Closes #1252

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The duplicate-active guard counted every active booking regardless of its
time, and no transition the party can reach ever closes one: there is no
completion route, and cancellation is refused at the offering's cutoff. A
party who kept an appointment was therefore refused that offering forever.

Bound the guard by time in both places that answer it, the offering-wide
read and the snapshot predicate, so the key means one live booking per
party per offering. A booking still consumes capacity for overlap after it
has passed; only its hold on the key is released.

Closes #1251

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

Release and cancel resolved the offering from the current policy before
anything reached the stored receipt, so once publication retired an
offering whose claims were all closed, the retry each receipt exists to
serve answered service.unavailable for the rest of the retention window.
A caller cannot tell that from an outage and keeps retrying.

Read the offering from the policy revision the claim names when the
current policy no longer carries it. The receipt is still reached through
an authorized request: the grant is matched against the offering as it
stood when the claim was committed, rather than the check being skipped to
reach the replay.

Closes #1250

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

The combined validation of a policy against the published window records
it governs ran only in the authoring tooling, against the files on an
operator's disk. Neither database write repeated it, so an operator who
edited the policy and restarted could deploy a pool that staffs a window
and also backs an exact-time offering. The two modes lock different
supply anchors, so their capacity transactions never serialize and the
ledger cannot observe that it sold the same staffing twice.

Both writes now re-run the same check under the locks they already hold:
publication loads the deployed window records, replacement loads the
retained document of the deployed policy revision. The authoring check
stays the single implementation; the writes narrow its findings to the
contradictions, excluding the two reasons that name an absence ordinary
operator sequencing depends on.

SCHEDULING-DEF-07 is promoted to the enforced SCHEDULING-SEC-26.

Closes #1249

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A resource pool and a published window anchor their capacity transactions
on the same row of scheduling_supply, keyed by the identifier, and the two
namespaces are authored separately. A collision aborted the records write
on the primary key and was silently skipped by the policy write, which
left a pool anchored on a row marked window that the next records
replacement deleted, refusing every commitment against that pool until
the policy was republished.

Refuse the collision by name from both sides of authoring, and give the
two anchor writes one insert that returns the kind the row settled on so
a standing anchor of the other kind is named rather than swallowed.
lock_supply now names the identifiers it could not find, which is what
SCHEDULING-SEC-02 already promised the operator's diagnostics would say.

Closes #1242

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 21, 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-21T15:03:58.905264Z 51de118 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: 2466ed21c4

ℹ️ 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-client/src/client.rs Outdated
Comment thread crates/registry-scheduling/src/service.rs Outdated
Comment thread crates/registry-scheduling-client/src/client.rs
The selector bound added for #1254 reused the route-identifier check, which
admits the alphabet a minted document id travels in: uppercase letters,
underscores, dots, and up to 128 bytes. The runtime validates the offering
query parameter with the policy grammar instead, which admits lowercase
letters, digits and hyphens up to 64 bytes and must begin with a letter. So
selectors such as Registry_Update or a 65-byte lowercase name still passed
both availability entry points, spent the round trip, and came back as a
server-side refusal rather than the caller-side InvalidRequest the bound
exists to give.

Validate the selector with the runtime's own valid_identifier. Route
identifiers keep the wider alphabet: they carry ids the runtime minted, not
identifiers a policy author wrote.

Refs #1254

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

The fix for #1250 read the policy revision a claim names only when the
current policy no longer carried its offering. Retirement is not the only
change a closed claim outlives: once nothing on an offering is live,
publication may also keep the identifier and sell it under a different
service or location, because only a live claim pins those. A release or
cancellation retry was then authorized against terms its claim never had.
The caller holding the grant that committed the claim was refused the
receipt that grant had already earned, and a grant over whatever the
identifier sells today could reach a receipt written under something else.

Resolve the offering from the revision the claim names unless the claim
still pins it, which is the sentence publication itself selects on when it
refuses to move an offering: an active booking, or a hold not yet past its
expiry. For such a claim the live policy and the retained revision carry the
same service, location, mode and supply, so the live one still answers and
nothing about a standing claim changes.

Refs #1250

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: c34c21e4b0

ℹ️ 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-client/src/client.rs
Comment thread crates/registry-scheduling/src/store.rs
A booking stays active once its time has passed, so the active claims
carrying one party's duplicate key accumulate for the life of the
deployment. The guard filters on the key, the offering, and the
booking's end, but the schema version 1 index carried the key alone and
left the other two to a recheck over that accumulating set. Schema
version 7 replaces it with an index over all three columns under the
same partial predicate.

Refs #1251

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: 51de118f76

ℹ️ 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-client/src/client.rs
@jeremi
jeremi merged commit 671e48f into main Sep 21, 2026
78 of 81 checks passed
@jeremi
jeremi deleted the fix/scheduling-ledger-followups branch September 21, 2026 16:04
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