diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 578913f10f..80de7cec04 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -24,6 +24,7 @@ "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", + "registry-platform-calendar", "registry-platform-canonical-json", "registry-platform-config", "registry-platform-crypto", @@ -64,6 +65,12 @@ "registry-casework-client-node", "registry-casework-client-py", ), + "scheduling": ( + "registry-scheduling-core", + "registry-scheduling", + "registry-schedulingctl", + "registry-scheduling-client", + ), "stack-client": ("registry-record", "registry-stack-client"), "evidence": ( "registry-evidence", @@ -92,6 +99,7 @@ RELAY_CLIENT_PACKAGES = frozenset(SHARDS["relay-client"]) BREG_PACKAGES = frozenset(SHARDS["breg"]) CASEWORK_PACKAGES = frozenset(SHARDS["casework"]) +SCHEDULING_PACKAGES = frozenset(SHARDS["scheduling"]) STACK_CLIENT_PACKAGES = frozenset(SHARDS["stack-client"]) # These are the cross-product semantic commitments implemented independently by @@ -698,6 +706,8 @@ def classify( seeds.update(BREG_PACKAGES) elif path.startswith("products/casework/"): seeds.update(CASEWORK_PACKAGES) + elif path.startswith("products/scheduling/"): + seeds.update(SCHEDULING_PACKAGES) elif path.startswith("products/identifiers/"): # The catalog gate compiles its focused Relay V2 exporter. # Catalog-only tooling does not require the full Rust matrix. @@ -950,7 +960,9 @@ def classify( for path in paths ), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), + "scheduling_contracts": bool(affected & SCHEDULING_PACKAGES), "casework_postgres": bool(affected & CASEWORK_PACKAGES), + "scheduling_postgres": bool(affected & SCHEDULING_PACKAGES), "release_tool": release_tool, "release_source_proof": release_source_proof, "docs": docs, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index b97728be7b..d9ee8e4d65 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -273,6 +273,12 @@ def test_deferred_ci_work_keeps_its_selector_and_explicit_status_guard( ) -> None: selectors = { "casework-postgres": "needs.changes.outputs.casework_postgres == 'true'", + "scheduling-postgres": ( + "needs.changes.outputs.scheduling_postgres == 'true'" + ), + "scheduling-contracts": ( + "needs.changes.outputs.scheduling_contracts == 'true'" + ), "platform-fuzz": "needs.changes.outputs.platform == 'true'", "platform-coverage": "needs.changes.outputs.platform == 'true'", "rust-quality": "needs.changes.outputs.rust == 'true'", @@ -338,6 +344,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "identifiers", "rust-result", "casework-postgres", + "scheduling-contracts", + "scheduling-postgres", "release-tool", "release-tool-required", "release-source-proof", @@ -371,6 +379,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "breg-wasm", "identifiers", "casework-postgres", + "scheduling-postgres", + "scheduling-contracts", ), "release-tool-required": ("changes", "release-tool"), "release-source-proof-required": ("changes", "release-source-proof"), @@ -394,6 +404,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "breg-wasm", "identifiers", "casework-postgres", + "scheduling-postgres", + "scheduling-contracts", "release-tool", "release-source-proof", "evidence-tutorials", @@ -449,7 +461,7 @@ def test_final_aggregate_flattens_rust_results_with_equivalent_outcomes( final_needs, previous_final_needs.difference({"rust-result"}).union(rust_needs), ) - self.assertEqual(29, len(final_needs)) + self.assertEqual(31, len(final_needs)) def embedded_python(job: dict[str, Any]) -> str: run = job["steps"][0]["run"] diff --git a/.github/scripts/test_nightly_rust_coverage.py b/.github/scripts/test_nightly_rust_coverage.py index 1b932b50ea..f88a2d032b 100644 --- a/.github/scripts/test_nightly_rust_coverage.py +++ b/.github/scripts/test_nightly_rust_coverage.py @@ -39,6 +39,7 @@ def test_all_live_shards_have_stable_flags_and_owned_packages(self) -> None: "relay-v2": "relay-v2", "breg": "breg", "casework": "casework", + "scheduling": "scheduling", "stack-client": "stack-client", "evidence": "evidence", "developer-tools": "developer-tools", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bce5dfb94..15f11fead6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,9 @@ jobs: relay_client_contracts: ${{ steps.filter.outputs.relay_client_contracts }} breg_contracts: ${{ steps.filter.outputs.breg_contracts }} casework_postgres: ${{ steps.filter.outputs.casework_postgres }} + scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }} evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} + scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} docs: ${{ steps.filter.outputs.docs }} @@ -702,6 +704,88 @@ jobs: CASEWORK_SOURCE_RETENTION_TEST_DATABASE_URL: postgresql://casework:casework_test@localhost:${{ job.services.postgres.ports['5432'] }}/casework_source_retention run: cargo test --locked --profile ci -p registry-casework --features postgres-test --test source_retention_postgres + scheduling-contracts: + name: Scheduling product contracts + needs: [changes, rust-policy] + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.scheduling_contracts == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Scheduling contract consistency + run: products/scheduling/scripts/check-contracts.sh + + scheduling-postgres: + name: Scheduling PostgreSQL transactions + needs: [changes, rust-policy] + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.scheduling_postgres == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + services: + postgres: + image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + env: + POSTGRES_DB: scheduling + POSTGRES_USER: scheduling + POSTGRES_PASSWORD: scheduling_test + options: >- + --health-cmd "pg_isready -U scheduling -d scheduling" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + - name: Rust cache + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build the Scheduling authoring tool + run: cargo build --locked --profile ci -p registry-schedulingctl + - name: Verify Scheduling product contracts and offline authoring journeys + env: + SCHEDULINGCTL_BIN: ${{ github.workspace }}/target/ci/schedulingctl + run: products/scheduling/scripts/check-checkpoint.sh + - name: Verify commitment ledger transactions + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling + run: cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments + - name: Create isolated authoring-apply test database + env: + POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + run: docker exec "$POSTGRES_CONTAINER_ID" createdb -U scheduling scheduling_records_apply + - name: Verify offline authoring record application + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling_records_apply + run: cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres + - name: Create isolated delivery-intent test database + env: + POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + run: docker exec "$POSTGRES_CONTAINER_ID" createdb -U scheduling scheduling_intents + - name: Verify undelivered delivery intent reads + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling_intents + run: cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres + breg-contracts: name: Base Registry Engine product contracts (${{ matrix.lane }}) needs: changes @@ -777,15 +861,18 @@ jobs: BREG_POSTGRES_LANE: ${{ matrix.lane }} run: products/breg/scripts/test-postgres.sh --lane "$BREG_POSTGRES_LANE" - - name: Casework task approval through stock issuer and BReg + - name: Casework task approval through stock issuer to Evidence, BReg, and Scheduling if: matrix.lane == 'postgres' shell: bash run: | set -euo pipefail + cargo build --locked --profile ci -p registry-scheduling \ + --example scheduling-auth-probe + SCHEDULING_AUTH_PROBE_BIN="${GITHUB_WORKSPACE}/target/ci/examples/scheduling-auth-probe" \ CASEWORK_ASSIGNMENT_TEST_DATABASE_URL="${BREG_TEST_DATABASE_URL}" \ cargo test --locked --profile ci -p registry-casework \ --features postgres-test --lib \ - approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes \ + approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid \ -- --ignored cargo build --locked --profile ci -p registry-casework -p registry-caseworkctl cargo test --locked --profile ci -p registry-casework \ @@ -928,6 +1015,8 @@ jobs: - breg-wasm - identifiers - casework-postgres + - scheduling-postgres + - scheduling-contracts runs-on: ubuntu-slim env: RUST_JOB_RESULTS: ${{ toJSON(needs) }} @@ -1910,6 +1999,8 @@ jobs: - breg-wasm - identifiers - casework-postgres + - scheduling-postgres + - scheduling-contracts - release-tool - release-source-proof - evidence-tutorials diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index c0a4face13..1ed9982d57 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -69,7 +69,7 @@ jobs: PY printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - image_names=(relay evidence discovery breg casework) + image_names=(relay evidence discovery breg casework scheduling) write_image_reports() { local name="$1" @@ -109,6 +109,7 @@ jobs: "evidence-image", "breg-image", "casework-image", + "scheduling-image", "relay-image" ] }' > "${evidence_root}/advisory-verdict.json" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index dd5cefd9e6..2a7b1dfba9 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -255,7 +255,7 @@ jobs: strategy: fail-fast: false matrix: - group: [core, breg, casework] + group: [core, breg, casework, scheduling] steps: - name: Checkout exact candidate source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -340,6 +340,12 @@ jobs: name: canonical-linux-binaries-casework-${{ needs.validate.outputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} path: binary-shards/casework + - name: Download canonical Linux Scheduling binary shard + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: canonical-linux-binaries-scheduling-${{ needs.validate.outputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: binary-shards/scheduling + - name: Merge and smoke the canonical Linux payload run: | set -euo pipefail @@ -349,6 +355,7 @@ jobs: --core binary-shards/core \ --breg binary-shards/breg \ --casework binary-shards/casework \ + --scheduling binary-shards/scheduling \ --output dist \ --builder-image "${RELEASE_BUILDER_IMAGE}" test "$("dist/bin/relay-${{ needs.validate.outputs.tag }}-linux-amd64" --version)" = \ @@ -369,6 +376,10 @@ jobs: test "$("dist/bin/caseworkctl-${{ needs.validate.outputs.tag }}-linux-amd64" --version)" = \ "caseworkctl ${{ needs.validate.outputs.version }}" fi + if (( release_major > 0 || release_minor >= 33 )); then + test "$(dist/image-bin/scheduling --version)" = \ + "scheduling ${{ needs.validate.outputs.version }}" + fi - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index d842c7d803..68509bb8d1 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -128,7 +128,7 @@ jobs: strategy: fail-fast: false matrix: - group: [core, breg, casework] + group: [core, breg, casework, scheduling] steps: - name: Checkout exact prepared source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -225,6 +225,12 @@ jobs: name: release-rehearsal-binaries-casework-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} path: binary-shards/casework + - name: Download canonical Linux Scheduling binary shard + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-rehearsal-binaries-scheduling-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: binary-shards/scheduling + - name: Merge and smoke the canonical Linux payload env: REHEARSAL_VERSION: ${{ inputs.version }} @@ -237,6 +243,7 @@ jobs: --core binary-shards/core \ --breg binary-shards/breg \ --casework binary-shards/casework \ + --scheduling binary-shards/scheduling \ --output dist \ --builder-image "${RELEASE_BUILDER_IMAGE}" test "$("dist/bin/breg-v${REHEARSAL_VERSION}-linux-amd64" --version)" = \ @@ -251,6 +258,10 @@ jobs: test "$("dist/bin/caseworkctl-v${REHEARSAL_VERSION}-linux-amd64" --version)" = \ "caseworkctl ${REHEARSAL_VERSION}" fi + if (( release_major > 0 || release_minor >= 33 )); then + test "$(dist/image-bin/scheduling --version)" = \ + "scheduling ${REHEARSAL_VERSION}" + fi - name: Set up Docker Buildx for advisory evidence if: ${{ inputs.advisory_evidence }} diff --git a/AGENTS.md b/AGENTS.md index 5d267018a8..51b0b800a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This is the Registry Stack monorepo: registry-facing services over the data institutions already hold and the registries they do not hold yet. Pre-1.0; APIs and deployment contracts may change. -Four independent runtime products are relevant: +Five independent runtime products are relevant: - **Base Registry Engine** compiles a declared registry project into a PostgreSQL-backed writable registry: schema, REST API, per-profile @@ -16,12 +16,22 @@ Four independent runtime products are relevant: - **Registry Casework** gives authorized human teams a coordinated inbox over source-owned work while leaving eligibility and registry mutations with the source system. +- **Registry Scheduling** publishes bookable services over anchored supply + and gives authorized callers short holds and confirmed appointments, with + every capacity decision made inside one transaction under a task grant. The products compose without merging their boundaries. Evidence may use a Base Registry Engine route or a Relay-protected API as a fixed HTTP source and inherits neither one's authorization. Casework may present source-owned work from a Base Registry Engine through its adapter, while current source -visibility and action authority remain with that registry. +visibility and action authority remain with that registry. Registry Scheduling +may publish bookable supply for work another product governs, and another +product may show an appointment Scheduling holds, without either side gaining +the other's authority: eligibility stays with the source, and the authority to +commit capacity stays with the task grant Scheduling verifies. Scheduling owns +its capacity ledger absolutely. A hold or an 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. A Base Registry Engine governed action may also consume an Evidence assertion as an ordinary relying party: a Rhai handler declaring handler ABI @@ -78,6 +88,10 @@ The dependency runs one way only in production: no Evidence crate depends on | `crates/registry-casework-client` | Rust Casework client and its bounded problem and recovery contract | | `crates/registry-casework-client-node` | Internal napi-rs binding used to assemble the unified Node.js client | | `crates/registry-casework-client-py` | Internal PyO3 binding used to assemble the unified Python client | +| `crates/registry-scheduling-core` | Source-neutral Scheduling policy model, wire DTOs, problem codes, and authoring checks | +| `crates/registry-scheduling` | PostgreSQL-backed Scheduling runtime and the `scheduling` binary | +| `crates/registry-schedulingctl` | Scheduling authoring and local operator tooling and the `schedulingctl` binary | +| `crates/registry-scheduling-client` | Bounded Rust Scheduling client over the runtime's HTTP contract | | `crates/registry-record` | Product-neutral Registry Record v1 response DTOs shared by the Base Registry Engine and Relay clients | | `crates/registry-stack-client` | Rust facade over the maintained Registry Stack product clients | | `crates/registry-stack-client-node` | Public `@registrystack/client` facade and platform package definitions | @@ -126,6 +140,38 @@ caller-visible inboxes. Source adapters retain source visibility and action authority. The source-neutral core and generic clients must not depend on BReg protocol types, and BReg must not depend on Casework. +Registry Scheduling is implemented by `registry-scheduling`, +`registry-scheduling-core`, `registry-schedulingctl`, and its Rust client +crate. Its product contracts, generated schemas, examples, and focused gates +live under `products/scheduling`. +Scheduling sells capacity only over supply the operator anchored: resource +pools and published windows are runtime records the policy references by +identifier, a hold or appointment is a claim against that supply inside one +capacity transaction, and every mutation carries a task grant matched against +the offering's service, location, and action before that transaction opens, +with the grant's expiry re-checked inside it immediately before the claim +commits. Reminder delivery, observer-hook delivery, and retention are outbox +work, never inline side effects. Scheduling accepts only `after` URL observers +for `appointment.confirmed`, `appointment.rescheduled`, and +`appointment.cancelled`, with the product-owned closed projections documented +under `products/scheduling`. It refuses conditions, principals, local handlers, +and every proposal returned by an observer. The source-neutral core and the +client must not depend on the runtime or another product's protocol types. + +For the MVP, no scheduling crate may reach a BReg, Casework, or Evidence crate +in either direction, and the dependency-direction gate enforces that on the +whole forward closure. The restriction is scoped to the MVP because the crates +that would compose across those boundaries do not exist yet: a +`registry-scheduling-casework` adapter would depend on +`registry-casework-client`, a `registry-scheduling-breg` adapter on +`registry-breg-client`, and the runtime may one day consume an Evidence +assertion as an ordinary relying party through `registry-evidence-client` and +`registry-evidence-verifier`, the way a Base Registry Engine governed action +already does. Adding such a crate relaxes this sentence here and in the gate, +in the same change; until then the sentence stands as written and no local +exception may be taken to it. The runtime, the source-neutral core, the client, +and adopter tooling stay on the strict side of it in every case. + Registry Discovery is a curated index over public provider descriptions, not a trust broker, authorization service, protocol adapter, or data proxy. `registry-discovery-profile` is the narrow shared publication contract that @@ -378,6 +424,31 @@ follow `products/casework/README.md`: its destructive test suites require separate disposable databases and the `postgres-test` feature. A test binary that skips because its database URL is absent is not database verification. +Registry Scheduling product and gates: + +```bash +cargo build --locked -p registry-schedulingctl +products/scheduling/scripts/check-checkpoint.sh +products/scheduling/scripts/check-contracts.sh +SCHEDULING_TEST_DATABASE_URL= cargo test --locked \ + -p registry-scheduling --features postgres-test --test postgres_commitments +python3 products/identifiers/scripts/generate.py --check-references +``` + +The checkpoint script runs the database-free checks; the PostgreSQL suite +needs a disposable database named by `SCHEDULING_TEST_DATABASE_URL` under the +same `postgres-test` caveat. The contracts check holds +`products/scheduling/contracts/` against the code and the published reference: +every security invariant names a threat, an enforcement point, a refusal, and +a negative test that exists and is selectable by its own runner, and the +recorded decisions stay in step with the security review notes. The runtime +configuration schema is regenerated, never hand-edited: + +```bash +cargo run -p registry-scheduling --features schema --example runtime-schema -- \ + --output products/scheduling/generated/runtime +``` + The unified Node.js and Python packages are generated from the maintained product bindings. After changing a binding or facade, run: diff --git a/Cargo.lock b/Cargo.lock index 3d9f6cb298..3358e9fc5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5123,6 +5123,7 @@ dependencies = [ "chrono", "chrono-tz", "jsonschema", + "registry-platform-calendar", "registry-platform-canonical-json", "registry-platform-httputil", "serde", @@ -5182,6 +5183,8 @@ dependencies = [ "registry-relay-v2", "registry-relayctl", "registry-render", + "registry-scheduling", + "registry-schedulingctl", "serde", "serde_json", ] @@ -5660,6 +5663,15 @@ dependencies = [ name = "registry-platform-buildinfo" version = "0.32.0" +[[package]] +name = "registry-platform-calendar" +version = "0.32.0" +dependencies = [ + "chrono", + "chrono-tz", + "thiserror 2.0.20", +] + [[package]] name = "registry-platform-canonical-json" version = "0.32.0" @@ -6041,6 +6053,106 @@ dependencies = [ "zeroize", ] +[[package]] +name = "registry-scheduling" +version = "0.32.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "deadpool-postgres", + "futures", + "http", + "jsonwebtoken", + "registry-platform-audit", + "registry-platform-authcommon", + "registry-platform-buildinfo", + "registry-platform-calendar", + "registry-platform-canonical-json", + "registry-platform-config", + "registry-platform-crypto", + "registry-platform-hooks", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "registry-scheduling-core", + "reqwest", + "rustls", + "schemars", + "serde", + "serde_json", + "serde_norway", + "serde_path_to_error", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tower", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "wiremock", +] + +[[package]] +name = "registry-scheduling-client" +version = "0.32.0" +dependencies = [ + "axum", + "chrono", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-scheduling-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "url", +] + +[[package]] +name = "registry-scheduling-core" +version = "0.32.0" +dependencies = [ + "chrono", + "registry-platform-calendar", + "registry-platform-canonical-json", + "registry-platform-hooks", + "serde", + "serde_json", + "serde_norway", + "serde_path_to_error", + "sha2 0.11.0", + "thiserror 2.0.20", +] + +[[package]] +name = "registry-schedulingctl" +version = "0.32.0" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "clap", + "registry-platform-buildinfo", + "registry-platform-config", + "registry-scheduling", + "registry-scheduling-core", + "serde_json", + "serde_norway", + "serde_path_to_error", + "tempfile", + "tokio", + "tokio-postgres", + "uuid", +] + [[package]] name = "registry-stack-client" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index 537dc3d55f..943ac7a459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/registry-platform-audit", "crates/registry-platform-authcommon", "crates/registry-platform-buildinfo", + "crates/registry-platform-calendar", "crates/registry-platform-canonical-json", "crates/registry-platform-config", "crates/registry-platform-crypto", @@ -56,6 +57,10 @@ members = [ "crates/registry-stack-client", "crates/registry-language-server", "crates/registry-linkml", + "crates/registry-scheduling-core", + "crates/registry-scheduling", + "crates/registry-schedulingctl", + "crates/registry-scheduling-client", ] exclude = [ "products/breg/wasm-handler-sdk", @@ -112,12 +117,17 @@ registry-relay-client-node = { path = "crates/registry-relay-client-node", versi registry-relay-client-py = { path = "crates/registry-relay-client-py", version = "0.32.0" } registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.32.0" } registry-relayctl = { path = "crates/registry-relayctl", version = "0.32.0" } +registry-scheduling = { path = "crates/registry-scheduling", version = "0.32.0" } +registry-scheduling-core = { path = "crates/registry-scheduling-core", version = "0.32.0" } +registry-schedulingctl = { path = "crates/registry-schedulingctl", version = "0.32.0" } +registry-scheduling-client = { path = "crates/registry-scheduling-client", version = "0.32.0" } registry-breg = { path = "crates/registry-breg", version = "0.32.0", default-features = false } registry-bregctl = { path = "crates/registry-bregctl", version = "0.32.0" } registry-stack-client = { path = "crates/registry-stack-client", version = "0.32.0" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.32.0" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.32.0" } registry-platform-buildinfo = { path = "crates/registry-platform-buildinfo", version = "0.32.0" } +registry-platform-calendar = { path = "crates/registry-platform-calendar", version = "0.32.0" } registry-cli-reference = { path = "crates/registry-cli-reference", version = "0.32.0" } registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.32.0" } registry-platform-config = { path = "crates/registry-platform-config", version = "0.32.0" } diff --git a/README.md b/README.md index 25fc39706a..297c2e6d17 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ release manifests, and docs. ## What It Includes -Registry Stack ships six installable products on one release train. Each has +Registry Stack ships seven installable products on one release train. Each has its own deployment contract and adopter tooling, and each one is optional. - **Base Registry Engine:** a configuration-defined writable registry backed by @@ -66,12 +66,18 @@ its own deployment contract and adopter tooling, and each one is optional. and small human decisions requested by another service. It can run with a governed Base Registry Engine source or in standalone hosted mode. Docs: [Registry Casework overview](https://docs.registrystack.org/dev/start/casework/). +- **Registry Scheduling:** a coordinated booking surface over anchored supply: + published openings, exact-time offerings, arrival windows with channel + subquotas, holds, and accountable appointments, with every capacity decision + made inside one transaction under a task grant. + Docs: [Registry Scheduling API](https://docs.registrystack.org/reference/apis/registry-scheduling/). Evidence Gateway can use a Base Registry Engine or Registry Relay API as one of its fixed sources, and keeps its own authorization either way. The stack also includes Registry Platform shared primitives, the `bregctl`, `caseworkctl`, -`relayctl`, `evidencectl`, and `discoveryctl` adopter tools, unified Node.js and -Python clients, and release tooling for validating the public source model. +`schedulingctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter tools, +unified Node.js and Python clients, and release tooling for validating the +public source model. ### Install a released build @@ -82,6 +88,9 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl # Registry Casework: casework and caseworkctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash +# Registry Scheduling: scheduling and schedulingctl +curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/scheduling-install.sh | bash + # Registry Relay: relay and relayctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash @@ -91,13 +100,13 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl Each installer verifies the binaries against the published `SHA256SUMS` before writing them to `$HOME/.local/bin`, or to the directory `BREG_INSTALL_DIR`, -`CASEWORK_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or `EVIDENCECTL_INSTALL_DIR` -names. Registry Discovery and Registry Manifest publish a binary and no -installer: download `discovery--linux-amd64` or +`CASEWORK_INSTALL_DIR`, `SCHEDULING_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or +`EVIDENCECTL_INSTALL_DIR` names. Registry Discovery and Registry Manifest +publish a binary and no installer: download `discovery--linux-amd64` or `registry-manifest--linux-amd64` from the [release page](https://github.com/registrystack/registry-stack/releases) and check it against the release checksum chain. Container images for `breg`, -`casework`, `relay`, `evidence`, and `discovery` are published as +`casework`, `scheduling`, `relay`, `evidence`, and `discovery` are published as `ghcr.io/registrystack/:`. Which platforms each artifact supports, and what is not supported, is recorded in [known limitations](https://docs.registrystack.org/dev/explanation/known-limitations/#platform-support). @@ -128,13 +137,14 @@ flowchart LR ## Repository Layout - `crates/`: Rust crates and runnable binaries for Base Registry Engine, - Registry Relay, Evidence Gateway, Registry Casework, Registry Discovery, - Registry Manifest, Registry Platform, and the `bregctl`, - `caseworkctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter tools. - Base Registry Engine lives in `crates/registry-breg` with one `breg` - binary, Evidence Gateway in `crates/registry-evidence` with one `evidence` - binary, and Registry Casework in `crates/registry-casework` with one - `casework` binary. + Registry Relay, Evidence Gateway, Registry Casework, Registry Scheduling, + Registry Discovery, Registry Manifest, Registry Platform, and the `bregctl`, + `caseworkctl`, `schedulingctl`, `relayctl`, `evidencectl`, and `discoveryctl` + adopter tools. Base Registry Engine lives in `crates/registry-breg` with one + `breg` binary, Evidence Gateway in `crates/registry-evidence` with one + `evidence` binary, Registry Casework in `crates/registry-casework` with one + `casework` binary, and Registry Scheduling in `crates/registry-scheduling` + with one `scheduling` binary. - `products/`: product-owned docs, examples, Docker inputs, specs, security material, scripts, performance harnesses, and fixtures that are not normal workspace crates. diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs index 6fb91c0db6..54a1c60a7a 100644 --- a/crates/registry-breg/src/task_grant/tests.rs +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -187,6 +187,33 @@ fn binding_debug_and_scalar_validation_preserve_privacy_and_exact_subjects() { } } +#[test] +fn a_scheduling_grant_is_refused_by_the_breg_binding() { + // The grant union is closed, so a scheduling grant parses as a grant but + // carries no BREG permissions; the binding refuses it before any + // authorization decision or store access runs. + let scheduling = serde_json::json!({ + "grantId": Uuid::new_v4().to_string(), + "sourceIssuer": "https://casework.test", + "principal": "agent", + "client": "agent-client", + "resource": "urn:breg:test", + "purpose": "review", + "bounds": { + "type": "scheduling", + "permissions": [{ + "service": "urn:service:intake", + "location": "north", + "actions": ["book"] + }] + }, + "subjects": {"subject_reference": "synthetic-subject", "active": true}, + "expiresAt": chrono::Utc::now().timestamp() + 900 + }); + let grant: TaskGrantBinding = serde_json::from_value(scheduling).unwrap(); + assert_eq!(grant.validate(), Err(TaskGrantError::Refused)); +} + #[cfg(feature = "postgres-test")] #[test] fn write_idempotency_separates_grants_without_changing_read_authority() { diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 0488356c2c..9e2d413a3d 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -21,8 +21,9 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } -export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } | { type: 'scheduling'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 714c20a067..5beb33de7a 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -31,7 +31,14 @@ class EvidenceTaskBounds(TypedDict): class BregTaskBounds(TypedDict): type: Literal["breg"] permissions: list[TaskPermission] -TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds +class SchedulingTaskPermission(TypedDict): + service: str + location: str + actions: list[str] +class SchedulingTaskBounds(TypedDict): + type: Literal["scheduling"] + permissions: list[SchedulingTaskPermission] +TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds | SchedulingTaskBounds class EvidenceRequesterContext(TypedDict): requesterTags: list[str] audience: str diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index 5bfb5616e1..ab5c37ce78 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -35,12 +35,12 @@ pub use registry_casework_core::{ OccurrenceKind, OccurrenceState, OneOfPredicate, OpaqueActorRef, OperationName, Page, PageStatus, QueueRecord, RecoverAttemptRequest, ReleaseRequest, RequesterHostedItem, RoutingActivity, RoutingCondition, RoutingPredicate, RoutingRule, SaveDraftRequest, - SourceBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, - SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, TaskApprovalRequest, - TaskAssertionResponse, TaskGrantBounds, TaskGrantList, TaskGrantRevocation, TaskGrantStatus, - TaskGrantStatusDetails, TaskGrantView, TaskPermission, TaskTemplatePreview, - TaskTemplatePreviews, TeamRecord, WorkItem, WorkItemPage, WorkItemRouting, WorkingDaysAfter, - WorkingDaysBefore, WorkingWeekday, + SchedulingTaskPermission, SourceBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, + StaffingDiagnostic, SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, + TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, TaskGrantList, + TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, + TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, WorkItem, WorkItemPage, WorkItemRouting, + WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, }; pub use registry_platform_httputil::client::BearerToken; pub use task_assertion_source::CaseworkTaskAssertionSource; diff --git a/crates/registry-casework-core/Cargo.toml b/crates/registry-casework-core/Cargo.toml index 37d14e3a96..acd13f87d0 100644 --- a/crates/registry-casework-core/Cargo.toml +++ b/crates/registry-casework-core/Cargo.toml @@ -17,6 +17,7 @@ async-trait.workspace = true chrono = { workspace = true, features = ["serde"] } chrono-tz.workspace = true jsonschema.workspace = true +registry-platform-calendar.workspace = true registry-platform-canonical-json.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-casework-core/src/lib.rs b/crates/registry-casework-core/src/lib.rs index 0017ebea70..627cbf5604 100644 --- a/crates/registry-casework-core/src/lib.rs +++ b/crates/registry-casework-core/src/lib.rs @@ -7,7 +7,6 @@ mod adapter; mod assignment; mod attempt_settlement; -mod calendar; mod clock_runtime; mod config; mod hosted; @@ -23,13 +22,20 @@ mod transition; pub use adapter::*; pub use assignment::*; pub use attempt_settlement::*; -pub use calendar::*; pub use clock_runtime::*; pub use config::*; pub use hosted::*; pub use http::*; pub use model::*; pub use policy::*; +// Casework's clocks are working-day deadlines, so it re-exports that +// evaluator and nothing else. The weekly opening-pattern evaluator in the +// same platform crate serves a different product and is not Casework's to +// publish. +pub use registry_platform_calendar::{ + evaluate_working_day_deadline, CalendarEvaluationError, HolidaySetRevision, WorkingCalendar, + WorkingDayDeadline, WorkingDayDeadlineRule, MAXIMUM_WORKING_DAY_OFFSET, +}; pub use routing::*; pub use source_retention::*; pub use task_grant::*; diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index f3c4d32668..cedb7574c5 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -32,8 +32,8 @@ pub struct TaskTemplate { pub purpose: String, pub bounds: TaskGrantBounds, /// Evidence-only requester context signed into the authority assertion. - /// BREG derives neither requester admission nor relying-party audience - /// from these fields and therefore forbids the block entirely. + /// Other products derive neither requester admission nor relying-party + /// audience from these fields and therefore forbid the block entirely. #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_context: Option, /// Exact token identity keys mapped to governed source logical fields. @@ -70,8 +70,15 @@ impl fmt::Debug for TaskTemplate { #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum TaskGrantBounds { - Evidence { requirement: String }, - Breg { permissions: Vec }, + Evidence { + requirement: String, + }, + Breg { + permissions: Vec, + }, + Scheduling { + permissions: Vec, + }, } #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] @@ -81,6 +88,19 @@ pub struct TaskPermission { pub operations: Vec, } +/// Exact Scheduling commitment authority copied into a task grant. +/// +/// This duplicates the public wire shape deliberately: Casework is an +/// authority for governed templates, not a dependency on Scheduling's runtime +/// or model crate. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingTaskPermission { + pub service: String, + pub location: String, + pub actions: Vec, +} + impl TaskGrantBounds { pub fn check(&self) -> Result<(), TaskGrantError> { match self { @@ -101,6 +121,25 @@ impl TaskGrantBounds { } Ok(()) } + Self::Scheduling { permissions } + if !permissions.is_empty() && permissions.len() <= 64 => + { + let mut offerings = BTreeSet::new(); + for permission in permissions { + if !bounded_grant_identifier(&permission.service, 512) + || !bounded_grant_identifier(&permission.location, 512) + || !offerings.insert((&permission.service, &permission.location)) + || !unique(&permission.actions, 32) + || permission + .actions + .iter() + .any(|action| !valid_operation(action)) + { + return Err(TaskGrantError::Policy); + } + } + Ok(()) + } _ => Err(TaskGrantError::Policy), } } @@ -181,7 +220,7 @@ impl TaskTemplate { self.bounds.check()?; match (&self.bounds, &self.evidence_context) { (TaskGrantBounds::Evidence { .. }, Some(context)) => context.check(), - (TaskGrantBounds::Breg { .. }, None) => Ok(()), + (TaskGrantBounds::Breg { .. } | TaskGrantBounds::Scheduling { .. }, None) => Ok(()), _ => Err(TaskGrantError::Policy), } } @@ -308,6 +347,17 @@ fn bounded(value: &str, maximum: usize) -> bool { fn bounded_grant_identifier(value: &str, maximum: usize) -> bool { bounded(value, maximum) && !value.chars().any(char::is_whitespace) } +fn valid_operation(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= 128 + && !value.contains('*') + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} fn unique(values: &[String], maximum: usize) -> bool { !values.is_empty() && values.len() <= maximum @@ -400,7 +450,34 @@ mod tests { } #[test] - fn evidence_templates_require_closed_requester_context_and_breg_forbids_it() { + fn scheduling_bounds_match_the_runtime_claim_grammar() { + let bounds: TaskGrantBounds = serde_json::from_value(serde_json::json!({ + "type":"scheduling", + "permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create","appointment.reschedule"] + }] + })) + .unwrap(); + assert!(bounds.check().is_ok()); + + for value in [ + serde_json::json!({"type":"scheduling","permissions":[]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry update","location":"bangkok-counter","actions":["appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"*","actions":["appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":[]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["Appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create","appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create"]},{"service":"registry-update","location":"bangkok-counter","actions":["appointment.cancel"]}]}), + ] { + let bounds: TaskGrantBounds = serde_json::from_value(value).unwrap(); + assert!(bounds.check().is_err()); + } + } + + #[test] + fn evidence_templates_require_closed_requester_context_and_other_products_forbid_it() { let project: CaseworkProject = serde_json::from_value(serde_json::json!({ "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, "casework": {"id":"tasks", "version":"1"}, @@ -435,6 +512,15 @@ mod tests { .unwrap(); assert!(template.check(&project).is_err()); + template.bounds = serde_json::from_value(serde_json::json!({ + "type":"scheduling", "permissions":[{ + "service":"registry-update", "location":"bangkok-counter", + "actions":["appointment.create"] + }] + })) + .unwrap(); + assert!(template.check(&project).is_err()); + template.bounds = serde_json::from_value(serde_json::json!({ "type":"evidence", "requirement":"urn:test:requirement:adult" })) diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 778f8342be..9be19cc2f1 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -780,7 +780,7 @@ fn grant_view(stored: &StoredTaskGrant) -> TaskGrantView { mod evidence_assertion_tests { use super::*; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use registry_casework_core::{IssuerPrincipal, SubjectRef}; + use registry_casework_core::{IssuerPrincipal, SubjectRef, TaskGrantBounds}; use std::collections::BTreeMap; #[test] @@ -867,6 +867,45 @@ mod evidence_assertion_tests { assert!(!serialized.contains("approver-subject-canary")); assert!(payload.get("evidence_context").is_none()); } + + #[test] + fn governed_scheduling_bounds_are_the_platform_verifiers_wire_contract() { + let bounds: TaskGrantBounds = serde_json::from_value(json!({ + "type":"scheduling", + "permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create"] + }] + })) + .unwrap(); + bounds.check().unwrap(); + + let claims: registry_platform_oidc::Claims = serde_json::from_value(json!({ + "sub":"agent", + "exp":2000, + "registry_grant_id":"00000000-0000-4000-8000-000000000001", + "registry_grant_source_issuer":"https://casework.test", + "registry_grant_client":"scheduling-agent", + "registry_grant_resource":"urn:test:scheduling", + "registry_grant_exp":1900, + "registry_grant_bounds":serde_json::to_value(bounds).unwrap(), + "registry_purpose":"schedule-registry-update", + "registry_approver":"approver-reference" + })) + .unwrap(); + let grant = registry_platform_oidc::grant_claims( + &claims, + ®istry_platform_oidc::ClaimNames::default(), + 1000, + ) + .unwrap() + .unwrap(); + let permissions = grant.bounds().scheduling_permissions().unwrap(); + assert_eq!(permissions[0].service(), "registry-update"); + assert_eq!(permissions[0].location(), "bangkok-counter"); + assert_eq!(permissions[0].actions(), ["appointment.create"]); + } } #[cfg(all(test, feature = "postgres-test"))] diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 45c662de63..c7f78f12a9 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -1,8 +1,10 @@ -//! Actual Casework approval -> native RFC 8693 exchange -> Evidence and BREG resources. +//! Actual Casework approval -> native RFC 8693 exchange -> Evidence, BREG, and +//! Scheduling's real bearer authenticator, without a product crate dependency. //! Credentials and protected response bodies stay in memory and never enter logs or argv. -//! Set the two disposable database variables named by the ignore reason, then run +//! Set the two disposable database variables and absolute Scheduling probe path +//! named by the ignore reason, then run //! `cargo test --locked -p registry-casework --features postgres-test --lib -//! approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes -- --ignored`. +//! approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid -- --ignored`. use super::native_resource as resource; use super::*; use async_trait::async_trait; @@ -21,8 +23,10 @@ use registry_thunderid_tooling::{container::Session, description::*, local, rend use std::{ collections::BTreeMap, fs, + io::Write, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + process::{Command, Stdio}, sync::Arc, }; use wiremock::{ @@ -32,10 +36,12 @@ use wiremock::{ const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; const BREG_RESOURCE: &str = "urn:breg:task-test"; const EVIDENCE_RESOURCE: &str = "urn:registry:evidence:fixture"; +const SCHEDULING_RESOURCE: &str = "urn:registry:scheduling:fixture"; const EVIDENCE_REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; const EVIDENCE_TAG: &str = "fixture-agency"; const AUTHORITY: &str = "https://casework.example"; +const SCHEDULING_AUTH_PROBE_ENV: &str = "SCHEDULING_AUTH_PROBE_BIN"; const EVIDENCE_SIGNING_KEY: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; fn binding() -> SourceBinding { @@ -500,6 +506,23 @@ fn start_issuer( }], }], }); + description.resource_servers.push(ResourceServer { + id: Uuid::new_v4().to_string(), + name: "Scheduling task target".into(), + identifier: SCHEDULING_RESOURCE.into(), + description: "Scheduling booking reached only after task exchange".into(), + resources: vec![Resource { + name: "Scheduling".into(), + handle: "scheduling".into(), + parent: None, + description: "Scheduling commitment".into(), + actions: vec![Action { + name: "Commit".into(), + handle: "commit".into(), + description: "Commit bounded Scheduling capacity".into(), + }], + }], + }); description .machine_clients .iter_mut() @@ -597,6 +620,53 @@ fn payload(token: &str) -> Value { ) .unwrap() } + +/// Cross the process boundary into Scheduling without introducing a product +/// crate dependency. The credential and public verifier material travel only +/// over stdin; the probe returns one non-sensitive verdict on stdout. +fn prove_scheduling_authentication(token: &str, issuer: &Issuer) { + let probe = PathBuf::from( + std::env::var_os(SCHEDULING_AUTH_PROBE_ENV) + .expect("the absolute Scheduling authentication probe path is required"), + ); + assert!( + probe.is_absolute(), + "the Scheduling probe path must be absolute" + ); + let input = serde_json::to_vec(&json!({ + "token": token, + "issuer": issuer.url(), + "audience": SCHEDULING_RESOURCE, + "client": "task-agent", + "assertionIssuer": AUTHORITY, + "jwks": issuer.jwks, + "service": "registry-update", + "location": "bangkok-counter", + "action": "appointment.create" + })) + .unwrap(); + let mut child = Command::new(&probe) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the Scheduling authentication probe starts"); + child + .stdin + .take() + .expect("the Scheduling authentication probe has stdin") + .write_all(&input) + .expect("the Scheduling authentication probe reads its bounded input"); + let output = child + .wait_with_output() + .expect("the Scheduling authentication probe finishes"); + assert!( + output.status.success(), + "the exact exchanged bearer was refused by Scheduling" + ); + assert_eq!(output.stdout, b"allowed\n"); + assert!(output.stderr.is_empty()); +} struct Fixture { app: Router, item: Uuid, @@ -661,7 +731,8 @@ async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> let operations = breg["accessProfiles"][1]["permissions"][0]["operations"].clone(); let template:TaskTemplate=serde_json::from_value(json!({"id":"draft","version":"1","label":"Prepare correction draft","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":BREG_RESOURCE,"purpose":"review","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"correction-requests","operations":operations}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); let evidence_template:TaskTemplate=serde_json::from_value(json!({"id":"evidence-check","version":"1","label":"Check adult status","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":evidence_agent},"client":"evidence-task-agent","resource":EVIDENCE_RESOURCE,"purpose":"fixture-eligibility","scopes":["evidence:invoke"],"bounds":{"type":"evidence","requirement":EVIDENCE_REQUIREMENT},"evidenceContext":{"requesterTags":[EVIDENCE_TAG],"audience":EVIDENCE_AUDIENCE},"subjects":{"birth_date":"birth_date","family_name":"family_name","given_name":"given_name"},"lifetimeSeconds":900})).unwrap(); - let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template,evidence_template]})).unwrap(); + let scheduling_template:TaskTemplate=serde_json::from_value(json!({"id":"schedule-update","version":"1","label":"Book a registry update","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":SCHEDULING_RESOURCE,"purpose":"schedule-registry-update","scopes":["scheduling:commit"],"bounds":{"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create"]}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template,evidence_template,scheduling_template]})).unwrap(); store .activate_task_templates(&project.task_templates) .await @@ -764,9 +835,9 @@ async fn request( ) } -#[ignore = "requires Docker plus disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL"] +#[ignore = "requires Docker, disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL, and absolute SCHEDULING_AUTH_PROBE_BIN"] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes() { +async fn approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid() { let root = tempfile::tempdir().unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); let casework_port = listener.local_addr().unwrap().port(); @@ -969,6 +1040,58 @@ async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_re "re-exchange must preserve {name}" ); } + let (code, scheduling_grant) = request( + &f.app, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"schedule-update","templateVersion":"1"})), + Some("native-scheduling-approval"), + ) + .await; + assert_eq!(code, StatusCode::OK); + let scheduling_grant_id = scheduling_grant["id"].as_str().unwrap(); + let (code, scheduling_assertion) = request( + &f.app, + "POST", + &format!("/v1/task-grants/{scheduling_grant_id}/assertion"), + &bootstrap, + false, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + let scheduling_exchange = provider( + &issuer.url(), + "task-agent", + &agent_key, + SCHEDULING_RESOURCE, + "scheduling:commit", + ); + let scheduling_token = bearer( + &scheduling_exchange + .exchange(scheduling_assertion["assertion"].as_str().unwrap()) + .await + .unwrap(), + ); + let scheduling_claims = payload(&scheduling_token); + assert_eq!( + scheduling_claims["registry_grant_bounds"], + json!({"type":"scheduling","permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create"] + }]}) + ); + assert_eq!( + scheduling_claims["registry_grant_resource"], + SCHEDULING_RESOURCE + ); + assert_eq!(scheduling_claims["registry_grant_client"], "task-agent"); + assert_eq!(scheduling_claims["scope"], "scheduling:commit"); + prove_scheduling_authentication(&scheduling_token, &issuer); let (code, evidence_grant) = request( &f.app, "POST", diff --git a/crates/registry-cli-docs/Cargo.toml b/crates/registry-cli-docs/Cargo.toml index a110f63f6a..35d96ea139 100644 --- a/crates/registry-cli-docs/Cargo.toml +++ b/crates/registry-cli-docs/Cargo.toml @@ -24,5 +24,7 @@ registry-breg = { workspace = true, features = ["runtime"] } registry-bregctl.workspace = true registry-caseworkctl.workspace = true registry-casework.workspace = true +registry-scheduling.workspace = true +registry-schedulingctl.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index 246631481b..cb93115ea6 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -14,6 +14,8 @@ pub fn catalog() -> Catalog { command_reference(registry_evidencectl::command(), None, None), command_reference(registry_caseworkctl::command(), None, None), command_reference(registry_casework::command(), None, None), + command_reference(registry_scheduling::runtime::command(), None, None), + command_reference(registry_schedulingctl::command(), None, None), command_reference(registry_breg::command(), None, None), command_reference( registry_bregctl::command(), @@ -91,6 +93,8 @@ mod tests { "registry-render", "relay", "relayctl", + "scheduling", + "schedulingctl", ] ); } diff --git a/crates/registry-platform-calendar/Cargo.toml b/crates/registry-platform-calendar/Cargo.toml new file mode 100644 index 0000000000..b31587f968 --- /dev/null +++ b/crates/registry-platform-calendar/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "registry-platform-calendar" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Timezone-correct working-day and opening-pattern calendar evaluation for Registry Stack." +repository.workspace = true +readme = "README.md" +publish = false + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +chrono-tz.workspace = true +thiserror.workspace = true diff --git a/crates/registry-platform-calendar/README.md b/crates/registry-platform-calendar/README.md new file mode 100644 index 0000000000..b79947b02e --- /dev/null +++ b/crates/registry-platform-calendar/README.md @@ -0,0 +1,21 @@ +# Registry Platform Calendar + +`registry-platform-calendar` is the shared Registry Stack implementation of +timezone-correct calendar evaluation. It takes calendar identifiers, named IANA +timezones, pinned holiday content, and bounded arithmetic, and returns exact +instants or intervals. It performs no input or output and holds no product +semantics, so the same evaluator serves any runtime that must derive a +commitment-bearing time from authored calendar content. + +Two evaluators live here. The working-day evaluator shifts a date across +working weekdays and holidays to a local `dueTime`, as used by Registry +Casework clocks. The weekly-opening evaluator expands a bounded weekly pattern +into concrete half-open UTC intervals, with exception layering where a blocking +closure wins over an ordinary opening and an exceptional reopening requires +explicit authority, names the closure it reopens, and stays inside it. + +A local time that falls in a daylight-saving gap or fold is an explicit error, +never a silent shift to the nearest instant, so no caller can derive an +invisible duplicate or shifted commitment. An interval that spans a transition +keeps its real elapsed length. Authored spans are bounded to ten years so +expansion cannot run away on mistyped effective dates. diff --git a/crates/registry-platform-calendar/src/lib.rs b/crates/registry-platform-calendar/src/lib.rs new file mode 100644 index 0000000000..649ff02e33 --- /dev/null +++ b/crates/registry-platform-calendar/src/lib.rs @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Timezone-correct calendar evaluation for Registry Stack. +//! +//! The crate deliberately contains no I/O and no product semantics: a +//! calendar id, a named IANA timezone, pinned holiday content, and bounded +//! arithmetic in, exact instants or intervals out. Daylight-saving gaps and +//! ambiguous local times are explicit errors, never silent shifts, so a +//! caller can never derive an invisible duplicate or shifted commitment. +//! +//! Two evaluators live here: +//! +//! - [`crate::working_day`]: working-day deadlines pinned to a calendar and +//! holiday-set revision, as used by Registry Casework clocks. +//! - [`crate::weekly`]: expansion of a bounded weekly opening pattern into +//! concrete half-open UTC intervals, with exception layering where a +//! blocking closure wins over an ordinary opening and an exceptional +//! reopening requires explicit authority. + +mod weekly; +mod working_day; + +pub use weekly::*; +pub use working_day::*; + +use chrono::{DateTime, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; + +/// A calendar evaluation refused the input instead of guessing. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CalendarEvaluationError { + #[error("the calendar id is empty")] + EmptyCalendarId, + #[error("the holiday-set id is empty")] + EmptyHolidaySetId, + #[error("the holiday-set revision must be positive")] + InvalidHolidayRevision, + #[error("the calendar has no working weekdays")] + NoWorkingWeekdays, + #[error("the calendar timezone is not a named IANA timezone")] + InvalidTimezone, + #[error("holiday date at index {index} is not a valid YYYY-MM-DD date")] + InvalidHolidayDate { index: usize }, + #[error("dueTime is not a valid HH:MM local time")] + InvalidDueTime, + #[error("after.workingDays must be between 1 and 3650")] + InvalidWorkingDayOffset, + #[error("atRisk.workingDaysBefore must be between 1 and 3650")] + InvalidWarningOffset, + #[error("reminders[].workingDaysBefore must be between 1 and 3650")] + InvalidReminderOffset, + #[error("the calculated local time does not exist in the calendar timezone")] + NonexistentLocalTime, + #[error("the calculated local time is ambiguous in the calendar timezone")] + AmbiguousLocalTime, + #[error("working-day calendar arithmetic overflowed")] + Overflow, + #[error("the opening-pattern id is empty")] + EmptyPatternId, + #[error("the opening pattern has no weekdays")] + NoPatternWeekdays, + #[error("opening-pattern times must be valid HH:MM local values with start before end")] + InvalidPatternTimes, + #[error("opening-pattern date at index {index} is not a valid YYYY-MM-DD date")] + InvalidPatternDate { index: usize }, + #[error("the opening-pattern effective range must have from on or before until")] + InvalidEffectiveRange, + #[error("the opening-pattern effective range spans more than 3650 days")] + PatternSpanTooLarge, + #[error("an exception id is empty")] + EmptyExceptionId, + #[error("exception times must be valid HH:MM local values with start before end")] + InvalidExceptionTimes, + #[error("exception date for {id} is not a valid YYYY-MM-DD date")] + InvalidExceptionDate { id: String }, + #[error("the exception id {id} appears more than once")] + DuplicateExceptionId { id: String }, + #[error("the blocking closure {id} carries reopening fields")] + ClosureCarriesReopeningFields { id: String }, + #[error("the exceptional reopening {id} does not name an existing closure")] + UnknownReopenedClosure { id: String }, + #[error("the exceptional reopening {id} has no explicit authority")] + ReopenAuthorityMissing { id: String }, + #[error("the exceptional reopening {id} is not contained in the closure it reopens")] + ReopenOutsideClosure { id: String }, + #[error("the exceptional reopening {id} overlaps a closure other than the one it reopens")] + ReopenOverlapsUnrelatedClosure { id: String }, + #[error("the reopening {id} is not inside the opening pattern's time on its date")] + ReopenOutsidePattern { id: String }, + #[error("the interval date is not a valid YYYY-MM-DD date")] + InvalidIntervalDate, + #[error("interval times must be valid HH:MM local values with start before end")] + InvalidIntervalTimes, +} + +fn parse_date(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) + { + return None; + } + NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() +} + +fn parse_hh_mm(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 5 + || bytes[2] != b':' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| index != 2 && !byte.is_ascii_digit()) + { + return None; + } + NaiveTime::parse_from_str(value, "%H:%M").ok() +} + +fn local_instant( + date: NaiveDate, + time: NaiveTime, + timezone: Tz, +) -> Result, CalendarEvaluationError> { + match timezone.from_local_datetime(&NaiveDateTime::new(date, time)) { + LocalResult::Single(value) => Ok(value.with_timezone(&Utc)), + LocalResult::None => Err(CalendarEvaluationError::NonexistentLocalTime), + LocalResult::Ambiguous(_, _) => Err(CalendarEvaluationError::AmbiguousLocalTime), + } +} + +/// Convert one local wall-clock interval on a date into its half-open UTC +/// interval, in a named IANA timezone. +/// +/// A start or end instant that falls in a daylight-saving gap or fold is an +/// explicit error, never a silent shift. This is the record-side companion of +/// the pattern expansion: a caller holding a dated exception as data converts +/// it the same way the expansion would. +pub fn local_interval( + date: &str, + start_time: &str, + end_time: &str, + timezone: &str, +) -> Result { + let zone: Tz = timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let date = parse_date(date).ok_or(CalendarEvaluationError::InvalidIntervalDate)?; + let start = parse_hh_mm(start_time).ok_or(CalendarEvaluationError::InvalidIntervalTimes)?; + let end = parse_hh_mm(end_time).ok_or(CalendarEvaluationError::InvalidIntervalTimes)?; + if start >= end { + return Err(CalendarEvaluationError::InvalidIntervalTimes); + } + day_interval(date, start, end, zone) +} + +fn day_interval( + date: NaiveDate, + start: NaiveTime, + end: NaiveTime, + timezone: Tz, +) -> Result { + Ok(CalendarInterval { + start: local_instant(date, start, timezone)?, + end: local_instant(date, end, timezone)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn instant(value: &str) -> DateTime { + value + .parse::>() + .expect("a valid RFC 3339 instant") + } + + #[test] + fn a_local_interval_converts_to_its_half_open_utc_interval() { + let interval = + local_interval("2026-10-05", "09:00", "12:00", "Asia/Bangkok").expect("converts"); + assert_eq!(interval.start, instant("2026-10-05T02:00:00Z")); + assert_eq!(interval.end, instant("2026-10-05T05:00:00Z")); + } + + #[test] + fn a_local_interval_spanning_a_transition_keeps_real_elapsed_time() { + // 00:00 to 02:00 across the 2026-11-01 fall-back is three real hours. + let interval = + local_interval("2026-11-01", "00:00", "02:00", "America/New_York").expect("converts"); + assert_eq!(interval.start, instant("2026-11-01T04:00:00Z")); + assert_eq!(interval.end, instant("2026-11-01T07:00:00Z")); + assert_eq!((interval.end - interval.start).num_minutes(), 180); + } + + #[test] + fn local_interval_endpoints_inside_a_gap_or_fold_are_explicit_errors() { + assert_eq!( + local_interval("2026-03-08", "02:00", "02:30", "America/New_York"), + Err(CalendarEvaluationError::NonexistentLocalTime) + ); + assert_eq!( + local_interval("2026-11-01", "01:30", "02:00", "America/New_York"), + Err(CalendarEvaluationError::AmbiguousLocalTime) + ); + } + + /// The crate serves every product's calendar, so a gap or fold refusal + /// names the instant it could not convert and no caller's authoring + /// vocabulary: a Casework deadline and a Scheduling opening pattern read + /// the same sentence. + #[test] + fn a_gap_or_fold_refusal_names_no_caller_s_vocabulary() { + assert_eq!( + CalendarEvaluationError::NonexistentLocalTime.to_string(), + "the calculated local time does not exist in the calendar timezone" + ); + assert_eq!( + CalendarEvaluationError::AmbiguousLocalTime.to_string(), + "the calculated local time is ambiguous in the calendar timezone" + ); + } + + #[test] + fn local_interval_reports_its_own_invalid_inputs() { + assert_eq!( + local_interval("2026-10-5", "09:00", "12:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalDate) + ); + assert_eq!( + local_interval("2026-10-05", "9:00", "12:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalTimes) + ); + assert_eq!( + local_interval("2026-10-05", "09:00", "12:00", "Not/A_Zone"), + Err(CalendarEvaluationError::InvalidTimezone) + ); + assert_eq!( + local_interval("2026-10-05", "12:00", "09:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalTimes) + ); + } +} diff --git a/crates/registry-platform-calendar/src/weekly.rs b/crates/registry-platform-calendar/src/weekly.rs new file mode 100644 index 0000000000..f9c247ab46 --- /dev/null +++ b/crates/registry-platform-calendar/src/weekly.rs @@ -0,0 +1,1158 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Expansion of a bounded weekly opening pattern into concrete intervals. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, Datelike, Days, NaiveDate, Utc, Weekday}; +use chrono_tz::Tz; + +use crate::{day_interval, parse_date, parse_hh_mm, CalendarEvaluationError, HolidaySetRevision}; + +/// Ten years is also the maximum authored opening-pattern span. +pub const MAXIMUM_PATTERN_SPAN_DAYS: u32 = 3_650; + +/// A bounded weekly opening pattern evaluated in a named IANA timezone. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WeeklyOpeningPattern<'a> { + pub id: &'a str, + /// A named IANA timezone such as `Asia/Bangkok`. + pub timezone: &'a str, + pub weekdays: &'a [Weekday], + /// Strict local wall time in `HH:MM` form. + pub start_time: &'a str, + /// Strict local wall time in `HH:MM` form, after `start_time` on the same + /// local date. Patterns do not cross midnight. + pub end_time: &'a str, + /// Strict ISO calendar date in `YYYY-MM-DD` form, inclusive. + pub effective_from: &'a str, + /// Strict ISO calendar date in `YYYY-MM-DD` form, inclusive. + pub effective_until: &'a str, +} + +/// One dated exception over local wall-clock time in the pattern's timezone. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CalendarException<'a> { + pub id: &'a str, + pub kind: CalendarExceptionKind, + /// Strict ISO calendar date in `YYYY-MM-DD` form. + pub date: &'a str, + /// Strict local wall time in `HH:MM` form. + pub start_time: &'a str, + /// Strict local wall time in `HH:MM` form, after `start_time` on the same + /// local date. + pub end_time: &'a str, + /// The closure an exceptional reopening reopens. Required when `kind` is + /// [`CalendarExceptionKind::Opening`], absent otherwise. + pub reopens: Option<&'a str>, + /// Explicit authority for an exceptional reopening. Required when `kind` + /// is [`CalendarExceptionKind::Opening`], absent otherwise. + pub authority: Option<&'a str>, +} + +/// The layer an exception occupies. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CalendarExceptionKind { + /// A blocking closure. It wins over an ordinary opening and over every + /// pattern-generated interval it overlaps. + Closure, + /// An exceptional reopening. It requires explicit authority, must sit + /// inside the one closure it names, and may not overlap any other + /// closure, so it can never silently override unrelated closures. + Opening, +} + +/// A half-open UTC interval `[start, end)`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CalendarInterval { + pub start: DateTime, + pub end: DateTime, +} + +/// Expand a bounded weekly opening pattern into sorted, merged half-open UTC +/// intervals without reading mutable calendar or runtime state. +/// +/// Layering: a holiday date produces no interval; a blocking closure removes +/// opening time; an exceptional reopening adds time back only inside the +/// closure it names, only under explicit authority, and only within the +/// pattern's own hours on its date, so it can restore lost time but never +/// extend the published hours. A local start or end +/// instant that falls in a daylight-saving gap or fold is an explicit error, +/// never a silent shift; an interval that spans a transition (for example +/// `01:00` to `03:00` across a spring-forward jump) keeps its real elapsed +/// length, so no invisible duplicate or shifted commitment appears. +pub fn expand_weekly_openings( + pattern: &WeeklyOpeningPattern<'_>, + exceptions: &[CalendarException<'_>], + holiday_revision: &HolidaySetRevision<'_>, +) -> Result, CalendarEvaluationError> { + if pattern.id.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyPatternId); + } + if pattern.weekdays.is_empty() { + return Err(CalendarEvaluationError::NoPatternWeekdays); + } + let timezone: Tz = pattern + .timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let start_time = + parse_hh_mm(pattern.start_time).ok_or(CalendarEvaluationError::InvalidPatternTimes)?; + let end_time = + parse_hh_mm(pattern.end_time).ok_or(CalendarEvaluationError::InvalidPatternTimes)?; + if start_time >= end_time { + return Err(CalendarEvaluationError::InvalidPatternTimes); + } + let effective_from = parse_date(pattern.effective_from) + .ok_or(CalendarEvaluationError::InvalidPatternDate { index: 0 })?; + let effective_until = parse_date(pattern.effective_until) + .ok_or(CalendarEvaluationError::InvalidPatternDate { index: 1 })?; + if effective_from > effective_until { + return Err(CalendarEvaluationError::InvalidEffectiveRange); + } + if effective_until + .signed_duration_since(effective_from) + .num_days() + > i64::from(MAXIMUM_PATTERN_SPAN_DAYS) + { + return Err(CalendarEvaluationError::PatternSpanTooLarge); + } + + if holiday_revision.holiday_set.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyHolidaySetId); + } + if holiday_revision.revision == 0 { + return Err(CalendarEvaluationError::InvalidHolidayRevision); + } + let holidays: BTreeSet = holiday_revision + .dates + .iter() + .enumerate() + .map(|(index, value)| { + parse_date(value).ok_or(CalendarEvaluationError::InvalidHolidayDate { index }) + }) + .collect::>()?; + + let weekdays: BTreeSet = pattern + .weekdays + .iter() + .map(|weekday| weekday.num_days_from_monday()) + .collect(); + let mut openings = Vec::new(); + let mut date = effective_from; + while date <= effective_until { + if weekdays.contains(&date.weekday().num_days_from_monday()) && !holidays.contains(&date) { + openings.push(day_interval(date, start_time, end_time, timezone)?); + } + date = date + .checked_add_days(Days::new(1)) + .ok_or(CalendarEvaluationError::Overflow)?; + } + + apply_exceptions_to_openings(openings, pattern.timezone, exceptions) +} + +/// Layer dated closures and authorized reopenings over one location's merged +/// published openings. +/// +/// Callers with several weekly patterns expand and merge those patterns +/// first, then call this function once. A reopening is therefore checked +/// against the location's complete schedule rather than being rejected by an +/// unrelated pattern that does not cover its date or time. +pub fn apply_exceptions_to_openings( + openings: Vec, + timezone: &str, + exceptions: &[CalendarException<'_>], +) -> Result, CalendarEvaluationError> { + let timezone: Tz = timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let exceptions_by_id = parse_exceptions(exceptions, timezone)?; + validate_layers(&exceptions_by_id)?; + let published = merge_intervals(openings); + let mut effective = published.clone(); + + // Closures remove published time first; authorized reopenings then add + // time back, so a reopening can never be subtracted by a later closure. + for exception in exceptions_by_id.values() { + if exception.kind == CalendarExceptionKind::Closure { + subtract_interval(&mut effective, exception.interval); + } + } + for (id, exception) in &exceptions_by_id { + if exception.kind == CalendarExceptionKind::Opening { + let covered = published.iter().any(|interval| { + interval.start <= exception.interval.start && exception.interval.end <= interval.end + }); + if covered { + effective.push(exception.interval); + continue; + } + return Err(CalendarEvaluationError::ReopenOutsidePattern { id: id.clone() }); + } + } + Ok(merge_intervals(effective)) +} + +struct ParsedException<'a> { + kind: CalendarExceptionKind, + interval: CalendarInterval, + reopens: Option<&'a str>, + authority: Option<&'a str>, +} + +fn parse_exceptions<'e>( + exceptions: &[CalendarException<'e>], + timezone: Tz, +) -> Result>, CalendarEvaluationError> { + let mut parsed = BTreeMap::new(); + for exception in exceptions { + if exception.id.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyExceptionId); + } + let interval = parse_exception_times(exception, timezone)?; + if parsed + .insert( + exception.id.to_owned(), + ParsedException { + kind: exception.kind, + interval, + reopens: exception.reopens, + authority: exception.authority, + }, + ) + .is_some() + { + return Err(CalendarEvaluationError::DuplicateExceptionId { + id: exception.id.to_owned(), + }); + } + } + Ok(parsed) +} + +fn parse_exception_times( + exception: &CalendarException<'_>, + timezone: Tz, +) -> Result { + let date = parse_date(exception.date).ok_or(CalendarEvaluationError::InvalidExceptionDate { + id: exception.id.to_owned(), + })?; + let start = + parse_hh_mm(exception.start_time).ok_or(CalendarEvaluationError::InvalidExceptionTimes)?; + let end = + parse_hh_mm(exception.end_time).ok_or(CalendarEvaluationError::InvalidExceptionTimes)?; + if start >= end { + return Err(CalendarEvaluationError::InvalidExceptionTimes); + } + day_interval(date, start, end, timezone) +} + +/// Enforce the exception layering rules across the parsed exceptions. +fn validate_layers( + exceptions_by_id: &BTreeMap>, +) -> Result<(), CalendarEvaluationError> { + for (id, exception) in exceptions_by_id { + match exception.kind { + CalendarExceptionKind::Closure => { + if exception.reopens.is_some() || exception.authority.is_some() { + return Err(CalendarEvaluationError::ClosureCarriesReopeningFields { + id: id.clone(), + }); + } + } + CalendarExceptionKind::Opening => { + let named = exception + .reopens + .and_then(|reopens| exceptions_by_id.get(reopens)) + .filter(|named| named.kind == CalendarExceptionKind::Closure) + .ok_or_else(|| CalendarEvaluationError::UnknownReopenedClosure { + id: id.clone(), + })?; + if exception + .authority + .is_none_or(|authority| authority.trim().is_empty()) + { + return Err(CalendarEvaluationError::ReopenAuthorityMissing { id: id.clone() }); + } + if exception.interval.start < named.interval.start + || exception.interval.end > named.interval.end + { + return Err(CalendarEvaluationError::ReopenOutsideClosure { id: id.clone() }); + } + let strays = exceptions_by_id.iter().any(|(other_id, other)| { + other.kind == CalendarExceptionKind::Closure + && Some(other_id.as_str()) != exception.reopens + && exception.interval.start < other.interval.end + && other.interval.start < exception.interval.end + }); + if strays { + return Err(CalendarEvaluationError::ReopenOverlapsUnrelatedClosure { + id: id.clone(), + }); + } + } + } + } + Ok(()) +} + +/// Remove `closure` from every interval in `openings`, splitting as needed. +fn subtract_interval(openings: &mut Vec, closure: CalendarInterval) { + let mut result = Vec::with_capacity(openings.len() + 1); + for interval in openings.drain(..) { + let overlaps = interval.start < closure.end && closure.start < interval.end; + if !overlaps { + result.push(interval); + continue; + } + if interval.start < closure.start { + result.push(CalendarInterval { + start: interval.start, + end: closure.start, + }); + } + if closure.end < interval.end { + result.push(CalendarInterval { + start: closure.end, + end: interval.end, + }); + } + } + *openings = result; +} + +/// Merge overlapping or adjacent half-open intervals into a canonical form. +fn merge_intervals(mut intervals: Vec) -> Vec { + intervals.sort_by_key(|interval| (interval.start, interval.end)); + let mut merged: Vec = Vec::new(); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.start <= last.end => { + last.end = last.end.max(interval.end); + } + _ => merged.push(interval), + } + } + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + const WEEKDAYS: &[Weekday] = &[ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + ]; + const ALL_WEEKDAYS: &[Weekday] = &[ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + Weekday::Sat, + Weekday::Sun, + ]; + + fn instant(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) + } + + fn utc(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) + .unwrap() + } + + fn pattern<'a>( + timezone: &'a str, + from: &'a str, + until: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> WeeklyOpeningPattern<'a> { + pattern_with(WEEKDAYS, timezone, from, until, start_time, end_time) + } + + fn pattern_with<'a>( + weekdays: &'a [Weekday], + timezone: &'a str, + from: &'a str, + until: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> WeeklyOpeningPattern<'a> { + WeeklyOpeningPattern { + id: "counter-hours", + timezone, + weekdays, + start_time, + end_time, + effective_from: from, + effective_until: until, + } + } + + fn holidays<'a>(dates: &'a [&'a str]) -> HolidaySetRevision<'a> { + HolidaySetRevision { + holiday_set: "office-holidays", + revision: 7, + dates, + } + } + + fn closure<'a>( + id: &'a str, + date: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> CalendarException<'a> { + CalendarException { + id, + kind: CalendarExceptionKind::Closure, + date, + start_time, + end_time, + reopens: None, + authority: None, + } + } + + fn reopening<'a>( + id: &'a str, + reopens: &'a str, + authority: &'a str, + date: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> CalendarException<'a> { + CalendarException { + id, + kind: CalendarExceptionKind::Opening, + date, + start_time, + end_time, + reopens: Some(reopens), + authority: Some(authority), + } + } + + fn expand( + pattern: &WeeklyOpeningPattern<'_>, + exceptions: &[CalendarException<'_>], + dates: &[&str], + ) -> Result, CalendarEvaluationError> { + expand_weekly_openings(pattern, exceptions, &holidays(dates)) + } + + #[test] + fn weekday_pattern_expands_to_half_open_instants() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-06T09:00:00+07:00"), + end: instant("2026-10-06T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-07T09:00:00+07:00"), + end: instant("2026-10-07T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-08T09:00:00+07:00"), + end: instant("2026-10-08T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-09T09:00:00+07:00"), + end: instant("2026-10-09T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn holiday_dates_produce_no_interval() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-07", "09:00", "12:00"), + &[], + &["2026-10-06"], + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].start, instant("2026-10-05T09:00:00+07:00")); + assert_eq!(result[1].start, instant("2026-10-07T09:00:00+07:00")); + } + + #[test] + fn daylight_saving_gap_and_fold_are_explicit_errors() { + // America/New_York springs forward on 2026-03-08: 02:00 becomes 03:00. + let gap = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-03-08", + "2026-03-08", + "02:00", + "02:30", + ), + &[], + &[], + ); + assert_eq!(gap, Err(CalendarEvaluationError::NonexistentLocalTime)); + + // America/New_York falls back on 2026-11-01: 01:30 occurs twice. + let fold = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-11-01", + "2026-11-01", + "01:30", + "02:00", + ), + &[], + &[], + ); + assert_eq!(fold, Err(CalendarEvaluationError::AmbiguousLocalTime)); + } + + #[test] + fn an_interval_spanning_a_transition_keeps_real_elapsed_time() { + // 01:00 to 03:00 across the 2026-03-08 spring-forward jump is one + // real hour, and both wall-clock endpoints exist. + let result = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-03-08", + "2026-03-08", + "01:00", + "03:00", + ), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-03-08T01:00:00-05:00"), + end: instant("2026-03-08T03:00:00-04:00"), + }] + ); + assert_eq!((result[0].end - result[0].start).num_minutes(), 60); + } + + #[test] + fn a_blocking_closure_wins_over_an_ordinary_opening() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("staff-meeting", "2026-10-05", "09:00", "10:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T10:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + + // A closure that swallows the whole opening removes it entirely. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("all-day", "2026-10-05", "08:00", "17:00")], + &[], + ) + .unwrap(); + assert_eq!(result, Vec::::new()); + + // A closure inside the opening splits it in two, never truncates or + // shifts silently. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("midday", "2026-10-05", "10:00", "11:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T10:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T11:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn closures_do_not_touch_time_outside_the_opening() { + // A closure outside pattern hours removes nothing and adds nothing. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("early", "2026-10-05", "07:00", "09:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + } + + #[test] + fn a_closure_may_not_carry_reopening_fields() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + let mut stray = closure("odd", "2026-10-05", "09:00", "10:00"); + stray.reopens = Some("other"); + assert_eq!( + expand(&base, &[stray], &[]), + Err(CalendarEvaluationError::ClosureCarriesReopeningFields { + id: "odd".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_requires_authority_and_a_named_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + let staff_meeting = closure("staff-meeting", "2026-10-05", "09:00", "10:00"); + + let mut no_authority = reopening( + "re-open", + "staff-meeting", + "", + "2026-10-05", + "09:15", + "09:45", + ); + no_authority.authority = Some(" "); + assert_eq!( + expand(&base, &[staff_meeting, no_authority], &[]), + Err(CalendarEvaluationError::ReopenAuthorityMissing { + id: "re-open".to_owned() + }) + ); + + assert_eq!( + expand( + &base, + &[ + staff_meeting, + reopening( + "re-open", + "unknown", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ) + ], + &[] + ), + Err(CalendarEvaluationError::UnknownReopenedClosure { + id: "re-open".to_owned() + }) + ); + + // Naming another reopening, not a closure, is refused the same way. + assert_eq!( + expand( + &base, + &[ + staff_meeting, + reopening( + "first", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ), + reopening( + "second", + "first", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ), + ], + &[] + ), + Err(CalendarEvaluationError::UnknownReopenedClosure { + id: "second".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_stays_inside_its_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + assert_eq!( + expand( + &base, + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:30" + ) + ], + &[] + ), + Err(CalendarEvaluationError::ReopenOutsideClosure { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_cannot_override_an_unrelated_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + assert_eq!( + expand( + &base, + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + closure("training", "2026-10-05", "09:40", "10:20"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:00" + ) + ], + &[] + ), + Err(CalendarEvaluationError::ReopenOverlapsUnrelatedClosure { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn an_authorized_reopening_adds_time_back_inside_its_closure() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:15", + "09:45", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:15:00+07:00"), + end: instant("2026-10-05T09:45:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T10:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn a_reopening_may_not_extend_hours_past_the_pattern() { + // The closure reaches outside pattern hours, so a reopening inside it + // would open 07:30-08:30 as net-new time. A reopening restores lost + // pattern time; it may not extend the published hours. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("building-work", "2026-10-05", "07:00", "12:00"), + reopening( + "early-open", + "building-work", + "duty-officer", + "2026-10-05", + "07:30", + "08:30" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "early-open".to_owned() + }) + ); + + // A reopening inside pattern hours but reaching past the pattern's + // end through its closure is refused for the same reason. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("late-work", "2026-10-05", "10:00", "13:00"), + reopening( + "late-open", + "late-work", + "duty-officer", + "2026-10-05", + "12:00", + "13:00" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "late-open".to_owned() + }) + ); + } + + #[test] + fn a_reopening_on_a_holiday_or_a_non_pattern_weekday_is_refused() { + let exceptions = |date: &'static str| { + [ + closure("staff-meeting", date, "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + date, + "09:15", + "09:45", + ), + ] + }; + + // 2026-10-05 is a Monday inside the pattern, but a holiday. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-06", "09:00", "12:00"), + &exceptions("2026-10-05"), + &["2026-10-05"], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + + // 2026-10-04 is a Sunday, which the pattern does not cover. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-04", "2026-10-06", "09:00", "12:00"), + &exceptions("2026-10-04"), + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn a_reopening_dated_outside_the_effective_range_is_refused() { + // 2026-10-12 is a Monday, but the pattern ends on 2026-10-09. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-12", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-12", + "09:15", + "09:45" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn the_identity_reopening_restores_the_whole_closure() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:00", + "10:00", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + } + + #[test] + fn a_reopening_adjacent_to_an_unrelated_closure_is_allowed() { + // The reopened closure runs to 10:00, where the unrelated training + // closure begins, so the reopening touches it at its endpoint without + // overlapping it. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + closure("training", "2026-10-05", "10:00", "10:30"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:00", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:30:00+07:00"), + end: instant("2026-10-05T10:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T10:30:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + } + ] + ); + } + + #[test] + fn duplicate_weekday_entries_collapse_to_one_interval() { + let duplicated = pattern_with( + &[Weekday::Mon, Weekday::Mon], + "Asia/Bangkok", + "2026-10-05", + "2026-10-05", + "09:00", + "12:00", + ); + let single = pattern_with( + &[Weekday::Mon], + "Asia/Bangkok", + "2026-10-05", + "2026-10-05", + "09:00", + "12:00", + ); + assert_eq!( + expand(&duplicated, &[], &[]).unwrap(), + expand(&single, &[], &[]).unwrap() + ); + } + + #[test] + fn an_interval_spanning_the_fold_keeps_real_elapsed_time() { + // 00:00 to 02:00 across the 2026-11-01 fall-back is three real hours: + // the repeated local hour happens twice in UTC. + let result = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-11-01", + "2026-11-01", + "00:00", + "02:00", + ), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-11-01T00:00:00-04:00"), + end: instant("2026-11-01T02:00:00-05:00"), + }] + ); + assert_eq!((result[0].end - result[0].start).num_minutes(), 180); + } + + #[test] + fn the_exactly_3650_day_span_is_within_bounds() { + // 2020-01-01 plus 3650 days is 2029-12-28; the bound rejects only + // spans strictly beyond it. + let result = expand( + &pattern_with( + &[Weekday::Mon], + "Asia/Bangkok", + "2020-01-01", + "2029-12-28", + "09:00", + "12:00", + ), + &[], + &[], + ); + assert!(result.is_ok()); + } + + #[test] + fn pattern_metadata_and_bounds_are_reported_before_arithmetic() { + let mut base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"); + + base.id = " "; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::EmptyPatternId) + ); + base.id = "counter-hours"; + base.weekdays = &[]; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::NoPatternWeekdays) + ); + base.weekdays = WEEKDAYS; + base.timezone = "Not/A_Zone"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidTimezone) + ); + base.timezone = "Asia/Bangkok"; + base.end_time = "09:00"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidPatternTimes) + ); + base.end_time = "12:00"; + base.effective_from = "2026-10-06"; + base.effective_until = "2026-10-05"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidEffectiveRange) + ); + base.effective_from = "2026-01-01"; + base.effective_until = "2036-01-02"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::PatternSpanTooLarge) + ); + base.effective_from = "2026-13-01"; + base.effective_until = "2026-10-09"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidPatternDate { index: 0 }) + ); + } + + #[test] + fn exception_identifiers_and_times_are_validated() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + + let empty_id = closure(" ", "2026-10-05", "09:00", "10:00"); + assert_eq!( + expand(&base, &[empty_id], &[]), + Err(CalendarEvaluationError::EmptyExceptionId) + ); + + assert_eq!( + expand( + &base, + &[ + closure("a", "2026-10-05", "09:00", "10:00"), + closure("a", "2026-10-06", "09:00", "10:00"), + ], + &[] + ), + Err(CalendarEvaluationError::DuplicateExceptionId { id: "a".to_owned() }) + ); + + assert_eq!( + expand(&base, &[closure("a", "2026-02-30", "09:00", "10:00")], &[]), + Err(CalendarEvaluationError::InvalidExceptionDate { id: "a".to_owned() }) + ); + + assert_eq!( + expand(&base, &[closure("a", "2026-10-05", "10:00", "09:00")], &[]), + Err(CalendarEvaluationError::InvalidExceptionTimes) + ); + } + + #[test] + fn adjacent_intervals_merge_into_canonical_form() { + let mut merged = merge_intervals(vec![ + CalendarInterval { + start: utc(2026, 10, 5, 3, 0), + end: utc(2026, 10, 5, 4, 0), + }, + CalendarInterval { + start: utc(2026, 10, 5, 4, 0), + end: utc(2026, 10, 5, 5, 0), + }, + ]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].end, utc(2026, 10, 5, 5, 0)); + + merged = merge_intervals(vec![ + CalendarInterval { + start: utc(2026, 10, 5, 4, 30), + end: utc(2026, 10, 5, 6, 0), + }, + CalendarInterval { + start: utc(2026, 10, 5, 3, 0), + end: utc(2026, 10, 5, 5, 0), + }, + ]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].start, utc(2026, 10, 5, 3, 0)); + assert_eq!(merged[0].end, utc(2026, 10, 5, 6, 0)); + } +} diff --git a/crates/registry-casework-core/src/calendar.rs b/crates/registry-platform-calendar/src/working_day.rs similarity index 85% rename from crates/registry-casework-core/src/calendar.rs rename to crates/registry-platform-calendar/src/working_day.rs index baffdb1ccc..e07f2849e7 100644 --- a/crates/registry-casework-core/src/calendar.rs +++ b/crates/registry-platform-calendar/src/working_day.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 +//! Working-day deadlines pinned to calendar and holiday-set content. + use std::collections::BTreeSet; -use chrono::{ - DateTime, Datelike, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc, - Weekday, -}; +use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, Utc, Weekday}; use chrono_tz::Tz; +use crate::{local_instant, parse_date, CalendarEvaluationError}; + /// Ten years is the maximum authored working-day offset evaluated locally. pub const MAXIMUM_WORKING_DAY_OFFSET: u32 = 3_650; @@ -48,36 +49,6 @@ pub struct WorkingDayDeadline { pub reminder_at: Option>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] -pub enum CalendarEvaluationError { - #[error("the calendar id is empty")] - EmptyCalendarId, - #[error("the holiday-set id is empty")] - EmptyHolidaySetId, - #[error("the holiday-set revision must be positive")] - InvalidHolidayRevision, - #[error("the calendar has no working weekdays")] - NoWorkingWeekdays, - #[error("the calendar timezone is not a named IANA timezone")] - InvalidTimezone, - #[error("holiday date at index {index} is not a valid YYYY-MM-DD date")] - InvalidHolidayDate { index: usize }, - #[error("dueTime is not a valid HH:MM local time")] - InvalidDueTime, - #[error("after.workingDays must be between 1 and 3650")] - InvalidWorkingDayOffset, - #[error("atRisk.workingDaysBefore must be between 1 and 3650")] - InvalidWarningOffset, - #[error("reminders[].workingDaysBefore must be between 1 and 3650")] - InvalidReminderOffset, - #[error("the calculated local due time does not exist in the calendar timezone")] - NonexistentLocalTime, - #[error("the calculated local due time is ambiguous in the calendar timezone")] - AmbiguousLocalTime, - #[error("working-day calendar arithmetic overflowed")] - Overflow, -} - /// Evaluate a working-day deadline without reading mutable calendar or runtime state. /// /// The offset excludes the anchor's local date. Warning and reminder offsets @@ -184,33 +155,8 @@ fn parse_holidays(dates: &[&str]) -> Result, CalendarEvaluat .collect() } -fn parse_date(value: &str) -> Option { - let bytes = value.as_bytes(); - if bytes.len() != 10 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) - { - return None; - } - NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() -} - fn parse_due_time(value: &str) -> Result { - let bytes = value.as_bytes(); - if bytes.len() != 5 - || bytes[2] != b':' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| index != 2 && !byte.is_ascii_digit()) - { - return Err(CalendarEvaluationError::InvalidDueTime); - } - NaiveTime::parse_from_str(value, "%H:%M").map_err(|_| CalendarEvaluationError::InvalidDueTime) + crate::parse_hh_mm(value).ok_or(CalendarEvaluationError::InvalidDueTime) } #[derive(Clone, Copy)] @@ -290,18 +236,6 @@ fn is_working_date( is_listed_weekday(date, working_weekdays) && !holidays.contains(&date) } -fn local_instant( - date: NaiveDate, - time: NaiveTime, - timezone: Tz, -) -> Result, CalendarEvaluationError> { - match timezone.from_local_datetime(&NaiveDateTime::new(date, time)) { - LocalResult::Single(value) => Ok(value.with_timezone(&Utc)), - LocalResult::None => Err(CalendarEvaluationError::NonexistentLocalTime), - LocalResult::Ambiguous(_, _) => Err(CalendarEvaluationError::AmbiguousLocalTime), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/registry-platform-hooks/src/delivery_schema.rs b/crates/registry-platform-hooks/src/delivery_schema.rs index d0807409c5..ec3bcbf25d 100644 --- a/crates/registry-platform-hooks/src/delivery_schema.rs +++ b/crates/registry-platform-hooks/src/delivery_schema.rs @@ -31,7 +31,10 @@ const DELIVERY_STATEMENTS: &[&str] = &[ outbox_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_id uuid NOT NULL UNIQUE, event_type text NOT NULL CHECK (event_type <> ''), - trigger text NOT NULL CHECK (trigger IN ('created', 'patched', 'tombstoned', 'request_lifecycle')), + trigger text NOT NULL + CONSTRAINT registry_outbox_trigger_check CHECK ( + trigger <> '' AND octet_length(trigger) <= 128 + ), entity_id text NOT NULL CHECK (entity_id <> ''), record_reference text NOT NULL CHECK (record_reference <> ''), record_revision bigint NOT NULL CHECK (record_revision > 0), @@ -245,8 +248,9 @@ const DELIVERY_STATEMENTS: &[&str] = &[ " ALTER TABLE {schema}.registry_outbox DROP CONSTRAINT IF EXISTS registry_outbox_trigger_check;", " ALTER TABLE {schema}.registry_outbox - ADD CONSTRAINT registry_outbox_trigger_check - CHECK (trigger IN ('created', 'patched', 'tombstoned', 'request_lifecycle'));", + ADD CONSTRAINT registry_outbox_trigger_check CHECK ( + trigger <> '' AND octet_length(trigger) <= 128 + );", " UPDATE {schema}.registry_outbox SET payload_expires_at = created_at + interval '7 days' WHERE payload_expires_at IS NULL;", @@ -742,6 +746,18 @@ mod tests { assert_eq!(bounds, 2); } + #[test] + fn the_shared_outbox_bounds_but_does_not_own_product_triggers() { + let statements = rendered(KERNEL_SCHEMA).join("\n"); + assert!(statements.contains("trigger <> '' AND octet_length(trigger) <= 128")); + for product_trigger in ["created", "patched", "tombstoned", "request_lifecycle"] { + assert!( + !statements.contains(&format!("trigger IN ('{product_trigger}'")), + "the shared schema must not freeze {product_trigger} as platform vocabulary" + ); + } + } + #[test] fn a_local_handler_row_carries_no_destination() { let statements = rendered(KERNEL_SCHEMA); diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index 2f41196c0c..c6a1adceaf 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -75,9 +75,53 @@ async fn build_verifier() -> Result> { present. Once present, every core member, the configured approver claim, and both token and grant deadlines are required. Product runtimes still own trusted source-issuer mappings and the supported operation vocabulary. +- Grant bounds are a closed tagged union: `evidence`, `breg`, or `scheduling`. + A verifier built before a variant existed rejects the unknown tag as a + malformed claim, so a grant minted for one product can never be interpreted + by another product's runtime. `SchedulingPermission` values are scoped to + one service and location pair, carry at most 64 permissions of 32 actions, + and never admit wildcards; their `Debug` output redacts every claim value. - Store replay state, authorization decisions, and tenant boundaries in the consuming service. +### Task-grant `scheduling` bounds: review record + +The `scheduling` tag was added to the closed `GrantBounds` union so a task +grant can carry the scheduling permissions a registry booking surface needs. +The four elements of the change: + +- **Threat.** A grant minted for another product being replayed against a + scheduling runtime, or a scheduling grant carrying unbounded, wildcard, or + oversized permission content that a verifier would accept and later fail to + enforce. The closed union answers the first: an unknown tag is a malformed + claim for every verifier, old or new. The per-value bounds answer the + second, and they mirror the BREG bounds exactly (64 permissions, 32 actions + each, 512-byte service and location values, unique `(service, location)` + pairs), so the worst case a signed claim can carry is the same + compositional bound BREG already allows. +- **Wire format.** The claim serializes as `"scheduling": {"permissions": + [{"service": ..., "location": ..., "actions": [...]}]}` with + `deny_unknown_fields` at every level; unknown members are refused, not + ignored. `SchedulingPermission` is exported from the crate root beside + `BregPermission`, so issuers construct scheduling grants programmatically + and relying products can name the type in their signatures. +- **Issuer and verifier compatibility.** Issuers may mint the `scheduling` + tag only once every verifier they target understands it: a verifier built + before this variant rejects the whole grant as malformed (fail-closed, both + for old binaries and for products that never add scheduling support). + Rolling a verifier back to a pre-`scheduling` build therefore hard-fails + scheduling grants; that is the intended direction, but an operator doing a + rollback needs to know it. The first Registry Stack release whose verifier + accepts the tag should be recorded here when the release is cut. +- **Test evidence.** The scheduling-grant tests in `authorization_claims.rs` + cover acceptance of a valid scheduling grant, refusal of unknown fields at + the union and nested-struct levels, the 512/513-byte service and + 128/129-byte action boundary values, the 64-permission and 32-action + limits, unique `(service, location)` enforcement, wildcard refusal, + redacted `Debug` output, round-trip serialization of the tag spelling, and + a downstream case that BREG's binding refuses a scheduling grant because it + carries no BREG permissions. + ## Testing ```sh diff --git a/crates/registry-platform-oidc/src/authorization_claims.rs b/crates/registry-platform-oidc/src/authorization_claims.rs index c35d52cdb3..11c72e0ee3 100644 --- a/crates/registry-platform-oidc/src/authorization_claims.rs +++ b/crates/registry-platform-oidc/src/authorization_claims.rs @@ -16,6 +16,8 @@ const MAX_CLAIM_VALUE_BYTES: usize = 512; const MAX_PURPOSE_BYTES: usize = 128; const MAX_BREG_PERMISSIONS: usize = 64; const MAX_BREG_OPERATIONS: usize = 32; +const MAX_SCHEDULING_PERMISSIONS: usize = 64; +const MAX_SCHEDULING_ACTIONS: usize = 32; /// Claim naming the assertion authority that signed the exchanged subject token. /// @@ -164,12 +166,56 @@ impl fmt::Debug for BregPermission { } } +/// A bounded Scheduling permission carried inside a signed grant. +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SchedulingPermission { + service: String, + location: String, + actions: Vec, +} + +impl SchedulingPermission { + #[must_use] + pub fn service(&self) -> &str { + &self.service + } + + #[must_use] + pub fn location(&self) -> &str { + &self.location + } + + #[must_use] + pub fn actions(&self) -> &[String] { + &self.actions + } +} + +impl fmt::Debug for SchedulingPermission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingPermission") + .field("service", &"") + .field("location", &"") + .field("action_count", &self.actions.len()) + .finish() + } +} + /// Product-specific bounds carried by a task grant. #[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum GrantBounds { - Evidence { requirement: String }, - Breg { permissions: Vec }, + Evidence { + requirement: String, + }, + Breg { + permissions: Vec, + }, + Scheduling { + permissions: Vec, + }, } impl GrantBounds { @@ -177,18 +223,26 @@ impl GrantBounds { pub fn evidence_requirement(&self) -> Option<&str> { match self { Self::Evidence { requirement } => Some(requirement), - Self::Breg { .. } => None, + Self::Breg { .. } | Self::Scheduling { .. } => None, } } #[must_use] pub fn breg_permissions(&self) -> Option<&[BregPermission]> { match self { - Self::Evidence { .. } => None, + Self::Evidence { .. } | Self::Scheduling { .. } => None, Self::Breg { permissions } => Some(permissions), } } + #[must_use] + pub fn scheduling_permissions(&self) -> Option<&[SchedulingPermission]> { + match self { + Self::Evidence { .. } | Self::Breg { .. } => None, + Self::Scheduling { permissions } => Some(permissions), + } + } + fn validate(&self) -> Result<(), ClaimError> { match self { Self::Evidence { requirement } => { @@ -218,6 +272,32 @@ impl GrantBounds { } Ok(()) } + Self::Scheduling { permissions } => { + if permissions.is_empty() || permissions.len() > MAX_SCHEDULING_PERMISSIONS { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut scopes = HashSet::with_capacity(permissions.len()); + for permission in permissions { + if !validate_bound_value(&permission.service, MAX_CLAIM_VALUE_BYTES) + || !validate_bound_value(&permission.location, MAX_CLAIM_VALUE_BYTES) + || !scopes + .insert((permission.service.as_str(), permission.location.as_str())) + || permission.actions.is_empty() + || permission.actions.len() > MAX_SCHEDULING_ACTIONS + { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut actions = HashSet::with_capacity(permission.actions.len()); + if permission + .actions + .iter() + .any(|action| !valid_operation(action) || !actions.insert(action.as_str())) + { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + } + Ok(()) + } } } } @@ -233,6 +313,10 @@ impl fmt::Debug for GrantBounds { .debug_struct("Breg") .field("permission_count", &permissions.len()) .finish(), + Self::Scheduling { permissions } => formatter + .debug_struct("Scheduling") + .field("permission_count", &permissions.len()) + .finish(), } } } @@ -933,29 +1017,48 @@ mod tests { } #[test] - fn evidence_and_breg_bounds_are_distinct_and_strict() { + fn grant_bounds_variants_are_distinct_and_strict() { let evidence = claims(complete_grant( json!({"type":"evidence","requirement":"urn:requirement:one"}), )); - assert!(matches!( - grant_claims(&evidence, &ClaimNames::default(), 1_500) - .expect("valid") - .expect("present") - .bounds(), - GrantBounds::Evidence { .. } - )); + let evidence_bounds = grant_claims(&evidence, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(evidence_bounds, GrantBounds::Evidence { .. })); + assert!(evidence_bounds.breg_permissions().is_none()); + assert!(evidence_bounds.scheduling_permissions().is_none()); let breg = claims(complete_grant(json!({ "type":"breg", "permissions":[{"collection":"person.reviewer","operations":["get","patch"]}] }))); - assert!(matches!( - grant_claims(&breg, &ClaimNames::default(), 1_500) - .expect("valid") - .expect("present") - .bounds(), - GrantBounds::Breg { .. } - )); + let breg_bounds = grant_claims(&breg, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(breg_bounds, GrantBounds::Breg { .. })); + assert!(breg_bounds.evidence_requirement().is_none()); + assert!(breg_bounds.scheduling_permissions().is_none()); + + let scheduling = claims(complete_grant(json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"urn:location:north-counter", + "actions":["book","hold","cancel"] + }] + }))); + let scheduling_bounds = grant_claims(&scheduling, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(scheduling_bounds, GrantBounds::Scheduling { .. })); + assert!(scheduling_bounds.evidence_requirement().is_none()); + assert!(scheduling_bounds.breg_permissions().is_none()); for invalid in [ json!({"type":"evidence","requirement":"*"}), @@ -964,6 +1067,16 @@ mod tests { json!({"type":"breg","permissions":[{"collection":"person.*","operations":["get"]}]}), json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":[]}]}), json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":["*"]}]}), + json!({"type":"scheduling","permissions":[]}), + json!({"type":"scheduling","permissions":[{"service":"intake counter","location":"north","actions":["book"]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake*","location":"north","actions":["book"]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":[]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":["Book"]}]}), + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"]}, + {"service":"intake","location":"north","actions":["hold"]} + ]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":["book","book"]}]}), ] { assert_eq!( grant_claims( @@ -976,6 +1089,164 @@ mod tests { } } + #[test] + fn scheduling_permission_cardinality_bounds_are_enforced() { + let at_limit = json!({ + "type":"scheduling", + "permissions":(0..MAX_SCHEDULING_PERMISSIONS) + .map(|index| json!({ + "service": format!("urn:service:{index}"), + "location": "north", + "actions": (0..MAX_SCHEDULING_ACTIONS) + .map(|action| format!("book.{action}")) + .collect::>() + })) + .collect::>() + }); + assert_eq!( + grant_claims( + &claims(complete_grant(at_limit)), + &ClaimNames::default(), + 1_500 + ) + .map(|grant| grant.is_some()), + Ok(true) + ); + + let over_limit = json!({ + "type":"scheduling", + "permissions":(0..=MAX_SCHEDULING_PERMISSIONS) + .map(|index| json!({ + "service": format!("urn:service:{index}"), + "location": "north", + "actions": ["book"] + })) + .collect::>() + }); + assert_eq!( + grant_claims( + &claims(complete_grant(over_limit)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + + let over_actions = json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"north", + "actions":(0..=MAX_SCHEDULING_ACTIONS) + .map(|action| format!("book.{action}")) + .collect::>() + }] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(over_actions)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + + #[test] + fn scheduling_value_boundaries_and_unknown_fields_are_enforced() { + let at_bound = json!({ + "type":"scheduling", + "permissions":[{ + "service":"s".repeat(MAX_CLAIM_VALUE_BYTES), + "location":"l".repeat(MAX_CLAIM_VALUE_BYTES), + "actions":["a".repeat(MAX_PURPOSE_BYTES)] + }] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(at_bound)), + &ClaimNames::default(), + 1_500 + ) + .map(|grant| grant.is_some()), + Ok(true) + ); + + for (over_service, over_location, over_action) in [ + (Some("s".repeat(MAX_CLAIM_VALUE_BYTES + 1)), None, None), + (None, Some("l".repeat(MAX_CLAIM_VALUE_BYTES + 1)), None), + (None, None, Some("a".repeat(MAX_PURPOSE_BYTES + 1))), + ] { + let permission = json!({ + "service": over_service.unwrap_or_else(|| "urn:service:intake".to_owned()), + "location": over_location.unwrap_or_else(|| "north".to_owned()), + "actions": [over_action.unwrap_or_else(|| "book".to_owned())] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(json!({ + "type":"scheduling","permissions":[permission] + }))), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + + // Unknown members are refused at the union level and inside a + // permission value alike. + for permission_extra in [ + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"],"extra":true} + ]}), + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"]} + ],"extra":true}), + ] { + assert_eq!( + grant_claims( + &claims(complete_grant(permission_extra)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + } + + #[test] + fn the_scheduling_tag_round_trips_through_serde() { + let bounds = GrantBounds::Scheduling { + permissions: vec![SchedulingPermission { + service: "urn:service:intake".to_owned(), + location: "urn:location:north-counter".to_owned(), + actions: vec!["book".to_owned(), "hold.cancel".to_owned()], + }], + }; + let wire = serde_json::to_value(&bounds).expect("serializes"); + assert_eq!( + wire, + json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"urn:location:north-counter", + "actions":["book","hold.cancel"] + }] + }) + ); + let parsed = serde_json::from_value::(wire.clone()).expect("round trips"); + assert_eq!(parsed, bounds); + // A write-side regression that changed the tag spelling would stop + // parsing before any validation runs. + assert!(serde_json::from_value::(json!({ + "type":"Scheduling", + "permissions":[{"service":"intake","location":"north","actions":["book"]}] + })) + .is_err()); + } + #[test] fn context_binding_uses_normalized_verified_client_and_exact_resource() { let input = claims(complete_grant(json!({ @@ -1046,4 +1317,27 @@ mod tests { assert!(!rendered.contains(canary)); } } + + #[test] + fn scheduling_debug_redacts_service_location_and_actions() { + let input = claims(complete_grant(json!({ + "type":"scheduling", + "permissions":[{ + "service":"sensitive-service-canary", + "location":"sensitive-location-canary", + "actions":["sensitive-action-canary"] + }] + }))); + let grant = grant_claims(&input, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present"); + let rendered = format!("{:?}", grant.bounds()); + for canary in [ + "sensitive-service-canary", + "sensitive-location-canary", + "sensitive-action-canary", + ] { + assert!(!rendered.contains(canary)); + } + } } diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index 9efa0da3ad..84e7628940 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -9,7 +9,8 @@ mod authorization_claims; pub use authorization_claims::{ actor_kind, grant_claims, ActorKind, BregPermission, ClaimError, ClaimMember, ClaimNames, - GrantBounds, GrantClaims, GrantContextError, MatchedClientError, ASSERTION_ISSUER_CLAIM, + GrantBounds, GrantClaims, GrantContextError, MatchedClientError, SchedulingPermission, + ASSERTION_ISSUER_CLAIM, }; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; diff --git a/crates/registry-scheduling-client/Cargo.toml b/crates/registry-scheduling-client/Cargo.toml new file mode 100644 index 0000000000..23d6ba099d --- /dev/null +++ b/crates/registry-scheduling-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "registry-scheduling-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Canonical bounded Rust client for Registry Scheduling." +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-httputil.workspace = true +registry-scheduling-core.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url.workspace = true + +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/registry-scheduling-client/README.md b/crates/registry-scheduling-client/README.md new file mode 100644 index 0000000000..ef01df0c67 --- /dev/null +++ b/crates/registry-scheduling-client/README.md @@ -0,0 +1,16 @@ +# Registry Scheduling client + +`registry-scheduling-client` is the canonical bounded Rust client for Registry +Scheduling. It exposes Scheduling's exact-time and arrival-window HTTP +contract over the wire documents and closed problem vocabulary of +`registry-scheduling-core`. It does not contain the Scheduling runtime, its +PostgreSQL store, or its operator tooling, and it depends on no other +product's runtime or protocol types. + +The client takes a borrowed bearer token for each call, the only credential +Scheduling accepts. It never retains the token, follows redirects, or +retries a mutation. Mutating calls carry a caller-supplied idempotency key, +validated against the pinned bound before any network input or output. +Responses are read under a bounded byte ceiling, and exactly validated +product problems surface as their typed `ProblemCode`; every other failure +is a caller-side request defect, a transport failure, or a protocol failure. diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs new file mode 100644 index 0000000000..b131ddbcfa --- /dev/null +++ b/crates/registry-scheduling-client/src/client.rs @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use chrono::{DateTime, SecondsFormat, Utc}; +use registry_platform_httpsec::{response_trace_id, ProblemDocument}; +use registry_platform_httputil::client::{ + build_client, read_failure_kind, send_failure_kind, OutboundOptions, ServiceBaseUrl, +}; +use registry_platform_httputil::{ + read_bounded, url::append_path_segments, validate_response_headers, +}; +use registry_scheduling_core::{ + type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, + AvailabilityEntry, CancelAppointmentRequest, CreateAppointmentRequest, ExplainDocument, + HoldDocument, LocationDocument, OfferingDocument, PageDocument, ProblemCode, + RescheduleAppointmentRequest, ResourceDocument, SchedulingServiceDocument, ServiceDocument, + APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, + HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, + MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, +}; +use reqwest::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{RequestBuilder, Response, StatusCode, Url}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::{ + SchedulingAuth, SchedulingClientConfig, SchedulingClientError, SchedulingComplete, + SchedulingProtocolFailure, +}; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; +const MAXIMUM_PROBLEM_BYTES: u64 = 8 * 1024; +const MAXIMUM_CURSOR_BYTES: usize = 4096; +const MAXIMUM_IDENTIFIER_BYTES: usize = 128; +const OFFERING_QUERY_PARAMETER: &str = "offering"; +const START_QUERY_PARAMETER: &str = "start"; +const END_QUERY_PARAMETER: &str = "end"; + +pub struct SchedulingClient { + http: reqwest::Client, + base_url: ServiceBaseUrl, + max_response_bytes: u64, +} + +impl fmt::Debug for SchedulingClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingClient") + .field("base_url", &"") + .field("max_response_bytes", &self.max_response_bytes) + .finish_non_exhaustive() + } +} + +impl SchedulingClient { + pub fn new(config: SchedulingClientConfig) -> Result { + let base_url = config.validate()?; + let http = build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config.trusted_root_certificates.as_deref(), + }) + .map_err(|_| SchedulingClientError::configuration("the HTTP client could not be built"))?; + Ok(Self { + http, + base_url, + max_response_bytes: config.max_response_bytes, + }) + } + + /// Which deployment and which policy this service answers with. + pub async fn get_scheduling( + &self, + auth: SchedulingAuth<'_>, + ) -> Result, SchedulingClientError> { + self.get_json(&auth, SCHEDULING_PATH, &[]).await + } + + pub async fn list_services( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, SERVICES_PATH, &cursor_query(cursor)) + .await + } + + pub async fn list_offerings( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, OFFERINGS_PATH, &cursor_query(cursor)) + .await + } + + /// Bounded availability. Exact-time offerings answer in grid slots and + /// arrival-window offerings in windows; `start` and `end` bound the + /// searched interval, `cursor` and `limit` bound the page. + pub async fn availability( + &self, + auth: SchedulingAuth<'_>, + offering: &str, + start: Option>, + end: Option>, + cursor: Option<&str>, + limit: Option, + ) -> Result>, SchedulingClientError> { + validate_availability(offering, start, end, cursor, limit)?; + let mut query: Vec<(&str, String)> = Vec::new(); + query.push((OFFERING_QUERY_PARAMETER, offering.to_owned())); + if let Some(start) = start { + query.push((START_QUERY_PARAMETER, rfc3339(start))); + } + if let Some(end) = end { + query.push((END_QUERY_PARAMETER, rfc3339(end))); + } + if let Some(cursor) = cursor { + query.push((CURSOR_QUERY_PARAMETER, cursor.to_owned())); + } + if let Some(limit) = limit { + query.push((LIMIT_QUERY_PARAMETER, limit.to_string())); + } + let request = self.authorized( + self.http + .get(self.url_from_constant(AVAILABILITY_PATH)?) + .query(&query), + &auth, + ); + self.send_json(request, StatusCode::OK).await + } + + /// The separately authorized explanation of one refused start. + pub async fn explain( + &self, + auth: SchedulingAuth<'_>, + offering: &str, + start: DateTime, + ) -> Result, SchedulingClientError> { + if offering.is_empty() { + return Err(SchedulingClientError::invalid_request( + "the offering selector is invalid", + )); + } + let query = [ + (OFFERING_QUERY_PARAMETER, offering.to_owned()), + (START_QUERY_PARAMETER, rfc3339(start)), + ]; + let request = self.authorized( + self.http + .get(self.url_from_constant(AVAILABILITY_EXPLAIN_PATH)?) + .query(&query), + &auth, + ); + self.send_json(request, StatusCode::OK).await + } + + /// Reserve an admission instead of committing it. The request body is + /// the same admission ask a direct create carries. + pub async fn create_hold( + &self, + auth: SchedulingAuth<'_>, + idempotency_key: &str, + request: &AdmissionRequest, + ) -> Result, SchedulingClientError> { + self.post_json( + &auth, + HOLDS_PATH, + idempotency_key, + request, + StatusCode::CREATED, + ) + .await + } + + /// Give a hold's capacity back. The hold identifier is opaque and is + /// never invented here: it comes from a minted hold document. + pub async fn release_hold( + &self, + auth: SchedulingAuth<'_>, + hold_id: &str, + ) -> Result, SchedulingClientError> { + validate_identifier(hold_id, "the hold identifier is invalid")?; + let url = self.url_from_constant(&format!("{HOLDS_PATH}/{hold_id}"))?; + let request = self.authorized(self.http.delete(url), &auth); + self.send_empty(request, StatusCode::NO_CONTENT).await + } + + /// Confirm a hold or create an appointment directly; the two request + /// shapes are mutually exclusive by the core's own contract. + pub async fn create_appointment( + &self, + auth: SchedulingAuth<'_>, + idempotency_key: &str, + request: &CreateAppointmentRequest, + ) -> Result, SchedulingClientError> { + self.post_json( + &auth, + APPOINTMENTS_PATH, + idempotency_key, + request, + StatusCode::CREATED, + ) + .await + } + + pub async fn get_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.get_json(&auth, &format!("{APPOINTMENTS_PATH}/{appointment_id}"), &[]) + .await + } + + pub async fn reschedule_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + idempotency_key: &str, + request: &RescheduleAppointmentRequest, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.post_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/reschedule"), + idempotency_key, + request, + StatusCode::OK, + ) + .await + } + + pub async fn cancel_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + idempotency_key: &str, + request: &CancelAppointmentRequest, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.post_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/cancel"), + idempotency_key, + request, + StatusCode::OK, + ) + .await + } + + pub async fn appointment_history( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + cursor: Option<&str>, + ) -> Result< + SchedulingComplete>, + SchedulingClientError, + > { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + validate_cursor(cursor)?; + self.get_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/history"), + &cursor_query(cursor), + ) + .await + } + + pub async fn list_resources( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, RESOURCES_PATH, &cursor_query(cursor)) + .await + } + + pub async fn list_locations( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, LOCATIONS_PATH, &cursor_query(cursor)) + .await + } + + async fn get_json( + &self, + auth: &SchedulingAuth<'_>, + path: &str, + query: &[(&str, &str)], + ) -> Result, SchedulingClientError> { + let request = self.authorized( + self.http.get(self.url_from_constant(path)?).query(query), + auth, + ); + self.send_json(request, StatusCode::OK).await + } + + async fn post_json( + &self, + auth: &SchedulingAuth<'_>, + path: &str, + idempotency_key: &str, + body: &B, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + validate_idempotency_key(idempotency_key)?; + let request = self + .authorized( + self.http.post(self.url_from_constant(path)?).json(body), + auth, + ) + .header( + HeaderName::from_static(IDEMPOTENCY_KEY_HEADER), + HeaderValue::from_str(idempotency_key).map_err(|_| { + SchedulingClientError::invalid_request("the idempotency key is invalid") + })?, + ); + self.send_json(request, expected_status).await + } + + fn authorized(&self, request: RequestBuilder, auth: &SchedulingAuth<'_>) -> RequestBuilder { + request + .header(AUTHORIZATION, auth.token.authorization_header_value()) + .header(ACCEPT, JSON_MEDIA_TYPE) + } + + fn url_from_constant(&self, path: &str) -> Result { + let segments = path.trim_start_matches('/').split('/').collect::>(); + append_path_segments(self.base_url.as_url(), &segments) + .map_err(|_| SchedulingClientError::invalid_request("a route identifier is invalid")) + } + + async fn send_json( + &self, + request: RequestBuilder, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + let response = self.send(request).await?; + let status = response.status(); + if status != expected_status { + return Err(self.problem_or_status(response).await); + } + let trace_id = response_trace(status, response.headers())?; + if !exact_media_type(response.headers(), JSON_MEDIA_TYPE) { + return Err(protocol( + status, + SchedulingProtocolFailure::MediaType, + Some(trace_id), + )); + } + let body = read_bounded(response, self.max_response_bytes) + .await + .map_err(|error| SchedulingClientError::Transport { + kind: read_failure_kind(&error), + })?; + let value = serde_json::from_slice(&body).map_err(|_| { + protocol( + status, + SchedulingProtocolFailure::Body, + Some(trace_id.clone()), + ) + })?; + Ok(SchedulingComplete { value, trace_id }) + } + + async fn send_empty( + &self, + request: RequestBuilder, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + let response = self.send(request).await?; + let status = response.status(); + if status != expected_status { + return Err(self.problem_or_status(response).await); + } + let trace_id = response_trace(status, response.headers())?; + let body = + read_bounded(response, 1) + .await + .map_err(|error| SchedulingClientError::Transport { + kind: read_failure_kind(&error), + })?; + if !body.is_empty() { + return Err(protocol( + status, + SchedulingProtocolFailure::Body, + Some(trace_id), + )); + } + Ok(SchedulingComplete { + value: (), + trace_id, + }) + } + + async fn send(&self, request: RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|error| SchedulingClientError::Transport { + kind: send_failure_kind(&error), + })?; + validate_response_headers(response.headers()).map_err(|_| { + protocol( + response.status(), + SchedulingProtocolFailure::HeaderBounds, + None, + ) + })?; + Ok(response) + } + + async fn problem_or_status(&self, response: Response) -> SchedulingClientError { + let status = response.status(); + let trace = response_trace(status, response.headers()).ok(); + if !exact_media_type(response.headers(), PROBLEM_MEDIA_TYPE) { + return protocol(status, SchedulingProtocolFailure::Status, trace); + } + let body = match read_bounded(response, MAXIMUM_PROBLEM_BYTES).await { + Ok(value) => value, + Err(error) => { + return SchedulingClientError::Transport { + kind: read_failure_kind(&error), + } + } + }; + let document = match ProblemDocument::parse_exact(&body, MAXIMUM_PROBLEM_BYTES as usize) { + Ok(value) => value, + Err(_) => return protocol(status, SchedulingProtocolFailure::Problem, trace), + }; + domain_problem(status, trace.as_deref(), &document) + } +} + +/// Decide between a typed domain problem and a protocol failure. +/// +/// A domain problem requires every one of these to hold: the document's +/// status equals the response status, the code is in the closed vocabulary +/// and pins that same status, the response trace header matches the +/// document's trace identifier, and the type URI equals the pinned +/// definition. Any mismatch, and any code outside the closed vocabulary (a +/// platform-owned transport problem carrying a different type base, for +/// example), is the edge talking: a protocol failure, never a domain +/// problem. +/// +/// The title and the remediation detail are deliberately not compared. They +/// are human-readable members of the answer, and the caller reads its own +/// pinned copies through `code.title()` and `code.detail()`. Holding the +/// deployment to this build's wording would make an editorial change to a +/// title, or a deployment one release ahead, an unrecoverable protocol +/// failure over a refusal the code already named exactly. +pub(crate) fn domain_problem( + status: StatusCode, + header_trace: Option<&str>, + document: &ProblemDocument, +) -> SchedulingClientError { + let trace_id = header_trace.map(str::to_owned); + let Some(code) = ProblemCode::from_code(&document.code) else { + return protocol(status, SchedulingProtocolFailure::Problem, trace_id); + }; + if document.status != status.as_u16() + || code.http_status() != status.as_u16() + || header_trace != Some(document.trace_id.as_str()) + || document.type_uri != type_uri(code.code()) + { + return protocol(status, SchedulingProtocolFailure::Problem, trace_id); + } + SchedulingClientError::Problem { + status: status.as_u16(), + code, + trace_id, + } +} + +fn response_trace( + status: StatusCode, + headers: &reqwest::header::HeaderMap, +) -> Result { + response_trace_id(headers) + .map(|value| value.as_str().to_owned()) + .map_err(|_| protocol(status, SchedulingProtocolFailure::TraceContext, None)) +} + +fn exact_media_type(headers: &reqwest::header::HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + matches!( + (values.next(), values.next()), + (Some(value), None) if value.as_bytes() == expected.as_bytes() + ) +} + +fn protocol( + status: StatusCode, + failure: SchedulingProtocolFailure, + trace_id: Option, +) -> SchedulingClientError { + SchedulingClientError::Protocol { + status: status.as_u16(), + failure, + trace_id, + } +} + +fn cursor_query(cursor: Option<&str>) -> Vec<(&'static str, &str)> { + cursor + .map(|value| vec![(CURSOR_QUERY_PARAMETER, value)]) + .unwrap_or_default() +} + +fn rfc3339(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::AutoSi, true) +} + +fn validate_idempotency_key(idempotency_key: &str) -> Result<(), SchedulingClientError> { + if idempotency_key.is_empty() + || idempotency_key.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES + || !idempotency_key.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(SchedulingClientError::invalid_request( + "the idempotency key is invalid", + )); + } + Ok(()) +} + +fn validate_cursor(cursor: Option<&str>) -> Result<(), SchedulingClientError> { + if cursor.is_some_and(|value| value.is_empty() || value.len() > MAXIMUM_CURSOR_BYTES) { + return Err(SchedulingClientError::invalid_request( + "the cursor is invalid", + )); + } + Ok(()) +} + +/// A route identifier is an opaque string a minted document carried, so it is +/// validated as a whole before it becomes a path segment: anything that would +/// split into several segments, or leave the segment alphabet the runtime's +/// own identifiers live in, is a caller defect, never a silent detour to a +/// different route. +fn validate_identifier( + identifier: &str, + reason: &'static str, +) -> Result<(), SchedulingClientError> { + if identifier.is_empty() + || identifier.len() > MAXIMUM_IDENTIFIER_BYTES + || !identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(SchedulingClientError::invalid_request(reason)); + } + Ok(()) +} + +fn validate_availability( + offering: &str, + _start: Option>, + _end: Option>, + cursor: Option<&str>, + limit: Option, +) -> Result<(), SchedulingClientError> { + if offering.is_empty() { + return Err(SchedulingClientError::invalid_request( + "the offering selector is invalid", + )); + } + validate_cursor(cursor)?; + if limit.is_some_and(|value| value == 0) { + return Err(SchedulingClientError::invalid_request( + "the page size is outside the accepted range", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + #[test] + fn idempotency_keys_are_bounded_before_io() { + assert!(validate_idempotency_key("hold-7").is_ok()); + assert!(validate_idempotency_key(&"x".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES)).is_ok()); + assert!(validate_idempotency_key("").is_err()); + assert!(validate_idempotency_key(&"x".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES + 1)).is_err()); + assert!(validate_idempotency_key("line\nbreak").is_err()); + } + + #[test] + fn cursors_are_bounded_before_io() { + assert!(validate_cursor(None).is_ok()); + assert!(validate_cursor(Some("opaque-cursor")).is_ok()); + assert!(validate_cursor(Some("")).is_err()); + assert!(validate_cursor(Some(&"x".repeat(MAXIMUM_CURSOR_BYTES + 1))).is_err()); + } + + #[test] + fn route_identifiers_stay_one_path_segment() { + let reason = "the appointment identifier is invalid"; + let uuid = "0199d0e0-8f2a-7c3b-9d4e-5f6a7b8c9d0e"; + assert!(validate_identifier(uuid, reason).is_ok()); + assert!(validate_identifier("appt-1", reason).is_ok()); + assert!(validate_identifier("claim_appt.1", reason).is_ok()); + for foreign in [ + "", + "appt/1", + "appt?x", + "appt#y", + "appt%2F1", + "space id", + "../appt", + &"x".repeat(MAXIMUM_IDENTIFIER_BYTES + 1), + ] { + assert!( + validate_identifier(foreign, reason).is_err(), + "{foreign:?} must be refused" + ); + } + } + + #[test] + fn availability_selectors_are_validated_before_io() { + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + assert!(validate_availability( + "registry-update-30", + Some(start), + Some(end), + None, + Some(25) + ) + .is_ok()); + assert!(validate_availability("", None, None, None, None).is_err()); + assert!(validate_availability("registry-update-30", None, None, Some(""), None).is_err()); + assert!(validate_availability("registry-update-30", None, None, None, Some(0)).is_err()); + } + + #[test] + fn availability_ranges_preserve_runtime_normalization() { + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + assert!( + validate_availability("registry-update-30", Some(start), Some(start), None, None) + .is_ok() + ); + assert!( + validate_availability("registry-update-30", Some(end), Some(start), None, None).is_ok() + ); + } + + /// Times render exactly as the runtime answers them: UTC instants with + /// no sub-second part carry the `Z` designator, and a sub-second part + /// keeps its precision instead of being truncated. + #[test] + fn query_instants_render_as_whole_second_rfc3339() { + let whole = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + assert_eq!(rfc3339(whole), "2026-10-05T02:00:00Z"); + let precise = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap() + + chrono::Duration::milliseconds(250); + assert_eq!(rfc3339(precise), "2026-10-05T02:00:00.250Z"); + } +} diff --git a/crates/registry-scheduling-client/src/config.rs b/crates/registry-scheduling-client/src/config.rs new file mode 100644 index 0000000000..fa4bb91ea3 --- /dev/null +++ b/crates/registry-scheduling-client/src/config.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{fmt, time::Duration}; + +use registry_platform_httputil::client::{ + ServiceBaseUrl, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, + MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES, +}; +use url::Url; + +use crate::SchedulingClientError; + +const DEFAULT_MAXIMUM_RESPONSE_BYTES: u64 = 4 * 1024 * 1024; +const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Clone)] +pub struct SchedulingClientConfig { + pub(crate) base_url: Url, + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) max_response_bytes: u64, + pub(crate) user_agent: Option, + pub(crate) trusted_root_certificates: Option>, +} + +impl SchedulingClientConfig { + #[must_use] + pub fn new(base_url: Url) -> Self { + Self { + base_url, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + user_agent: None, + trusted_root_certificates: None, + } + } + + #[must_use] + pub fn with_request_timeout(mut self, value: Duration) -> Self { + self.request_timeout = value; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, value: Duration) -> Self { + self.connect_timeout = value; + self + } + + #[must_use] + pub fn with_max_response_bytes(mut self, value: u64) -> Self { + self.max_response_bytes = value; + self + } + + #[must_use] + pub fn with_user_agent(mut self, value: impl Into) -> Self { + self.user_agent = Some(value.into()); + self + } + + #[must_use] + pub fn with_trusted_root_certificates(mut self, value: Vec) -> Self { + self.trusted_root_certificates = Some(value); + self + } + + pub(crate) fn validate(&self) -> Result { + let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { + SchedulingClientError::configuration("the service base URL is not usable") + })?; + if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + return Err(SchedulingClientError::configuration( + "client timeouts must be greater than zero", + )); + } + if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + return Err(SchedulingClientError::configuration( + "the response body bound is outside the accepted range", + )); + } + if self + .trusted_root_certificates + .as_ref() + .is_some_and(|value| value.len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES) + { + return Err(SchedulingClientError::configuration( + "the trusted root certificate bundle exceeds the accepted bound", + )); + } + Ok(base_url) + } +} + +impl fmt::Debug for SchedulingClientConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingClientConfig") + .field("base_url", &"") + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("user_agent_present", &self.user_agent.is_some()) + .field( + "trusted_root_certificates_present", + &self.trusted_root_certificates.is_some(), + ) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_withholds_url_and_certificate_material() { + let config = SchedulingClientConfig::new( + Url::parse("https://secret.example.invalid/private").expect("fixture URL"), + ) + .with_user_agent("secret-agent") + .with_trusted_root_certificates(b"secret-certificate".to_vec()); + let debug = format!("{config:?}"); + assert!(!debug.contains("secret.example")); + assert!(!debug.contains("private")); + assert!(!debug.contains("secret-agent")); + assert!(!debug.contains("secret-certificate")); + } + + #[test] + fn configuration_bounds_are_enforced_before_any_client_exists() { + let url = Url::parse("https://scheduling.example.invalid/").expect("fixture URL"); + let zero_timeout = + SchedulingClientConfig::new(url.clone()).with_request_timeout(Duration::ZERO); + assert!(matches!( + zero_timeout.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let zero_body_bound = SchedulingClientConfig::new(url.clone()).with_max_response_bytes(0); + assert!(matches!( + zero_body_bound.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let oversized_body_bound = SchedulingClientConfig::new(url.clone()) + .with_max_response_bytes(MAXIMUM_RESPONSE_BYTES + 1); + assert!(matches!( + oversized_body_bound.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let oversized_certificate_bundle = SchedulingClientConfig::new(url) + .with_trusted_root_certificates(vec![ + 0; + MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES + 1 + ]); + assert!(matches!( + oversized_certificate_bundle.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + } +} diff --git a/crates/registry-scheduling-client/src/error.rs b/crates/registry-scheduling-client/src/error.rs new file mode 100644 index 0000000000..c4c0fa8dbb --- /dev/null +++ b/crates/registry-scheduling-client/src/error.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The client's error taxonomy: caller-side request defects, transport +//! failures, protocol failures, and exactly validated product problems. +//! +//! The taxonomy is the caller's recovery contract: every outcome is +//! matchable without string inspection, and a product problem carries the +//! core's typed `ProblemCode`, whose pinned title and remediation detail the +//! caller reads with `code.title()` and `code.detail()`. The client +//! validates the identifying members of a problem, its status, code, type +//! URI, and trace, against that pinned definition before surfacing it. The +//! human-readable title and detail are read from the core, never compared +//! against the answer, so a deployment that rewords one still answers a +//! refusal this caller can recover from. + +use registry_platform_httputil::client::TransportKind; +use registry_scheduling_core::ProblemCode; +use thiserror::Error; + +/// The stage inside the exchange where the wire stopped matching the pinned +/// contract. Every variant is a protocol failure: the caller cannot repair +/// it by editing the request, only by stopping or reporting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SchedulingProtocolFailure { + /// The response headers exceed the shared client bounds. + HeaderBounds, + /// The response carries no single canonical W3C Trace Context field. + TraceContext, + /// A JSON answer was not exactly `application/json`. + MediaType, + /// The body was not empty where emptiness was required, was + /// unparseable, or carried a field the contract refuses. + Body, + /// The problem document did not match the closed problem definition its + /// code names, or named no code in the closed vocabulary. + Problem, + /// A failure answer was not a problem document at all. + Status, +} + +/// Every failure a Scheduling call can produce. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SchedulingClientError { + #[error("Registry Scheduling client configuration is invalid: {reason}")] + Configuration { reason: &'static str }, + #[error("Registry Scheduling client request is invalid: {reason}")] + InvalidRequest { reason: &'static str }, + #[error("Registry Scheduling exchange did not complete: {kind}")] + Transport { kind: TransportKind }, + #[error( + "Registry Scheduling refused the request (HTTP {status}, problem {})", + .code.code() + )] + Problem { + status: u16, + code: ProblemCode, + trace_id: Option, + }, + #[error("Registry Scheduling returned an invalid response")] + Protocol { + status: u16, + failure: SchedulingProtocolFailure, + trace_id: Option, + }, +} + +impl SchedulingClientError { + pub(crate) fn configuration(reason: &'static str) -> Self { + Self::Configuration { reason } + } + + pub(crate) fn invalid_request(reason: &'static str) -> Self { + Self::InvalidRequest { reason } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::domain_problem; + use registry_platform_httpsec::{ProblemDocument, TraceId}; + use registry_scheduling_core::type_uri; + use reqwest::StatusCode; + + const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + + fn exhaustive_document() -> ProblemDocument { + let code = ProblemCode::CapacityExhausted; + ProblemDocument { + type_uri: type_uri(code.code()), + title: code.title().to_owned(), + status: code.http_status(), + detail: code.detail().to_owned(), + code: code.code().to_owned(), + trace_id: TraceId::parse(TRACE_ID).expect("fixture trace identifier"), + } + } + + #[test] + fn an_exact_problem_document_is_a_typed_domain_problem() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + match domain_problem(StatusCode::CONFLICT, trace, &document) { + SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + trace_id, + } => assert_eq!(trace_id.as_deref(), trace), + other => panic!("expected a typed domain problem, got {other:?}"), + } + } + + /// The request-edge family is part of the closed vocabulary, so the + /// product's own route-not-found answer is a typed problem, never edge + /// talk; only a foreign dialect's is. + #[test] + fn the_products_own_request_edge_problem_is_typed() { + let code = ProblemCode::RequestNotFound; + let document = ProblemDocument { + type_uri: type_uri(code.code()), + title: code.title().to_owned(), + status: code.http_status(), + detail: code.detail().to_owned(), + code: code.code().to_owned(), + trace_id: TraceId::parse(TRACE_ID).expect("fixture trace identifier"), + }; + match domain_problem(StatusCode::NOT_FOUND, Some(TRACE_ID), &document) { + SchedulingClientError::Problem { + status: 404, + code: ProblemCode::RequestNotFound, + .. + } => {} + other => panic!("expected a typed request-edge problem, got {other:?}"), + } + } + + /// The pinned title and remediation detail are this client's own copy of + /// the vocabulary, not members to hold a deployment to. A deployment that + /// rewords one has not changed which refusal it answered, and a client + /// that refused the answer over the wording would turn an editorial + /// change into an unrecoverable protocol failure. + #[test] + fn a_reworded_title_or_detail_is_still_the_problem_the_code_names() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + let reworded = vec![ + ProblemDocument { + title: "No capacity remains".to_owned(), + ..document.clone() + }, + ProblemDocument { + detail: "Every opening at that start is taken.".to_owned(), + ..document.clone() + }, + ]; + for answer in reworded { + match domain_problem(StatusCode::CONFLICT, trace, &answer) { + SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + .. + } => {} + other => panic!("expected a typed domain problem, got {other:?}"), + } + } + } + + #[test] + fn every_problem_mismatch_degrades_to_a_protocol_failure() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + + let mismatches: Vec = vec![ + ProblemDocument { + status: 500, + ..document.clone() + }, + ProblemDocument { + type_uri: format!( + "https://example.test/problems/other/{}", + document.code.replace('.', "/") + ), + ..document.clone() + }, + ProblemDocument { + code: "capacity.unexhausted".to_owned(), + ..document.clone() + }, + ]; + for mismatch in mismatches { + assert!( + matches!( + domain_problem(StatusCode::CONFLICT, trace, &mismatch), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + ), + "{mismatch:?}" + ); + } + + // The response trace header must match the document's own trace + // identifier; a missing or different one is edge talk, never a + // domain problem. + assert!(matches!( + domain_problem( + StatusCode::CONFLICT, + Some("ffffffffffffffffffffffffffffffff"), + &document + ), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + )); + assert!(matches!( + domain_problem(StatusCode::CONFLICT, None, &document), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + )); + } + + #[test] + fn display_names_the_product_the_stage_and_the_typed_code() { + let document = exhaustive_document(); + let problem = domain_problem( + StatusCode::CONFLICT, + Some(document.trace_id.as_str()), + &document, + ); + assert_eq!( + problem.to_string(), + "Registry Scheduling refused the request (HTTP 409, problem capacity.exhausted)" + ); + let configuration = SchedulingClientError::configuration("fixture reason"); + assert_eq!( + configuration.to_string(), + "Registry Scheduling client configuration is invalid: fixture reason" + ); + let invalid_request = SchedulingClientError::invalid_request("fixture reason"); + assert_eq!( + invalid_request.to_string(), + "Registry Scheduling client request is invalid: fixture reason" + ); + let transport = SchedulingClientError::Transport { + kind: TransportKind::Timeout, + }; + assert_eq!( + transport.to_string(), + "Registry Scheduling exchange did not complete: the configured timeout elapsed" + ); + let protocol = SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::MediaType, + trace_id: None, + }; + assert_eq!( + protocol.to_string(), + "Registry Scheduling returned an invalid response" + ); + } +} diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs new file mode 100644 index 0000000000..ca77d81a85 --- /dev/null +++ b/crates/registry-scheduling-client/src/lib.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Canonical, bounded client for Registry Scheduling. +//! +//! A thin relying-party client over the pinned Scheduling HTTP contract. The +//! wire documents, the closed problem vocabulary, and every route, query, and +//! header name come from `registry-scheduling-core`; the shared transport +//! primitives, the exact problem document shape, and trace correlation come +//! from `registry-platform-httpsec` and `registry-platform-httputil`. This +//! crate never depends on the Scheduling runtime or its operator tooling, +//! adds no scheduling semantics of its own, and never re-declares a name the +//! core already owns: a caller matches recoverable outcomes against the +//! core's `ProblemCode`, not a second vocabulary. +//! +//! Authentication is a borrowed bearer token per call, the only credential +//! Scheduling accepts. The client never retains the token, follows +//! redirects, or retries a mutation. Mutating calls carry a caller-supplied +//! idempotency key, validated against the pinned bound before any network +//! input or output. Responses are read under a bounded byte ceiling, the core +//! answer documents tolerate a member a later deployment added while the +//! request documents the runtime reads refuse one, and every failure lands +//! in one of three named shapes: a caller-side request defect, a transport +//! failure, or a protocol failure, with validated product problems surfaced +//! as their typed code. +//! +//! Re-exports are flat, so a caller names a document, a route constant, or a +//! vocabulary entry through this crate exactly as the core spells it. + +#![deny(unsafe_code)] + +mod client; +mod config; +mod error; +mod model; + +pub use client::SchedulingClient; +pub use config::SchedulingClientConfig; +pub use error::{SchedulingClientError, SchedulingProtocolFailure}; +pub use model::{SchedulingAuth, SchedulingComplete}; +pub use registry_platform_httputil::client::{BearerToken, TransportKind}; +pub use registry_scheduling_core::{ + type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, + AppointmentStateDocument, AvailabilityEntry, CancelAppointmentRequest, + CreateAppointmentRequest, ExplainDocument, HoldDocument, LocationDocument, OfferingDocument, + PageDocument, PartyCounts, ProblemCode, ReminderDocument, RescheduleAppointmentRequest, + ResourceDocument, SchedulingModeDocument, SchedulingServiceDocument, ServiceDocument, + WindowDocument, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, + CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, + LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, + SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, +}; diff --git a/crates/registry-scheduling-client/src/model.rs b/crates/registry-scheduling-client/src/model.rs new file mode 100644 index 0000000000..c17c0a9815 --- /dev/null +++ b/crates/registry-scheduling-client/src/model.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use registry_platform_httputil::client::BearerToken; +use serde::{Deserialize, Serialize}; + +/// Authentication for exactly one call: a borrowed bearer token, the only +/// credential Registry Scheduling accepts. Scheduling declares no profile +/// header, so there is nothing else to select. +/// +/// The client borrows the token, attaches it to one request, and never +/// retains it in client state or a result. +pub struct SchedulingAuth<'a> { + pub token: &'a BearerToken, +} + +impl<'a> SchedulingAuth<'a> { + #[must_use] + pub fn new(token: &'a BearerToken) -> Self { + Self { token } + } +} + +impl fmt::Debug for SchedulingAuth<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingAuth") + .field("token", &"") + .finish() + } +} + +/// One completed call: the response document and the response's validated +/// trace identifier, so a caller correlates the answer with its own +/// trace context without re-reading headers. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingComplete { + pub value: T, + pub trace_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_debug_redacts_the_token() { + let token = BearerToken::new("secret-token").expect("fixture token"); + let auth = SchedulingAuth::new(&token); + let debug = format!("{auth:?}"); + assert!(!debug.contains("secret-token")); + } +} diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs new file mode 100644 index 0000000000..d4ea8a5fd5 --- /dev/null +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -0,0 +1,864 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::Router; +use chrono::{TimeZone as _, Utc}; +use registry_scheduling_client::{ + type_uri, AdmissionRequest, AvailabilityEntry, BearerToken, CancelAppointmentRequest, + CreateAppointmentRequest, PartyCounts, ProblemCode, RescheduleAppointmentRequest, + SchedulingAuth, SchedulingClient, SchedulingClientConfig, SchedulingClientError, + SchedulingProtocolFailure, TransportKind, +}; +use url::Url; + +const TRACEPARENT: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; +const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + +#[derive(Clone)] +struct Captured { + method: String, + uri: String, + headers: HeaderMap, + body: Bytes, +} + +type Observations = Arc>>; + +/// One route's canned answer, plus the record of every request it received. +#[derive(Clone)] +struct Fixture { + observations: Observations, + status: StatusCode, + content_type: &'static str, + body: String, + /// Answered when the request carries a cursor, for round-trip checks. + continuation: Option, + traced: bool, +} + +impl Fixture { + fn json(observations: &Observations, status: StatusCode, body: &str) -> Self { + Self { + observations: observations.clone(), + status, + content_type: "application/json", + body: body.to_owned(), + continuation: None, + traced: true, + } + } + + fn with_content_type(mut self, content_type: &'static str) -> Self { + self.content_type = content_type; + self + } + + fn observe(&self, method: Method, uri: Uri, headers: HeaderMap, body: Bytes) { + self.observations + .lock() + .expect("observations") + .push(Captured { + method: method.to_string(), + uri: uri.to_string(), + headers, + body, + }); + } + + fn respond(&self, uri: &Uri) -> (StatusCode, HeaderMap, String) { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + self.content_type.parse().expect("fixture content type"), + ); + if self.traced { + headers.insert("traceparent", HeaderValue::from_static(TRACEPARENT)); + } + let body = match ( + uri.query().unwrap_or_default().contains("cursor="), + &self.continuation, + ) { + (true, Some(continuation)) => continuation.clone(), + _ => self.body.clone(), + }; + (self.status, headers, body) + } +} + +async fn capture_get( + State(fixture): State, + method: Method, + uri: Uri, + headers: HeaderMap, +) -> impl IntoResponse { + fixture.observe(method, uri.clone(), headers, Bytes::new()); + fixture.respond(&uri) +} + +async fn capture_call( + State(fixture): State, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + fixture.observe(method, uri.clone(), headers, body); + fixture.respond(&uri) +} + +async fn spawn(app: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture"); + let address = listener.local_addr().expect("fixture address").to_string(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve fixture"); + }); + (address, server) +} + +fn client(address: &str) -> SchedulingClient { + SchedulingClient::new(SchedulingClientConfig::new( + Url::parse(&format!("http://{address}/")).expect("fixture URL"), + )) + .expect("client") +} + +fn auth(token: &BearerToken) -> SchedulingAuth<'_> { + SchedulingAuth::new(token) +} + +fn admission() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(), + party: PartyCounts { + recipients: 1, + attendees: 2, + }, + channel: Some("public".to_owned()), + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 4, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + } +} + +const SCHEDULING_DOCUMENT: &str = + r#"{"schedulingId":"scheduling-1","policyRevision":4,"policyDigest":"sha256:aa"}"#; + +const APPOINTMENT_DOCUMENT: &str = concat!( + r#"{"appointmentId":"appt-1","offering":"registry-update-30","#, + r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, + r#""resource":"station-1","units":1,"channel":null,"revision":2,"state":"confirmed","#, + r#""policyRevision":4,"createdAt":"2026-10-04T09:00:00Z","cancelledAt":null}"# +); + +#[tokio::test] +async fn scheduling_document_answers_with_the_parsed_service_and_trace() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/v1/scheduling", get(capture_get)) + .with_state(Fixture::json( + &observations, + StatusCode::OK, + SCHEDULING_DOCUMENT, + )); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let complete = client(&address) + .get_scheduling(auth(&token)) + .await + .expect("scheduling document"); + + assert_eq!(complete.value.scheduling_id, "scheduling-1"); + assert_eq!(complete.value.policy_revision, 4); + assert_eq!(complete.value.policy_digest, "sha256:aa"); + assert_eq!(complete.trace_id, TRACE_ID); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + let captured = &observations[0]; + assert_eq!(captured.method, "GET"); + assert_eq!(captured.uri, "/v1/scheduling"); + assert_eq!(captured.headers["authorization"], "Bearer fixture-secret"); + assert_eq!(captured.headers["accept"], "application/json"); + assert!(!captured.headers.contains_key("registry-scheduling-profile")); + assert!(!captured.headers.contains_key("idempotency-key")); + server.abort(); +} + +#[tokio::test] +async fn catalogue_listings_forward_and_round_trip_the_cursor() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let page = r#"{"items":[],"nextCursor":"next"}"#; + let app = Router::new() + .route("/v1/services", get(capture_get)) + .route("/v1/offerings", get(capture_get)) + .route("/v1/resources", get(capture_get)) + .route("/v1/locations", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, page)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + let services = client + .list_services(auth(&token), None) + .await + .expect("first services page"); + assert_eq!(services.value.next_cursor.as_deref(), Some("next")); + let services = client + .list_services(auth(&token), services.value.next_cursor.as_deref()) + .await + .expect("second services page"); + assert!(services.value.items.is_empty()); + + let offerings = client + .list_offerings(auth(&token), None) + .await + .expect("first offerings page"); + assert_eq!(offerings.value.next_cursor.as_deref(), Some("next")); + let offerings = client + .list_offerings(auth(&token), offerings.value.next_cursor.as_deref()) + .await + .expect("second offerings page"); + assert!(offerings.value.items.is_empty()); + + let resources = client + .list_resources(auth(&token), None) + .await + .expect("first resources page"); + assert_eq!(resources.value.next_cursor.as_deref(), Some("next")); + let resources = client + .list_resources(auth(&token), resources.value.next_cursor.as_deref()) + .await + .expect("second resources page"); + assert!(resources.value.items.is_empty()); + + let locations = client + .list_locations(auth(&token), None) + .await + .expect("first locations page"); + assert_eq!(locations.value.next_cursor.as_deref(), Some("next")); + let locations = client + .list_locations(auth(&token), locations.value.next_cursor.as_deref()) + .await + .expect("second locations page"); + assert!(locations.value.items.is_empty()); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 8); + assert_eq!(observations[0].uri, "/v1/services"); + assert_eq!(observations[1].uri, "/v1/services?cursor=next"); + assert_eq!(observations[2].uri, "/v1/offerings"); + assert_eq!(observations[3].uri, "/v1/offerings?cursor=next"); + assert_eq!(observations[4].uri, "/v1/resources"); + assert_eq!(observations[5].uri, "/v1/resources?cursor=next"); + assert_eq!(observations[6].uri, "/v1/locations"); + assert_eq!(observations[7].uri, "/v1/locations?cursor=next"); + server.abort(); +} + +#[tokio::test] +async fn availability_forwards_the_exact_query_and_validates_its_selectors_locally() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let first_page = concat!( + r#"{"items":[{"kind":"slot","start":"2026-10-05T02:00:00Z","#, + r#""end":"2026-10-05T02:30:00Z","free":2}],"nextCursor":"next"}"# + ); + // The continuation page answers in windows: the kind tag travels + // untouched through the same bounded page envelope. + let continuation = concat!( + r#"{"items":[{"kind":"window","window":"household-morning-window","#, + r#""start":"2026-10-10T08:00:00Z","end":"2026-10-10T10:00:00Z","#, + r#""remaining":3,"channelRemaining":null}],"nextCursor":null}"# + ); + let mut fixture = Fixture::json(&observations, StatusCode::OK, first_page); + fixture.continuation = Some(continuation.to_owned()); + let app = Router::new() + .route("/v1/availability", get(capture_get)) + .with_state(fixture); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + + let first = client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(end), + None, + Some(25), + ) + .await + .expect("first availability page"); + assert_eq!( + first.value.items, + vec![AvailabilityEntry::Slot { + start, + end, + free: 2, + }] + ); + let second = client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(end), + first.value.next_cursor.as_deref(), + Some(25), + ) + .await + .expect("second availability page"); + assert_eq!( + second.value.items, + vec![AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 10, 8, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 10, 0, 0).unwrap(), + remaining: 3, + channel_remaining: None, + }] + ); + assert_eq!(second.trace_id, TRACE_ID); + + assert!(matches!( + client + .availability(auth(&token), "", None, None, None, None) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .availability( + auth(&token), + "registry-update-30", + None, + None, + Some(""), + None + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .availability( + auth(&token), + "registry-update-30", + None, + None, + None, + Some(0) + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(start), + None, + None, + ) + .await + .expect("equal bounds reach the runtime for normalization"); + client + .availability( + auth(&token), + "registry-update-30", + Some(end), + Some(start), + None, + None, + ) + .await + .expect("reversed bounds reach the runtime for normalization"); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 4); + assert_eq!( + observations[0].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&limit=25" + ); + assert_eq!( + observations[1].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&cursor=next&limit=25" + ); + assert_eq!( + observations[2].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A00%3A00Z" + ); + assert_eq!( + observations[3].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A30%3A00Z&end=2026-10-05T02%3A00%3A00Z" + ); + server.abort(); +} + +#[tokio::test] +async fn explain_forwards_offering_and_start() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let explain = concat!( + r#"{"offering":"registry-update-30","start":"2026-10-05T02:00:00Z","#, + r#""publicCode":"capacity.exhausted","detailedCode":"resource.unavailable","#, + r#""explanation":"every capable member is unavailable"}"# + ); + let app = Router::new() + .route("/v1/availability/explain", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, explain)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let complete = client(&address) + .explain(auth(&token), "registry-update-30", start) + .await + .expect("explain document"); + assert_eq!( + complete.value.public_code.as_deref(), + Some("capacity.exhausted") + ); + assert_eq!( + complete.value.detailed_code.as_deref(), + Some("resource.unavailable") + ); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + assert_eq!( + observations[0].uri, + "/v1/availability/explain?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z" + ); + server.abort(); +} + +#[tokio::test] +async fn create_hold_sends_the_admission_body_under_its_idempotency_key() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let hold = concat!( + r#"{"holdId":"hold-7","offering":"registry-update-30","#, + r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, + r#""resource":"station-1","units":1,"expiresAt":"2026-10-05T01:45:00Z","#, + r#""policyRevision":4}"# + ); + let app = Router::new() + .route("/v1/holds", post(capture_call)) + .with_state(Fixture::json(&observations, StatusCode::CREATED, hold)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let request = admission(); + let complete = client(&address) + .create_hold(auth(&token), "hold-7", &request) + .await + .expect("minted hold"); + assert_eq!(complete.value.hold_id, "hold-7"); + assert_eq!(complete.value.resource.as_deref(), Some("station-1")); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1, "a hold is never retried"); + let captured = &observations[0]; + assert_eq!(captured.method, "POST"); + assert_eq!(captured.uri, "/v1/holds"); + assert_eq!(captured.headers["idempotency-key"], "hold-7"); + assert_eq!(captured.headers["authorization"], "Bearer fixture-secret"); + let sent: AdmissionRequest = serde_json::from_slice(&captured.body).expect("sent admission"); + assert_eq!(sent, request); + server.abort(); +} + +#[tokio::test] +async fn release_hold_deletes_and_accepts_an_exactly_empty_answer() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/v1/holds/hold-7", delete(capture_call)) + .with_state(Fixture::json(&observations, StatusCode::NO_CONTENT, "")); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let complete = client(&address) + .release_hold(auth(&token), "hold-7") + .await + .expect("released hold"); + assert_eq!(complete.value, ()); + assert_eq!(complete.trace_id, TRACE_ID); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + let captured = &observations[0]; + assert_eq!(captured.method, "DELETE"); + assert_eq!(captured.uri, "/v1/holds/hold-7"); + assert!(captured.body.is_empty()); + assert!(!captured.headers.contains_key("idempotency-key")); + server.abort(); +} + +#[tokio::test] +async fn appointment_commands_carry_their_keys_and_exact_bodies() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let history = concat!( + r#"{"items":[{"eventId":"event-1","kind":"created","revision":1,"occurredAt":"2026-10-04T09:00:00Z","#, + r#""actor":"hmac-sha256:v2:8f0a","detail":{}}],"nextCursor":null}"# + ); + let created = Fixture::json(&observations, StatusCode::CREATED, APPOINTMENT_DOCUMENT); + let shown = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let moved = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let cancelled = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let history_page = Fixture::json(&observations, StatusCode::OK, history); + let app = Router::new() + .route("/v1/appointments", post(capture_call).with_state(created)) + .route( + "/v1/appointments/appt-1", + get(capture_get).with_state(shown), + ) + .route( + "/v1/appointments/appt-1/reschedule", + post(capture_call).with_state(moved), + ) + .route( + "/v1/appointments/appt-1/cancel", + post(capture_call).with_state(cancelled), + ) + .route( + "/v1/appointments/appt-1/history", + get(capture_get).with_state(history_page), + ); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + let create = CreateAppointmentRequest { + hold: Some("hold-7".to_owned()), + admission: None, + }; + let confirmed = client + .create_appointment(auth(&token), "create-1", &create) + .await + .expect("confirmed appointment"); + assert_eq!(confirmed.value.appointment_id, "appt-1"); + assert_eq!(confirmed.value.state.as_str(), "confirmed"); + + let read = client + .get_appointment(auth(&token), "appt-1") + .await + .expect("appointment"); + assert_eq!(read.value.revision, 2); + + let reschedule_admission = admission(); + let moved = client + .reschedule_appointment( + auth(&token), + "appt-1", + "move-1", + &RescheduleAppointmentRequest { + observed_revision: 2, + admission: reschedule_admission.clone(), + }, + ) + .await + .expect("rescheduled appointment"); + assert_eq!(moved.trace_id, TRACE_ID); + + let cancelled = client + .cancel_appointment( + auth(&token), + "appt-1", + "cancel-1", + &CancelAppointmentRequest { + observed_revision: 2, + reason: Some("plans changed".to_owned()), + }, + ) + .await + .expect("cancelled appointment"); + assert_eq!(cancelled.value.appointment_id, "appt-1"); + + let history = client + .appointment_history(auth(&token), "appt-1", Some("h-next")) + .await + .expect("history page"); + assert_eq!(history.value.items[0].event_id, "event-1"); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 5); + assert_eq!(observations[0].method, "POST"); + assert_eq!(observations[0].uri, "/v1/appointments"); + assert_eq!(observations[0].headers["idempotency-key"], "create-1"); + let sent_create: CreateAppointmentRequest = + serde_json::from_slice(&observations[0].body).expect("sent create"); + assert_eq!(sent_create, create); + assert_eq!(observations[1].method, "GET"); + assert_eq!(observations[1].uri, "/v1/appointments/appt-1"); + assert!(!observations[1].headers.contains_key("idempotency-key")); + assert_eq!(observations[2].uri, "/v1/appointments/appt-1/reschedule"); + assert_eq!(observations[2].headers["idempotency-key"], "move-1"); + let sent_reschedule: RescheduleAppointmentRequest = + serde_json::from_slice(&observations[2].body).expect("sent reschedule"); + assert_eq!(sent_reschedule.admission, reschedule_admission); + assert_eq!(sent_reschedule.observed_revision, 2); + // The wire request carries no exclusion of its own: the runtime supplies + // the appointment's own claim from inside the reschedule transaction, so + // the serialized admission names no claim to leave out of the check. + let sent_reschedule_value: serde_json::Value = + serde_json::from_slice(&observations[2].body).expect("sent reschedule as json"); + assert!(!sent_reschedule_value["admission"] + .as_object() + .expect("the admission is an object") + .contains_key("rescheduleOf")); + assert_eq!(observations[3].uri, "/v1/appointments/appt-1/cancel"); + assert_eq!(observations[3].headers["idempotency-key"], "cancel-1"); + let sent_cancel: CancelAppointmentRequest = + serde_json::from_slice(&observations[3].body).expect("sent cancel"); + assert_eq!(sent_cancel.observed_revision, 2); + assert_eq!(sent_cancel.reason.as_deref(), Some("plans changed")); + assert_eq!( + observations[4].uri, + "/v1/appointments/appt-1/history?cursor=h-next" + ); + server.abort(); +} + +#[tokio::test] +async fn an_exact_problem_document_maps_to_the_typed_runtime_code() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let problem = serde_json::json!({ + "type": type_uri(ProblemCode::CapacityExhausted.code()), + "title": ProblemCode::CapacityExhausted.title(), + "status": ProblemCode::CapacityExhausted.http_status(), + "detail": ProblemCode::CapacityExhausted.detail(), + "code": ProblemCode::CapacityExhausted.code(), + "traceId": TRACE_ID, + }) + .to_string(); + let app = Router::new() + .route("/v1/holds", post(capture_call)) + .with_state( + Fixture::json(&observations, StatusCode::CONFLICT, &problem) + .with_content_type("application/problem+json"), + ); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let result = client(&address) + .create_hold(auth(&token), "hold-7", &admission()) + .await; + assert_eq!( + result.as_ref().expect_err("refused hold").to_string(), + "Registry Scheduling refused the request (HTTP 409, problem capacity.exhausted)" + ); + match result { + Err(SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + trace_id, + }) => assert_eq!(trace_id.as_deref(), Some(TRACE_ID)), + other => panic!("expected a typed capacity problem, got {other:?}"), + } + server.abort(); +} + +#[tokio::test] +async fn edge_answers_stay_edge_talk() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let foreign_problem = serde_json::json!({ + "type": "https://example.test/problems/other/request/not-found", + "title": "Route not found", + "status": 404, + "detail": "The requested route does not exist.", + "code": "request.not-found", + "traceId": TRACE_ID, + }) + .to_string(); + let mut untraced = Fixture::json(&observations, StatusCode::OK, SCHEDULING_DOCUMENT); + untraced.traced = false; + let not_found = Fixture::json(&observations, StatusCode::NOT_FOUND, &foreign_problem) + .with_content_type("application/problem+json"); + let plain_json = Fixture::json( + &observations, + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"nope":true}"#, + ); + let garbage_problem = Fixture::json( + &observations, + StatusCode::INTERNAL_SERVER_ERROR, + "this is not a problem document", + ) + .with_content_type("application/problem+json"); + let wrong_media = Fixture::json(&observations, StatusCode::OK, r#"{"items":[]}"#) + .with_content_type("text/plain"); + let unparseable = Fixture::json(&observations, StatusCode::CREATED, "not json"); + let app = Router::new() + .route("/v1/scheduling", get(capture_get).with_state(untraced)) + .route("/v1/services", get(capture_get).with_state(not_found)) + .route("/v1/offerings", get(capture_get).with_state(plain_json)) + .route( + "/v1/locations", + get(capture_get).with_state(garbage_problem), + ) + .route("/v1/resources", get(capture_get).with_state(wrong_media)) + .route("/v1/holds", post(capture_call).with_state(unparseable)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + // A route-not-found problem from a foreign dialect carries a type base + // outside this product's vocabulary: the edge talking. The product's own + // request-edge codes are typed by the error taxonomy tests. + assert!(matches!( + client.list_services(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 404, + failure: SchedulingProtocolFailure::Problem, + .. + }) + )); + // A failure answer that is not a problem document at all. + assert!(matches!( + client.list_offerings(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 500, + failure: SchedulingProtocolFailure::Status, + .. + }) + )); + // A success answer in the wrong media type. + assert!(matches!( + client.list_resources(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::MediaType, + .. + }) + )); + // A success answer with no trace context. + assert!(matches!( + client.get_scheduling(auth(&token)).await, + Err(SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::TraceContext, + .. + }) + )); + // A problem answer whose body is not an exact problem document. + assert!(matches!( + client.list_locations(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 500, + failure: SchedulingProtocolFailure::Problem, + .. + }) + )); + // A success answer whose body does not parse. + assert!(matches!( + client + .create_hold(auth(&token), "hold-7", &admission()) + .await, + Err(SchedulingClientError::Protocol { + status: 201, + failure: SchedulingProtocolFailure::Body, + .. + }) + )); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 6, "no failure is ever retried"); + server.abort(); +} + +#[tokio::test] +async fn oversized_answers_stay_bounded() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let page = format!("{{\"items\":[],\"nextCursor\":\"{}\"}}", "x".repeat(200)); + let app = Router::new() + .route("/v1/resources", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, &page)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let bounded = SchedulingClient::new( + SchedulingClientConfig::new( + Url::parse(&format!("http://{address}/")).expect("fixture URL"), + ) + .with_max_response_bytes(64), + ) + .expect("client"); + match bounded.list_resources(auth(&token), None).await { + Err(SchedulingClientError::Transport { + kind: TransportKind::ResponseTooLarge, + }) => {} + other => panic!("expected a bounded-read refusal, got {other:?}"), + } + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + server.abort(); +} + +#[tokio::test] +async fn invalid_client_input_never_reaches_the_wire() { + // Port 1 refuses every connection, so any attempted round trip would + // surface as a transport failure instead of a request defect. + let client = SchedulingClient::new(SchedulingClientConfig::new( + Url::parse("http://127.0.0.1:1/").expect("fixture URL"), + )) + .expect("client"); + let token = BearerToken::new("fixture-secret").expect("fixture token"); + + assert!(matches!( + client.create_hold(auth(&token), "", &admission()).await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + let oversized_key = "x".repeat(129); + assert!(matches!( + client + .create_hold(auth(&token), &oversized_key, &admission()) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .cancel_appointment( + auth(&token), + "appt-1", + "line\nbreak", + &CancelAppointmentRequest { + observed_revision: 2, + reason: None, + }, + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client.list_services(auth(&token), Some("")).await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + // An identifier that would split into several path segments never reaches + // the wire as a different route. + assert!(matches!( + client.get_appointment(auth(&token), "appt/1").await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client.release_hold(auth(&token), "../holds").await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); +} diff --git a/crates/registry-scheduling-core/Cargo.toml b/crates/registry-scheduling-core/Cargo.toml new file mode 100644 index 0000000000..1f4a2b6b28 --- /dev/null +++ b/crates/registry-scheduling-core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "registry-scheduling-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Source-neutral model, policy types, and pure admission evaluators for Registry Scheduling." +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true, features = ["serde"] } +registry-platform-calendar.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-hooks.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +sha2.workspace = true +thiserror.workspace = true diff --git a/crates/registry-scheduling-core/examples/problem-catalog.rs b/crates/registry-scheduling-core/examples/problem-catalog.rs new file mode 100644 index 0000000000..0f93497e9c --- /dev/null +++ b/crates/registry-scheduling-core/examples/problem-catalog.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_scheduling_core::{type_uri, ProblemCode}; +use serde::Serialize; +use std::{env, fs, path::PathBuf, process::ExitCode}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemCatalog<'a> { + entries: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemEntry<'a> { + uri: String, + code: &'a str, + title: &'a str, + description: &'a str, + http_statuses: [u16; 1], +} + +fn main() -> ExitCode { + let mut arguments = env::args_os().skip(1); + let Some(flag) = arguments.next() else { + return usage(); + }; + let Some(output) = arguments.next() else { + return usage(); + }; + if flag != "--output" || arguments.next().is_some() { + return usage(); + } + + let catalog = ProblemCatalog { + entries: ProblemCode::ALL + .iter() + .copied() + .map(|problem| ProblemEntry { + uri: type_uri(problem.code()), + code: problem.code(), + title: problem.title(), + description: problem.detail(), + http_statuses: [problem.http_status()], + }) + .collect(), + }; + let mut bytes = match serde_json::to_vec_pretty(&catalog) { + Ok(bytes) => bytes, + Err(_) => { + eprintln!("problem catalog could not be serialized"); + return ExitCode::FAILURE; + } + }; + bytes.push(b'\n'); + if fs::write(PathBuf::from(output), bytes).is_err() { + eprintln!("problem catalog could not be written"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +fn usage() -> ExitCode { + eprintln!("usage: problem-catalog --output "); + ExitCode::from(2) +} diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs new file mode 100644 index 0000000000..12857f7041 --- /dev/null +++ b/crates/registry-scheduling-core/src/admission.rs @@ -0,0 +1,1765 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The pure admission evaluators. +//! +//! One admission decision is one function of the resolved policy, the +//! environment facts, the authoritative ledger snapshot, the request, and the +//! observed instant. Nothing here reads a clock, opens a socket, or touches a +//! database: the runtime calls these functions inside its capacity +//! transaction, the explain endpoint calls them again for an authorized +//! caller, and offline fixture replay calls them with synthetic facts. The +//! three callers therefore cannot disagree about what admits. +//! +//! A refusal carries two problem codes: the public one every caller may see, +//! and the detailed one the separately authorized explain path may see. The +//! one place they differ is resource unavailability: the public answer to +//! "why not" is that capacity is exhausted, never which resource was absent +//! or why, because a specific resource's absence can disclose another +//! person's booking or a private staff reason. + +use chrono::{DateTime, Duration, Utc}; + +use registry_platform_calendar::CalendarInterval; + +use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember}; +use crate::policy::{Channel, ExactTimeOffering, OfferingPolicy, PublishedWindow}; +use crate::problem::ProblemCode; +use crate::units::UnitsEvaluationError; + +/// A granted admission and the allocation it would create. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Admission { + pub offering: String, + /// The displayed start. + pub start: DateTime, + /// The displayed end, start plus duration. + pub end: DateTime, + /// The occupied interval including setup and cleanup buffers, the interval + /// the ledger records. + pub occupied_start: DateTime, + pub occupied_end: DateTime, + /// The concrete pool member chosen for an exact-time admission. Pooled + /// supply is its members, so the decision names one. + pub resource: Option, + /// The recipient units consumed. + pub units: u32, +} + +/// Which side of the booking horizon the request fell on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HorizonKind { + /// The start is closer than the offering's lead time allows. + TooSoon, + /// The start is further ahead than the offering's horizon allows. + TooFar, +} + +/// A refused admission. The variants are the decision; the codes are the +/// disclosure. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum AdmissionRefusal { + #[error("the policy revision changed before the request committed")] + PolicyChanged, + #[error("the window revision changed before the request committed")] + RevisionMismatch { observed: u64 }, + #[error("the start is outside the booking horizon")] + HorizonOutside { kind: HorizonKind }, + #[error("no published schedule serves that start")] + ScheduleUnpublished, + #[error("a closure covers that start")] + LocationClosed, + #[error("the party is larger than the offering can ever serve")] + PartyCapacityInadequate, + #[error("the party is missing required prerequisites")] + PrerequisiteMissing { missing: Vec }, + #[error("the request names a channel the closed vocabulary or the policy does not serve")] + ChannelUnknown, + #[error("the party or backing supply lacks a required capability")] + CapabilityUnmatched, + #[error("an active booking already holds this party's duplicate key")] + DuplicateActiveBooking, + #[error("the offering keys duplicate actives, so the request must carry a key")] + DuplicateKeyRequired, + #[error("every capable member is unavailable")] + ResourceUnavailable, + #[error("the supply is fully committed for that interval")] + CapacityExhausted, + #[error("the hold expired before confirmation")] + HoldExpired, + #[error("the claim is not an active hold")] + HoldReleased, +} + +impl AdmissionRefusal { + /// The problem code every caller may see. + #[must_use] + pub const fn public_code(&self) -> ProblemCode { + match self { + Self::ResourceUnavailable { .. } => ProblemCode::CapacityExhausted, + Self::PolicyChanged { .. } => ProblemCode::PolicyChanged, + Self::RevisionMismatch { .. } => ProblemCode::RevisionMismatch, + Self::HorizonOutside { .. } => ProblemCode::HorizonOutside, + Self::ScheduleUnpublished { .. } => ProblemCode::ScheduleUnpublished, + Self::LocationClosed { .. } => ProblemCode::LocationClosed, + Self::PartyCapacityInadequate { .. } => ProblemCode::PartyCapacityInadequate, + Self::PrerequisiteMissing { .. } => ProblemCode::PrerequisiteMissing, + Self::ChannelUnknown => ProblemCode::RequestUnprocessable, + Self::CapabilityUnmatched { .. } => ProblemCode::CapabilityUnmatched, + Self::DuplicateActiveBooking { .. } => ProblemCode::BookingDuplicateActive, + Self::DuplicateKeyRequired { .. } => ProblemCode::PreconditionRequired, + Self::CapacityExhausted { .. } => ProblemCode::CapacityExhausted, + Self::HoldExpired { .. } => ProblemCode::HoldExpired, + Self::HoldReleased { .. } => ProblemCode::HoldReleased, + } + } + + /// The problem code only the separately authorized explain path may see. + #[must_use] + pub const fn detailed_code(&self) -> ProblemCode { + match self { + Self::ResourceUnavailable { .. } => ProblemCode::ResourceUnavailable, + other => other.public_code(), + } + } +} + +/// The resolved state an exact-time admission runs against. +pub struct ExactTimeContext<'a> { + pub offering: &'a OfferingPolicy, + pub exact: &'a ExactTimeOffering, + /// The pool's members, as runtime records hold them. + pub members: &'a [PoolMember], + /// The expanded published openings for the offering's location. + pub open: &'a [CalendarInterval], + /// The blocking closures for the offering's location, so a refused start + /// can distinguish a closure from no published schedule. + pub closures: &'a [CalendarInterval], + pub snapshot: &'a LedgerSnapshot, + pub policy_revision: u64, + pub now: DateTime, +} + +/// The resolved state an arrival-window admission runs against. +pub struct WindowContext<'a> { + pub offering: &'a OfferingPolicy, + pub window: &'a PublishedWindow, + /// Effective published openings for the window's location, after dated + /// closures and authorized reopenings have been layered. + pub open: &'a [CalendarInterval], + /// Raw blocking closures, used only to distinguish `location.closed` + /// from a start outside every published opening. + pub closures: &'a [CalendarInterval], + /// The arrival block's lead time and horizon, resolved from the offering. + pub lead_time_minutes: u32, + pub horizon_days: u32, + pub snapshot: &'a LedgerSnapshot, + pub policy_revision: u64, + /// The channels the policy declares it serves. The channel vocabulary + /// itself is closed; a declaration narrows it to the served subset, and + /// a request naming a channel outside either is refused before any + /// capacity is counted. An empty slice declares nothing beyond the + /// vocabulary. + pub channels: &'a [Channel], + pub now: DateTime, +} + +/// Evaluate one exact-time admission request. +/// +/// The checks run in a fixed order so two callers replaying the same state +/// always see the same first refusal: policy revision, window-agnostic +/// horizon, party bounds, prerequisites, duplicate key, published schedule +/// coverage including the start grid, member capability and availability, +/// then member occupancy including buffers. +/// +/// `exclude` is the one standing claim this request replaces: a reschedule +/// must not compete with the appointment it is moving. It is never a value a +/// caller sends. The runtime supplies the appointment's own id from inside +/// the commitment transaction, and offline replay supplies the claim the +/// authored case names, so no create path can name another party's +/// allocation out of the capacity check. +pub fn evaluate_exact_time_admission( + context: &ExactTimeContext<'_>, + request: &AdmissionRequest, + exclude: Option<&str>, +) -> Result { + let ExactTimeContext { + offering, + exact, + members, + open, + closures, + snapshot, + policy_revision, + now, + } = context; + + if request.policy_revision != *policy_revision { + return Err(AdmissionRefusal::PolicyChanged); + } + check_horizon( + request.start, + *now, + exact.lead_time_minutes, + exact.horizon_days, + )?; + + if request.party.recipients == 0 || request.party.recipients > exact.max_recipients { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + check_capabilities(offering, request)?; + check_prerequisites(offering, request)?; + check_duplicate(offering, request, snapshot, exclude, *now)?; + + // Interval arithmetic that leaves representable time is a horizon + // refusal, never a mislabeled capacity answer: a start whose service or + // occupied interval cannot exist is outside every bookable horizon. + let end = request + .start + .checked_add_signed(Duration::minutes(i64::from(exact.duration_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + })?; + let covered = open + .iter() + .find(|interval| interval.start <= request.start && end <= interval.end); + let Some(covering) = covered else { + let overlaps_closure = closures + .iter() + .any(|closure| request.start < closure.end && closure.start < end); + return Err(if overlaps_closure { + AdmissionRefusal::LocationClosed + } else { + AdmissionRefusal::ScheduleUnpublished + }); + }; + // The start grid is part of the published schedule: the offering serves + // starts at fixed increments from the opening's start. A zero increment + // publishes no grid at all, so every start is off it; the check refuses + // zero, and the evaluator still refuses to divide by it rather than trust + // its caller. Alignment is exact: a start inside the minute after a grid + // point, whatever seconds it carries, is off the grid availability + // publishes. + let offset = request.start.signed_duration_since(covering.start); + let grid_seconds = i64::from(exact.start_increment_minutes) * 60; + let on_grid = offset >= Duration::zero() + && grid_seconds != 0 + && offset.subsec_nanos() == 0 + && offset.num_seconds() % grid_seconds == 0; + if !on_grid { + return Err(AdmissionRefusal::ScheduleUnpublished); + } + + // Buffers reaching past representable time refuse rather than clamp: a + // clamped buffer would silently shrink the occupied interval the ledger + // records. + let occupied_start = request + .start + .checked_sub_signed(Duration::minutes(i64::from(exact.buffer_before_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon, + })?; + let occupied_end = end + .checked_add_signed(Duration::minutes(i64::from(exact.buffer_after_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + })?; + + let capable: Vec<&PoolMember> = members + .iter() + .filter(|member| member.serves(&offering.requires_capabilities)) + .collect(); + if capable.is_empty() { + return Err(AdmissionRefusal::CapabilityUnmatched); + } + let available: Vec<&PoolMember> = capable + .iter() + .copied() + .filter(|member| member.available) + .collect(); + if available.is_empty() { + return Err(AdmissionRefusal::ResourceUnavailable); + } + + let mut free: Vec<&PoolMember> = available + .iter() + .copied() + .filter(|member| { + !snapshot.supply_occupied( + &member.resource_id, + occupied_start, + occupied_end, + exclude, + *now, + ) + }) + .collect(); + if free.is_empty() { + return Err(AdmissionRefusal::CapacityExhausted); + } + // Deterministic selection: the same state always picks the same member, + // so replay and explain agree with the runtime's commit. + free.sort_by_key(|member| member.resource_id.clone()); + + let chosen = free[0]; + Ok(Admission { + offering: offering.id.clone(), + start: request.start, + end, + occupied_start, + occupied_end, + resource: Some(chosen.resource_id.clone()), + units: 1, + }) +} + +/// Evaluate one arrival-window admission request. +/// +/// The window's units policy converts the party to recipient units; the +/// channel subquota and the total window capacity are both checked, and a +/// party the published units could never carry is refused as inadequate +/// rather than as exhausted. +/// +/// `exclude` carries the same meaning it carries for an exact-time request: +/// the one standing claim this request replaces, supplied by the runtime or +/// by offline replay and never by a caller. +pub fn evaluate_window_admission( + context: &WindowContext<'_>, + request: &AdmissionRequest, + exclude: Option<&str>, +) -> Result { + let WindowContext { + offering, + window, + open, + closures, + lead_time_minutes, + horizon_days, + snapshot, + policy_revision, + channels, + now, + } = context; + + if request.policy_revision != *policy_revision { + return Err(AdmissionRefusal::PolicyChanged); + } + if request.window_revision != Some(window.revision) { + return Err(AdmissionRefusal::RevisionMismatch { + observed: window.revision, + }); + } + check_horizon(window.start, *now, *lead_time_minutes, *horizon_days)?; + if request.start != window.start { + return Err(AdmissionRefusal::ScheduleUnpublished); + } + let covered = open + .iter() + .any(|interval| interval.start <= window.start && window.end <= interval.end); + if !covered { + let overlaps_closure = closures + .iter() + .any(|closure| window.start < closure.end && closure.start < window.end); + return Err(if overlaps_closure { + AdmissionRefusal::LocationClosed + } else { + AdmissionRefusal::ScheduleUnpublished + }); + } + + if request.party.recipients == 0 { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + let required = window + .units_policy + .evaluate(request.party.recipients) + .map_err(|error| match error { + UnitsEvaluationError::RefusedAboveHighestBand | UnitsEvaluationError::Overflow => { + AdmissionRefusal::PartyCapacityInadequate + } + })?; + if required > window.units { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + check_capabilities(offering, request)?; + check_prerequisites(offering, request)?; + check_duplicate(offering, request, snapshot, exclude, *now)?; + + if let Some(name) = &request.channel { + // The channel vocabulary is closed, and a policy that declares the + // channels it serves narrows it further. A name outside either once + // matched no subquota and drew from the window's total unconstrained, + // so it is refused before any capacity is counted. + let channel = Channel::from_name(name).ok_or(AdmissionRefusal::ChannelUnknown)?; + if !channels.is_empty() && !channels.contains(&channel) { + return Err(AdmissionRefusal::ChannelUnknown); + } + if let Some(subquota) = window + .subquotas + .iter() + .find(|subquota| subquota.channel == channel) + { + let allocated = snapshot.window_units_allocated(&window.id, Some(name), exclude, *now); + if allocated.saturating_add(required) > subquota.units { + return Err(AdmissionRefusal::CapacityExhausted); + } + } + } + let allocated = snapshot.window_units_allocated(&window.id, None, exclude, *now); + if allocated.saturating_add(required) > window.units { + return Err(AdmissionRefusal::CapacityExhausted); + } + + Ok(Admission { + offering: offering.id.clone(), + start: window.start, + end: window.end, + occupied_start: window.start, + occupied_end: window.end, + resource: None, + units: required, + }) +} + +/// Evaluate a hold's state at an instant. +/// +/// An active hold confirms. A hold that expired at or before `now` is +/// expired, whatever the cleanup worker has or has not done. A claim that is +/// not a hold at all (a booking, or a hold already released or consumed) is +/// not confirmable; release and consumption are store states the runtime +/// reports with `hold.released` when it resolves the claim, so seeing one +/// here means the caller passed the wrong claim. +pub fn evaluate_hold_state( + hold: &LedgerClaim, + now: DateTime, +) -> Result<&LedgerClaim, AdmissionRefusal> { + if hold.kind != LedgerKind::Hold { + return Err(AdmissionRefusal::HoldReleased); + } + match hold.expires_at { + Some(expiry) if expiry > now => Ok(hold), + _ => Err(AdmissionRefusal::HoldExpired), + } +} + +fn check_horizon( + start: DateTime, + now: DateTime, + lead_time_minutes: u32, + horizon_days: u32, +) -> Result<(), AdmissionRefusal> { + let lead = start.signed_duration_since(now); + if lead < Duration::minutes(i64::from(lead_time_minutes)) { + return Err(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon, + }); + } + if lead > Duration::minutes(i64::from(horizon_days) * 24 * 60) { + return Err(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + }); + } + Ok(()) +} + +fn check_prerequisites( + offering: &OfferingPolicy, + request: &AdmissionRequest, +) -> Result<(), AdmissionRefusal> { + let missing: Vec = offering + .prerequisites + .iter() + .filter(|wanted| !request.prerequisites.contains(wanted)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(AdmissionRefusal::PrerequisiteMissing { missing }) + } +} + +fn check_capabilities( + offering: &OfferingPolicy, + request: &AdmissionRequest, +) -> Result<(), AdmissionRefusal> { + if offering + .requires_capabilities + .iter() + .all(|wanted| request.capabilities.contains(wanted)) + { + Ok(()) + } else { + Err(AdmissionRefusal::CapabilityUnmatched) + } +} + +fn check_duplicate( + offering: &OfferingPolicy, + request: &AdmissionRequest, + snapshot: &LedgerSnapshot, + exclude: Option<&str>, + now: DateTime, +) -> Result<(), AdmissionRefusal> { + if offering.duplicate_active_key.is_none() { + return Ok(()); + } + match &request.duplicate_key { + // An offering that keys duplicate actives refuses a request without a + // key: skipping the check would let the same party hold two active + // bookings by omission. + None => Err(AdmissionRefusal::DuplicateKeyRequired), + Some(key) if snapshot.duplicate_active(&offering.id, key, exclude, now) => { + Err(AdmissionRefusal::DuplicateActiveBooking) + } + Some(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PartyCounts; + use crate::policy::{ + ArrivalOffering, Channel, ExactTimeOffering, OfferingPolicy, PublishedWindow, + SchedulingMode, WindowSubquota, + }; + use crate::units::{BandedInput, RequiredUnitsPolicy}; + use chrono::TimeZone as _; + use registry_platform_calendar::{ + expand_weekly_openings, CalendarException, CalendarExceptionKind, HolidaySetRevision, + WeeklyOpeningPattern, + }; + + fn utc(day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, day, hour, minute, 0) + .unwrap() + } + + /// The shared pool membership, computed once and borrowed for the + /// lifetime of every context the tests build. + fn members() -> &'static [PoolMember] { + static MEMBERS: std::sync::OnceLock> = std::sync::OnceLock::new(); + MEMBERS.get_or_init(|| { + vec![ + PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec![], + available: true, + }, + PoolMember { + resource_id: "station-2".to_owned(), + capabilities: vec![], + available: true, + }, + ] + }) + } + + fn exact_offering() -> (OfferingPolicy, ExactTimeOffering) { + ( + OfferingPolicy { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingMode::ExactTime, + location: "north-counter".to_owned(), + because: "A 30-minute update at an interchangeable station.".to_owned(), + exact_time: None, + arrival: None, + cancellation_cutoff_minutes: 240, + reminders: Vec::new(), + duplicate_active_key: Some("subject".to_owned()), + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }, + ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "update-stations".to_owned(), + start_increment_minutes: 30, + max_recipients: 1, + }, + ) + } + + /// The shared published openings, computed once and borrowed for the + /// lifetime of every context the tests build. + fn open_intervals() -> &'static [CalendarInterval] { + static OPEN: std::sync::OnceLock> = std::sync::OnceLock::new(); + OPEN.get_or_init(|| { + vec![ + CalendarInterval { + start: utc(5, 2, 0), + end: utc(5, 5, 30), + }, + CalendarInterval { + start: utc(6, 2, 0), + end: utc(6, 5, 30), + }, + ] + }) + } + + fn exact_context<'a>( + offering: &'a OfferingPolicy, + exact: &'a ExactTimeOffering, + members: &'a [PoolMember], + snapshot: &'a LedgerSnapshot, + now: DateTime, + ) -> ExactTimeContext<'a> { + ExactTimeContext { + offering, + exact, + members, + open: open_intervals(), + closures: &[], + snapshot, + policy_revision: 1, + now, + } + } + + fn exact_request(start: DateTime) -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start, + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn booking_claim( + id: &str, + supply: &str, + start: DateTime, + end: DateTime, + ) -> LedgerClaim { + LedgerClaim { + id: id.to_owned(), + offering: "registry-update-30".to_owned(), + supply_id: supply.to_owned(), + kind: LedgerKind::Booking, + channel: None, + start, + end, + units: 1, + duplicate_key: None, + expires_at: None, + } + } + + fn window_claim(id: &str, units: u32, channel: &str) -> LedgerClaim { + LedgerClaim { + id: id.to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some(channel.to_owned()), + start: utc(10, 8, 0), + end: utc(10, 10, 0), + units, + duplicate_key: None, + expires_at: None, + } + } + + /// AT-01, pure half: two callers against the last free unit. The first is + /// admitted onto a concrete member; the second, replayed against the + /// ledger the first created, is refused with a recoverable conflict. + #[test] + fn the_second_caller_against_the_last_unit_is_refused() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + let first = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None) + .expect("the first caller is admitted"); + assert_eq!(first.resource.as_deref(), Some("station-1")); + + let mut after = snapshot.clone(); + after.claims.push(booking_claim( + "claim-1", + "station-1", + first.occupied_start, + first.occupied_end, + )); + let second_context = exact_context(&offering, &exact, members(), &after, now); + let second = + evaluate_exact_time_admission(&second_context, &exact_request(utc(5, 2, 30)), None) + .map(|admission| admission.resource) + .map_err(|refusal| refusal.public_code()); + assert_eq!(second, Ok(Some("station-2".to_owned()))); + + // With one station left occupied at the same displayed time, the + // other station serves; once both are occupied the refusal is a + // recoverable capacity conflict. + after.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 2, 25), + utc(5, 3, 5), + )); + let third_context = exact_context(&offering, &exact, members(), &after, now); + let third = + evaluate_exact_time_admission(&third_context, &exact_request(utc(5, 2, 30)), None); + assert_eq!( + third.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + /// AT-09: buffers make touching displayed times conflict. + #[test] + fn buffer_overlap_rejects_touching_displayed_times() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + // Claims on both stations displayed 02:00-02:30, occupied 01:55-02:35. + snapshot.claims.push(booking_claim( + "claim-1", + "station-1", + utc(5, 1, 55), + utc(5, 2, 35), + )); + snapshot.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 1, 55), + utc(5, 2, 35), + )); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + // A 02:30 start displays as touching, but its 02:25 buffer start + // overlaps both claims' cleanup, so every member is blocked. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + + // At 03:00, with the increment grid observed, both stations are free. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), None); + assert!(result.is_ok()); + } + + /// AT-06, duplicate half: the same subject with an active booking is + /// refused; a reschedule of that booking excludes itself. + #[test] + fn an_active_duplicate_key_is_refused_unless_rescheduling_it() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + let mut existing = booking_claim("claim-1", "station-1", utc(5, 1, 55), utc(5, 2, 35)); + existing.duplicate_key = Some("subject:one".to_owned()); + snapshot.claims.push(existing); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + let fresh = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), None); + assert_eq!( + fresh.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::BookingDuplicateActive) + ); + + let moved = + evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), Some("claim-1")); + assert!(moved.is_ok(), "{moved:?}"); + } + + /// A keyed offering refuses a request without a duplicate key: the + /// duplicate check may not be skipped by omission. + #[test] + fn a_keyed_offering_refuses_a_request_without_a_duplicate_key() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let request = AdmissionRequest { + duplicate_key: None, + ..exact_request(utc(5, 2, 30)) + }; + let refusal = evaluate_exact_time_admission(&context, &request, None) + .expect_err("refused for the missing key"); + assert_eq!(refusal.public_code(), ProblemCode::PreconditionRequired); + assert_eq!(refusal.detailed_code(), ProblemCode::PreconditionRequired); + } + + /// Interval arithmetic that leaves representable time refuses as a + /// horizon answer: never a mislabeled capacity refusal, never a silently + /// clamped buffer that undercounts occupancy. + #[test] + fn unrepresentable_intervals_refuse_as_horizon_never_as_capacity() { + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + let snapshot = LedgerSnapshot::default(); + + // A start whose displayed end leaves representable time. + let now = DateTime::::MAX_UTC - Duration::minutes(10); + let at_the_edge = now + Duration::minutes(5); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(at_the_edge), None).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + ); + + // A displayed interval that fits, whose cleanup buffer does not. + let now = DateTime::::MAX_UTC - Duration::minutes(60); + let start = DateTime::::MAX_UTC - Duration::minutes(30); + assert!(start.checked_add_signed(Duration::minutes(30)).is_some()); + exact.buffer_after_minutes = 30; + let open = [CalendarInterval { + start: now, + end: DateTime::::MAX_UTC, + }]; + let context = ExactTimeContext { + open: &open, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(start), None).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + ); + + // A start whose setup buffer reaches before representable time. + let now = DateTime::::MIN_UTC + Duration::minutes(1); + let start = DateTime::::MIN_UTC + Duration::minutes(2); + let open = [CalendarInterval { + start, + end: start + Duration::minutes(30), + }]; + let context = ExactTimeContext { + open: &open, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(start), None).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon + }) + ); + } + + /// A zero increment publishes no grid at all; the evaluator refuses every + /// start rather than dividing by zero. The policy check refuses zero, and + /// this holds the line for hand-built contexts. + #[test] + fn a_zero_increment_grid_serves_no_start() { + let (offering, mut exact) = exact_offering(); + exact.start_increment_minutes = 0; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + /// Grid alignment is exact, not minute-truncated: a start inside the + /// minute after a grid point is off the grid availability publishes, + /// whatever seconds it carries. + #[test] + fn a_start_inside_the_minute_after_a_grid_point_is_off_the_grid() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let grid_point = utc(5, 2, 30); + assert!( + evaluate_exact_time_admission(&context, &exact_request(grid_point), None).is_ok(), + "the grid point itself admits" + ); + for off_grid in [ + grid_point + Duration::seconds(45), + grid_point + Duration::milliseconds(500), + grid_point + Duration::minutes(29) + Duration::seconds(59), + ] { + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(off_grid), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished), + "a start off the grid by less than a minute is still off it: {off_grid}" + ); + } + } + + /// Self-overlap: a reschedule onto its own current slot succeeds, because + /// its own allocation is excluded, while another caller at that slot is + /// refused. + #[test] + fn a_reschedule_does_not_compete_with_its_own_allocation() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + let mut existing = booking_claim("claim-1", "station-1", utc(5, 1, 55), utc(5, 2, 35)); + existing.duplicate_key = Some("subject:one".to_owned()); + snapshot.claims.push(existing); + // Both stations hold the slot, so only the reschedule's own exclusion + // can get a caller onto 02:00. + snapshot.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 1, 55), + utc(5, 2, 35), + )); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + // The exclusion is the evaluator's own argument, not something the + // request carries, so this is the shape the runtime calls. + let result = + evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), Some("claim-1")); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(result.unwrap().resource.as_deref(), Some("station-1")); + + // The identical request without the exclusion is refused, which is + // what a create path is: no caller can ask for one. + let other = AdmissionRequest { + duplicate_key: Some("subject:two".to_owned()), + ..exact_request(utc(5, 2, 0)) + }; + let result = evaluate_exact_time_admission(&context, &other, None); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + /// AT-05, pure half: an expired hold consumes nothing, and confirming it + /// late is refused. + #[test] + fn an_expired_hold_consumes_nothing_and_cannot_be_confirmed() { + let now = utc(4, 4, 0); + let expired_hold = LedgerClaim { + id: "hold-1".to_owned(), + offering: "registry-update-30".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(5, 1, 55), + end: utc(5, 2, 35), + units: 1, + duplicate_key: Some("subject:one".to_owned()), + expires_at: Some(utc(4, 3, 55)), + }; + let snapshot = LedgerSnapshot { + claims: vec![expired_hold.clone()], + }; + + assert_eq!( + evaluate_hold_state(&expired_hold, now).err(), + Some(AdmissionRefusal::HoldExpired) + ); + + let (offering, exact) = exact_offering(); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + // The expired hold neither blocks the slot nor blocks the duplicate + // key: its capacity is bookable again. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), None); + assert!(result.is_ok(), "{result:?}"); + } + + #[test] + fn a_live_hold_still_consumes() { + let now = utc(4, 4, 0); + let live_hold = LedgerClaim { + id: "hold-1".to_owned(), + offering: "registry-update-30".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(5, 1, 55), + end: utc(5, 2, 35), + units: 1, + duplicate_key: None, + expires_at: Some(utc(4, 4, 30)), + }; + assert!(evaluate_hold_state(&live_hold, now).is_ok()); + + let snapshot = LedgerSnapshot { + claims: vec![live_hold], + }; + let (offering, exact) = exact_offering(); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), None); + // station-1 is held; station-2 still serves the party. + assert_eq!( + result.map(|admission| admission.resource), + Ok(Some("station-2".to_owned())) + ); + } + + /// BKG-02: a public refusal distinguishes horizon, closure, unpublished + /// schedule, capability, and party bounds; and never names an unavailable + /// resource. + #[test] + fn refusals_carry_their_distinguished_public_codes() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + // Too soon: 02:00 is inside the 60-minute lead time at 04:00? No: it + // is in the past, which is the strongest form of too soon. + let past = evaluate_exact_time_admission(&context, &exact_request(utc(4, 4, 30)), None); + assert!(matches!( + past.err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon + }) + )); + + // Beyond the 30-day horizon: November 10 is 36 days out. + let far = AdmissionRequest { + start: Utc.with_ymd_and_hms(2026, 11, 10, 2, 30, 0).unwrap(), + ..exact_request(utc(5, 2, 30)) + }; + assert!(matches!( + evaluate_exact_time_admission(&context, &far, None).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + )); + + // Off-grid start: no published schedule serves 02:15. + let off_grid = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 15)), None); + assert_eq!( + off_grid.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + + // Off the published day entirely. + let unpublished_day = + evaluate_exact_time_admission(&context, &exact_request(utc(9, 2, 30)), None); + assert_eq!( + unpublished_day.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + + // A closure over the start is a closure, not an unpublished day. The + // expansion has already removed the closure from the published + // openings, so the uncovered start overlaps the closure interval. + let closures = vec![CalendarInterval { + start: utc(5, 2, 0), + end: utc(5, 3, 0), + }]; + let open_after_closure = [CalendarInterval { + start: utc(5, 3, 0), + end: utc(5, 5, 30), + }]; + let closed_context = ExactTimeContext { + open: &open_after_closure, + closures: &closures, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + let closed = + evaluate_exact_time_admission(&closed_context, &exact_request(utc(5, 2, 30)), None); + assert_eq!( + closed.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + + // A party larger than the offering can ever serve. + let party = AdmissionRequest { + party: PartyCounts { + recipients: 2, + attendees: 2, + }, + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &party, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::PartyCapacityInadequate) + ); + + // A required capability no member carries. + let (wanting, exact_wanting) = { + let (mut offering, exact) = exact_offering(); + offering.requires_capabilities = vec!["interpreter".to_owned()]; + (offering, exact) + }; + let capable_members = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["notary".to_owned()], + available: true, + }]; + let wanting_context = + exact_context(&wanting, &exact_wanting, &capable_members, &snapshot, now); + let capable_request = AdmissionRequest { + capabilities: vec!["interpreter".to_owned()], + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&wanting_context, &capable_request, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + + // The request must carry the offering capability too; a capable + // backing member does not make up for a party that lacks it. + let available = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: true, + }]; + let available_context = exact_context(&wanting, &exact_wanting, &available, &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&available_context, &exact_request(utc(5, 2, 30)), None,) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + + // Capable members that are all unavailable: the public answer is + // exhausted capacity, the detailed answer names unavailability. + let unavailable = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: false, + }]; + let unavailable_context = + exact_context(&wanting, &exact_wanting, &unavailable, &snapshot, now); + let refused = evaluate_exact_time_admission(&unavailable_context, &capable_request, None) + .expect_err("refused"); + assert_eq!(refused.public_code(), ProblemCode::CapacityExhausted); + assert_eq!(refused.detailed_code(), ProblemCode::ResourceUnavailable); + } + + #[test] + fn policy_change_outranks_a_window_revision_mismatch() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let stale = AdmissionRequest { + policy_revision: 0, + window_revision: Some(7), + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &stale, None).err(), + Some(AdmissionRefusal::PolicyChanged) + ); + } + + #[test] + fn missing_prerequisites_are_named() { + let (mut offering, exact) = exact_offering(); + offering.prerequisites = vec![ + "case:approved-application".to_owned(), + "urn:evidence:residency".to_owned(), + ]; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let request = AdmissionRequest { + prerequisites: vec!["urn:evidence:residency".to_owned()], + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &request, None).err(), + Some(AdmissionRefusal::PrerequisiteMissing { + missing: vec!["case:approved-application".to_owned()] + }) + ); + } + + fn window() -> PublishedWindow { + PublishedWindow { + id: "household-morning-window".to_owned(), + revision: 2, + offering: "household-morning".to_owned(), + location: "civic-hall".to_owned(), + start: utc(10, 8, 0), + end: utc(10, 10, 0), + units: 3, + units_policy: RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }, + subquotas: vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 2, + because: "Most households book the public channel.".to_owned(), + }], + leftover: None, + staffing: None, + because: "The Saturday morning household block.".to_owned(), + } + } + + fn arrival_offering() -> OfferingPolicy { + OfferingPolicy { + id: "household-morning".to_owned(), + service: "household-day".to_owned(), + label: "Morning household block".to_owned(), + mode: SchedulingMode::ArrivalWindow, + location: "civic-hall".to_owned(), + because: "Households arrive in a served morning block.".to_owned(), + exact_time: None, + arrival: Some(ArrivalOffering { + window: "household-morning-window".to_owned(), + lead_time_minutes: 1, + horizon_days: 60, + }), + cancellation_cutoff_minutes: 1440, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn window_request(recipients: u32, attendees: u32) -> AdmissionRequest { + AdmissionRequest { + offering: "household-morning".to_owned(), + start: utc(10, 8, 0), + party: PartyCounts { + recipients, + attendees, + }, + channel: Some("public".to_owned()), + duplicate_key: None, + policy_revision: 1, + window_revision: Some(2), + capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn published_window_interval(window: &PublishedWindow) -> [CalendarInterval; 1] { + [CalendarInterval { + start: window.start, + end: window.end, + }] + } + + /// AT-03: three attendees with two recipients consume two units, never + /// three. + #[test] + fn attendee_count_never_becomes_consumed_units() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let admission = + evaluate_window_admission(&context, &window_request(2, 3), None).expect("admitted"); + assert_eq!(admission.units, 2); + } + + /// AT-02: a two-recipient party against one remaining unit is refused + /// without a partial or undercounted booking. + #[test] + fn a_party_that_does_not_fit_remaining_capacity_is_refused_whole() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let result = evaluate_window_admission(&context, &window_request(2, 2), None); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + + // The one remaining unit sits outside the public slice: a caller the + // window does not channel still fits it. + let unchanneled = AdmissionRequest { + channel: None, + ..window_request(1, 1) + }; + let single = evaluate_window_admission(&context, &unchanneled, None); + assert_eq!(single.map(|admission| admission.units), Ok(1)); + } + + #[test] + fn an_arrival_window_requires_the_partys_capabilities() { + let mut offering = arrival_offering(); + offering.requires_capabilities = vec!["interpreter".to_owned()]; + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let missing = evaluate_window_admission(&context, &window_request(1, 1), None); + assert_eq!( + missing.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + let held = AdmissionRequest { + capabilities: vec!["interpreter".to_owned()], + ..window_request(1, 1) + }; + assert!(evaluate_window_admission(&context, &held, None).is_ok()); + } + + #[test] + fn a_party_the_published_units_could_never_carry_is_inadequate_not_exhausted() { + let offering = arrival_offering(); + let mut window = window(); + window.units = 2; + window.units_policy = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![crate::units::UnitBand { + up_to: 2, + units: 2, + because: "A household of two shares one block.".to_owned(), + }], + above_highest_band: crate::units::AboveHighestBand::Refuse, + because: "Household sizing reviewed in 2026.".to_owned(), + }; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + // Four recipients are above the highest band: inadequate party. + assert_eq!( + evaluate_window_admission(&context, &window_request(4, 4), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::PartyCapacityInadequate) + ); + // Two recipients fall in the band at 2 units, exactly the published 2: + // admitted, not inadequate. + assert!(evaluate_window_admission(&context, &window_request(2, 2), None).is_ok()); + } + + #[test] + fn subquota_channels_are_checked_before_the_total() { + let offering = arrival_offering(); + let window = window(); + // Public subquota of 2, already fully allocated. + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let result = evaluate_window_admission(&context, &window_request(1, 1), None); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + /// COR-3 / D5(a): the channel vocabulary is closed, and a policy that + /// declares the channels it serves narrows it further. A spelling + /// outside the vocabulary, or a vocabulary member the deployment does + /// not serve, once matched no subquota and drew from the window's total + /// unconstrained. + #[test] + fn a_channel_the_policy_does_not_serve_is_refused_not_let_loose() { + let offering = arrival_offering(); + let window = window(); + // Public subquota of 2, already fully allocated. + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + // The vocabulary itself refuses a misspelled channel: "Public" is + // not a channel any subquota can name, so it may not book as one. + let misspelled = AdmissionRequest { + channel: Some("Public".to_owned()), + ..window_request(1, 1) + }; + assert_eq!( + evaluate_window_admission(&context, &misspelled, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::RequestUnprocessable) + ); + + // A policy that declares only the public channel refuses a request + // naming another vocabulary member, whatever its slice would be. + let narrowed = WindowContext { + channels: &[Channel::Public], + ..context + }; + let undeclared = AdmissionRequest { + channel: Some("urgent".to_owned()), + ..window_request(1, 1) + }; + assert_eq!( + evaluate_window_admission(&narrowed, &undeclared, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::RequestUnprocessable) + ); + + // The declared channel itself still meets its subquota: the public + // slice is full, so the same refusal the plain test expects. + assert_eq!( + evaluate_window_admission(&narrowed, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + #[test] + fn window_revisions_must_match_and_policy_change_wins() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let stale_window = AdmissionRequest { + window_revision: Some(1), + ..window_request(1, 1) + }; + assert!(matches!( + evaluate_window_admission(&context, &stale_window, None).err(), + Some(AdmissionRefusal::RevisionMismatch { observed: 2 }) + )); + + let stale_both = AdmissionRequest { + policy_revision: 0, + ..stale_window + }; + assert_eq!( + evaluate_window_admission(&context, &stale_both, None).err(), + Some(AdmissionRefusal::PolicyChanged) + ); + } + + #[test] + fn window_request_start_must_match_the_published_window() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now: utc(4, 9, 0), + }; + let request = AdmissionRequest { + start: utc(4, 10, 30), + ..window_request(1, 1) + }; + + assert_eq!( + evaluate_window_admission(&context, &request, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + #[test] + fn window_outside_openings_distinguishes_closure_from_unpublished_schedule() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let closure = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &[], + closures: &closure, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now: utc(4, 9, 0), + }; + + assert_eq!( + evaluate_window_admission(&context, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + + let uncovered = WindowContext { + closures: &[], + ..context + }; + assert_eq!( + evaluate_window_admission(&uncovered, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + /// AT-19: opening expansion across a daylight-saving fold feeds admission + /// with real elapsed intervals, and the repeated wall-clock hour yields two + /// distinct bookable instants, never an invisible duplicate. + #[test] + fn the_folded_hour_yields_two_distinct_bookable_instants() { + // America/New_York falls back on 2026-11-01: local 01:30 occurs twice, + // at 05:30Z EDT and 06:30Z EST. A pattern may not start or end inside + // the repeated hour, but it may span the fold: the hall opens + // 00:30-02:30 local, whose endpoints exist unambiguously. The two + // wall-clock hours span three real hours, [04:30Z, 07:30Z). + let pattern = WeeklyOpeningPattern { + id: "fold-day-hours", + timezone: "America/New_York", + weekdays: &[chrono::Weekday::Sun], + start_time: "00:30", + end_time: "02:30", + effective_from: "2026-11-01", + effective_until: "2026-11-01", + }; + let holidays = HolidaySetRevision { + holiday_set: "office-holidays", + revision: 1, + dates: &[], + }; + let open = expand_weekly_openings(&pattern, &[], &holidays).expect("expands"); + assert_eq!(open.len(), 1); + assert_eq!( + open[0].start, + Utc.with_ymd_and_hms(2026, 11, 1, 4, 30, 0).unwrap() + ); + assert_eq!( + open[0].end, + Utc.with_ymd_and_hms(2026, 11, 1, 7, 30, 0).unwrap() + ); + assert_eq!((open[0].end - open[0].start).num_minutes(), 180); + + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + exact.start_increment_minutes = 30; + exact.duration_minutes = 30; + let snapshot = LedgerSnapshot::default(); + let now = Utc.with_ymd_and_hms(2026, 11, 1, 4, 0, 0).unwrap(); + let context = ExactTimeContext { + offering: &offering, + exact: &exact, + members: members(), + open: &open, + closures: &[], + snapshot: &snapshot, + policy_revision: 1, + now, + }; + // Both 01:30 instants are on the grid and inside the real interval: + // the first (EDT) ends 06:00Z, the second (EST) ends 07:00Z. + let first = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 30, 0).unwrap()), + None, + ) + .expect("the first 01:30 admits"); + assert_eq!( + first.end, + Utc.with_ymd_and_hms(2026, 11, 1, 6, 0, 0).unwrap() + ); + let second = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 6, 30, 0).unwrap()), + None, + ) + .expect("the second 01:30 admits"); + assert_eq!( + second.end, + Utc.with_ymd_and_hms(2026, 11, 1, 7, 0, 0).unwrap() + ); + + // A start at the interval's end is outside the real opening: no + // published schedule serves it, whatever the wall clock says. + let outside = Utc.with_ymd_and_hms(2026, 11, 1, 7, 30, 0).unwrap(); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(outside), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + /// A closure applied during expansion removes the bookable interval, and + /// the evaluator reports the closure, not an unpublished schedule. + #[test] + fn a_closure_over_a_fold_day_interval_closes_the_slot() { + // Same fold-day opening, [04:30Z, 07:30Z). A closure before the fold + // whose endpoints sit in one offset, 00:15-00:45 local, removes the + // first half hour: the opening starts 04:45Z, and a request inside the + // closed stretch reports the closure. + let pattern = WeeklyOpeningPattern { + id: "fold-day-hours", + timezone: "America/New_York", + weekdays: &[chrono::Weekday::Sun], + start_time: "00:30", + end_time: "02:30", + effective_from: "2026-11-01", + effective_until: "2026-11-01", + }; + let holidays = HolidaySetRevision { + holiday_set: "office-holidays", + revision: 1, + dates: &[], + }; + let closure = CalendarException { + id: "systems-training", + kind: CalendarExceptionKind::Closure, + date: "2026-11-01", + start_time: "00:15", + end_time: "00:45", + reopens: None, + authority: None, + }; + let open = expand_weekly_openings(&pattern, &[closure], &holidays).expect("expands"); + assert_eq!(open.len(), 1); + assert_eq!( + open[0].start, + Utc.with_ymd_and_hms(2026, 11, 1, 4, 45, 0).unwrap() + ); + + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + let snapshot = LedgerSnapshot::default(); + let now = Utc.with_ymd_and_hms(2026, 11, 1, 4, 0, 0).unwrap(); + let closures = vec![CalendarInterval { + start: Utc.with_ymd_and_hms(2026, 11, 1, 4, 15, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 11, 1, 4, 45, 0).unwrap(), + }]; + let context = ExactTimeContext { + offering: &offering, + exact: &exact, + members: members(), + open: &open, + closures: &closures, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + // Inside the closed stretch: a closure, not an unpublished schedule. + let closed_start = Utc.with_ymd_and_hms(2026, 11, 1, 4, 30, 0).unwrap(); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(closed_start), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + // On the moved grid (increments from 04:45Z) the hall still serves. + let served = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 15, 0).unwrap()), + None, + ) + .expect("the post-closure grid serves"); + assert_eq!( + served.end, + Utc.with_ymd_and_hms(2026, 11, 1, 5, 45, 0).unwrap() + ); + } +} diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs new file mode 100644 index 0000000000..629ee9926f --- /dev/null +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Field-addressed diagnostics for scheduling policy checks. +//! +//! A diagnostic names the exact policy path that failed and a closed reason, +//! so an authoring tool can point at the offending line and a report consumer +//! can never mistake one failure family for another. + +use std::fmt; + +/// A closed reason a policy path failed its check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PolicyCheckReason { + /// The envelope apiVersion is not the scheduling policy apiVersion. + UnsupportedApiVersion, + /// The envelope kind is not the scheduling policy kind. + UnsupportedKind, + /// An identifier is empty or outside the identifier grammar. + InvalidIdentifier, + /// The same identifier appears twice in one collection. + DuplicateIdentifier, + /// A required `because` is missing, blank, or longer than 256 bytes. + InvalidBecause, + /// A collection that must carry at least one entry is empty. + EmptyCollection, + /// A referenced service does not exist. + UnknownService, + /// A referenced offering does not exist. + UnknownOffering, + /// A referenced window does not exist. + UnknownWindow, + /// A reference resolves to a record owned by another declaration. + MismatchedReference, + /// A referenced holiday set does not exist. + UnknownHolidaySet, + /// A subquota names a channel outside the package's declared channel + /// set. + UnknownChannel, + /// A field required by the offering's scheduling mode is absent. + MissingModeField, + /// A field that belongs to the other scheduling mode is present. + WrongModeField, + /// A value's text is outside the grammar its field requires: a clock that + /// is not `HH:MM`, a date that is not `YYYY-MM-DD`. + MalformedValue, + /// A numeric bound is zero, negative, or inverted. + InvalidBound, + /// An opening's effective range spans more days than the weekly calendar + /// expansion can serve. + PatternSpanTooLarge, + /// The banded table is empty, non-increasing, or carries non-positive + /// units. + InvalidBands, + /// Channel subquotas overlap or sum above the window's published units. + SubquotaOverdrawn, + /// Two declarations draw on the same supply without a partition the + /// ledger can enforce. + SharedSupplyUnpartitioned, + /// A local hook omits or changes the shared handler ABI. + UnsupportedHookAbi, + /// A hook phase and handler kind cannot run together. + UnsupportedHookPhase, + /// A hook trigger is outside Scheduling's closed lifecycle vocabulary. + UnsupportedHookTrigger, + /// Scheduling's observer slice does not evaluate hook conditions. + UnsupportedHookCondition, + /// Scheduling observer hooks cannot carry proposal authority. + UnsupportedHookPrincipal, + /// A projected field is unavailable for the selected Scheduling trigger. + UnsupportedHookProjection, + /// A URL hook names no valid deployment-owned logical destination. + InvalidHookDestination, + /// A shared hook declaration violates a rule unknown to this product. + InvalidHookDeclaration, + /// This version reads no leftover policy, so a declared one never + /// applies. + LeftoverUnsupported, +} + +impl PolicyCheckReason { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::UnsupportedApiVersion => "unsupported-api-version", + Self::UnsupportedKind => "unsupported-kind", + Self::InvalidIdentifier => "invalid-identifier", + Self::DuplicateIdentifier => "duplicate-identifier", + Self::InvalidBecause => "invalid-because", + Self::EmptyCollection => "empty-collection", + Self::UnknownService => "unknown-service", + Self::UnknownOffering => "unknown-offering", + Self::UnknownWindow => "unknown-window", + Self::MismatchedReference => "mismatched-reference", + Self::UnknownHolidaySet => "unknown-holiday-set", + Self::UnknownChannel => "unknown-channel", + Self::MissingModeField => "missing-mode-field", + Self::WrongModeField => "wrong-mode-field", + Self::MalformedValue => "malformed-value", + Self::InvalidBound => "invalid-bound", + Self::PatternSpanTooLarge => "pattern-span-too-large", + Self::InvalidBands => "invalid-bands", + Self::SubquotaOverdrawn => "subquota-overdrawn", + Self::SharedSupplyUnpartitioned => "shared-supply-unpartitioned", + Self::UnsupportedHookAbi => "unsupported-hook-abi", + Self::UnsupportedHookPhase => "unsupported-hook-phase", + Self::UnsupportedHookTrigger => "unsupported-hook-trigger", + Self::UnsupportedHookCondition => "unsupported-hook-condition", + Self::UnsupportedHookPrincipal => "unsupported-hook-principal", + Self::UnsupportedHookProjection => "unsupported-hook-projection", + Self::InvalidHookDestination => "invalid-hook-destination", + Self::InvalidHookDeclaration => "invalid-hook-declaration", + Self::LeftoverUnsupported => "leftover-unsupported", + } + } + + /// Whether the reason describes a value whose text is outside the grammar + /// its field requires. + /// + /// Such a value is not an unfinished project: no addition makes it valid, + /// only a correction. Adopter tooling refuses a document carrying one + /// rather than reporting it as incomplete authoring. + #[must_use] + pub const fn is_malformed_value(self) -> bool { + matches!(self, Self::MalformedValue) + } +} + +impl fmt::Display for PolicyCheckReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One policy check finding: the path that failed, and why. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SchedulingDiagnostic { + pub path: String, + pub reason: PolicyCheckReason, +} + +impl SchedulingDiagnostic { + pub fn new(path: impl Into, reason: PolicyCheckReason) -> Self { + Self { + path: path.into(), + reason, + } + } +} + +impl fmt::Display for SchedulingDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.path, self.reason) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reasons_render_as_closed_slugs() { + assert_eq!( + PolicyCheckReason::SharedSupplyUnpartitioned.as_str(), + "shared-supply-unpartitioned" + ); + assert_eq!( + SchedulingDiagnostic::new("windows[0].offering", PolicyCheckReason::UnknownOffering) + .to_string(), + "windows[0].offering: unknown-offering" + ); + } + + #[test] + fn only_a_malformed_value_says_the_text_is_wrong() { + assert!(PolicyCheckReason::MalformedValue.is_malformed_value()); + assert_eq!( + PolicyCheckReason::MalformedValue.as_str(), + "malformed-value" + ); + for reason in [ + PolicyCheckReason::InvalidBound, + PolicyCheckReason::InvalidIdentifier, + PolicyCheckReason::InvalidBecause, + PolicyCheckReason::EmptyCollection, + ] { + assert!(!reason.is_malformed_value(), "{reason}"); + } + } +} diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs new file mode 100644 index 0000000000..2a1e43b203 --- /dev/null +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -0,0 +1,1147 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Offline replay fixtures and the replay itself. +//! +//! A fixture is synthetic facts, a starting ledger, and an ordered list of +//! cases with their expected outcomes. Replay runs the same pure evaluators +//! the runtime runs, entirely offline: no network, no database, no clock. An +//! admitted case joins the ledger the later cases run against, and an +//! admitted reschedule replaces the allocation it names exactly once, so a +//! fixture can tell the story of several callers in sequence. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use registry_platform_calendar::CalendarEvaluationError; + +use crate::admission::{ + evaluate_exact_time_admission, evaluate_window_admission, ExactTimeContext, WindowContext, +}; +use crate::model::{ + AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, PartyCounts, SchedulingFacts, +}; +use crate::naming::{SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND}; +use crate::policy::{SchedulingMode, SchedulingPolicy}; +use crate::problem::ProblemCode; +use crate::resolve::{ + covers_span, location_closure_intervals, location_open_intervals, ResolveError, +}; + +/// One case's expected outcome. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "outcome", rename_all = "camelCase", deny_unknown_fields)] +pub enum FixtureExpectation { + Admitted { + units: u32, + #[serde(default)] + resource: Option, + }, + Refused { + /// A public problem code, exactly as a caller would receive it. + code: String, + }, +} + +/// One replayed request as an author writes it. +/// +/// This is the authoring form of an admission request, not the wire form. It +/// carries everything the wire request carries plus `rescheduleOf`, the claim +/// this case replaces: a fixture legitimately says "this case reschedules that +/// one", where an HTTP caller may not, because on the wire the exclusion is +/// the runtime's to supply from inside its own transaction. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixtureAdmissionRequest { + pub offering: String, + pub start: DateTime, + pub party: PartyCounts, + pub channel: Option, + pub duplicate_key: Option, + pub policy_revision: u64, + pub window_revision: Option, + pub capabilities: Vec, + pub prerequisites: Vec, + /// The claim this case replaces. Its allocation is left out of this + /// case's conflict checks and removed when the case is admitted, so a + /// reschedule never competes with what it replaces. + pub reschedule_of: Option, +} + +impl FixtureAdmissionRequest { + /// The wire request this authored case makes, without the exclusion: + /// that travels beside it, as the evaluator's own argument. + /// + /// Both sides are written out in full on purpose. Adding a field to + /// either shape stops compiling here until the author decides what the + /// other shape does with it. + #[must_use] + pub fn admission(&self) -> AdmissionRequest { + let Self { + offering, + start, + party, + channel, + duplicate_key, + policy_revision, + window_revision, + capabilities, + prerequisites, + reschedule_of: _, + } = self; + AdmissionRequest { + offering: offering.clone(), + start: *start, + party: *party, + channel: channel.clone(), + duplicate_key: duplicate_key.clone(), + policy_revision: *policy_revision, + window_revision: *window_revision, + capabilities: capabilities.clone(), + prerequisites: prerequisites.clone(), + } + } +} + +/// One replayed request and its expectation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixtureCase { + pub name: String, + pub request: FixtureAdmissionRequest, + pub expect: FixtureExpectation, +} + +/// An offline replay fixture: synthetic facts, a starting ledger, and cases. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingFixture { + pub api_version: String, + pub kind: String, + pub name: String, + /// The single observed instant every case runs at. + pub now: DateTime, + pub facts: SchedulingFacts, + #[serde(default)] + pub initial: Vec, + pub cases: Vec, +} + +/// Parse a fixture from its authored YAML, with the failing path preserved. +pub fn parse_fixture_yaml( + text: &str, +) -> Result> { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer) +} + +/// Whether one replayed case matched its expectation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaseStatus { + Pass, + Fail, +} + +impl CaseStatus { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Pass => "pass", + Self::Fail => "fail", + } + } +} + +/// The outcome of one replayed case. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CaseOutcome { + pub name: String, + pub status: CaseStatus, + pub detail: Option, +} + +/// Replay stopped before a case could be evaluated: the fixture and policy do +/// not fit together, or an expectation names a code outside the closed +/// vocabulary. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ReplayError { + #[error("the fixture envelope is not a scheduling fixture")] + UnsupportedEnvelope, + #[error("case {case} requests unknown offering {offering}")] + UnknownOffering { case: String, offering: String }, + #[error("case {case} needs pool {pool}, which the facts do not declare")] + UnknownPool { case: String, pool: String }, + #[error("case {case} needs pool {pool}, which has no members")] + EmptyPool { case: String, pool: String }, + #[error("case {case} needs location {location}, which the facts do not declare")] + UnknownLocation { case: String, location: String }, + #[error("case {case} needs window {window}, which the facts do not declare")] + UnknownWindow { case: String, window: String }, + #[error("case name {case} is used more than once; case names identify claims")] + DuplicateCaseName { case: String }, + #[error("case {case} reschedules claim {claim}, which no claim in the ledger carries")] + UnknownRescheduleTarget { case: String, claim: String }, + #[error("case {case} expects code {code}, which is outside the closed vocabulary")] + UnknownExpectationCode { case: String, code: String }, + #[error( + "case {case} expects code {code}, which only the authorized explain path discloses; \ + expect the public code instead" + )] + DetailedExpectationCode { case: String, code: String }, + #[error( + "case {case} targets offering {offering}, whose mode block is absent; the policy does \ + not pass its check" + )] + ModeBlockMissing { case: String, offering: String }, + #[error( + "opening {opening} names holiday set {holiday_set}, which the policy does not declare" + )] + HolidaySetMissing { + opening: String, + holiday_set: String, + }, + #[error("window {window} does not sit inside the location's published openings")] + WindowOutsideOpenings { window: String }, + #[error("the calendar refused the configuration: {0}")] + Calendar(#[from] CalendarEvaluationError), +} + +impl SchedulingFixture { + /// Check the fixture envelope and every expectation code before any case + /// runs, so a broken fixture fails fast instead of half-way through. + pub fn check(&self, policy: &SchedulingPolicy) -> Result<(), ReplayError> { + if self.api_version != SCHEDULING_FIXTURE_API_VERSION + || self.kind != SCHEDULING_FIXTURE_KIND + { + return Err(ReplayError::UnsupportedEnvelope); + } + // A replayed claim is identified by its case name, and a reschedule + // names the claim it replaces. Two cases under one name would answer + // to each other's id: the second silently erases the first's claim, + // and one reschedule frees two allocations. + let mut seen: Vec<&str> = Vec::with_capacity(self.cases.len()); + for case in &self.cases { + if seen.contains(&case.name.as_str()) { + return Err(ReplayError::DuplicateCaseName { + case: case.name.clone(), + }); + } + seen.push(&case.name); + } + for case in &self.cases { + if let FixtureExpectation::Refused { code } = &case.expect { + match ProblemCode::from_code(code) { + None => { + return Err(ReplayError::UnknownExpectationCode { + case: case.name.clone(), + code: code.clone(), + }); + } + Some(problem) if problem.is_detailed_only() => { + return Err(ReplayError::DetailedExpectationCode { + case: case.name.clone(), + code: code.clone(), + }); + } + Some(_) => {} + } + } + let offering = policy.offering(&case.request.offering).ok_or_else(|| { + ReplayError::UnknownOffering { + case: case.name.clone(), + offering: case.request.offering.clone(), + } + })?; + let location = &offering.location; + if self.facts.location(location).is_none() { + return Err(ReplayError::UnknownLocation { + case: case.name.clone(), + location: location.clone(), + }); + } + match offering.mode { + SchedulingMode::ExactTime => { + // The mode block is a policy check finding, not something + // the fixture harness may assume away: a policy that does + // not pass its check stops here instead of panicking. + let Some(exact) = offering.exact_time.as_ref() else { + return Err(ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }); + }; + let pool_id = exact.pool.clone(); + let pool = + self.facts + .pool(&pool_id) + .ok_or_else(|| ReplayError::UnknownPool { + case: case.name.clone(), + pool: pool_id.clone(), + })?; + if pool.members.is_empty() { + return Err(ReplayError::EmptyPool { + case: case.name.clone(), + pool: pool_id, + }); + } + } + SchedulingMode::ArrivalWindow => { + let Some(arrival) = offering.arrival.as_ref() else { + return Err(ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }); + }; + let window_id = arrival.window.clone(); + let Some(window) = self.facts.window(&window_id) else { + return Err(ReplayError::UnknownWindow { + case: case.name.clone(), + window: window_id, + }); + }; + // The window must sit inside the location's published + // openings; a window floating outside every opening is a + // fixture telling a story the schedule never publishes. + let timezone = self + .facts + .location(&offering.location) + .expect("the location was resolved above") + .timezone + .clone(); + // Structural validation uses the authored schedule before + // dated exceptions. A closure is a valid fixture fact + // whose case must reach admission and refuse as + // `location.closed`, not an invalid fixture definition. + let open = location_open_intervals(policy, &offering.location, &timezone, &[])?; + if !covers_span(&open, window.start, window.end) { + return Err(ReplayError::WindowOutsideOpenings { window: window_id }); + } + } + } + } + Ok(()) + } + + /// Replay the cases in order against the policy, entirely offline. + /// + /// Every case runs at the fixture's fixed `now` through the same pure + /// evaluators the runtime calls. An admitted case is recorded into the + /// ledger as a confirmed booking; an admitted reschedule removes the + /// allocation it replaces, exactly once. A refused case consumes nothing. + pub fn replay(&self, policy: &SchedulingPolicy) -> Result, ReplayError> { + self.check(policy)?; + let mut snapshot = LedgerSnapshot { + claims: self.initial.clone(), + }; + let mut outcomes = Vec::with_capacity(self.cases.len()); + for case in &self.cases { + let outcome = match replay_case(policy, self, case, &mut snapshot) { + Ok(admitted) => Ok(admitted), + Err(CaseStoppage::Replay(error)) => return Err(error), + Err(CaseStoppage::Refusal(refusal)) => Err(refusal), + }; + let status = match (&case.expect, &outcome) { + (FixtureExpectation::Admitted { units, resource }, Ok(recorded)) => { + let units_match = recorded.units == *units; + let resource_match = match resource { + None => true, + Some(expected) => recorded.resource.as_deref() == Some(expected.as_str()), + }; + if units_match && resource_match { + CaseStatus::Pass + } else { + CaseStatus::Fail + } + } + (FixtureExpectation::Refused { code }, Err(refusal)) => { + if refusal.public_code().code() == code.as_str() { + CaseStatus::Pass + } else { + CaseStatus::Fail + } + } + _ => CaseStatus::Fail, + }; + let detail = match &outcome { + Ok(admitted) => Some(format!( + "admitted onto {} for {} unit(s)", + admitted.resource.as_deref().unwrap_or("the window"), + admitted.units + )), + Err(refusal) => Some(format!( + "refused {} (detailed: {})", + refusal.public_code().code(), + refusal.detailed_code().code() + )), + }; + outcomes.push(CaseOutcome { + name: case.name.clone(), + status, + detail, + }); + } + Ok(outcomes) + } +} + +/// What stopped one replayed case: a replay-level configuration problem, or +/// the admission refusal the evaluators returned. +#[derive(Debug)] +enum CaseStoppage { + Replay(ReplayError), + Refusal(crate::admission::AdmissionRefusal), +} + +impl From for CaseStoppage { + fn from(refusal: crate::admission::AdmissionRefusal) -> Self { + Self::Refusal(refusal) + } +} + +fn replay_case( + policy: &SchedulingPolicy, + fixture: &SchedulingFixture, + case: &FixtureCase, + snapshot: &mut LedgerSnapshot, +) -> Result { + let request = &case.request.admission(); + // A reschedule of a claim the ledger does not carry excludes nothing and + // replaces nothing, so it would quietly replay as an ordinary booking and + // pass against an expectation it never tested. It is an authoring error, + // not a caller's refusal. + let exclude = case.request.reschedule_of.as_deref(); + if let Some(target) = exclude { + if !snapshot.claims.iter().any(|claim| claim.id == target) { + return Err(CaseStoppage::Replay(ReplayError::UnknownRescheduleTarget { + case: case.name.clone(), + claim: target.to_owned(), + })); + } + } + + let offering = policy + .offering(&request.offering) + .ok_or(CaseStoppage::Refusal( + crate::admission::AdmissionRefusal::ScheduleUnpublished, + ))?; + let location = fixture + .facts + .location(&offering.location) + .ok_or(CaseStoppage::Replay(ReplayError::UnknownLocation { + case: case.name.clone(), + location: offering.location.clone(), + }))?; + + let revision = policy.scheduling.version; + match offering.mode { + SchedulingMode::ExactTime => { + let exact = offering.exact_time.as_ref().ok_or(CaseStoppage::Replay( + ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }, + ))?; + let pool = fixture.facts.pool(&exact.pool).ok_or(CaseStoppage::Replay( + ReplayError::UnknownPool { + case: case.name.clone(), + pool: exact.pool.clone(), + }, + ))?; + let exceptions: Vec<_> = fixture + .facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + ) + .map_err(|error| CaseStoppage::Replay(error.into()))?; + let closures = + location_closure_intervals(&fixture.facts, &offering.location, &location.timezone) + .map_err(|error| CaseStoppage::Replay(error.into()))?; + let context = ExactTimeContext { + offering, + exact, + members: &pool.members, + open: &open, + closures: &closures, + snapshot, + policy_revision: revision, + now: fixture.now, + }; + let admitted = evaluate_exact_time_admission(&context, request, exclude)?; + let supply = admitted + .resource + .clone() + .expect("an exact-time admission names its member"); + record_admission(&case.name, &admitted, &supply, &case.request, snapshot); + Ok(admitted) + } + SchedulingMode::ArrivalWindow => { + let arrival = offering.arrival.as_ref().ok_or(CaseStoppage::Replay( + ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }, + ))?; + let window = fixture + .facts + .window(&arrival.window) + .ok_or(CaseStoppage::Replay(ReplayError::UnknownWindow { + case: case.name.clone(), + window: arrival.window.clone(), + }))?; + let exceptions: Vec<_> = fixture + .facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + ) + .map_err(|error| CaseStoppage::Replay(error.into()))?; + let closures = + location_closure_intervals(&fixture.facts, &offering.location, &location.timezone) + .map_err(|error| CaseStoppage::Replay(error.into()))?; + let context = WindowContext { + offering, + window, + open: &open, + closures: &closures, + lead_time_minutes: arrival.lead_time_minutes, + horizon_days: arrival.horizon_days, + snapshot, + policy_revision: revision, + channels: &policy.channels, + now: fixture.now, + }; + let admitted = evaluate_window_admission(&context, request, exclude)?; + // A window claim occupies the window itself, so later cases sum + // against the window's published units. + record_admission(&case.name, &admitted, &window.id, &case.request, snapshot); + Ok(admitted) + } + } +} + +/// Record an admitted case into the replay ledger, replacing the allocation a +/// reschedule names exactly once. The recorded claim id is `case:` plus the +/// case name, so a later case can reschedule what an earlier case booked. +fn record_admission( + case: &str, + admitted: &crate::admission::Admission, + supply_id: &str, + request: &FixtureAdmissionRequest, + snapshot: &mut LedgerSnapshot, +) { + if let Some(replaced) = &request.reschedule_of { + snapshot.claims.retain(|claim| &claim.id != replaced); + } + snapshot.claims.push(LedgerClaim { + id: format!("case:{case}"), + offering: request.offering.clone(), + supply_id: supply_id.to_owned(), + kind: LedgerKind::Booking, + channel: request.channel.clone(), + start: admitted.occupied_start, + end: admitted.occupied_end, + units: admitted.units, + duplicate_key: request.duplicate_key.clone(), + // A booking recorded by replay never expires; holds are exercised + // through the evaluators directly. + expires_at: None, + }); +} + +impl From for ReplayError { + fn from(error: ResolveError) -> Self { + match error { + ResolveError::HolidaySetMissing { + opening, + holiday_set, + } => Self::HolidaySetMissing { + opening, + holiday_set, + }, + ResolveError::Calendar(error) => Self::Calendar(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{CalendarExceptionRecord, ExceptionRecordKind, LocationRecord, PartyCounts}; + use registry_platform_calendar::CalendarInterval; + + fn exact_time_fixture_yaml() -> &'static str { + r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: last-station +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: north-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-the-last-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T03:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active +"# + } + + fn policy_for_fixture() -> SchedulingPolicy { + crate::policy::parse_policy_yaml( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 30 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: counter-hours + location: north-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-05" + effectiveUntil: "2026-10-05" + because: Counter opening hours reviewed by the office manager. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#, + ) + .expect("the policy parses") + } + + #[test] + fn fixtures_parse_and_refuse_unknown_fields() { + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!(fixture.name, "last-station"); + assert!( + parse_fixture_yaml(&format!("{}stray: true\n", exact_time_fixture_yaml())).is_err() + ); + } + + #[test] + fn replay_threads_admitted_cases_into_the_ledger() { + let policy = policy_for_fixture(); + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].status, CaseStatus::Pass); + assert_eq!(outcomes[1].status, CaseStatus::Pass); + // The first case's duplicate key is live for the second. + assert!(outcomes[1] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("booking.duplicate-active"))); + } + + #[test] + fn an_admitted_reschedule_replaces_its_allocation_once() { + let policy = policy_for_fixture(); + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: move-one-booking +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: north-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true +initial: + - id: booking-9 + offering: registry-update-30 + supplyId: station-1 + kind: booking + start: 2026-10-05T02:00:00Z + end: 2026-10-05T02:30:00Z + units: 1 + duplicateKey: subject:one +cases: + - name: reschedule-excludes-and-replaces-its-own-allocation + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + rescheduleOf: booking-9 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 +"#; + let fixture = parse_fixture_yaml(yaml).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert_eq!(outcomes.len(), 2); + assert!(outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass)); + } + + /// COR-6. A replayed claim is identified by its case name, so two cases + /// under one name answer to each other's id: the second erases the + /// first's claim and one reschedule frees two allocations. + #[test] + fn duplicate_case_names_are_refused_before_any_case_runs() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + let repeated = fixture.cases[0].name.clone(); + fixture.cases[1].name = repeated.clone(); + assert_eq!( + fixture.check(&policy), + Err(ReplayError::DuplicateCaseName { + case: repeated.clone() + }) + ); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::DuplicateCaseName { case: repeated }) + ); + } + + /// COR-7. A reschedule naming a claim the ledger does not carry excludes + /// nothing and replaces nothing, so it would replay as an ordinary + /// booking and pass against an expectation it never tested. + #[test] + fn a_reschedule_of_a_claim_the_ledger_does_not_carry_is_reported() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + fixture.cases[0].request.reschedule_of = Some("booking-404".to_owned()); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::UnknownRescheduleTarget { + case: fixture.cases[0].name.clone(), + claim: "booking-404".to_owned(), + }) + ); + } + + #[test] + fn broken_fixtures_stop_before_running() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "authorization.refused".to_owned(), + }; + assert_eq!( + fixture.check(&policy), + Err(ReplayError::UnknownExpectationCode { + case: "same-key-retry-is-refused".to_owned(), + code: "authorization.refused".to_owned() + }) + ); + + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "capacity.exhausted".to_owned(), + }; + fixture.cases[0].request.offering = "no-such-offering".to_owned(); + assert!(matches!( + fixture.check(&policy), + Err(ReplayError::UnknownOffering { .. }) + )); + } + + /// A detailed-only expectation code can never match: replay compares the + /// public projection, so the check refuses it up front with the code named. + #[test] + fn a_detailed_only_expectation_code_is_refused_before_any_case_runs() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "resource.unavailable".to_owned(), + }; + assert_eq!( + fixture.check(&policy), + Err(ReplayError::DetailedExpectationCode { + case: "same-key-retry-is-refused".to_owned(), + code: "resource.unavailable".to_owned(), + }) + ); + } + + /// A policy whose mode block is missing fails the fixture with a typed + /// error naming the case and the offering, never a panic: replay must not + /// assume the policy passed a check it never ran. + #[test] + fn a_policy_missing_its_mode_block_fails_the_fixture_not_the_process() { + let mut policy = policy_for_fixture(); + policy.offerings[0].exact_time = None; + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.check(&policy), + Err(ReplayError::ModeBlockMissing { + case: "first-caller-takes-the-last-station".to_owned(), + offering: "registry-update-30".to_owned(), + }) + ); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::ModeBlockMissing { + case: "first-caller-takes-the-last-station".to_owned(), + offering: "registry-update-30".to_owned(), + }) + ); + } + + /// An opening referencing a holiday set the policy does not declare stops + /// replay at the opening that carries it, through both the arrival-window + /// check path and the exact-time expansion path. + #[test] + fn an_opening_referencing_an_undeclared_holiday_set_fails_the_fixture() { + let mut policy = policy_for_fixture(); + policy.openings[0].holiday_set = "no-such-holidays".to_owned(); + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::HolidaySetMissing { + opening: "counter-hours".to_owned(), + holiday_set: "no-such-holidays".to_owned(), + }) + ); + } + + #[test] + fn closures_and_openings_come_from_the_facts_for_the_location() { + let policy = policy_for_fixture(); + let exceptions: Vec> = Vec::new(); + let open = location_open_intervals(&policy, "north-counter", "Asia/Bangkok", &exceptions) + .expect("expands"); + // 2026-10-05 is a Monday: one 09:00-12:30 Bangkok interval, and + // 09:00 Bangkok is 02:00Z the same day. + assert_eq!(open.len(), 1); + assert_eq!(open[0].start.to_rfc3339(), "2026-10-05T02:00:00+00:00"); + assert_eq!(open[0].end.to_rfc3339(), "2026-10-05T05:30:00+00:00"); + + let facts = SchedulingFacts { + locations: vec![LocationRecord { + id: "north-counter".to_owned(), + timezone: "Asia/Bangkok".to_owned(), + }], + pools: vec![], + windows: vec![], + exceptions: vec![crate::model::CalendarExceptionRecord { + id: "systems-training".to_owned(), + location: "north-counter".to_owned(), + kind: crate::model::ExceptionRecordKind::Closure, + date: "2026-10-05".to_owned(), + start_time: "09:00".to_owned(), + end_time: "10:00".to_owned(), + reopens: None, + authority: None, + }], + }; + let closures = + location_closure_intervals(&facts, "north-counter", "Asia/Bangkok").expect("converts"); + assert_eq!(closures.len(), 1); + assert_eq!(closures[0].start.to_rfc3339(), "2026-10-05T02:00:00+00:00"); + + // An exception at another location never closes this one. + let other = + location_closure_intervals(&facts, "south-counter", "Asia/Bangkok").expect("converts"); + assert!(other.is_empty()); + } + + /// A window fixture threads admitted cases into the window's own supply, + /// so the total and the channel subquota both deplete as callers arrive. + #[test] + fn window_cases_deplete_the_published_window() { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + pools: [] + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block. +initial: [] +cases: + - name: first-household + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: second-household-overdraws-the-public-subquota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 +"#; + let policy = crate::policy::parse_policy_yaml( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#, + ) + .expect("the policy parses"); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + + let fixture = parse_fixture_yaml(yaml).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert!( + outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass), + "{outcomes:?}" + ); + + let mut closed = parse_fixture_yaml(yaml).expect("parses a closed-window fixture"); + closed.facts.exceptions.push(CalendarExceptionRecord { + id: "hall-closure".to_owned(), + location: "civic-hall".to_owned(), + kind: ExceptionRecordKind::Closure, + date: "2026-10-10".to_owned(), + start_time: "08:00".to_owned(), + end_time: "12:00".to_owned(), + reopens: None, + authority: None, + }); + closed.cases.truncate(1); + closed.cases[0].expect = FixtureExpectation::Refused { + code: "location.closed".to_owned(), + }; + let outcomes = closed + .replay(&policy) + .expect("the closure reaches admission"); + assert_eq!(outcomes[0].status, CaseStatus::Pass, "{outcomes:?}"); + } + + #[test] + fn party_counts_parse_from_the_fixture_surface() { + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.cases[0].request.party, + PartyCounts { + recipients: 1, + attendees: 1, + } + ); + } + + #[test] + fn span_coverage_requires_an_unbroken_union() { + use chrono::TimeZone as _; + let at = + |hour: u32, minute: u32| Utc.with_ymd_and_hms(2026, 10, 10, hour, minute, 0).unwrap(); + let span = |sh: u32, sm: u32, eh: u32, em: u32| CalendarInterval { + start: at(sh, sm), + end: at(eh, em), + }; + + // Two touching halves cover the whole; a gap in the middle does not. + assert!(covers_span( + &[span(1, 0, 2, 0), span(2, 0, 3, 0)], + at(1, 0), + at(3, 0) + )); + assert!(!covers_span( + &[span(1, 0, 2, 0), span(2, 30, 4, 0)], + at(1, 0), + at(3, 0) + )); + assert!(!covers_span(&[span(2, 0, 4, 0)], at(1, 0), at(3, 0))); + assert!(!covers_span(&[], at(1, 0), at(3, 0))); + } +} diff --git a/crates/registry-scheduling-core/src/lib.rs b/crates/registry-scheduling-core/src/lib.rs new file mode 100644 index 0000000000..133eb5e715 --- /dev/null +++ b/crates/registry-scheduling-core/src/lib.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Source-neutral core of Registry Scheduling. +//! +//! This crate owns the model an adopter authors and the decisions that model +//! implies, and nothing else: the policy types with their validators, the +//! supply ledger and its pure query methods, the admission evaluators, the +//! offline replay fixtures, the closed problem vocabulary, the HTTP wire +//! documents, and the fixed artifact names. It deliberately contains no +//! source protocol types, no SQL, +//! no I/O, and no clock: every evaluator takes the observed instant as an +//! explicit parameter, so the runtime inside its capacity transaction, the +//! explain path, and an offline fixture replay all run the same pure functions +//! and cannot disagree about what admits. +//! +//! The runtime crate `registry-scheduling` builds its PostgreSQL store, HTTP +//! surface, and workers on top of this model. Adopter tooling +//! (`registry-schedulingctl`) checks and replays authored projects against it. +//! The dependency runs one way: this crate depends only on shared +//! `registry-platform-*` primitives and never on another product's runtime or +//! protocol types. + +mod admission; +mod diagnostics; +mod fixture; +mod model; +mod naming; +mod policy; +mod problem; +mod resolve; +mod units; +mod wire; + +pub use admission::*; +pub use diagnostics::*; +pub use fixture::*; +pub use model::*; +pub use naming::*; +pub use policy::*; +pub use problem::*; +pub use resolve::*; +pub use units::*; +pub use wire::*; diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs new file mode 100644 index 0000000000..4b3b07a497 --- /dev/null +++ b/crates/registry-scheduling-core/src/model.rs @@ -0,0 +1,728 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The source-neutral scheduling model: lifecycle states, the authoritative +//! supply ledger, the environment records admission runs against, and the +//! admission request with its idempotent request hash. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use registry_platform_calendar::{CalendarException, CalendarExceptionKind}; + +/// Lifecycle of a temporary hold. An expired hold stops consuming capacity the +/// moment it expires, whether or not a cleanup worker has run since. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum HoldState { + Active, + Consumed, + Released, + Expired, +} + +/// Lifecycle of a confirmed booking. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BookingState { + Confirmed, + Cancelled, +} + +/// Recorded attendance of a booking. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AttendanceState { + NotRecorded, + Arrived, + InService, + Completed, + NoShow, + LeftWithoutService, +} + +/// The party summary capacity is derived from. Recipients are the people the +/// service is delivered to; attendees are everyone present, recipients +/// included. A parent accompanying two children is three attendees and two +/// recipients, and consumes recipient units, never attendee counts. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PartyCounts { + pub recipients: u32, + pub attendees: u32, +} + +/// What a ledger entry allocates supply for. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LedgerKind { + /// A confirmed booking. + Booking, + /// A temporary hold. + Hold, +} + +/// One entry in the authoritative supply ledger. +/// +/// `start` and `end` are the occupied interval including any setup and cleanup +/// buffers, so two claims that merely touch at their displayed times still +/// conflict when their buffers overlap. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LedgerClaim { + pub id: String, + /// The offering whose duplicate-active policy owns this claim. + pub offering: String, + /// The pool member resource (exact time) or the window (arrival) this + /// claim occupies. + pub supply_id: String, + pub kind: LedgerKind, + /// The authenticated channel the claim was allocated in, when the supply + /// carries channel subquotas. + pub channel: Option, + pub start: DateTime, + pub end: DateTime, + /// Recipient units consumed. Exact-time claims on one member consume one. + pub units: u32, + /// The duplicate-active key this claim holds, when the offering defines + /// one. + pub duplicate_key: Option, + /// Hold expiry. An expired hold stops consuming capacity at this instant + /// even when the cleanup worker is delayed. + pub expires_at: Option>, +} + +/// The authoritative supply ledger at one observed instant, holding exactly +/// the claims that consume capacity now. +/// +/// The store is expected to have filtered released, cancelled, and consumed +/// claims before building a snapshot; the evaluator still re-checks hold +/// expiry against `now` so a delayed cleanup worker can never keep an expired +/// hold alive. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LedgerSnapshot { + pub claims: Vec, +} + +impl LedgerSnapshot { + /// The claims that consume capacity at `now`. + pub fn consuming<'a>( + &'a self, + now: DateTime, + ) -> impl Iterator + 'a { + self.claims + .iter() + .filter(move |claim| claim.consumes_at(now)) + } + + /// Whether any consuming claim occupies `supply_id` in the half-open + /// interval `[start, end)`, ignoring `exclude`. + pub fn supply_occupied( + &self, + supply_id: &str, + start: DateTime, + end: DateTime, + exclude: Option<&str>, + now: DateTime, + ) -> bool { + self.consuming(now).any(|claim| { + claim.supply_id == supply_id + && Some(claim.id.as_str()) != exclude + && claim.start < end + && start < claim.end + }) + } + + /// Recipient units consuming a window's supply at `now`, all channels or + /// one channel only, ignoring `exclude`. + pub fn window_units_allocated( + &self, + window_id: &str, + channel: Option<&str>, + exclude: Option<&str>, + now: DateTime, + ) -> u32 { + self.consuming(now) + .filter(|claim| { + claim.supply_id == window_id + && Some(claim.id.as_str()) != exclude + && channel.is_none_or(|wanted| claim.channel == Some(wanted.to_owned())) + }) + .map(|claim| claim.units) + // A snapshot past u32 units is already broken; saturating keeps + // every later admission refused instead of wrapping toward zero + // and re-admitting capacity no window holds. + .fold(0, u32::saturating_add) + } + + /// Whether a consuming claim already holds `duplicate_key` at `now`, + /// ignoring `exclude`. + pub fn duplicate_active( + &self, + offering: &str, + duplicate_key: &str, + exclude: Option<&str>, + now: DateTime, + ) -> bool { + self.consuming(now).any(|claim| { + claim.offering == offering + && claim.duplicate_key.as_deref() == Some(duplicate_key) + && Some(claim.id.as_str()) != exclude + }) + } +} + +impl LedgerClaim { + /// A confirmed booking always consumes; a hold consumes only until it + /// expires. + pub fn consumes_at(&self, now: DateTime) -> bool { + match self.kind { + LedgerKind::Booking => true, + LedgerKind::Hold => self.expires_at.is_some_and(|expiry| expiry > now), + } + } +} + +/// A location record: the layer of configuration an operator maintains at +/// runtime, not inside the authored policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocationRecord { + pub id: String, + /// A named IANA timezone such as `Asia/Bangkok`. + pub timezone: String, +} + +/// One interchangeable member of a resource pool. +/// +/// `available` carries no reason. Why a member is unavailable is private staff +/// information; the public answer to a caller is that capacity is exhausted. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PoolMember { + pub resource_id: String, + pub capabilities: Vec, + pub available: bool, +} + +impl PoolMember { + /// Whether this member carries every capability an offering requires. + /// Availability counting and admission share this one predicate, so a + /// free count never advertises a member admission would refuse. + #[must_use] + pub fn serves(&self, requires_capabilities: &[String]) -> bool { + requires_capabilities + .iter() + .all(|wanted| self.capabilities.iter().any(|held| held == wanted)) + } +} + +/// A pool of interchangeable resources backing exact-time offerings. A pool is +/// its concrete members, never an independent counter. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourcePool { + pub id: String, + pub members: Vec, +} + +/// An owned dated exception over local wall-clock time, the record form of +/// `registry_platform_calendar::CalendarException`. An exception is scoped to +/// one location: a closure at one counter never closes another. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CalendarExceptionRecord { + pub id: String, + /// The location this exception applies to. + pub location: String, + pub kind: ExceptionRecordKind, + pub date: String, + pub start_time: String, + pub end_time: String, + pub reopens: Option, + pub authority: Option, +} + +/// The layer an exception occupies, mirrored for records. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExceptionRecordKind { + Closure, + Opening, +} + +impl CalendarExceptionRecord { + /// Borrow this record for calendar expansion. + pub fn borrowed(&self) -> CalendarException<'_> { + CalendarException { + id: &self.id, + kind: match self.kind { + ExceptionRecordKind::Closure => CalendarExceptionKind::Closure, + ExceptionRecordKind::Opening => CalendarExceptionKind::Opening, + }, + date: &self.date, + start_time: &self.start_time, + end_time: &self.end_time, + reopens: self.reopens.as_deref(), + authority: self.authority.as_deref(), + } + } +} + +/// The environment records an admission runs against: locations, pools, +/// published windows, and dated exceptions. In production these are runtime records; in an offline +/// replay they are fixture facts. The evaluator never reads or mutates them +/// directly; its caller resolves them into the context the evaluator takes. +/// A fixture may omit an empty collection; replay fails fast when a case +/// references a record the facts do not carry. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingFacts { + #[serde(default)] + pub locations: Vec, + #[serde(default)] + pub pools: Vec, + /// Published arrival-window supply owned by this deployment's operator + /// records. The policy references these records by identifier. + #[serde(default)] + pub windows: Vec, + #[serde(default)] + pub exceptions: Vec, +} + +impl SchedulingFacts { + #[must_use] + pub fn location(&self, id: &str) -> Option<&LocationRecord> { + self.locations.iter().find(|location| location.id == id) + } + + #[must_use] + pub fn pool(&self, id: &str) -> Option<&ResourcePool> { + self.pools.iter().find(|pool| pool.id == id) + } + + #[must_use] + pub fn window(&self, id: &str) -> Option<&crate::policy::PublishedWindow> { + self.windows.iter().find(|window| window.id == id) + } +} + +/// A request for one admission decision. The evaluator is pure: it resolves +/// nothing, fetches nothing, and reads exactly what it is handed. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdmissionRequest { + pub offering: String, + pub start: DateTime, + pub party: PartyCounts, + /// The authenticated channel, when the deployment allocates supply + /// through channel subquotas. + pub channel: Option, + /// The duplicate-active key, derived the same way the offering's policy + /// prescribes. + pub duplicate_key: Option, + /// The policy revision the caller resolved the offering under. + pub policy_revision: u64, + /// The window revision the caller resolved an arrival window under. + pub window_revision: Option, + /// The party's held capability references, matched against the offering's + /// requirements. + pub capabilities: Vec, + /// The party's held prerequisite references, matched against the + /// offering's requirements. + pub prerequisites: Vec, +} + +/// The idempotent hash of an admission request. +/// +/// The tuple is fixed in code, in this order, so a field reorder elsewhere can +/// never change a stored hash. The same request always hashes identically; +/// the same idempotency key with a different payload hashes differently, which +/// is exactly the distinction a retry must be able to make. +/// +/// Capabilities and prerequisites are sets the caller happens to send in an +/// order, so they are sorted before hashing: two spellings of one set are one +/// request, and a retry that reorders them replays rather than executing a +/// second time. Repeats are kept, because a hash never edits its input. +#[must_use] +pub fn admission_request_hash(request: &AdmissionRequest) -> String { + let mut capabilities = request.capabilities.clone(); + capabilities.sort(); + let mut prerequisites = request.prerequisites.clone(); + prerequisites.sort(); + let fixed = ( + &request.offering, + request.start.to_rfc3339(), + request.party.recipients, + request.party.attendees, + &request.channel, + &request.duplicate_key, + request.policy_revision, + request.window_revision, + &capabilities, + &prerequisites, + ); + let value = serde_json::to_value(&fixed).expect("the fixed tuple always serializes"); + let canonical = registry_platform_canonical_json::canonicalize_json(&value) + .expect("the fixed tuple always canonicalizes"); + let digest = Sha256::digest(&canonical); + format!("sha256:{}", hex(&digest)) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn utc(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + } + + fn request() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: utc(2, 0), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: Some("public".to_owned()), + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 4, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + #[test] + fn a_confirmed_booking_consumes_but_an_expired_hold_does_not() { + let now = utc(4, 0); + let booking = LedgerClaim { + id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let live_hold = LedgerClaim { + id: "claim-2".to_owned(), + offering: "offering-a".to_owned(), + supply_id: "station-2".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: Some(utc(4, 30)), + }; + let expired_hold = LedgerClaim { + id: "claim-3".to_owned(), + offering: "offering-a".to_owned(), + supply_id: "station-3".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: Some(now), + }; + let snapshot = LedgerSnapshot { + claims: vec![booking, live_hold.clone(), expired_hold], + }; + + let consuming: Vec<&str> = snapshot + .consuming(now) + .map(|claim| claim.id.as_str()) + .collect(); + assert_eq!(consuming, vec!["claim-1", "claim-2"]); + + // A hold that expires exactly at `now` is already expired: expiry is + // exclusive, so the caller can never observe a hold that died while + // being evaluated. + assert!(!live_hold.consumes_at(live_hold.expires_at.unwrap())); + } + + #[test] + fn supply_occupancy_is_half_open_and_honors_the_excluded_claim() { + let now = utc(4, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![claim], + }; + + assert!(snapshot.supply_occupied("station-1", utc(1, 30), utc(2, 30), None, now)); + // Touching at an endpoint is not overlapping: [3:00, 3:30) starts + // exactly where the claim ends. + assert!(!snapshot.supply_occupied("station-1", utc(3, 0), utc(3, 30), None, now)); + assert!(!snapshot.supply_occupied("station-2", utc(2, 0), utc(3, 0), None, now)); + // A reschedule excludes its own allocation. + assert!(!snapshot.supply_occupied("station-1", utc(2, 0), utc(3, 0), Some("claim-1"), now)); + } + + #[test] + fn window_allocation_sums_per_channel_or_overall() { + let now = utc(4, 0); + let claim = |id: &str, channel: Option<&str>, units: u32| LedgerClaim { + id: id.to_owned(), + offering: "offering-a".to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: channel.map(str::to_owned), + start: utc(1, 0), + end: utc(4, 0), + units, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![ + claim("claim-1", Some("public"), 2), + claim("claim-2", Some("assisted"), 1), + ], + }; + + assert_eq!( + snapshot.window_units_allocated("morning-window", None, None, now), + 3 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", Some("public"), None, now), + 2 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", Some("walk-in"), None, now), + 0 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", None, Some("claim-2"), now), + 2 + ); + } + + #[test] + fn window_allocation_saturates_instead_of_wrapping_past_u32() { + let now = utc(4, 0); + let claim = |id: &str, units: u32| LedgerClaim { + id: id.to_owned(), + offering: "offering-a".to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(1, 0), + end: utc(4, 0), + units, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![claim("claim-1", u32::MAX), claim("claim-2", 1)], + }; + // The saturated total keeps refusing; a wrapped one would read as 0 + // and hand the window its whole capacity back. + assert_eq!( + snapshot.window_units_allocated("morning-window", None, None, now), + u32::MAX + ); + } + + #[test] + fn duplicate_keys_are_detected_only_on_consuming_claims() { + let now = utc(4, 0); + let expired_hold = LedgerClaim { + id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(1, 0), + end: utc(4, 0), + units: 1, + duplicate_key: Some("subject:one".to_owned()), + expires_at: Some(utc(3, 0)), + }; + let other_offering = LedgerClaim { + id: "claim-2".to_owned(), + offering: "offering-b".to_owned(), + kind: LedgerKind::Booking, + expires_at: None, + ..expired_hold.clone() + }; + let snapshot = LedgerSnapshot { + claims: vec![expired_hold, other_offering], + }; + assert!(!snapshot.duplicate_active("offering-a", "subject:one", None, now)); + assert!(snapshot.duplicate_active("offering-b", "subject:one", None, now)); + } + + #[test] + fn the_same_request_always_hashes_identically() { + assert_eq!( + admission_request_hash(&request()), + admission_request_hash(&request()) + ); + assert!(admission_request_hash(&request()).starts_with("sha256:")); + assert_eq!( + admission_request_hash(&request()).len(), + "sha256:".len() + 64 + ); + } + + /// AT-06, pure half: a changed payload under the same caller-visible key + /// must hash differently, or a retry could silently return the wrong + /// appointment. + #[test] + fn a_changed_payload_hashes_differently() { + let base = admission_request_hash(&request()); + + let moved_start = AdmissionRequest { + start: utc(2, 30), + ..request() + }; + assert_ne!(base, admission_request_hash(&moved_start)); + + let grew_party = AdmissionRequest { + party: PartyCounts { + recipients: 2, + attendees: 3, + }, + ..request() + }; + assert_ne!(base, admission_request_hash(&grew_party)); + + let other_offering = AdmissionRequest { + offering: "registry-update-45".to_owned(), + ..request() + }; + assert_ne!(base, admission_request_hash(&other_offering)); + + let other_channel = AdmissionRequest { + channel: Some("assisted".to_owned()), + ..request() + }; + assert_ne!(base, admission_request_hash(&other_channel)); + } + + #[test] + fn exception_records_borrow_into_calendar_expansion_form() { + let record = CalendarExceptionRecord { + id: "staff-meeting".to_owned(), + location: "north-counter".to_owned(), + kind: ExceptionRecordKind::Closure, + date: "2026-10-05".to_owned(), + start_time: "09:00".to_owned(), + end_time: "10:00".to_owned(), + reopens: None, + authority: None, + }; + let borrowed = record.borrowed(); + assert_eq!(borrowed.id, "staff-meeting"); + assert_eq!(borrowed.kind, CalendarExceptionKind::Closure); + assert_eq!(borrowed.date, "2026-10-05"); + } + + #[test] + fn lifecycle_enums_round_trip_and_refuse_unknown_states() { + let hold: HoldState = serde_norway::from_str("active").unwrap(); + assert_eq!(hold, HoldState::Active); + assert!(serde_norway::from_str::("parked").is_err()); + + let attendance: AttendanceState = serde_norway::from_str("leftWithoutService").unwrap(); + assert_eq!(attendance, AttendanceState::LeftWithoutService); + assert!(serde_norway::from_str::("no-show").is_err()); + + let booking: BookingState = serde_norway::from_str("confirmed").unwrap(); + assert_eq!(booking, BookingState::Confirmed); + } + + #[test] + fn ledger_and_request_shapes_refuse_unknown_fields() { + let claim_yaml = + "id: claim-1\noffering: registry-update-30\nsupplyId: station-1\nkind: booking\n\ + start: 2026-10-05T02:00:00Z\nend: 2026-10-05T03:00:00Z\nunits: 1\n"; + assert!(serde_norway::from_str::(claim_yaml).is_ok()); + assert!( + serde_norway::from_str::(&format!("{claim_yaml}note: stray\n")).is_err() + ); + + assert!(serde_norway::from_str::("recipients: 1\nattendees: 2\n").is_ok()); + assert!(serde_norway::from_str::("recipients: 1\n").is_err()); + } + + /// The wire request carries no exclusion of its own. A caller who names a + /// claim to leave out of the capacity check is refused at the edge, so no + /// create path can be handed another caller's allocation to ignore. + #[test] + fn an_admission_request_refuses_a_caller_supplied_exclusion() { + let body = "offering: registry-update-30\nstart: 2026-10-05T02:00:00Z\n\ + party:\n recipients: 1\n attendees: 1\npolicyRevision: 4\n\ + capabilities: []\nprerequisites: []\n"; + assert!(serde_norway::from_str::(body).is_ok()); + assert!( + serde_norway::from_str::(&format!("{body}rescheduleOf: claim-1\n")) + .is_err(), + "the exclusion is the runtime's to supply, never the caller's" + ); + } + + /// Capabilities and prerequisites are sets the caller happens to send in + /// an order. Two spellings of the same set are the same request, so a + /// retry that reorders them replays instead of executing again. + #[test] + fn set_order_never_changes_the_request_hash() { + let ordered = AdmissionRequest { + capabilities: vec!["cap-a".to_owned(), "cap-b".to_owned()], + prerequisites: vec!["proof-a".to_owned(), "proof-b".to_owned()], + ..request() + }; + let reordered = AdmissionRequest { + capabilities: vec!["cap-b".to_owned(), "cap-a".to_owned()], + prerequisites: vec!["proof-b".to_owned(), "proof-a".to_owned()], + ..request() + }; + assert_eq!( + admission_request_hash(&ordered), + admission_request_hash(&reordered) + ); + + // Membership still counts: a set with a different member is a + // different request. + let widened = AdmissionRequest { + capabilities: vec!["cap-a".to_owned(), "cap-c".to_owned()], + ..ordered.clone() + }; + assert_ne!( + admission_request_hash(&ordered), + admission_request_hash(&widened) + ); + } +} diff --git a/crates/registry-scheduling-core/src/naming.rs b/crates/registry-scheduling-core/src/naming.rs new file mode 100644 index 0000000000..4883c5091c --- /dev/null +++ b/crates/registry-scheduling-core/src/naming.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Fixed names of the Scheduling authoring and runtime artifacts. + +/// apiVersion of an authored scheduling policy package. +pub const SCHEDULING_POLICY_API_VERSION: &str = + "registry.registrystack.org/scheduling-policy-package/v1alpha1"; + +/// Kind of an authored scheduling policy package. +pub const SCHEDULING_POLICY_KIND: &str = "SchedulingPolicyPackage"; + +/// File name of the package manifest written beside the authored policy. +pub const SCHEDULING_PACKAGE_MANIFEST_FILE: &str = "scheduling.package.json"; + +/// File name of the authored policy inside a scheduling project. +pub const AUTHORED_POLICY_FILE: &str = "scheduling.yaml"; + +/// apiVersion of the operator runtime configuration document. +pub const SCHEDULING_RUNTIME_API_VERSION: &str = + "registry.registrystack.org/scheduling-runtime/v1alpha1"; + +/// Kind of the operator runtime configuration document. +pub const SCHEDULING_RUNTIME_KIND: &str = "SchedulingRuntimeConfig"; + +/// File name of the runtime configuration document. +pub const RUNTIME_SCHEMA_FILE: &str = "runtime.schema.json"; + +/// `$id` of the runtime configuration JSON Schema. +pub const SCHEDULING_RUNTIME_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json"; + +/// Base of every Scheduling problem type URI. +pub const SCHEDULING_PROBLEM_TYPE_BASE: &str = + "https://id.registrystack.org/problems/registry-scheduling/"; + +/// apiVersion of an offline replay fixture. +pub const SCHEDULING_FIXTURE_API_VERSION: &str = + "registry.registrystack.org/scheduling-fixture/v1alpha1"; + +/// Kind of an offline replay fixture. +pub const SCHEDULING_FIXTURE_KIND: &str = "SchedulingFixture"; + +// HTTP wire names the runtime and every client share. They live here, beside +// the artifact names, because they are contract: a route that moves strands +// every caller at once. + +/// Route answering which deployment and which policy revision. +pub const SCHEDULING_PATH: &str = "/v1/scheduling"; + +/// Route listing the service catalogue. +pub const SERVICES_PATH: &str = "/v1/services"; + +/// Route listing the offering catalogue. +pub const OFFERINGS_PATH: &str = "/v1/offerings"; + +/// Route answering bounded availability searches. +pub const AVAILABILITY_PATH: &str = "/v1/availability"; + +/// Route answering the separately authorized explanation of one refusal. +pub const AVAILABILITY_EXPLAIN_PATH: &str = "/v1/availability/explain"; + +/// Route minting holds. +pub const HOLDS_PATH: &str = "/v1/holds"; + +/// Route confirming holds and creating appointments. +pub const APPOINTMENTS_PATH: &str = "/v1/appointments"; + +/// Route listing backing resources. +pub const RESOURCES_PATH: &str = "/v1/resources"; + +/// Route listing locations. +pub const LOCATIONS_PATH: &str = "/v1/locations"; + +/// Header carrying the scoped idempotency key of a mutating command. +pub const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; + +/// Query parameter continuing a bounded listing. +pub const CURSOR_QUERY_PARAMETER: &str = "cursor"; + +/// Query parameter capping one page of a bounded listing. +pub const LIMIT_QUERY_PARAMETER: &str = "limit"; + +/// The most bytes of one idempotency key, mirroring the stack convention. +pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128; + +#[cfg(test)] +mod tests { + use super::*; + + /// The names are contract: a change here is a product decision, never a + /// formatting pass. + #[test] + fn fixed_names_are_pinned() { + assert_eq!( + SCHEDULING_POLICY_API_VERSION, + "registry.registrystack.org/scheduling-policy-package/v1alpha1" + ); + assert_eq!(SCHEDULING_POLICY_KIND, "SchedulingPolicyPackage"); + assert_eq!(SCHEDULING_PACKAGE_MANIFEST_FILE, "scheduling.package.json"); + assert_eq!(AUTHORED_POLICY_FILE, "scheduling.yaml"); + assert_eq!( + SCHEDULING_RUNTIME_API_VERSION, + "registry.registrystack.org/scheduling-runtime/v1alpha1" + ); + assert_eq!(SCHEDULING_RUNTIME_KIND, "SchedulingRuntimeConfig"); + assert_eq!(RUNTIME_SCHEMA_FILE, "runtime.schema.json"); + assert_eq!( + SCHEDULING_RUNTIME_SCHEMA_ID, + "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json" + ); + assert_eq!( + SCHEDULING_PROBLEM_TYPE_BASE, + "https://id.registrystack.org/problems/registry-scheduling/" + ); + assert_eq!( + SCHEDULING_FIXTURE_API_VERSION, + "registry.registrystack.org/scheduling-fixture/v1alpha1" + ); + assert_eq!(SCHEDULING_FIXTURE_KIND, "SchedulingFixture"); + } + + /// The HTTP wire names are contract the same way: routes move only with + /// the product's compatibility decisions, never in a formatting pass. + #[test] + fn http_wire_names_are_pinned() { + assert_eq!(SCHEDULING_PATH, "/v1/scheduling"); + assert_eq!(SERVICES_PATH, "/v1/services"); + assert_eq!(OFFERINGS_PATH, "/v1/offerings"); + assert_eq!(AVAILABILITY_PATH, "/v1/availability"); + assert_eq!(AVAILABILITY_EXPLAIN_PATH, "/v1/availability/explain"); + assert_eq!(HOLDS_PATH, "/v1/holds"); + assert_eq!(APPOINTMENTS_PATH, "/v1/appointments"); + assert_eq!(RESOURCES_PATH, "/v1/resources"); + assert_eq!(LOCATIONS_PATH, "/v1/locations"); + assert_eq!(IDEMPOTENCY_KEY_HEADER, "idempotency-key"); + assert_eq!(CURSOR_QUERY_PARAMETER, "cursor"); + assert_eq!(LIMIT_QUERY_PARAMETER, "limit"); + assert_eq!(MAXIMUM_IDEMPOTENCY_KEY_BYTES, 128); + } +} diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs new file mode 100644 index 0000000000..bdae09464b --- /dev/null +++ b/crates/registry-scheduling-core/src/policy.rs @@ -0,0 +1,2419 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The authored scheduling policy: services, offerings, opening patterns, +//! holiday sets, and the hold policy, with the validators a publication gate +//! runs. Published arrival-window supply is an operator record referenced by +//! identifier from an arrival offering. +//! +//! The policy is layer one of the configuration model. It declares what a +//! deployment publishes and why; locations, resource pools, arrival windows, +//! and dated exceptions are runtime records the policy references by +//! identifier but never embeds, so the same published policy governs every +//! environment that resolves those identifiers. + +use chrono::{DateTime, Utc}; +use registry_platform_hooks::{validate_hooks, HookHandlerSource, HookPhase, HookValidationError}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +use crate::diagnostics::{PolicyCheckReason, SchedulingDiagnostic}; +use crate::naming::SCHEDULING_POLICY_API_VERSION; +use crate::naming::SCHEDULING_POLICY_KIND; +use crate::units::{check_because, RequiredUnitsPolicy}; + +/// The maximum number of entries one policy collection may carry. +pub const MAXIMUM_COLLECTION_ENTRIES: usize = 256; + +/// The maximum bytes of a human-facing label. +pub const MAXIMUM_LABEL_BYTES: usize = 128; + +/// One weekday of an opening pattern. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PolicyWeekday { + Mon, + Tue, + Wed, + Thu, + Fri, + Sat, + Sun, +} + +impl PolicyWeekday { + #[must_use] + pub const fn to_chrono(self) -> chrono::Weekday { + match self { + Self::Mon => chrono::Weekday::Mon, + Self::Tue => chrono::Weekday::Tue, + Self::Wed => chrono::Weekday::Wed, + Self::Thu => chrono::Weekday::Thu, + Self::Fri => chrono::Weekday::Fri, + Self::Sat => chrono::Weekday::Sat, + Self::Sun => chrono::Weekday::Sun, + } + } +} + +/// The identity of the deployment the policy governs. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyIdentity { + pub id: String, + pub version: u64, +} + +/// A service a deployment schedules. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServicePolicy { + pub id: String, + pub label: String, +} + +/// How an offering delivers its service. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SchedulingMode { + ExactTime, + ArrivalWindow, +} + +/// The exact-time block of an offering: appointment length, protections, and +/// the interchangeable pool that backs it. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExactTimeOffering { + pub duration_minutes: u32, + /// Setup time occupied before the displayed start. + pub buffer_before_minutes: u32, + /// Cleanup time occupied after the displayed end. + pub buffer_after_minutes: u32, + /// The earliest lead time a caller may book at. + pub lead_time_minutes: u32, + /// How far ahead booking is open. + pub horizon_days: u32, + /// The pool of interchangeable members backing this offering. A pool is + /// its members, never an independent counter. + pub pool: String, + /// The grid displayed starts must land on. + pub start_increment_minutes: u32, + /// The largest party the offering serves. + pub max_recipients: u32, +} + +/// The arrival-window block of an offering. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ArrivalOffering { + /// The published window this offering serves arrivals in. + pub window: String, + /// The earliest lead time a caller may book the window at. + pub lead_time_minutes: u32, + /// How far ahead of the window's start booking is open. + pub horizon_days: u32, +} + +/// One authored reminder offset: a notification intent becomes due this many +/// minutes before the displayed start of an appointment on the offering. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReminderOffsetPolicy { + pub minutes_before: u32, + pub because: String, +} + +/// One bookable thing a deployment offers. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OfferingPolicy { + pub id: String, + pub service: String, + pub label: String, + pub mode: SchedulingMode, + pub location: String, + pub because: String, + /// Present exactly when `mode` is `exact-time`. + pub exact_time: Option, + /// Present exactly when `mode` is `arrival-window`. + pub arrival: Option, + /// How late before the displayed start a booking may still be cancelled. + pub cancellation_cutoff_minutes: u32, + /// Authored reminder offsets for this offering. The runtime mints one + /// revision-bound intent per offset whose due time is still ahead when + /// the appointment commits; message transport stays external (INT-02). + #[serde(default)] + pub reminders: Vec, + /// The caller attribute an active-booking duplicate check keys on, when + /// the service defines one. Phone numbers and email addresses are never + /// valid duplicate keys. + pub duplicate_active_key: Option, + /// Capabilities every backing member must have for this offering. + pub requires_capabilities: Vec, + /// Prerequisite references a requesting party must hold. + pub prerequisites: Vec, +} + +/// A bounded weekly opening pattern over local wall-clock time. The timezone +/// comes from the location record the pattern references. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OpeningPatternPolicy { + pub id: String, + pub location: String, + pub holiday_set: String, + pub weekdays: Vec, + pub start_time: String, + pub end_time: String, + pub effective_from: String, + pub effective_until: String, + pub because: String, +} + +/// A named, revisioned set of holiday dates. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HolidaySetPolicy { + pub id: String, + pub revision: u64, + pub because: String, + pub dates: Vec, +} + +/// The authenticated channel a subquota reserves capacity for. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Channel { + Public, + Assisted, + Urgent, + WalkIn, +} + +impl Channel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Public => "public", + Self::Assisted => "assisted", + Self::Urgent => "urgent", + Self::WalkIn => "walk-in", + } + } + + /// The channel a name names, or `None` when the name is outside the + /// closed vocabulary. The vocabulary is the one thing every spelling of + /// a channel is measured against, whether a policy declares its served + /// subset or not. + #[must_use] + pub fn from_name(name: &str) -> Option { + match name { + "public" => Some(Self::Public), + "assisted" => Some(Self::Assisted), + "urgent" => Some(Self::Urgent), + "walk-in" => Some(Self::WalkIn), + _ => None, + } + } +} + +/// A non-overlapping reserved slice of a window's published capacity. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowSubquota { + pub id: String, + pub channel: Channel, + pub units: u32, + pub because: String, +} + +/// What a window says happens to its leftover capacity when it closes. The +/// declaration names an intent; this version of the runtime reads none of +/// it, so `check` refuses a window that declares one rather than accept a +/// promise nothing keeps. There is no default and no borrowing from the +/// next window. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LeftoverCapacityPolicy { + /// Leftover units would lapse with the window. + ExpiresUnused, + /// Leftover units would become available to the walk-in channel. + BecomesWalkIn, +} + +/// The staffing block a window's supply is carved from, when the window is +/// backed by the same concrete resources an exact-time pool sells. +/// +/// A pool that backs a window may not also back an exact-time offering: a +/// window claim occupies the window's own supply while an exact-time claim +/// occupies the member it books, so the two modes would double-book the same +/// staffing in a ledger that cannot see the conflict. That mix is refused at +/// publication whatever this block declares. +/// +/// `reserved_members` records how many pool members the window's supply is +/// intended to use. The current ledger does not enforce that partition between +/// windows, so two windows backed by the same pool may not overlap, whatever +/// either staffing block declares. Disjoint windows may reuse the pool. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowStaffing { + pub pool: String, + pub reserved_members: Option, + pub because: String, +} + +/// A published arrival window with its capacity. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PublishedWindow { + pub id: String, + pub revision: u64, + pub offering: String, + pub location: String, + pub start: DateTime, + pub end: DateTime, + /// Published capacity in the unit basis the units policy defines. + pub units: u32, + pub units_policy: RequiredUnitsPolicy, + pub subquotas: Vec, + /// Declared intent for the window's leftover capacity. Nothing reads it + /// in this version, so a declaration is refused at authoring; the field + /// stays so a refusal can name it. + #[serde(default)] + pub leftover: Option, + pub staffing: Option, + pub because: String, +} + +/// The hold policy every offering inherits. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HoldPolicy { + pub ttl_minutes: u32, + pub max_per_caller: u32, + pub because: String, +} + +/// The shared Registry Stack hook declaration shape. Scheduling adds a closed +/// observer contract: after-commit URL handlers over three appointment +/// lifecycle triggers, with no condition or proposal principal. +pub type HookPolicy = registry_platform_hooks::HookDeclaration; + +pub const APPOINTMENT_CONFIRMED_TRIGGER: &str = "appointment.confirmed"; +pub const APPOINTMENT_RESCHEDULED_TRIGGER: &str = "appointment.rescheduled"; +pub const APPOINTMENT_CANCELLED_TRIGGER: &str = "appointment.cancelled"; + +const APPOINTMENT_OBSERVER_FIELDS: &[&str] = &[ + "appointmentId", + "end", + "offering", + "policyRevision", + "revision", + "start", + "state", +]; +const CANCELLATION_OBSERVER_FIELDS: &[&str] = &["appointmentId", "revision", "state"]; + +/// The authored scheduling policy package. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingPolicy { + pub api_version: String, + pub kind: String, + pub scheduling: PolicyIdentity, + pub services: Vec, + pub offerings: Vec, + pub holiday_sets: Vec, + pub openings: Vec, + /// The channels this deployment declares it serves. The vocabulary is + /// closed by the `Channel` type itself; a declaration narrows it to the + /// served subset, every subquota must draw on a declared channel, and a + /// request naming anything else is refused. An absent declaration + /// declares nothing beyond the vocabulary. + #[serde(default)] + pub channels: Vec, + pub hold_policy: HoldPolicy, + #[serde(default)] + pub hooks: Vec, +} + +/// Parse a policy from its authored YAML, with the failing path preserved. +pub fn parse_policy_yaml( + text: &str, +) -> Result> { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer) +} + +impl SchedulingPolicy { + #[must_use] + pub fn offering(&self, id: &str) -> Option<&OfferingPolicy> { + self.offerings.iter().find(|offering| offering.id == id) + } + + #[must_use] + pub fn holiday_set(&self, id: &str) -> Option<&HolidaySetPolicy> { + self.holiday_sets.iter().find(|set| set.id == id) + } + + /// The canonical digest of this policy package: `sha256:` followed by 64 + /// hex digits over the canonical JSON of the envelope and policy. The + /// same policy always digests identically; any authored change does not. + pub fn policy_digest(&self) -> String { + let envelope = serde_json::json!({ + "schema": SCHEDULING_POLICY_API_VERSION, + "policy": self, + }); + let canonical = registry_platform_canonical_json::canonicalize_json(&envelope) + .expect("a policy envelope always canonicalizes"); + let digest = Sha256::digest(&canonical); + format!("sha256:{}", hex(&digest)) + } + + /// Check the whole policy. Every finding names the path that failed. + pub fn check(&self) -> Vec { + let mut findings = Vec::new(); + if self.api_version != SCHEDULING_POLICY_API_VERSION { + findings.push(SchedulingDiagnostic::new( + "apiVersion", + PolicyCheckReason::UnsupportedApiVersion, + )); + } + if self.kind != SCHEDULING_POLICY_KIND { + findings.push(SchedulingDiagnostic::new( + "kind", + PolicyCheckReason::UnsupportedKind, + )); + } + if !valid_identifier(&self.scheduling.id) { + findings.push(SchedulingDiagnostic::new( + "scheduling.id", + PolicyCheckReason::InvalidIdentifier, + )); + } + if self.scheduling.version == 0 { + findings.push(SchedulingDiagnostic::new( + "scheduling.version", + PolicyCheckReason::InvalidBound, + )); + } + + check_collection_non_empty(&self.services, "services", &mut findings); + check_collection_non_empty(&self.offerings, "offerings", &mut findings); + check_collection_non_empty(&self.holiday_sets, "holidaySets", &mut findings); + check_collection_non_empty(&self.openings, "openings", &mut findings); + check_collection_bound(&self.services, "services", &mut findings); + check_collection_bound(&self.offerings, "offerings", &mut findings); + check_collection_bound(&self.holiday_sets, "holidaySets", &mut findings); + check_collection_bound(&self.openings, "openings", &mut findings); + + // A declared channel set is a closed list of the channels this + // deployment serves; naming one twice declares it once. + let mut declared_channels: Vec = Vec::new(); + for (index, channel) in self.channels.iter().enumerate() { + if declared_channels.contains(channel) { + findings.push(SchedulingDiagnostic::new( + format!("channels[{index}]"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + declared_channels.push(*channel); + } + + let mut service_ids = Vec::new(); + for (index, service) in self.services.iter().enumerate() { + let path = format!("services[{index}]"); + if !valid_identifier(&service.id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::InvalidIdentifier, + )); + } + if service.label.trim().is_empty() || service.label.len() > MAXIMUM_LABEL_BYTES { + findings.push(SchedulingDiagnostic::new( + format!("{path}.label"), + PolicyCheckReason::InvalidBound, + )); + } + push_unique(&mut service_ids, &service.id, &path, &mut findings); + } + + for (index, set) in self.holiday_sets.iter().enumerate() { + let path = format!("holidaySets[{index}]"); + // A holiday set with no dates is complete: it observes none. + if set.revision == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.revision"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&set.because, &format!("{path}.because"), &mut findings); + check_identifier(&set.id, &format!("{path}.id"), &mut findings); + for (date_index, date) in set.dates.iter().enumerate() { + if !valid_iso_date(date) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.dates[{date_index}]"), + PolicyCheckReason::MalformedValue, + )); + } + } + self.check_unique_id(&set.id, &path, "holidaySets", &mut findings); + } + + for (index, opening) in self.openings.iter().enumerate() { + let path = format!("openings[{index}]"); + check_identifier(&opening.id, &format!("{path}.id"), &mut findings); + check_identifier( + &opening.location, + &format!("{path}.location"), + &mut findings, + ); + if self.holiday_set(&opening.holiday_set).is_none() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.holidaySet"), + PolicyCheckReason::UnknownHolidaySet, + )); + } + check_collection_non_empty( + &opening.weekdays, + &format!("{path}.weekdays"), + &mut findings, + ); + // A value outside its grammar is named where it stands, and the + // range it belongs to is left unjudged: comparing text that is not + // a clock, or not a date, would answer a question the author never + // asked. + for (field, value) in [ + ("startTime", &opening.start_time), + ("endTime", &opening.end_time), + ] { + if !valid_hh_mm(value) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{field}"), + PolicyCheckReason::MalformedValue, + )); + } + } + let clocks_are_clocks = + valid_hh_mm(&opening.start_time) && valid_hh_mm(&opening.end_time); + if clocks_are_clocks && opening.start_time >= opening.end_time { + findings.push(SchedulingDiagnostic::new( + format!("{path}.endTime"), + PolicyCheckReason::InvalidBound, + )); + } + for (field, value) in [ + ("effectiveFrom", &opening.effective_from), + ("effectiveUntil", &opening.effective_until), + ] { + if !valid_iso_date(value) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{field}"), + PolicyCheckReason::MalformedValue, + )); + } + } + let dates_are_dates = + valid_iso_date(&opening.effective_from) && valid_iso_date(&opening.effective_until); + if dates_are_dates && opening.effective_from > opening.effective_until { + findings.push(SchedulingDiagnostic::new( + format!("{path}.effectiveUntil"), + PolicyCheckReason::InvalidBound, + )); + } else if let (Some(from), Some(until)) = ( + chrono::NaiveDate::parse_from_str(&opening.effective_from, "%Y-%m-%d").ok(), + chrono::NaiveDate::parse_from_str(&opening.effective_until, "%Y-%m-%d").ok(), + ) { + // The weekly expansion refuses a span this wide at evaluation + // time; the authoring check refuses it first, so a checked + // policy never deploys into a runtime that cannot expand it. + if until.signed_duration_since(from).num_days() + > i64::from(registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS) + { + findings.push(SchedulingDiagnostic::new( + format!("{path}.effectiveUntil"), + PolicyCheckReason::PatternSpanTooLarge, + )); + } + } + check_because(&opening.because, &format!("{path}.because"), &mut findings); + self.check_unique_id(&opening.id, &path, "openings", &mut findings); + } + + for (index, offering) in self.offerings.iter().enumerate() { + self.check_offering(offering, index, &mut findings); + } + + check_because( + &self.hold_policy.because, + "holdPolicy.because", + &mut findings, + ); + if self.hold_policy.ttl_minutes == 0 || self.hold_policy.max_per_caller == 0 { + findings.push(SchedulingDiagnostic::new( + "holdPolicy", + PolicyCheckReason::InvalidBound, + )); + } + + check_collection_bound(&self.hooks, "hooks", &mut findings); + for (index, hook) in self.hooks.iter().enumerate() { + check_identifier(&hook.id, &format!("hooks[{index}].id"), &mut findings); + check_scheduling_hook(hook, index, &mut findings); + } + if let Err(error) = validate_hooks(&self.hooks) { + findings.push(hook_validation_diagnostic(&error)); + } + + findings + } + + fn check_offering( + &self, + offering: &OfferingPolicy, + index: usize, + findings: &mut Vec, + ) { + let path = format!("offerings[{index}]"); + check_identifier(&offering.id, &format!("{path}.id"), findings); + check_identifier(&offering.service, &format!("{path}.service"), findings); + if !service_ids_contains(self, &offering.service) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.service"), + PolicyCheckReason::UnknownService, + )); + } + check_identifier(&offering.location, &format!("{path}.location"), findings); + if offering.label.trim().is_empty() || offering.label.len() > MAXIMUM_LABEL_BYTES { + findings.push(SchedulingDiagnostic::new( + format!("{path}.label"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&offering.because, &format!("{path}.because"), findings); + self.check_unique_id(&offering.id, &path, "offerings", findings); + + let (block, block_reason_path) = match offering.mode { + SchedulingMode::ExactTime => (offering.exact_time.is_some(), "exactTime"), + SchedulingMode::ArrivalWindow => (offering.arrival.is_some(), "arrival"), + }; + if !block { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{block_reason_path}"), + PolicyCheckReason::MissingModeField, + )); + } + match offering.mode { + SchedulingMode::ExactTime => { + if offering.arrival.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.arrival"), + PolicyCheckReason::WrongModeField, + )); + } + if let Some(exact) = &offering.exact_time { + let block_path = format!("{path}.exactTime"); + if exact.duration_minutes == 0 + || exact.lead_time_minutes == 0 + || exact.horizon_days == 0 + || exact.start_increment_minutes == 0 + || exact.max_recipients == 0 + { + findings.push(SchedulingDiagnostic::new( + block_path.clone(), + PolicyCheckReason::InvalidBound, + )); + } + check_identifier(&exact.pool, &format!("{block_path}.pool"), findings); + let horizon_minutes = u64::from(exact.horizon_days) * 24 * 60; + if u64::from(exact.lead_time_minutes) > horizon_minutes { + findings.push(SchedulingDiagnostic::new( + format!("{block_path}.leadTimeMinutes"), + PolicyCheckReason::InvalidBound, + )); + } + } + } + SchedulingMode::ArrivalWindow => { + if offering.exact_time.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.exactTime"), + PolicyCheckReason::WrongModeField, + )); + } + if let Some(arrival) = &offering.arrival { + let block_path = format!("{path}.arrival"); + check_identifier(&arrival.window, &format!("{block_path}.window"), findings); + if arrival.lead_time_minutes == 0 || arrival.horizon_days == 0 { + findings.push(SchedulingDiagnostic::new( + block_path.clone(), + PolicyCheckReason::InvalidBound, + )); + } + let horizon_minutes = u64::from(arrival.horizon_days) * 24 * 60; + if u64::from(arrival.lead_time_minutes) > horizon_minutes { + findings.push(SchedulingDiagnostic::new( + format!("{block_path}.leadTimeMinutes"), + PolicyCheckReason::InvalidBound, + )); + } + } + } + } + + if let Some(key) = &offering.duplicate_active_key { + check_identifier(key, &format!("{path}.duplicateActiveKey"), findings); + } + check_collection_bound(&offering.reminders, &format!("{path}.reminders"), findings); + let mut reminder_offsets: Vec = Vec::new(); + for (offset_index, reminder) in offering.reminders.iter().enumerate() { + let reminder_path = format!("{path}.reminders[{offset_index}]"); + if reminder.minutes_before == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{reminder_path}.minutesBefore"), + PolicyCheckReason::InvalidBound, + )); + } + // Two offsets at the same distance would mint two intents due at + // the same instant for one appointment: one reminder per moment. + if reminder_offsets.contains(&reminder.minutes_before) { + findings.push(SchedulingDiagnostic::new( + format!("{reminder_path}.minutesBefore"), + PolicyCheckReason::DuplicateIdentifier, + )); + } else { + reminder_offsets.push(reminder.minutes_before); + } + check_because( + &reminder.because, + &format!("{reminder_path}.because"), + findings, + ); + } + for capability in &offering.requires_capabilities { + if !valid_identifier(capability) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.requiresCapabilities"), + PolicyCheckReason::InvalidIdentifier, + )); + } + } + for prerequisite in &offering.prerequisites { + if !valid_reference(prerequisite) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.prerequisites"), + PolicyCheckReason::InvalidIdentifier, + )); + } + } + } + + /// Validate the published windows in one environment-records document + /// against this policy. Policy authoring can validate the identifier + /// reference alone; existence and the window's capacity contract belong + /// to the operator records that carry the supply. + pub fn check_window_records(&self, windows: &[PublishedWindow]) -> Vec { + let mut findings = Vec::new(); + check_collection_bound(windows, "windows", &mut findings); + for (index, offering) in self.offerings.iter().enumerate() { + if let Some(arrival) = &offering.arrival { + match windows.iter().find(|window| window.id == arrival.window) { + None => findings.push(SchedulingDiagnostic::new( + format!("offerings[{index}].arrival.window"), + PolicyCheckReason::UnknownWindow, + )), + Some(window) + if window.offering != offering.id + || window.location != offering.location => + { + findings.push(SchedulingDiagnostic::new( + format!("offerings[{index}].arrival.window"), + PolicyCheckReason::MismatchedReference, + )); + } + Some(_) => {} + } + } + } + for (index, window) in windows.iter().enumerate() { + self.check_window(window, windows, index, &mut findings); + } + findings + } + + fn check_window( + &self, + window: &PublishedWindow, + windows: &[PublishedWindow], + index: usize, + findings: &mut Vec, + ) { + let path = format!("windows[{index}]"); + check_identifier(&window.id, &format!("{path}.id"), findings); + if window.revision == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.revision"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&window.because, &format!("{path}.because"), findings); + if windows.iter().filter(|other| other.id == window.id).count() > 1 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + if window.units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.units"), + PolicyCheckReason::InvalidBound, + )); + } + if window.end <= window.start { + findings.push(SchedulingDiagnostic::new( + format!("{path}.end"), + PolicyCheckReason::InvalidBound, + )); + } + findings.extend(window.units_policy.check(&format!("{path}.unitsPolicy"))); + + if window.leftover.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.leftover"), + PolicyCheckReason::LeftoverUnsupported, + )); + } + + match self.offering(&window.offering) { + None => findings.push(SchedulingDiagnostic::new( + format!("{path}.offering"), + PolicyCheckReason::UnknownOffering, + )), + Some(offering) => { + if offering.mode != SchedulingMode::ArrivalWindow + || offering + .arrival + .as_ref() + .is_none_or(|arrival| arrival.window != window.id) + || offering.location != window.location + { + findings.push(SchedulingDiagnostic::new( + format!("{path}.offering"), + PolicyCheckReason::WrongModeField, + )); + } + } + } + + let mut channels = Vec::new(); + let mut subquota_units = 0_u32; + for (subquota_index, subquota) in window.subquotas.iter().enumerate() { + let subquota_path = format!("{path}.subquotas[{subquota_index}]"); + check_identifier(&subquota.id, &format!("{subquota_path}.id"), findings); + check_because( + &subquota.because, + &format!("{subquota_path}.because"), + findings, + ); + if subquota.units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.units"), + PolicyCheckReason::InvalidBound, + )); + } else { + subquota_units = subquota_units.saturating_add(subquota.units); + } + if channels.contains(&subquota.channel) { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.channel"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + // A declared set is closed: a subquota may not reserve capacity + // for a channel the deployment does not say it serves. + if !self.channels.is_empty() && !self.channels.contains(&subquota.channel) { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.channel"), + PolicyCheckReason::UnknownChannel, + )); + } + channels.push(subquota.channel); + } + if subquota_units > window.units { + findings.push(SchedulingDiagnostic::new( + format!("{path}.subquotas"), + PolicyCheckReason::SubquotaOverdrawn, + )); + } + + if let Some(staffing) = &window.staffing { + check_identifier(&staffing.pool, &format!("{path}.staffing.pool"), findings); + check_because( + &staffing.because, + &format!("{path}.staffing.because"), + findings, + ); + // A pool that backs a window may not also back an exact-time + // offering: the two modes count the same staffing differently, so + // the mix would double-book it in a ledger that cannot see the + // conflict. No authored partition attributes the supply across + // the two modes, so the refusal stands whatever this block + // declares. + let mixed_modes = self.offerings.iter().any(|offering| { + offering + .exact_time + .as_ref() + .is_some_and(|exact| exact.pool == staffing.pool) + }); + if mixed_modes { + findings.push(SchedulingDiagnostic::new( + format!("{path}.staffing.pool"), + PolicyCheckReason::SharedSupplyUnpartitioned, + )); + } + // Window claims occupy their window ids, so the ledger cannot + // enforce an authored staffing partition between two windows. + // Refuse every overlapping use of one pool; disjoint windows may + // reuse it because their staffing cannot be booked twice at once. + let doubly_claimed = windows.iter().any(|other| { + other.id != window.id + && other.staffing.as_ref().is_some_and(|other_staffing| { + other_staffing.pool == staffing.pool + && other.start < window.end + && window.start < other.end + }) + }); + if doubly_claimed { + findings.push(SchedulingDiagnostic::new( + format!("{path}.staffing.pool"), + PolicyCheckReason::SharedSupplyUnpartitioned, + )); + } + } + } + + fn check_unique_id( + &self, + id: &str, + path: &str, + collection: &str, + findings: &mut Vec, + ) { + let duplicate = match collection { + "offerings" => { + self.offerings + .iter() + .filter(|offering| offering.id == id) + .count() + > 1 + } + "openings" => { + self.openings + .iter() + .filter(|opening| opening.id == id) + .count() + > 1 + } + "holidaySets" => self.holiday_sets.iter().filter(|set| set.id == id).count() > 1, + _ => false, + }; + if duplicate { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + } +} + +fn check_scheduling_hook( + hook: &HookPolicy, + index: usize, + findings: &mut Vec, +) { + let path = format!("hooks[{index}]"); + if hook.phase != HookPhase::After || !matches!(hook.handler, HookHandlerSource::Url { .. }) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.phase"), + PolicyCheckReason::UnsupportedHookPhase, + )); + } + if let HookHandlerSource::Url { destination_id } = &hook.handler { + if !valid_hook_destination_id(destination_id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.handler.destinationId"), + PolicyCheckReason::InvalidHookDestination, + )); + } + } + let allowed_projection = match hook.trigger.as_str() { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => { + Some(APPOINTMENT_OBSERVER_FIELDS) + } + APPOINTMENT_CANCELLED_TRIGGER => Some(CANCELLATION_OBSERVER_FIELDS), + _ => None, + }; + let Some(allowed_projection) = allowed_projection else { + findings.push(SchedulingDiagnostic::new( + format!("{path}.trigger"), + PolicyCheckReason::UnsupportedHookTrigger, + )); + return; + }; + if hook.when.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.when"), + PolicyCheckReason::UnsupportedHookCondition, + )); + } + if hook.principal.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.principal"), + PolicyCheckReason::UnsupportedHookPrincipal, + )); + } + for field in &hook.projection { + if !allowed_projection.contains(&field.as_str()) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.projection"), + PolicyCheckReason::UnsupportedHookProjection, + )); + break; + } + } +} + +fn valid_hook_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +/// One window, or one channel slice of a window, whose proposed capacity fell +/// below what is already committed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapacityReduction { + pub window: String, + /// The channel whose subquota the reduction strands, or `None` when the + /// deficit is the window total's. + pub channel: Option, + pub committed_units: u32, + pub proposed_units: u32, +} + +/// Assess proposed window records against the commitments already standing in +/// a ledger snapshot. +/// +/// A normal capacity reduction that would leave confirmed bookings and live +/// holds above the proposed capacity is rejected: it comes back here as a +/// finding with the deficit, never as a successful validation. Both levels of +/// published capacity are assessed on both sides of the proposal: the window +/// total, and each channel subquota, which is its own committed slice a +/// proposal may strand by reducing it, by dropping it, or by introducing it +/// below what the channel already holds. A window the proposal keeps is +/// assessed over the interval it now publishes: a claim whose interval the +/// new window no longer covers is a commitment the proposal strands against +/// zero, and no longer capacity the moved window must cover. An emergency +/// reduction is a different operation entirely, recorded as an incident with +/// its affected bookings, and does not pass through this assessment. +pub fn assess_window_record_impact( + current: &[PublishedWindow], + proposed: &[PublishedWindow], + snapshot: &crate::model::LedgerSnapshot, + now: DateTime, +) -> Vec { + let mut reductions = Vec::new(); + let window_ids: BTreeSet<&str> = current + .iter() + .map(|window| window.id.as_str()) + .chain( + snapshot + .consuming(now) + .map(|claim| claim.supply_id.as_str()), + ) + .collect(); + for window_id in window_ids { + let current_window = current.iter().find(|candidate| candidate.id == window_id); + let proposed_window = proposed.iter().find(|candidate| candidate.id == window_id); + let proposed_units = proposed_window.map_or(0, |proposed| proposed.units); + // A window the proposal drops has no interval left to meet, so every + // claim counts against the zero it proposes. + let committed = match proposed_window { + Some(proposed) => { + units_committed_within(snapshot, window_id, None, proposed.start, proposed.end, now) + } + None => snapshot.window_units_allocated(window_id, None, None, now), + }; + if committed > proposed_units { + reductions.push(CapacityReduction { + window: window_id.to_owned(), + channel: None, + committed_units: committed, + proposed_units, + }); + } + if let Some(proposed) = proposed_window { + // The commitments the proposed interval no longer covers are + // stranded: the proposal publishes no capacity where they stand, + // which is a reduction to zero for them. + let stranded = + units_committed_outside(snapshot, window_id, proposed.start, proposed.end, now); + if stranded > 0 { + reductions.push(CapacityReduction { + window: window_id.to_owned(), + channel: None, + committed_units: stranded, + proposed_units: 0, + }); + } + } + // A subquota dropped from the proposal proposes zero for its channel. + for subquota in current_window + .map(|window| window.subquotas.as_slice()) + .unwrap_or_default() + { + let proposed_subquota = proposed_window + .and_then(|proposed| { + proposed + .subquotas + .iter() + .find(|candidate| candidate.channel == subquota.channel) + }) + .map_or(0, |candidate| candidate.units); + if proposed_subquota >= subquota.units { + continue; + } + let committed_channel = match proposed_window { + Some(proposed) => units_committed_within( + snapshot, + window_id, + Some(subquota.channel.as_str()), + proposed.start, + proposed.end, + now, + ), + None => snapshot.window_units_allocated( + window_id, + Some(subquota.channel.as_str()), + None, + now, + ), + }; + if committed_channel > proposed_subquota { + reductions.push(CapacityReduction { + window: window_id.to_owned(), + channel: Some(subquota.channel), + committed_units: committed_channel, + proposed_units: proposed_subquota, + }); + } + } + // A subquota that exists only in the proposal introduces a ceiling + // the channel never had, so the commitments it already holds are + // assessed against it: a slice below them strands them exactly as a + // reduction of an existing slice would. + if let Some(proposed) = proposed_window { + for subquota in &proposed.subquotas { + let already_published = current_window.is_some_and(|window| { + window + .subquotas + .iter() + .any(|current| current.channel == subquota.channel) + }); + if already_published { + continue; + } + let committed_channel = units_committed_within( + snapshot, + window_id, + Some(subquota.channel.as_str()), + proposed.start, + proposed.end, + now, + ); + if committed_channel > subquota.units { + reductions.push(CapacityReduction { + window: window_id.to_owned(), + channel: Some(subquota.channel), + committed_units: committed_channel, + proposed_units: subquota.units, + }); + } + } + } + } + reductions +} + +/// Recipient units standing against a window's supply at `now`, counting only +/// claims whose occupied interval is fully contained in `[start, end)`, so a +/// proposal that moves or shortens the window cannot count a partly stranded +/// commitment against the capacity it still publishes. +fn units_committed_within( + snapshot: &crate::model::LedgerSnapshot, + window_id: &str, + channel: Option<&str>, + start: DateTime, + end: DateTime, + now: DateTime, +) -> u32 { + snapshot + .consuming(now) + .filter(|claim| { + claim.supply_id == window_id + && channel.is_none_or(|wanted| claim.channel.as_deref() == Some(wanted)) + && start <= claim.start + && claim.end <= end + }) + .map(|claim| claim.units) + // Saturating, for the same reason the snapshot's own sum saturates: + // a total past u32 units must refuse, never wrap toward zero. + .fold(0, u32::saturating_add) +} + +/// Recipient units standing against a window's supply at `now` whose occupied +/// interval is not fully contained in `[start, end)`: the commitments a +/// proposal publishing that interval no longer covers. +fn units_committed_outside( + snapshot: &crate::model::LedgerSnapshot, + window_id: &str, + start: DateTime, + end: DateTime, + now: DateTime, +) -> u32 { + snapshot + .consuming(now) + .filter(|claim| claim.supply_id == window_id && !(start <= claim.start && claim.end <= end)) + .map(|claim| claim.units) + .fold(0, u32::saturating_add) +} + +fn service_ids_contains(policy: &SchedulingPolicy, id: &str) -> bool { + policy.services.iter().any(|service| service.id == id) +} + +fn hook_validation_diagnostic(error: &HookValidationError) -> SchedulingDiagnostic { + match error { + HookValidationError::EmptyHookId { index } => SchedulingDiagnostic::new( + format!("hooks[{index}].id"), + PolicyCheckReason::InvalidIdentifier, + ), + HookValidationError::DuplicateHookId { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].id"), + PolicyCheckReason::DuplicateIdentifier, + ), + HookValidationError::BeforePhaseRemoteHandler { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].phase"), + PolicyCheckReason::UnsupportedHookPhase, + ), + HookValidationError::AbiMissing { index, .. } + | HookValidationError::AbiUnknown { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].handler.abi"), + PolicyCheckReason::UnsupportedHookAbi, + ), + _ => SchedulingDiagnostic::new("hooks", PolicyCheckReason::InvalidHookDeclaration), + } +} + +fn push_unique( + seen: &mut Vec, + id: &str, + path: &str, + findings: &mut Vec, +) { + if seen.iter().any(|known| known == id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } else { + seen.push(id.to_owned()); + } +} + +fn check_collection_non_empty( + collection: &[T], + path: &str, + findings: &mut Vec, +) { + if collection.is_empty() { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::EmptyCollection, + )); + } +} + +fn check_collection_bound( + collection: &[T], + path: &str, + findings: &mut Vec, +) { + if collection.len() > MAXIMUM_COLLECTION_ENTRIES { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidBound, + )); + } +} + +fn check_identifier(id: &str, path: &str, findings: &mut Vec) { + if !valid_identifier(id) { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidIdentifier, + )); + } +} + +/// The identifier grammar shared by every policy id: non-empty, at most 64 +/// bytes, starting with a lowercase letter and continuing with lowercase +/// letters, digits, and hyphens. +#[must_use] +pub fn valid_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..] + .iter() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-')) +} + +/// A reference an offering may require a party to hold: a stable scoped +/// identifier such as `case:1234` or `urn:...`. +#[must_use] +pub fn valid_reference(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} + +fn valid_hh_mm(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 5 + && bytes[2] == b':' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 2 || byte.is_ascii_digit()) + && value[..2].parse::().is_ok_and(|hour| hour < 24) + && value[3..].parse::().is_ok_and(|minute| minute < 60) +} + +fn valid_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{LedgerClaim, LedgerKind, PartyCounts}; + use chrono::TimeZone as _; + + fn utc(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 10, hour, minute, 0).unwrap() + } + + pub fn minimal_exact_time_policy() -> SchedulingPolicy { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: ["2026-12-25"] +openings: + - id: counter-hours + location: north-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + serde_norway::from_str(yaml).expect("the minimal policy parses") + } + + pub fn household_window_policy() -> SchedulingPolicy { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#; + serde_norway::from_str(yaml).expect("the household policy parses") + } + + fn household_windows() -> Vec { + let records: crate::model::SchedulingFacts = serde_norway::from_str( + r#" +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T08:00:00Z + end: 2026-10-10T10:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. +"#, + ) + .expect("the household window records parse"); + records.windows + } + + #[test] + fn the_minimal_policies_carry_no_findings() { + assert!( + minimal_exact_time_policy().check().is_empty(), + "{:?}", + minimal_exact_time_policy().check() + ); + assert!( + household_window_policy().check().is_empty(), + "{:?}", + household_window_policy().check() + ); + } + + #[test] + fn the_policy_digest_is_stable_and_moves_with_content() { + let policy = minimal_exact_time_policy(); + assert_eq!(policy.policy_digest(), policy.clone().policy_digest()); + assert!(policy.policy_digest().starts_with("sha256:")); + assert_eq!(policy.policy_digest().len(), 71); + + let edited = SchedulingPolicy { + hold_policy: HoldPolicy { + ttl_minutes: 6, + ..policy.hold_policy.clone() + }, + ..policy.clone() + }; + assert_ne!(policy.policy_digest(), edited.policy_digest()); + } + + #[test] + fn envelope_and_identity_findings_are_path_addressed() { + let mut policy = minimal_exact_time_policy(); + policy.api_version = "registry.registrystack.org/other/v1".to_owned(); + policy.kind = "Other".to_owned(); + policy.scheduling.version = 0; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"apiVersion: unsupported-api-version".to_owned())); + assert!(rendered.contains(&"kind: unsupported-kind".to_owned())); + assert!(rendered.contains(&"scheduling.version: invalid-bound".to_owned())); + } + + #[test] + fn mode_fields_must_match_the_declared_mode() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].exact_time = None; + policy.offerings[0].arrival = Some(ArrivalOffering { + window: "no-such-window".to_owned(), + lead_time_minutes: 60, + horizon_days: 45, + }); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"offerings[0].exactTime: missing-mode-field".to_owned())); + // The stray arrival block is refused as a whole; its window reference + // is not validated because the block must be removed, not repaired. + assert!(rendered.contains(&"offerings[0].arrival: wrong-mode-field".to_owned())); + } + + #[test] + fn references_into_missing_collections_are_named() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].service = "no-such-service".to_owned(); + policy.openings[0].holiday_set = "no-such-holidays".to_owned(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"offerings[0].service: unknown-service".to_owned())); + assert!(rendered.contains(&"openings[0].holidaySet: unknown-holiday-set".to_owned())); + } + + #[test] + fn every_arrival_offering_must_own_its_referenced_window() { + let mut policy = household_window_policy(); + let windows = household_windows(); + let mut neighbour = policy.offerings[0].clone(); + neighbour.id = "household-afternoon".to_owned(); + policy.offerings.push(neighbour); + + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"offerings[1].arrival.window: mismatched-reference".to_owned())); + } + + /// The weekly expansion serves at most MAXIMUM_PATTERN_SPAN_DAYS days, and + /// the authoring check refuses a wider span at exactly that boundary, so a + /// checked policy never deploys into a runtime that cannot expand it. + #[test] + fn an_opening_span_is_refused_only_past_the_calendar_boundary() { + let base = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("a well-formed base date"); + let until = |days: u64| { + base.checked_add_days(chrono::Days::new(days)) + .expect("a bounded span") + .format("%Y-%m-%d") + .to_string() + }; + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_from = base.format("%Y-%m-%d").to_string(); + policy.openings[0].effective_until = until(u64::from( + registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS, + )); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + + policy.openings[0].effective_until = + until(u64::from(registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS) + 1); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"openings[0].effectiveUntil: pattern-span-too-large".to_owned())); + } + + #[test] + fn duplicate_ids_across_one_collection_are_rejected() { + let mut policy = minimal_exact_time_policy(); + policy.offerings.push(policy.offerings[0].clone()); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|f| f.ends_with("duplicate-identifier")) + .count(), + 2 + ); + } + + /// Authored reminder offsets are part of the reviewed policy: a zero + /// offset, a blank because, and two offsets at the same distance are all + /// findings, and a well-formed schedule passes the check untouched. + #[test] + fn reminder_offsets_are_validated_like_every_authored_choice() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].reminders = vec![ + ReminderOffsetPolicy { + minutes_before: 0, + because: " ".to_owned(), + }, + ReminderOffsetPolicy { + minutes_before: 1440, + because: "One reminder the day before a counter update.".to_owned(), + }, + ReminderOffsetPolicy { + minutes_before: 1440, + because: "A second reminder at the same distance.".to_owned(), + }, + ]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"offerings[0].reminders[0].minutesBefore: invalid-bound".to_owned()) + ); + assert!(rendered.contains(&"offerings[0].reminders[0].because: invalid-because".to_owned())); + assert!(rendered + .contains(&"offerings[0].reminders[2].minutesBefore: duplicate-identifier".to_owned())); + + policy.offerings[0].reminders.truncate(2); + policy.offerings[0].reminders[0] = ReminderOffsetPolicy { + minutes_before: 60, + because: "One reminder an hour before.".to_owned(), + }; + assert!(policy.check().is_empty(), "{:?}", policy.check()); + } + + #[test] + fn subquota_slices_may_not_overdraw_the_window() { + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].subquotas[0].units = 3; + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"windows[0].subquotas: subquota-overdrawn".to_owned())); + } + + #[test] + fn one_channel_may_not_hold_two_slices_of_one_window() { + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].subquotas[1].channel = Channel::Public; + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].subquotas[1].channel: duplicate-identifier".to_owned()) + ); + } + + /// COR-3 / D5(a): a declared channel set is closed. A subquota may not + /// reserve capacity for a channel the deployment does not say it serves, + /// and a declaration naming one channel twice declares it once. An + /// absent declaration declares nothing beyond the vocabulary. + #[test] + fn a_subquota_may_not_draw_on_an_undeclared_channel() { + let mut policy = household_window_policy(); + let windows = household_windows(); + // The window serves public and assisted; the deployment declares + // only assisted. + policy.channels = vec![Channel::Assisted]; + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].subquotas[0].channel: unknown-channel".to_owned()), + "{rendered:?}" + ); + assert!(!rendered + .iter() + .any(|finding| finding.starts_with("windows[0].subquotas[1].channel:"))); + + // With no declaration, the vocabulary alone governs and the same + // subquotas check clean. + policy.channels = Vec::new(); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered + .iter() + .all(|finding| !finding.ends_with("unknown-channel")), + "{rendered:?}" + ); + + // A repeated declaration is a duplicate identifier. + policy.channels = vec![Channel::Public, Channel::Public]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"channels[1]: duplicate-identifier".to_owned()), + "{rendered:?}" + ); + } + + /// AT-22, static half: an unpartitioned staffing claim over a pool that an + /// exact-time offering also sells is rejected at publication. + /// + /// COR-8 widened the refusal to the mode mix itself: a window claim + /// occupies the window's own supply while an exact-time claim occupies the + /// member it books, so a pool backing both modes double-books the same + /// staffing in a ledger that cannot see the conflict. No authored + /// partition attributes the supply across the two modes, so the mix is + /// refused whether the block declares one or not. + #[test] + fn an_unpartitioned_shared_staffing_block_is_rejected() { + let mut policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The morning block is staffed by the duty officers.".to_owned(), + }); + policy.offerings.push(OfferingPolicy { + id: "urgent-five".to_owned(), + service: "household-day".to_owned(), + label: "Urgent five-minute slot".to_owned(), + mode: SchedulingMode::ExactTime, + location: "civic-hall".to_owned(), + because: "Urgent matters get exact five-minute slots.".to_owned(), + exact_time: Some(ExactTimeOffering { + duration_minutes: 5, + buffer_before_minutes: 0, + buffer_after_minutes: 0, + lead_time_minutes: 30, + horizon_days: 14, + pool: "officer-pool".to_owned(), + start_increment_minutes: 5, + max_recipients: 1, + }), + arrival: None, + cancellation_cutoff_minutes: 60, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) + ); + + // A partition does not attribute the supply across modes, so the mix + // is refused with the partition declared. + windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: Some(2), + because: "Two of the four duty officers staff the block.".to_owned(), + }); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) + ); + + // A partitioned staffing block over a pool no exact-time offering + // sells makes the share explicit and passes. + windows[0].staffing = Some(WindowStaffing { + pool: "hall-officers".to_owned(), + reserved_members: Some(2), + because: "Two of the four duty officers staff the block.".to_owned(), + }); + assert!( + policy.check_window_records(&windows).is_empty(), + "{:?}", + policy.check_window_records(&windows) + ); + + // A window with no staffing block draws on no pool at all, so it + // carries no edge an exact-time pool could share: the mode mix a + // staffing block would create cannot exist without one. + windows[0].staffing = None; + assert!(policy.check_window_records(&windows).is_empty()); + } + + #[test] + fn overlapping_windows_may_not_share_a_staffing_pool() { + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The morning block is staffed by the duty officers.".to_owned(), + }); + windows.push(PublishedWindow { + id: "household-noon-window".to_owned(), + revision: 1, + offering: "household-morning".to_owned(), + location: "civic-hall".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 10, 9, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 11, 30, 0).unwrap(), + units: 2, + units_policy: RequiredUnitsPolicy::Fixed { + units: 1, + because: "One unit per party.".to_owned(), + }, + subquotas: Vec::new(), + leftover: None, + staffing: Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The noon block claims the same officers.".to_owned(), + }), + because: "A second block in the same hall.".to_owned(), + }); + // The second window's offering linkage is wrong on purpose in this + // fixture; only the supply finding is asserted here. + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); + + // A whole-pool claim and an attributed subset still have independent + // window ledgers, so the authored subset cannot make the overlap safe. + windows[1].staffing.as_mut().unwrap().reserved_members = Some(1); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); + + // Two attributed subsets are equally unenforceable: their claims lock + // and consume separate window ids rather than a shared staffing slice. + windows[0].staffing.as_mut().unwrap().reserved_members = Some(1); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); + + // Disjoint windows may reuse the pool regardless of their advisory + // attributed member counts. + let disjoint = PublishedWindow { + start: Utc.with_ymd_and_hms(2026, 10, 10, 13, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 15, 0, 0).unwrap(), + ..windows[1].clone() + }; + windows[1] = disjoint; + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(!rendered + .iter() + .any(|f| f.ends_with("shared-supply-unpartitioned"))); + } + + /// AT-10, pure half: a normal reduction below standing commitments is + /// rejected with the deficit, while a reduction that keeps commitments + /// covered passes. + #[test] + fn a_reduction_below_standing_commitments_is_rejected_with_its_deficit() { + let current = household_windows(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + let mut reduced = current.clone(); + reduced[0].units = 1; + assert_eq!( + assess_window_record_impact(¤t, &reduced, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 1, + }] + ); + + // Dropping the window entirely reduces both the total and the public + // slice to zero; the total reduction is reported first. + let mut removed = current.clone(); + removed.clear(); + let reductions = assess_window_record_impact(¤t, &removed, &snapshot, now); + assert_eq!(reductions[0].proposed_units, 0); + assert_eq!(reductions[0].channel, None); + + // A reduction that still covers commitments, and an increase, pass. + let mut adequate = current.clone(); + adequate[0].units = 2; + assert!(assess_window_record_impact(¤t, &adequate, &snapshot, now).is_empty()); + let mut increased = current.clone(); + increased[0].units = 9; + assert!(assess_window_record_impact(¤t, &increased, &snapshot, now).is_empty()); + } + + /// AT-10, channel half: a subquota reduced below its channel's standing + /// commitments is rejected even when the window total is unchanged, and a + /// dropped subquota proposes zero for its channel. + #[test] + fn a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total() { + let current = household_windows(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + // The total stays at 3 while the public slice shrinks below the 2 + // units that channel already holds. + let mut narrowed = current.clone(); + narrowed[0].subquotas[0].units = 1; + assert_eq!( + assess_window_record_impact(¤t, &narrowed, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 1, + }] + ); + + // Dropping the subquota entirely is a reduction of that channel to + // zero; only the channel strand is reported, the total still covers. + let mut dropped = current.clone(); + dropped[0].subquotas.remove(0); + assert_eq!( + assess_window_record_impact(¤t, &dropped, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 0, + }] + ); + + // A channel nothing has committed against never strands, and an + // unchanged slice passes. + let mut assisted_dropped = current.clone(); + assisted_dropped[0].subquotas.remove(1); + assert!( + assess_window_record_impact(¤t, &assisted_dropped, &snapshot, now).is_empty() + ); + let mut widened = current.clone(); + widened[0].subquotas[0].units = 2; + assert!(assess_window_record_impact(¤t, &widened, &snapshot, now).is_empty()); + } + + /// BL-2: a subquota that exists only in the proposal was never assessed, + /// so a publication could add a channel slice below what that channel + /// already holds and strand its commitments silently. + #[test] + fn an_added_subquota_never_strands_its_channel_at_publication() { + let mut current = household_windows(); + current[0].subquotas.clear(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + // Two public units already stand; the proposal publishes a public + // slice of one for the first time. + let mut proposal = current.clone(); + proposal[0].subquotas = vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 1, + because: "One public unit in the revised block.".to_owned(), + }]; + assert_eq!( + assess_window_record_impact(¤t, &proposal, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 1, + }] + ); + + // A slice that still covers what the channel holds passes. + let mut covered = current.clone(); + covered[0].subquotas = vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 2, + because: "Two public units in the revised block.".to_owned(), + }]; + assert!(assess_window_record_impact(¤t, &covered, &snapshot, now).is_empty()); + } + + /// BL-3: a window moved under the same id was assessed by supply id + /// alone, so a proposal could move every standing commitment out of its + /// window and pass as safe. Each standing claim's interval is now + /// compared against the proposed window's interval. + #[test] + fn moving_a_published_window_reports_the_stranded_commitments() { + let current = household_windows(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim.clone()], + }; + + // The window moves two months under the same id; the two standing + // units fall outside the published interval and are stranded against + // the zero capacity that proposal leaves where they stand. + let mut moved = current.clone(); + moved[0].start = Utc.with_ymd_and_hms(2026, 12, 24, 8, 0, 0).unwrap(); + moved[0].end = Utc.with_ymd_and_hms(2026, 12, 24, 10, 0, 0).unwrap(); + assert_eq!( + assess_window_record_impact(¤t, &moved, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 0, + }] + ); + + // A shortened interval strands only the claims it no longer covers: + // the morning claim remains fully inside, while the late one only + // partly overlaps and therefore cannot be treated as covered. + let covered = LedgerClaim { + end: utc(9, 0), + ..claim + }; + let late = LedgerClaim { + id: "claim-2".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("assisted".to_owned()), + start: utc(9, 30), + end: utc(10, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![covered, late], + }; + let mut shortened = current.clone(); + shortened[0].end = utc(9, 45); + assert_eq!( + assess_window_record_impact(¤t, &shortened, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 1, + proposed_units: 0, + }] + ); + + // A window that stays put reports nothing: its claims still meet it. + assert!(assess_window_record_impact(¤t, ¤t.clone(), &snapshot, now).is_empty()); + } + + #[test] + fn standing_claims_are_guarded_when_the_current_window_record_is_missing() { + let proposed = household_windows(); + let now = utc(6, 0); + let snapshot = crate::model::LedgerSnapshot { + claims: vec![LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }], + }; + + assert!(assess_window_record_impact(&[], &proposed, &snapshot, now).is_empty()); + + let mut inadequate = proposed.clone(); + inadequate[0].units = 1; + assert_eq!( + assess_window_record_impact(&[], &inadequate, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 1, + }] + ); + } + + #[test] + fn scheduling_observer_hooks_accept_only_the_closed_runtime_contract() { + let mut policy = minimal_exact_time_policy(); + policy.hooks = vec![serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses")]; + assert!(policy.check().is_empty()); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "wasm", "module": "observer.wasm", "abi": "vendor.hook/v9"}, + })) + .expect("the declaration shape parses before semantic validation"); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"hooks[0].handler.abi: unsupported-hook-abi".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "before", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the declaration shape parses before semantic validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].phase: unsupported-hook-phase".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "rhai", "script": "observer.rhai"}, + })) + .expect("the declaration shape parses before semantic validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].handler.abi: unsupported-hook-abi".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "https://receiver.example"}, + })) + .expect("the declaration shape parses before product validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered + .contains(&"hooks[0].handler.destinationId: invalid-hook-destination".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses"); + policy.hooks.push(policy.hooks[0].clone()); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[1].id: duplicate-identifier".to_owned())); + + for (member, value, expected) in [ + ( + "trigger", + serde_json::json!("hold.expired"), + "hooks[0].trigger: unsupported-hook-trigger", + ), + ( + "when", + serde_json::json!({"state": "active"}), + "hooks[0].when: unsupported-hook-condition", + ), + ( + "principal", + serde_json::json!("scheduling-hook"), + "hooks[0].principal: unsupported-hook-principal", + ), + ( + "projection", + serde_json::json!(["actor"]), + "hooks[0].projection: unsupported-hook-projection", + ), + ] { + let mut declaration = serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + }); + declaration[member] = value; + policy.hooks = vec![serde_json::from_value(declaration).expect("the shape parses")]; + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&expected.to_owned()), "{rendered:?}"); + } + + policy.hooks = vec![serde_json::from_value(serde_json::json!({ + "id": "cancellation-observer", + "phase": "after", + "trigger": "appointment.cancelled", + "projection": ["reason"], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses")]; + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].projection: unsupported-hook-projection".to_owned())); + } + + #[test] + fn legacy_duplicate_and_mismatched_hook_members_are_refused_by_the_shared_shape() { + let base = + serde_norway::to_string(&minimal_exact_time_policy()).expect("the fixture serializes"); + for hooks in [ + "hooks:\n- id: observer\n abi: registry.scheduling-hook/v1\n because: legacy", + "hooks:\n- id: observer\n phase: after\n phase: before\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: url\n destinationId: appointment-events", + "hooks:\n- id: observer\n phase: after\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: wasm\n script: observer.rhai\n abi: registry.hook-handler/v1", + ] { + let yaml = base.replace("hooks: []", hooks); + assert!(parse_policy_yaml(&yaml).is_err(), "accepted:\n{hooks}"); + } + } + + #[test] + fn a_declared_leftover_policy_is_refused_because_nothing_reads_it() { + let policy = household_window_policy(); + let mut windows = household_windows(); + assert!(policy.check_window_records(&windows).is_empty()); + + windows[0].leftover = Some(LeftoverCapacityPolicy::BecomesWalkIn); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"windows[0].leftover: leftover-unsupported".to_owned())); + } + + #[test] + fn identifiers_dates_and_times_follow_their_grammars() { + assert!(valid_identifier("north-counter-2")); + assert!(!valid_identifier("North")); + assert!(!valid_identifier("north counter")); + assert!(!valid_identifier("")); + assert!(!valid_identifier(&"x".repeat(65))); + assert!(valid_hh_mm("09:00")); + assert!(!valid_hh_mm("9:00")); + assert!(!valid_hh_mm("24:00")); + assert!(valid_iso_date("2026-10-05")); + assert!(!valid_iso_date("2026-10-5")); + assert!(!valid_iso_date("2026-02-30")); + } + + /// A value whose text is outside its grammar is not an unfinished + /// project: no addition turns `9:00 AM` into a clock, only a correction. + /// The reason says so, and it names the field that is actually malformed, + /// so adopter tooling can refuse the document instead of reporting it as + /// incomplete authoring. A well-formed value that breaks its range stays + /// an invalid bound: the text parses, the range does not hold. + #[test] + fn a_clock_or_date_outside_its_grammar_is_malformed_rather_than_unbounded() { + let rendered = |policy: &SchedulingPolicy| -> Vec { + policy.check().iter().map(ToString::to_string).collect() + }; + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].start_time = "9:00 AM".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].startTime: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].end_time = "12.30".to_owned(); + assert_eq!(rendered(&policy), ["openings[0].endTime: malformed-value"]); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_from = "01/10/2026".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].effectiveFrom: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.holiday_sets[0].dates[0] = "25 December 2026".to_owned(); + assert_eq!( + rendered(&policy), + ["holidaySets[0].dates[0]: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].end_time = "08:00".to_owned(); + assert_eq!(rendered(&policy), ["openings[0].endTime: invalid-bound"]); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_until = "2026-09-30".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].effectiveUntil: invalid-bound"] + ); + } + + #[test] + fn weekdays_and_channels_serialize_in_their_published_spelling() { + assert_eq!( + serde_norway::to_string(&PolicyWeekday::Wed).unwrap(), + "wed\n" + ); + assert_eq!( + serde_norway::from_str::("wed").unwrap(), + PolicyWeekday::Wed + ); + assert_eq!(Channel::WalkIn.as_str(), "walk-in"); + assert_eq!( + serde_norway::from_str::("walk-in").unwrap(), + Channel::WalkIn + ); + assert_eq!(PolicyWeekday::Sun.to_chrono(), chrono::Weekday::Sun); + } + + #[test] + fn party_counts_still_parse_from_the_policy_surface() { + let party = PartyCounts { + recipients: 2, + attendees: 3, + }; + assert_eq!( + serde_json::to_value(party).unwrap(), + serde_json::json!({"recipients": 2, "attendees": 3}) + ); + } + + #[test] + fn unknown_policy_fields_are_refused() { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: broken + version: 1 +services: [] +offerings: [] +holidaySets: [] +openings: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short. +surprise: true +"#; + assert!(parse_policy_yaml(yaml).is_err()); + } + + #[test] + fn collection_bounds_are_enforced() { + let mut policy = minimal_exact_time_policy(); + policy.services = (0..MAXIMUM_COLLECTION_ENTRIES + 1) + .map(|index| ServicePolicy { + id: format!("service-{index}"), + label: "Filler service".to_owned(), + }) + .collect(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"services: invalid-bound".to_owned())); + + let at_bound = minimal_exact_time_policy(); + assert!(at_bound.check().is_empty()); + } +} diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs new file mode 100644 index 0000000000..1b05e820fb --- /dev/null +++ b/crates/registry-scheduling-core/src/problem.rs @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The closed problem vocabulary of Registry Scheduling. +//! +//! The vocabulary is closed on purpose: a caller can enumerate every problem +//! this product can ever return, and a problem code outside this list is a +//! defect, not a compatibility event. `authorization.refused` is deliberately +//! absent everywhere in this file: it is an audit reason family used by other +//! Registry Stack products to describe why a decision was recorded, and audit +//! reasons and problem codes must not be conflated. The authorization refusals +//! callers see here are `authentication.refused`, `operation.not-authorized`, +//! and `profile.not-authorized`. +//! +//! The `request.*` family carries the request-edge rejections (a route that +//! does not exist, a method the route refuses, a body too large or not JSON) +//! under this product's own prefix, exactly as Casework does: no shared +//! platform problem prefix exists in the Registry Stack catalog. + +use crate::naming::SCHEDULING_PROBLEM_TYPE_BASE; + +pub const AUTHENTICATION_REFUSED_PROBLEM: &str = "authentication.refused"; +pub const BOOKING_DUPLICATE_ACTIVE_PROBLEM: &str = "booking.duplicate-active"; +pub const CANCELLATION_CUTOFF_PASSED_PROBLEM: &str = "cancellation.cutoff-passed"; +pub const CAPABILITY_UNMATCHED_PROBLEM: &str = "capability.unmatched"; +pub const CAPACITY_EXHAUSTED_PROBLEM: &str = "capacity.exhausted"; +pub const CURSOR_EXPIRED_PROBLEM: &str = "cursor.expired"; +pub const CURSOR_INVALID_PROBLEM: &str = "cursor.invalid"; +pub const ELIGIBILITY_UNAVAILABLE_PROBLEM: &str = "eligibility.unavailable"; +pub const HOLD_EXPIRED_PROBLEM: &str = "hold.expired"; +pub const HOLD_RELEASED_PROBLEM: &str = "hold.released"; +pub const HORIZON_OUTSIDE_PROBLEM: &str = "horizon.outside"; +pub const HOOK_UNAVAILABLE_PROBLEM: &str = "hook.unavailable"; +pub const IDEMPOTENCY_EXPIRED_PROBLEM: &str = "idempotency.expired"; +pub const IDEMPOTENCY_KEY_REUSED_PROBLEM: &str = "idempotency.key-reused"; +pub const LOCATION_CLOSED_PROBLEM: &str = "location.closed"; +pub const OPERATION_NOT_AUTHORIZED_PROBLEM: &str = "operation.not-authorized"; +pub const PARTY_CAPACITY_INADEQUATE_PROBLEM: &str = "party.capacity-inadequate"; +pub const POLICY_CHANGED_PROBLEM: &str = "policy.changed"; +pub const PRECONDITION_FAILED_PROBLEM: &str = "precondition.failed"; +pub const PRECONDITION_REQUIRED_PROBLEM: &str = "precondition.required"; +pub const PREREQUISITE_MISSING_PROBLEM: &str = "prerequisite.missing"; +pub const PROFILE_NOT_AUTHORIZED_PROBLEM: &str = "profile.not-authorized"; +pub const REQUEST_BODY_TOO_LARGE_PROBLEM: &str = "request.body-too-large"; +pub const REQUEST_INVALID_PROBLEM: &str = "request.invalid"; +pub const REQUEST_METHOD_NOT_ALLOWED_PROBLEM: &str = "request.method-not-allowed"; +pub const REQUEST_NOT_FOUND_PROBLEM: &str = "request.not-found"; +pub const REQUEST_UNPROCESSABLE_PROBLEM: &str = "request.unprocessable"; +pub const REQUEST_UNSUPPORTED_MEDIA_TYPE_PROBLEM: &str = "request.unsupported-media-type"; +pub const RESOURCE_UNAVAILABLE_PROBLEM: &str = "resource.unavailable"; +pub const REVISION_MISMATCH_PROBLEM: &str = "revision.mismatch"; +pub const SCHEDULE_UNPUBLISHED_PROBLEM: &str = "schedule.unpublished"; +pub const SERVICE_UNAVAILABLE_PROBLEM: &str = "service.unavailable"; + +/// Every problem code Registry Scheduling can return, in code-string order. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ProblemCode { + AuthenticationRefused, + BookingDuplicateActive, + CancellationCutoffPassed, + CapabilityUnmatched, + CapacityExhausted, + CursorExpired, + CursorInvalid, + EligibilityUnavailable, + HoldExpired, + HoldReleased, + HookUnavailable, + HorizonOutside, + IdempotencyExpired, + IdempotencyKeyReused, + LocationClosed, + OperationNotAuthorized, + PartyCapacityInadequate, + PolicyChanged, + PreconditionFailed, + PreconditionRequired, + PrerequisiteMissing, + ProfileNotAuthorized, + RequestBodyTooLarge, + RequestInvalid, + RequestMethodNotAllowed, + RequestNotFound, + RequestUnprocessable, + RequestUnsupportedMediaType, + ResourceUnavailable, + RevisionMismatch, + ScheduleUnpublished, + ServiceUnavailable, +} + +impl ProblemCode { + /// The complete closed vocabulary, in code-string order. + pub const ALL: &'static [Self] = &[ + Self::AuthenticationRefused, + Self::BookingDuplicateActive, + Self::CancellationCutoffPassed, + Self::CapabilityUnmatched, + Self::CapacityExhausted, + Self::CursorExpired, + Self::CursorInvalid, + Self::EligibilityUnavailable, + Self::HoldExpired, + Self::HoldReleased, + Self::HookUnavailable, + Self::HorizonOutside, + Self::IdempotencyExpired, + Self::IdempotencyKeyReused, + Self::LocationClosed, + Self::OperationNotAuthorized, + Self::PartyCapacityInadequate, + Self::PolicyChanged, + Self::PreconditionFailed, + Self::PreconditionRequired, + Self::PrerequisiteMissing, + Self::ProfileNotAuthorized, + Self::RequestBodyTooLarge, + Self::RequestInvalid, + Self::RequestMethodNotAllowed, + Self::RequestNotFound, + Self::RequestUnprocessable, + Self::RequestUnsupportedMediaType, + Self::ResourceUnavailable, + Self::RevisionMismatch, + Self::ScheduleUnpublished, + Self::ServiceUnavailable, + ]; + + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::AuthenticationRefused => AUTHENTICATION_REFUSED_PROBLEM, + Self::BookingDuplicateActive => BOOKING_DUPLICATE_ACTIVE_PROBLEM, + Self::CancellationCutoffPassed => CANCELLATION_CUTOFF_PASSED_PROBLEM, + Self::CapabilityUnmatched => CAPABILITY_UNMATCHED_PROBLEM, + Self::CapacityExhausted => CAPACITY_EXHAUSTED_PROBLEM, + Self::CursorExpired => CURSOR_EXPIRED_PROBLEM, + Self::CursorInvalid => CURSOR_INVALID_PROBLEM, + Self::EligibilityUnavailable => ELIGIBILITY_UNAVAILABLE_PROBLEM, + Self::HoldExpired => HOLD_EXPIRED_PROBLEM, + Self::HoldReleased => HOLD_RELEASED_PROBLEM, + Self::HorizonOutside => HORIZON_OUTSIDE_PROBLEM, + Self::HookUnavailable => HOOK_UNAVAILABLE_PROBLEM, + Self::IdempotencyExpired => IDEMPOTENCY_EXPIRED_PROBLEM, + Self::IdempotencyKeyReused => IDEMPOTENCY_KEY_REUSED_PROBLEM, + Self::LocationClosed => LOCATION_CLOSED_PROBLEM, + Self::OperationNotAuthorized => OPERATION_NOT_AUTHORIZED_PROBLEM, + Self::PartyCapacityInadequate => PARTY_CAPACITY_INADEQUATE_PROBLEM, + Self::PolicyChanged => POLICY_CHANGED_PROBLEM, + Self::PreconditionFailed => PRECONDITION_FAILED_PROBLEM, + Self::PreconditionRequired => PRECONDITION_REQUIRED_PROBLEM, + Self::PrerequisiteMissing => PREREQUISITE_MISSING_PROBLEM, + Self::ProfileNotAuthorized => PROFILE_NOT_AUTHORIZED_PROBLEM, + Self::RequestBodyTooLarge => REQUEST_BODY_TOO_LARGE_PROBLEM, + Self::RequestInvalid => REQUEST_INVALID_PROBLEM, + Self::RequestMethodNotAllowed => REQUEST_METHOD_NOT_ALLOWED_PROBLEM, + Self::RequestNotFound => REQUEST_NOT_FOUND_PROBLEM, + Self::RequestUnprocessable => REQUEST_UNPROCESSABLE_PROBLEM, + Self::RequestUnsupportedMediaType => REQUEST_UNSUPPORTED_MEDIA_TYPE_PROBLEM, + Self::ResourceUnavailable => RESOURCE_UNAVAILABLE_PROBLEM, + Self::RevisionMismatch => REVISION_MISMATCH_PROBLEM, + Self::ScheduleUnpublished => SCHEDULE_UNPUBLISHED_PROBLEM, + Self::ServiceUnavailable => SERVICE_UNAVAILABLE_PROBLEM, + } + } + + /// Resolve a code string back to its variant, or `None` for any string + /// outside the closed vocabulary. + #[must_use] + pub fn from_code(code: &str) -> Option { + Self::ALL + .iter() + .copied() + .find(|candidate| candidate.code() == code) + } + + /// Whether this code is disclosed only through the separately authorized + /// explain path, never as the public projection of a refusal. A replay + /// expectation compares the public projection, so a detailed-only code can + /// never match one and is refused before any case runs. + #[must_use] + pub const fn is_detailed_only(self) -> bool { + matches!(self, Self::ResourceUnavailable) + } + + /// The HTTP status this problem answers with. The map lives here, beside + /// the vocabulary it belongs to, so the runtime emits and every client + /// validates one pinned status per code rather than each re-deriving its + /// own. + #[must_use] + pub const fn http_status(self) -> u16 { + match self { + Self::CursorInvalid | Self::RequestInvalid => 400, + Self::AuthenticationRefused => 401, + Self::OperationNotAuthorized | Self::ProfileNotAuthorized => 403, + Self::RequestNotFound => 404, + Self::RequestMethodNotAllowed => 405, + Self::RequestBodyTooLarge => 413, + Self::RequestUnsupportedMediaType => 415, + Self::BookingDuplicateActive + | Self::CancellationCutoffPassed + | Self::CapacityExhausted + | Self::HoldReleased + | Self::IdempotencyKeyReused + | Self::LocationClosed + | Self::ResourceUnavailable => 409, + Self::CursorExpired | Self::HoldExpired | Self::IdempotencyExpired => 410, + Self::PolicyChanged | Self::PreconditionFailed | Self::RevisionMismatch => 412, + Self::CapabilityUnmatched + | Self::HorizonOutside + | Self::PartyCapacityInadequate + | Self::PrerequisiteMissing + | Self::RequestUnprocessable + | Self::ScheduleUnpublished => 422, + Self::PreconditionRequired => 428, + Self::EligibilityUnavailable | Self::HookUnavailable | Self::ServiceUnavailable => 503, + } + } + + /// The fixed, value-free problem title. Titles and details carry no + /// identifiers, so a problem body never discloses another person's + /// booking, a private staff reason, or restricted eligibility + /// information. + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::AuthenticationRefused => "Authentication refused", + Self::BookingDuplicateActive => "Duplicate active booking", + Self::CancellationCutoffPassed => "Cancellation cutoff passed", + Self::CapabilityUnmatched => "Capability unmatched", + Self::CapacityExhausted => "Capacity exhausted", + Self::CursorExpired => "Cursor expired", + Self::CursorInvalid => "Cursor invalid", + Self::EligibilityUnavailable => "Eligibility unavailable", + Self::HoldExpired => "Hold expired", + Self::HoldReleased => "Hold released", + Self::HookUnavailable => "Hook unavailable", + Self::HorizonOutside => "Start outside the booking horizon", + Self::IdempotencyExpired => "Idempotency window expired", + Self::IdempotencyKeyReused => "Idempotency key reused", + Self::LocationClosed => "Location closed", + Self::OperationNotAuthorized => "Operation not authorized", + Self::PartyCapacityInadequate => "Party capacity inadequate", + Self::PolicyChanged => "Policy changed", + Self::PreconditionFailed => "Precondition failed", + Self::PreconditionRequired => "Precondition required", + Self::PrerequisiteMissing => "Prerequisite missing", + Self::ProfileNotAuthorized => "Profile not authorized", + Self::RequestBodyTooLarge => "Payload too large", + Self::RequestInvalid => "Request invalid", + Self::RequestMethodNotAllowed => "Method not allowed", + Self::RequestNotFound => "Route not found", + Self::RequestUnprocessable => "Request unprocessable", + Self::RequestUnsupportedMediaType => "Unsupported media type", + Self::ResourceUnavailable => "Resource unavailable", + Self::RevisionMismatch => "Revision mismatch", + Self::ScheduleUnpublished => "Schedule unpublished", + Self::ServiceUnavailable => "Scheduling service unavailable", + } + } + + /// The fixed remediation sentence, value-free like the title. + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + Self::AuthenticationRefused => { + "The bearer credential is missing, invalid, or expired. Sign in again." + } + Self::BookingDuplicateActive => { + "An active booking already holds this party's duplicate key. Cancel or complete it before booking again." + } + Self::CancellationCutoffPassed => { + "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled." + } + Self::CapabilityUnmatched => { + "The party or backing supply does not carry every capability this offering requires." + } + Self::CapacityExhausted => { + "The supply is fully committed for the requested interval. Choose another time." + } + Self::CursorExpired => { + "This cursor has expired. Start again without a cursor and deduplicate entries by id." + } + Self::CursorInvalid => "The cursor is invalid for this request.", + Self::EligibilityUnavailable => { + "A required eligibility check could not run. Retry; do not treat this as permission." + } + Self::HoldExpired => { + "The hold expired before confirmation. Its capacity is bookable again; start a new request." + } + Self::HoldReleased => { + "The claim is not an active hold. It may already be confirmed or released." + } + Self::HookUnavailable => { + "A required lifecycle hook could not run, so the request was refused rather than half-applied." + } + Self::HorizonOutside => { + "The requested start is earlier than the lead time allows or further ahead than the horizon allows." + } + Self::IdempotencyExpired => { + "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key." + } + Self::IdempotencyKeyReused => { + "This idempotency key was used for a different request." + } + Self::LocationClosed => { + "A closure covers the requested start. Choose a start outside the closure." + } + Self::OperationNotAuthorized => { + "Your current Scheduling authority does not allow this operation." + } + Self::PartyCapacityInadequate => { + "The party is larger than this offering can ever serve, or its size falls outside the published units." + } + Self::PolicyChanged => { + "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision." + } + Self::PreconditionFailed => { + "The appointment changed since you loaded it. Reload and try again." + } + Self::PreconditionRequired => { + "This mutation requires the revision you loaded, or the duplicate key this offering keys on." + } + Self::PrerequisiteMissing => { + "The party is missing a prerequisite this offering requires." + } + Self::ProfileNotAuthorized => { + "The selected Scheduling profile does not authorize this request." + } + Self::RequestBodyTooLarge => "The request body exceeds the accepted size.", + Self::RequestInvalid => "The request could not be read as a Scheduling request.", + Self::RequestMethodNotAllowed => "The route exists but not for this method.", + Self::RequestNotFound => "The requested route does not exist.", + Self::RequestUnprocessable => "The request body could not be processed.", + Self::RequestUnsupportedMediaType => "The request body is not JSON.", + Self::ResourceUnavailable => { + "Every capable member is unavailable for this interval." + } + Self::RevisionMismatch => { + "The window revision changed before the request committed. Reload the catalogue and try again." + } + Self::ScheduleUnpublished => { + "No published schedule serves that start. Choose a start on the published grid." + } + Self::ServiceUnavailable => { + "Scheduling storage is unavailable. Try again after the service recovers." + } + } + } +} + +/// Expand a dotted problem code into its full problem type URI. +#[must_use] +pub fn type_uri(code: &str) -> String { + format!("{}{}", SCHEDULING_PROBLEM_TYPE_BASE, code.replace('.', "/")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::naming::SCHEDULING_PROBLEM_TYPE_BASE; + + #[test] + fn the_vocabulary_is_complete_and_closed() { + assert_eq!(ProblemCode::ALL.len(), 32); + for code in ProblemCode::ALL { + assert_eq!(ProblemCode::from_code(code.code()), Some(*code)); + } + assert_eq!(ProblemCode::from_code("authorization.refused"), None); + assert_eq!(ProblemCode::from_code("capacity.unexhausted"), None); + assert_eq!(ProblemCode::from_code(""), None); + } + + /// Exactly one code is detailed-only. A second would mean the public and + /// detailed projections diverge in more than the one place the disclosure + /// contract describes, so this count is a decision point, not a tally. + #[test] + fn exactly_one_code_is_detailed_only() { + let detailed: Vec = ProblemCode::ALL + .iter() + .copied() + .filter(|code| code.is_detailed_only()) + .collect(); + assert_eq!(detailed, vec![ProblemCode::ResourceUnavailable]); + } + + #[test] + fn codes_are_listed_in_code_string_order() { + let codes: Vec<&str> = ProblemCode::ALL.iter().map(|code| code.code()).collect(); + let mut sorted = codes.clone(); + sorted.sort(); + assert_eq!(codes, sorted); + } + + #[test] + fn type_uris_expand_dots_into_path_segments() { + assert_eq!( + type_uri(CAPACITY_EXHAUSTED_PROBLEM), + format!("{SCHEDULING_PROBLEM_TYPE_BASE}capacity/exhausted") + ); + assert_eq!( + type_uri(IDEMPOTENCY_KEY_REUSED_PROBLEM), + format!("{SCHEDULING_PROBLEM_TYPE_BASE}idempotency/key-reused") + ); + } + + /// Every code carries one pinned status from the statuses this product + /// answers with, a non-empty title, and a non-empty value-free detail. + /// The request-edge family carries the transport statuses (404, 405, + /// 413, 415) under this product's own prefix, exactly as Casework does. + #[test] + fn every_code_pins_a_status_title_and_detail() { + let statuses = [ + 400, 401, 403, 404, 405, 409, 410, 412, 413, 415, 422, 428, 503, + ]; + for code in ProblemCode::ALL { + assert!( + statuses.contains(&code.http_status()), + "{}: {}", + code.code(), + code.http_status() + ); + assert!(!code.title().is_empty(), "{}", code.code()); + assert!(!code.detail().is_empty(), "{}", code.code()); + } + } + + /// The request-edge family is Casework's, pinned one status per code, so + /// the runtime's edge and every client agree on what a rejected request + /// looks like. + #[test] + fn the_request_edge_family_pins_casework_s_statuses() { + assert_eq!(ProblemCode::RequestInvalid.http_status(), 400); + assert_eq!(ProblemCode::RequestNotFound.http_status(), 404); + assert_eq!(ProblemCode::RequestMethodNotAllowed.http_status(), 405); + assert_eq!(ProblemCode::RequestBodyTooLarge.http_status(), 413); + assert_eq!(ProblemCode::RequestUnsupportedMediaType.http_status(), 415); + assert_eq!(ProblemCode::RequestUnprocessable.http_status(), 422); + for code in [ + ProblemCode::RequestInvalid, + ProblemCode::RequestNotFound, + ProblemCode::RequestMethodNotAllowed, + ProblemCode::RequestBodyTooLarge, + ProblemCode::RequestUnsupportedMediaType, + ProblemCode::RequestUnprocessable, + ] { + assert!(code.code().starts_with("request."), "{}", code.code()); + } + } + + /// The statuses the acceptance scenarios reason about: a recoverable + /// capacity conflict, a hold that can no longer be confirmed, the two + /// idempotency answers, and the two precondition answers. + #[test] + fn the_pinned_statuses_match_the_acceptance_reasoning() { + assert_eq!(ProblemCode::CapacityExhausted.http_status(), 409); + assert_eq!(ProblemCode::HoldExpired.http_status(), 410); + assert_eq!(ProblemCode::IdempotencyExpired.http_status(), 410); + assert_eq!(ProblemCode::IdempotencyKeyReused.http_status(), 409); + assert_eq!(ProblemCode::PreconditionFailed.http_status(), 412); + assert_eq!(ProblemCode::PreconditionRequired.http_status(), 428); + // The detailed-only code shares its public projection's status: the + // explain path names the reason, never a different outcome. + assert_eq!( + ProblemCode::ResourceUnavailable.http_status(), + ProblemCode::CapacityExhausted.http_status() + ); + } +} diff --git a/crates/registry-scheduling-core/src/resolve.rs b/crates/registry-scheduling-core/src/resolve.rs new file mode 100644 index 0000000000..08b6d3d17b --- /dev/null +++ b/crates/registry-scheduling-core/src/resolve.rs @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Policy-context resolution: the authored policy and the environment +//! records resolved into the concrete calendar intervals an evaluation, an +//! availability walk, or an offline replay runs against. +//! +//! This is the one place the product turns policy plus facts into openings +//! and closures, so the offline fixture replay and the live runtime expand +//! exactly the same intervals and can never disagree about what a location +//! published. + +use chrono::{DateTime, Utc}; +use registry_platform_calendar::{ + apply_exceptions_to_openings, expand_weekly_openings, local_interval, CalendarEvaluationError, + CalendarInterval, HolidaySetRevision, WeeklyOpeningPattern, +}; + +use crate::model::{ExceptionRecordKind, SchedulingFacts}; +use crate::policy::SchedulingPolicy; + +/// Resolution stopped because the policy and the facts do not fit together. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ResolveError { + #[error( + "opening {opening} names holiday set {holiday_set}, which the policy does not declare" + )] + HolidaySetMissing { + opening: String, + holiday_set: String, + }, + #[error("the calendar refused the configuration: {0}")] + Calendar(#[from] CalendarEvaluationError), +} + +/// Expand every authored opening at one location into concrete UTC intervals, +/// layering the location's dated exceptions and each opening's holiday set. +/// +/// The result is the canonical published schedule: sorted, disjoint, and with +/// intervals that overlap or meet joined into one, so authoring order is +/// never observable. That matters twice over, because both answers an +/// evaluator reads from this list are properties of a single interval: an +/// appointment is served when one interval covers it, and its start grid is +/// anchored to the start of the interval that covers it. Concatenating each +/// pattern's own expansion would let the order two openings happen to be +/// listed in decide which starts a caller may book, and would refuse an +/// appointment running across two openings the location publishes as +/// continuous. +pub fn location_open_intervals( + policy: &SchedulingPolicy, + location_id: &str, + timezone: &str, + exceptions: &[registry_platform_calendar::CalendarException<'_>], +) -> Result, ResolveError> { + let mut all = Vec::new(); + for opening in policy.openings.iter().filter(|o| o.location == location_id) { + // A holiday-set reference the policy does not declare is a policy + // check finding; the calendar cannot expand against it, so resolution + // stops with the opening that carries it instead of panicking. + let Some(holiday_set) = policy.holiday_set(&opening.holiday_set) else { + return Err(ResolveError::HolidaySetMissing { + opening: opening.id.clone(), + holiday_set: opening.holiday_set.clone(), + }); + }; + let weekdays: Vec = + opening.weekdays.iter().map(|day| day.to_chrono()).collect(); + let pattern = WeeklyOpeningPattern { + id: &opening.id, + timezone, + weekdays: &weekdays, + start_time: &opening.start_time, + end_time: &opening.end_time, + effective_from: &opening.effective_from, + effective_until: &opening.effective_until, + }; + let dates: Vec<&str> = holiday_set.dates.iter().map(String::as_str).collect(); + let revision = HolidaySetRevision { + holiday_set: &holiday_set.id, + revision: holiday_set.revision, + dates: &dates, + }; + all.extend(expand_weekly_openings(&pattern, &[], &revision)?); + } + apply_exceptions_to_openings(merge_intervals(all), timezone, exceptions).map_err(Into::into) +} + +/// Sort intervals and join every pair that overlaps or meets into one. +/// +/// Meeting counts as continuous: a location whose morning pattern ends where +/// its afternoon pattern begins never closed between them, and an hour of that +/// day is one published hour however many patterns authored it. +fn merge_intervals(mut intervals: Vec) -> Vec { + intervals.sort_by_key(|interval| (interval.start, interval.end)); + let mut merged: Vec = Vec::with_capacity(intervals.len()); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.start <= last.end => last.end = last.end.max(interval.end), + _ => merged.push(interval), + } + } + merged +} + +/// Whether the union of `intervals` covers the whole span `[start, end)`. +pub fn covers_span( + intervals: &[CalendarInterval], + start: DateTime, + end: DateTime, +) -> bool { + let mut overlapping: Vec = intervals + .iter() + .filter(|interval| interval.start < end && start < interval.end) + .copied() + .collect(); + overlapping.sort_by_key(|interval| (interval.start, interval.end)); + + let mut covered_until = start; + for interval in overlapping { + if interval.start > covered_until { + return false; + } + covered_until = covered_until.max(interval.end); + if covered_until >= end { + return true; + } + } + covered_until >= end +} + +/// The dated closures recorded against one location, as concrete intervals. +pub fn location_closure_intervals( + facts: &SchedulingFacts, + location_id: &str, + timezone: &str, +) -> Result, ResolveError> { + let mut closures = Vec::new(); + for exception in facts + .exceptions + .iter() + .filter(|exception| exception.location == location_id) + { + if exception.kind == ExceptionRecordKind::Closure { + closures.push(local_interval( + &exception.date, + &exception.start_time, + &exception.end_time, + timezone, + )?); + } + } + Ok(closures) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::parse_policy_yaml; + use registry_platform_calendar::{CalendarException, CalendarExceptionKind}; + + fn opening(id: &str, start: &str, end: &str) -> String { + format!( + " - id: {id}\n location: north-counter\n holidaySet: office-holidays\n weekdays: [mon]\n startTime: \"{start}\"\n endTime: \"{end}\"\n effectiveFrom: \"2026-10-05\"\n effectiveUntil: \"2026-10-05\"\n because: Counter opening hours reviewed by the office manager.\n" + ) + } + + fn policy_with(openings: &str) -> SchedulingPolicy { + let text = format!( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 30 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: +{openings}holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"# + ); + parse_policy_yaml(&text).expect("the policy parses") + } + + fn resolve(policy: &SchedulingPolicy) -> Vec { + location_open_intervals(policy, "north-counter", "Asia/Bangkok", &[]) + .expect("the openings expand") + } + + fn instant(value: &str) -> DateTime { + value.parse().expect("an RFC 3339 instant") + } + + /// The start grid an offering publishes is anchored to the opening that + /// covers the request, so the order two openings happen to be authored in + /// may never decide which starts a caller can book. + #[test] + fn overlapping_openings_resolve_the_same_whichever_order_they_are_authored_in() { + let early_first = policy_with(&format!( + "{}{}", + opening("early", "09:00", "17:00"), + opening("late", "09:10", "17:00") + )); + let late_first = policy_with(&format!( + "{}{}", + opening("late", "09:10", "17:00"), + opening("early", "09:00", "17:00") + )); + assert_eq!(resolve(&early_first), resolve(&late_first)); + // One covering interval anchored at the earliest published start. + assert_eq!( + resolve(&early_first), + vec![CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }] + ); + } + + /// A location that opens straight from one authored pattern into the next + /// never closed between them, so the two resolve into one continuous + /// interval and an appointment may run across the join. + #[test] + fn abutting_openings_resolve_into_one_continuous_interval() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:30"), + opening("afternoon", "12:30", "17:00") + )); + assert_eq!( + resolve(&policy), + vec![CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }] + ); + } + + /// Resolution answers the coverage question once. An evaluator asking for + /// the single interval that covers an appointment and a fixture asking + /// whether the union covers it read the same published schedule, so a span + /// the union covers always lies inside one resolved interval. + #[test] + fn a_covered_span_always_lies_inside_one_resolved_interval() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:30"), + opening("afternoon", "12:30", "17:00") + )); + let open = resolve(&policy); + // 12:15 to 12:45 local Bangkok runs across the join between the two + // authored patterns. + let start = instant("2026-10-05T05:15:00Z"); + let end = instant("2026-10-05T05:45:00Z"); + assert!(covers_span(&open, start, end)); + assert!(open + .iter() + .any(|interval| interval.start <= start && end <= interval.end)); + + // A span reaching past the published hours is covered by neither. + let past_close = instant("2026-10-05T10:15:00Z"); + assert!(!covers_span(&open, start, past_close)); + assert!(!open + .iter() + .any(|interval| interval.start <= start && past_close <= interval.end)); + } + + /// Two openings the day apart stay two intervals: merging joins what a + /// location publishes as continuous, never what it publishes as separate. + #[test] + fn openings_that_do_not_meet_stay_separate_intervals() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:00"), + opening("afternoon", "13:00", "17:00") + )); + let open = resolve(&policy); + assert_eq!(open.len(), 2); + assert_eq!(open[0].end, instant("2026-10-05T05:00:00Z")); + assert_eq!(open[1].start, instant("2026-10-05T06:00:00Z")); + } + + /// Exceptions apply to the location's combined published hours. A + /// reopening inside the morning pattern must not be rejected merely + /// because the same location also has a disjoint afternoon pattern. + #[test] + fn reopening_inside_one_of_two_disjoint_openings_resolves_the_combined_union() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:00"), + opening("afternoon", "13:00", "17:00") + )); + let exceptions = [ + CalendarException { + id: "morning-closure", + kind: CalendarExceptionKind::Closure, + date: "2026-10-05", + start_time: "09:30", + end_time: "11:30", + reopens: None, + authority: None, + }, + CalendarException { + id: "morning-reopening", + kind: CalendarExceptionKind::Opening, + date: "2026-10-05", + start_time: "10:00", + end_time: "10:30", + reopens: Some("morning-closure"), + authority: Some("office-manager"), + }, + ]; + + assert_eq!( + location_open_intervals(&policy, "north-counter", "Asia/Bangkok", &exceptions) + .expect("the combined location schedule accepts the morning reopening"), + vec![ + CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T02:30:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T03:00:00Z"), + end: instant("2026-10-05T03:30:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T04:30:00Z"), + end: instant("2026-10-05T05:00:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T06:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }, + ] + ); + } +} diff --git a/crates/registry-scheduling-core/src/units.rs b/crates/registry-scheduling-core/src/units.rs new file mode 100644 index 0000000000..7057329088 --- /dev/null +++ b/crates/registry-scheduling-core/src/units.rs @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! How a party maps to the recipient units a capacity window allocates. +//! +//! The grammar is closed: a window either fixes the units, scales them per +//! service recipient, or reads them from a banded table whose only input is +//! the service recipient count. Whichever form is chosen, the function from +//! party to units must be total over every party size the offering accepts, +//! and what happens above the highest band must be declared, never defaulted. + +use serde::{Deserialize, Serialize}; + +use crate::diagnostics::{PolicyCheckReason, SchedulingDiagnostic}; + +/// The closed input set a banded table may read. Tier 1 reads exactly one. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BandedInput { + ServiceRecipientCount, +} + +/// One band of a banded units table: parties up to `up_to` recipients cost +/// `units`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnitBand { + pub up_to: u32, + pub units: u32, + pub because: String, +} + +/// What a party above the highest band costs, or that it is refused. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "policy", rename_all = "camelCase", deny_unknown_fields)] +pub enum AboveHighestBand { + Refuse, + Units { units: u32 }, +} + +/// How an offering converts a party into consumed recipient units. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RequiredUnitsPolicy { + /// Every party costs the same fixed units. + Fixed { units: u32, because: String }, + /// A party costs its recipient count times `per_recipient`. + PerRecipient { per_recipient: u32, because: String }, + /// A party costs whatever band its recipient count falls in. + BandedTable { + input: BandedInput, + bands: Vec, + above_highest_band: AboveHighestBand, + because: String, + }, +} + +/// A units table refused a party instead of guessing a cost. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum UnitsEvaluationError { + #[error("the recipient count falls above the highest band and the table refuses it")] + RefusedAboveHighestBand, + #[error("the units arithmetic overflowed")] + Overflow, +} + +impl RequiredUnitsPolicy { + /// The total function from service recipient count to consumed units. + pub fn evaluate(&self, recipients: u32) -> Result { + match self { + Self::Fixed { units, .. } => Ok(*units), + Self::PerRecipient { per_recipient, .. } => u64::from(*per_recipient) + .checked_mul(u64::from(recipients)) + .filter(|total| *total <= u64::from(u32::MAX)) + .map(|total| total as u32) + .ok_or(UnitsEvaluationError::Overflow), + Self::BandedTable { + bands, + above_highest_band, + .. + } => { + let band = bands.iter().find(|band| recipients <= band.up_to); + match (band, above_highest_band) { + (Some(band), _) => Ok(band.units), + // Above the highest band the table does what it declares: + // refuse, or cost the declared units. Never a guess. + (None, AboveHighestBand::Refuse) => { + Err(UnitsEvaluationError::RefusedAboveHighestBand) + } + (None, AboveHighestBand::Units { units }) => Ok(*units), + } + } + } + } + + /// Check the authored form: bands must exist, rise strictly, carry + /// positive units, declare their behavior above the highest band, and + /// every `because` must hold its bytes. + pub fn check(&self, path: &str) -> Vec { + let mut findings = Vec::new(); + match self { + Self::Fixed { units, because } => { + if *units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.units"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(because, &format!("{path}.because"), &mut findings); + } + Self::PerRecipient { + per_recipient, + because, + } => { + if *per_recipient == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.perRecipient"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(because, &format!("{path}.because"), &mut findings); + } + Self::BandedTable { + input: _, + bands, + above_highest_band, + because, + } => { + if bands.is_empty() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.bands"), + PolicyCheckReason::InvalidBands, + )); + } + let mut highest = 0; + for (index, band) in bands.iter().enumerate() { + let band_path = format!("{path}.bands[{index}]"); + if band.up_to <= highest || band.units == 0 { + findings.push(SchedulingDiagnostic::new( + band_path.clone(), + PolicyCheckReason::InvalidBands, + )); + } + highest = highest.max(band.up_to); + check_because( + &band.because, + &format!("{band_path}.because"), + &mut findings, + ); + } + if let AboveHighestBand::Units { units } = above_highest_band { + if *units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.aboveHighestBand.units"), + PolicyCheckReason::InvalidBound, + )); + } + } + check_because(because, &format!("{path}.because"), &mut findings); + } + } + findings + } +} + +/// A `because` must be present, non-blank, and at most 256 bytes: every rule +/// that decides capacity carries its recorded reason. +pub fn check_because(because: &str, path: &str, findings: &mut Vec) { + if because.trim().is_empty() || because.len() > MAXIMUM_BECAUSE_BYTES { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidBecause, + )); + } +} + +/// The byte bound every recorded `because` obeys. +pub const MAXIMUM_BECAUSE_BYTES: usize = 256; + +#[cfg(test)] +mod tests { + use super::*; + + fn banded() -> RequiredUnitsPolicy { + RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![ + UnitBand { + up_to: 1, + units: 1, + because: "A single recipient consumes one serving slot.".to_owned(), + }, + UnitBand { + up_to: 4, + units: 2, + because: "A household of up to four shares one officers' block.".to_owned(), + }, + ], + above_highest_band: AboveHighestBand::Refuse, + because: "Household sizing reviewed by the registry office in 2026.".to_owned(), + } + } + + #[test] + fn fixed_per_recipient_and_banded_tables_are_total_over_their_domain() { + let fixed = RequiredUnitsPolicy::Fixed { + units: 1, + because: "One appointment per party.".to_owned(), + }; + assert_eq!(fixed.evaluate(3), Ok(1)); + + let per_recipient = RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }; + assert_eq!(per_recipient.evaluate(3), Ok(3)); + + let table = banded(); + assert_eq!(table.evaluate(1), Ok(1)); + assert_eq!(table.evaluate(2), Ok(2)); + assert_eq!(table.evaluate(4), Ok(2)); + } + + #[test] + fn above_the_highest_band_the_table_does_what_it_declares_and_nothing_else() { + assert_eq!( + banded().evaluate(5), + Err(UnitsEvaluationError::RefusedAboveHighestBand) + ); + + let capped = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![UnitBand { + up_to: 2, + units: 1, + because: "A pair shares one block.".to_owned(), + }], + above_highest_band: AboveHighestBand::Units { units: 3 }, + because: "Larger parties consume a full officer block.".to_owned(), + }; + assert_eq!(capped.evaluate(9), Ok(3)); + } + + #[test] + fn per_recipient_arithmetic_refuses_to_overflow_silently() { + let scaling = RequiredUnitsPolicy::PerRecipient { + per_recipient: u32::MAX, + because: "Pathological scaling kept for the bound test.".to_owned(), + }; + assert_eq!(scaling.evaluate(2), Err(UnitsEvaluationError::Overflow)); + } + + #[test] + fn well_formed_policies_carry_no_findings() { + assert!(banded().check("windows[0].unitsPolicy").is_empty()); + + let per_recipient = RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }; + assert!(per_recipient.check("windows[0].unitsPolicy").is_empty()); + } + + #[test] + fn broken_tables_are_named_band_by_band() { + let broken = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![ + UnitBand { + up_to: 4, + units: 2, + because: "A household of up to four shares one block.".to_owned(), + }, + UnitBand { + up_to: 4, + units: 0, + because: " ".to_owned(), + }, + ], + above_highest_band: AboveHighestBand::Units { units: 0 }, + because: String::new(), + }; + let findings = broken.check("windows[0].unitsPolicy"); + let rendered: Vec = findings.iter().map(|finding| finding.to_string()).collect(); + // Band 0 declares four first, so band 1 fails both the non-rising and + // the zero-units rule; its blank because is named separately. + assert_eq!( + rendered, + vec![ + "windows[0].unitsPolicy.bands[1]: invalid-bands".to_owned(), + "windows[0].unitsPolicy.bands[1].because: invalid-because".to_owned(), + "windows[0].unitsPolicy.aboveHighestBand.units: invalid-bound".to_owned(), + "windows[0].unitsPolicy.because: invalid-because".to_owned(), + ] + ); + } + + #[test] + fn because_bounds_are_inclusive_at_256_bytes() { + let at_bound = RequiredUnitsPolicy::Fixed { + units: 1, + because: "x".repeat(MAXIMUM_BECAUSE_BYTES), + }; + assert!(at_bound.check("p").is_empty()); + + let over_bound = RequiredUnitsPolicy::Fixed { + units: 1, + because: "x".repeat(MAXIMUM_BECAUSE_BYTES + 1), + }; + assert_eq!( + over_bound.check("p"), + vec![SchedulingDiagnostic::new( + "p.because", + PolicyCheckReason::InvalidBecause + )] + ); + } + + #[test] + fn the_units_grammar_is_closed() { + // Tier 1 reads exactly one input, spelled one way. + assert!(serde_norway::from_str::("serviceRecipientCount").is_ok()); + assert!(serde_norway::from_str::("partyCount").is_err()); + + let yaml = "kind: fixed\nunits: 1\nbecause: One appointment per party.\n"; + assert!(serde_norway::from_str::(yaml).is_ok()); + assert!(serde_norway::from_str::( + "kind: fixed\nunits: 1\nbecause: One appointment per party.\nmystery: true\n" + ) + .is_err()); + + // Above the highest band is declared, never defaulted: dropping the + // field is a deserialization failure, not a silent choice. + let banded_yaml = "kind: bandedTable\ninput: serviceRecipientCount\n\ + bands:\n - upTo: 2\n units: 1\n because: A pair shares one block.\n"; + assert!(serde_norway::from_str::(banded_yaml).is_err()); + assert!(serde_norway::from_str::(&format!( + "{banded_yaml}aboveHighestBand:\n policy: refuse\nbecause: Sizing reviewed.\n" + )) + .is_ok()); + assert!(serde_norway::from_str::(&format!( + "{banded_yaml}aboveHighestBand:\n policy: units\n units: 3\nbecause: Sizing reviewed.\n" + )) + .is_ok()); + } +} diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs new file mode 100644 index 0000000000..11aba4e245 --- /dev/null +++ b/crates/registry-scheduling-core/src/wire.rs @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP wire documents of the Scheduling API. +//! +//! These types are the contract between the runtime and every client, so they +//! live in the source-neutral core beside the model they project, the way the +//! Casework DTOs live in `registry-casework-core`. Every document is camelCase +//! on the wire, and the direction it travels decides how strictly it is read. +//! +//! A request document is read by the runtime and refuses a member it does not +//! declare: an undeclared member is either a caller mistake or a reach for a +//! field the store owns, and either way it must be answered, not ignored. An +//! answer document is read by a client that may be older than the deployment +//! answering it, and carries the opposite rule: a member added to an answer is +//! ignored, so an additive server change is not an outage for every client +//! compiled before it. +//! +//! The projections are deliberate: `because` review reasons are authoring +//! material and never published; occupied intervals (with buffers) are the +//! ledger's own view and callers see displayed times; and the one refusal +//! detail that names a resource appears only on the separately authorized +//! explain path. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::model::AdmissionRequest; + +/// What `GET /v1/scheduling` answers: which deployment and which policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SchedulingServiceDocument { + pub scheduling_id: String, + pub policy_revision: u64, + pub policy_digest: String, +} + +/// One service in the catalogue. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceDocument { + pub id: String, + pub label: String, +} + +/// One offering in the catalogue, the public view of the authored policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfferingDocument { + pub id: String, + pub service: String, + pub label: String, + pub mode: SchedulingModeDocument, + pub location: String, + pub lead_time_minutes: u32, + pub horizon_days: u32, + pub cancellation_cutoff_minutes: u32, + /// Present for exact-time offerings. + pub duration_minutes: Option, + /// Present for exact-time offerings. + pub buffer_before_minutes: Option, + /// Present for exact-time offerings. + pub buffer_after_minutes: Option, + /// Present for exact-time offerings. + pub start_increment_minutes: Option, + /// Present for exact-time offerings. + pub max_recipients: Option, + /// Present for arrival-window offerings. + pub window: Option, + pub reminders: Vec, + pub requires_capabilities: Vec, + pub prerequisites: Vec, +} + +/// How an offering delivers its service, on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SchedulingModeDocument { + ExactTime, + ArrivalWindow, +} + +/// One authored reminder offset, as the catalogue publishes it. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReminderDocument { + pub minutes_before: u32, +} + +/// A published arrival window in the catalogue. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowDocument { + pub id: String, + pub revision: u64, + pub start: DateTime, + pub end: DateTime, + pub units: u32, +} + +/// One backing resource in the resource listing: a concrete pool member, +/// because a pool is its members, never an independent counter. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceDocument { + pub resource_id: String, + pub pool: String, + pub capabilities: Vec, + pub available: bool, +} + +/// One location in the location listing, with the IANA timezone identifier +/// its published openings expand in. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocationDocument { + pub location_id: String, + pub timezone: String, +} + +/// One availability answer entry. Exact-time offerings answer in grid slots; +/// arrival-window offerings answer in windows. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum AvailabilityEntry { + /// A grid slot and how many pool members are still free across it. + Slot { + start: DateTime, + end: DateTime, + free: u32, + }, + /// A published window and its remaining recipient units, the caller's + /// channel slice included when the window reserves one for it. + Window { + window: String, + start: DateTime, + end: DateTime, + remaining: u32, + channel_remaining: Option, + }, +} + +/// One page of a bounded listing, with the opaque cursor that continues it. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageDocument { + pub items: Vec, + pub next_cursor: Option, +} + +/// A minted hold, the answer to `POST /v1/holds`. The hold request body is +/// the same admission request shape a direct create carries: a hold is an +/// admission ask that reserves instead of committing. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HoldDocument { + pub hold_id: String, + pub offering: String, + pub start: DateTime, + pub end: DateTime, + /// The pool member held, for exact-time offerings. + pub resource: Option, + pub units: u32, + pub expires_at: DateTime, + pub policy_revision: u64, +} + +/// The lifecycle state of an appointment on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AppointmentStateDocument { + Confirmed, + Cancelled, +} + +impl AppointmentStateDocument { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Confirmed => "confirmed", + Self::Cancelled => "cancelled", + } + } +} + +/// A confirmed appointment. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppointmentDocument { + pub appointment_id: String, + pub offering: String, + pub start: DateTime, + pub end: DateTime, + /// The pool member serving the appointment, for exact-time offerings. + pub resource: Option, + pub units: u32, + pub channel: Option, + pub revision: u64, + pub state: AppointmentStateDocument, + pub policy_revision: u64, + pub created_at: DateTime, + pub cancelled_at: Option>, +} + +/// The request behind `POST /v1/appointments`: confirm a held allocation, or +/// create an appointment directly. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateAppointmentRequest { + /// The hold being confirmed. Mutually exclusive with `admission`. + pub hold: Option, + /// The direct-create admission request. Mutually exclusive with `hold`. + pub admission: Option, +} + +/// The request behind `POST /v1/appointments/{id}/reschedule`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RescheduleAppointmentRequest { + pub observed_revision: u64, + pub admission: AdmissionRequest, +} + +/// The request behind `POST /v1/appointments/{id}/cancel`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelAppointmentRequest { + pub observed_revision: u64, + #[serde(default)] + pub reason: Option, +} + +/// One attributable step in an appointment's history. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppointmentHistoryEntryDocument { + pub event_id: String, + pub kind: String, + pub revision: u64, + pub occurred_at: DateTime, + /// The pseudonymized actor reference, when the step had one. + pub actor: Option, + pub detail: Value, +} + +/// The separately authorized answer to `GET /v1/availability/explain`: what +/// a refused start would have said, including the one detail the public path +/// never names. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExplainDocument { + pub offering: String, + pub start: DateTime, + /// The problem code every caller may see. Absent when the start admits + /// as things stand: there is no refusal to explain. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_code: Option, + /// The problem code only this path may disclose. Absent with + /// `public_code`. + #[serde(skip_serializing_if = "Option::is_none")] + pub detailed_code: Option, + /// The refusal in words, as the evaluator states it. Absent with + /// `public_code`. + #[serde(skip_serializing_if = "Option::is_none")] + pub explanation: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PartyCounts; + use chrono::TimeZone as _; + + fn utc(day: u32, hour: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, day, hour, 0, 0).unwrap() + } + + #[test] + fn catalogue_documents_round_trip() { + let offering = OfferingDocument { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingModeDocument::ExactTime, + location: "north-counter".to_owned(), + lead_time_minutes: 120, + horizon_days: 60, + cancellation_cutoff_minutes: 240, + duration_minutes: Some(30), + buffer_before_minutes: Some(5), + buffer_after_minutes: Some(5), + start_increment_minutes: Some(30), + max_recipients: Some(1), + window: None, + reminders: vec![ReminderDocument { + minutes_before: 1440, + }], + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }; + let json = serde_json::to_value(&offering).unwrap(); + assert_eq!(json["mode"], "exact-time"); + assert_eq!(json["leadTimeMinutes"], 120); + let parsed: OfferingDocument = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, offering); + } + + /// One answer document as a deployment ahead of this build would send + /// it: the same members, plus one this build has never heard of. + /// + /// An answer document that refused it would turn an additive change on + /// the server into a failed exchange for every client compiled before + /// that change, so the member is ignored and the rest still parses. + fn tolerates_a_later_member(document: &T) + where + T: Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let mut json = serde_json::to_value(document).expect("an answer document serializes"); + json.as_object_mut() + .expect("an answer document is a JSON object") + .insert("laterMember".to_owned(), Value::from(1)); + let parsed: T = serde_json::from_value(json) + .expect("an answer document tolerates a member a later deployment added"); + assert_eq!(&parsed, document); + } + + #[test] + fn answer_documents_tolerate_a_member_a_later_deployment_added() { + let window = WindowDocument { + id: "household-morning-window".to_owned(), + revision: 3, + start: utc(10, 8), + end: utc(10, 10), + units: 12, + }; + let service = ServiceDocument { + id: "registry-update".to_owned(), + label: "Registry record update".to_owned(), + }; + tolerates_a_later_member(&SchedulingServiceDocument { + scheduling_id: "counter-scheduling".to_owned(), + policy_revision: 4, + policy_digest: "sha256:0f".to_owned(), + }); + tolerates_a_later_member(&service); + tolerates_a_later_member(&ReminderDocument { + minutes_before: 1440, + }); + tolerates_a_later_member(&window); + tolerates_a_later_member(&OfferingDocument { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingModeDocument::ArrivalWindow, + location: "north-counter".to_owned(), + lead_time_minutes: 120, + horizon_days: 60, + cancellation_cutoff_minutes: 240, + duration_minutes: None, + buffer_before_minutes: None, + buffer_after_minutes: None, + start_increment_minutes: None, + max_recipients: None, + window: Some(window.clone()), + reminders: vec![ReminderDocument { + minutes_before: 1440, + }], + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }); + tolerates_a_later_member(&ResourceDocument { + resource_id: "station-1".to_owned(), + pool: "counters".to_owned(), + capabilities: vec!["sign-language".to_owned()], + available: true, + }); + tolerates_a_later_member(&LocationDocument { + location_id: "north-counter".to_owned(), + timezone: "Europe/Paris".to_owned(), + }); + tolerates_a_later_member(&AvailabilityEntry::Slot { + start: utc(5, 2), + end: utc(5, 3), + free: 2, + }); + tolerates_a_later_member(&AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: utc(10, 8), + end: utc(10, 10), + remaining: 1, + channel_remaining: Some(0), + }); + tolerates_a_later_member(&PageDocument { + items: vec![service], + next_cursor: None, + }); + tolerates_a_later_member(&HoldDocument { + hold_id: "0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + expires_at: utc(5, 1), + policy_revision: 1, + }); + tolerates_a_later_member(&AppointmentDocument { + appointment_id: "6e97f6a3-8524-4a13-9db8-ad0c4eb4d64b".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + channel: None, + revision: 2, + state: AppointmentStateDocument::Confirmed, + policy_revision: 1, + created_at: utc(4, 9), + cancelled_at: None, + }); + tolerates_a_later_member(&AppointmentHistoryEntryDocument { + event_id: "b31c0f4e-cc7f-4ba8-88f2-9c1a9102f0a1".to_owned(), + kind: "rescheduled".to_owned(), + revision: 2, + occurred_at: utc(4, 10), + actor: Some("hmac-sha256:v2:8f0a".to_owned()), + detail: serde_json::json!({"start": "2026-10-05T03:00:00Z"}), + }); + tolerates_a_later_member(&ExplainDocument { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + public_code: Some("capacity.exhausted".to_owned()), + detailed_code: Some("resource.unavailable".to_owned()), + explanation: Some("every capable member is unavailable".to_owned()), + }); + } + + #[test] + fn request_documents_refuse_a_member_they_do_not_declare() { + // The runtime reads these, and a member it does not declare is either + // a caller mistake or an attempt to reach a field the store owns. + // Tolerating one would let it pass unread and unanswered. + assert!(serde_json::from_str::( + r#"{"hold":"0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee","laterMember":1}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"observedRevision":2,"admission":{},"laterMember":1}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"observedRevision":2,"laterMember":1}"# + ) + .is_err()); + } + + #[test] + fn availability_entries_are_kind_tagged() { + let slot = AvailabilityEntry::Slot { + start: utc(5, 2), + end: utc(5, 3), + free: 2, + }; + let json = serde_json::to_value(&slot).unwrap(); + assert_eq!(json["kind"], "slot"); + assert_eq!(json["free"], 2); + + let window = AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: utc(10, 8), + end: utc(10, 10), + remaining: 1, + channel_remaining: Some(0), + }; + let json = serde_json::to_value(&window).unwrap(); + assert_eq!(json["kind"], "window"); + assert_eq!(json["channelRemaining"], 0); + assert_eq!( + serde_json::from_value::(json).unwrap(), + window + ); + } + + #[test] + fn create_requests_enforce_their_exclusive_shape() { + let admission = AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + }; + + let direct = CreateAppointmentRequest { + hold: None, + admission: Some(admission.clone()), + }; + let json = serde_json::to_value(&direct).unwrap(); + assert_eq!(json["admission"]["duplicateKey"], "subject:one"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + direct + ); + + let confirming = CreateAppointmentRequest { + hold: Some("0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee".to_owned()), + admission: None, + }; + let json = serde_json::to_value(&confirming).unwrap(); + assert_eq!( + serde_json::from_value::(json).unwrap(), + confirming + ); + + // Neither branch present, or a stray field, is refused. + assert!(serde_json::from_str::("{}").is_ok()); + assert!(serde_json::from_str::("{\"hold\":1}").is_err()); + } + + #[test] + fn appointment_and_history_documents_round_trip() { + let appointment = AppointmentDocument { + appointment_id: "6e97f6a3-8524-4a13-9db8-ad0c4eb4d64b".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + channel: None, + revision: 2, + state: AppointmentStateDocument::Confirmed, + policy_revision: 1, + created_at: utc(4, 9), + cancelled_at: None, + }; + let json = serde_json::to_value(&appointment).unwrap(); + assert_eq!(json["state"], "confirmed"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + appointment + ); + + let entry = AppointmentHistoryEntryDocument { + event_id: "b31c0f4e-cc7f-4ba8-88f2-9c1a9102f0a1".to_owned(), + kind: "rescheduled".to_owned(), + revision: 2, + occurred_at: utc(4, 10), + actor: Some("hmac-sha256:v2:8f0a".to_owned()), + detail: serde_json::json!({"start": "2026-10-05T03:00:00Z"}), + }; + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["kind"], "rescheduled"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + entry + ); + } + + /// The explain document carries both projections, so a test can pin that + /// the detailed code never equals a detailed-only value in the public + /// position, and that an admitted start carries neither. + #[test] + fn explain_documents_carry_both_projections() { + let explain = ExplainDocument { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + public_code: Some("capacity.exhausted".to_owned()), + detailed_code: Some("resource.unavailable".to_owned()), + explanation: Some("every capable member is unavailable".to_owned()), + }; + let json = serde_json::to_value(&explain).unwrap(); + assert_eq!(json["publicCode"], "capacity.exhausted"); + assert_eq!(json["detailedCode"], "resource.unavailable"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + explain + ); + + let admitted = ExplainDocument { + public_code: None, + detailed_code: None, + explanation: None, + ..explain + }; + let json = serde_json::to_value(&admitted).unwrap(); + assert!(json.get("publicCode").is_none()); + assert!(json.get("detailedCode").is_none()); + assert!(json.get("explanation").is_none()); + assert_eq!( + serde_json::from_value::(json).unwrap(), + admitted + ); + } + + #[test] + fn pages_carry_an_optional_cursor() { + let page = PageDocument { + items: vec![ServiceDocument { + id: "registry-update".to_owned(), + label: "Registry record update".to_owned(), + }], + next_cursor: None, + }; + let json = serde_json::to_value(&page).unwrap(); + assert_eq!(json["nextCursor"], Value::Null); + assert_eq!( + serde_json::from_value::>(json).unwrap(), + page + ); + } +} diff --git a/crates/registry-scheduling/Cargo.toml b/crates/registry-scheduling/Cargo.toml new file mode 100644 index 0000000000..de33cc435c --- /dev/null +++ b/crates/registry-scheduling/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "registry-scheduling" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Registry Scheduling HTTP service and PostgreSQL runtime." +repository.workspace = true +publish = false + +[[bin]] +name = "scheduling" +path = "src/main.rs" + +[[test]] +name = "postgres_commitments" +path = "tests/postgres_commitments.rs" +required-features = ["postgres-test"] + +[features] +default = [] +postgres-test = [] +schema = ["dep:schemars"] + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +chrono = { workspace = true, features = ["serde"] } +clap.workspace = true +deadpool-postgres.workspace = true +futures.workspace = true +http.workspace = true +jsonwebtoken.workspace = true +registry-scheduling-core.workspace = true +registry-platform-audit.workspace = true +registry-platform-authcommon.workspace = true +registry-platform-buildinfo.workspace = true +registry-platform-calendar.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-config.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httpsec = { workspace = true, features = ["server"] } +registry-platform-httputil.workspace = true +registry-platform-hooks = { workspace = true, features = ["postgres"] } +registry-platform-oidc.workspace = true +rustls.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +schemars = { workspace = true, optional = true } +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-postgres.workspace = true +tokio-postgres-rustls.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["serde"] } + +[dev-dependencies] +reqwest.workspace = true +tempfile.workspace = true +tower.workspace = true +wiremock.workspace = true diff --git a/crates/registry-scheduling/examples/runtime-schema.rs b/crates/registry-scheduling/examples/runtime-schema.rs new file mode 100644 index 0000000000..5e17e41885 --- /dev/null +++ b/crates/registry-scheduling/examples/runtime-schema.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "schema")] +fn main() -> std::process::ExitCode { + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--output")) { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + } + let Some(output) = arguments.next().map(std::path::PathBuf::from) else { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + }; + if arguments.next().is_some() { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + } + let documents = match registry_scheduling::schema::runtime_documents() { + Ok(documents) => documents, + Err(error) => { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + }; + if let Err(error) = std::fs::create_dir_all(&output) { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + for (name, contents) in documents { + if let Err(error) = std::fs::write(output.join(name), contents) { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + } + std::process::ExitCode::SUCCESS +} + +#[cfg(not(feature = "schema"))] +fn main() -> std::process::ExitCode { + eprintln!("runtime-schema requires the schema feature"); + std::process::ExitCode::from(2) +} diff --git a/crates/registry-scheduling/examples/scheduling-auth-probe.rs b/crates/registry-scheduling/examples/scheduling-auth-probe.rs new file mode 100644 index 0000000000..559972127d --- /dev/null +++ b/crates/registry-scheduling/examples/scheduling-auth-probe.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Test-only process boundary for proving that a bearer minted by the stock +//! institutional exchange is accepted by Scheduling's real authenticator. +//! +//! The complete input arrives on stdin so the credential never enters argv or +//! a process listing. Output is exactly `allowed` or `refused`; no input or +//! verifier detail is logged. + +use std::collections::BTreeMap; +use std::io::Read; +use std::sync::Arc; + +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_scheduling::auth::SchedulingAuthenticator; +use registry_scheduling::config::{OidcConfig, OidcJwksSource}; +use serde::Deserialize; + +const MAXIMUM_INPUT_BYTES: u64 = 1024 * 1024; +const MAXIMUM_VALUE_BYTES: usize = 512; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProbeInput { + token: String, + issuer: String, + audience: String, + client: String, + assertion_issuer: String, + jwks: serde_json::Value, + service: String, + location: String, + action: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> std::process::ExitCode { + let allowed = match read_input() { + Ok(input) if validate_input(&input) => authenticate(input).await.unwrap_or(false), + _ => false, + }; + if allowed { + println!("allowed"); + std::process::ExitCode::SUCCESS + } else { + println!("refused"); + std::process::ExitCode::FAILURE + } +} + +fn read_input() -> Result { + let mut bytes = Vec::new(); + std::io::stdin() + .take(MAXIMUM_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| ())?; + if bytes.len() as u64 > MAXIMUM_INPUT_BYTES { + return Err(()); + } + serde_json::from_slice(&bytes).map_err(|_| ()) +} + +fn validate_input(input: &ProbeInput) -> bool { + !input.token.is_empty() + && input.token.len() <= 64 * 1024 + && [ + &input.issuer, + &input.audience, + &input.client, + &input.assertion_issuer, + &input.service, + &input.location, + &input.action, + ] + .into_iter() + .all(|value| { + !value.is_empty() + && value.len() <= MAXIMUM_VALUE_BYTES + && !value.chars().any(char::is_control) + }) +} + +async fn authenticate(input: ProbeInput) -> Result { + let keys: JwkSet = serde_json::from_value(input.jwks).map_err(|_| ())?; + let assertion_issuers = + BTreeMap::from([(input.client.clone(), vec![input.assertion_issuer.clone()])]); + let oidc = OidcConfig { + allowed_clients: vec![input.client.clone()], + assertion_issuers: assertion_issuers.clone(), + issuer: input.issuer.clone(), + audience: input.audience.clone(), + jwks_uri: None, + jwks_source: OidcJwksSource::Discovery, + scope_claim: "scope".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + }; + let verifier = TokenVerifierConfig::access_token_profile( + input.issuer, + vec![input.audience], + vec![Algorithm::RS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("scope") + .with_allowed_clients(vec![input.client]) + .with_assertion_issuers(assertion_issuers); + let authenticator = SchedulingAuthenticator::new( + &oidc, + verifier, + Arc::new(JwksFetcher::new_static(keys, JwksFetcherConfig::defaults())), + ); + let caller = authenticator + .authenticate_mutate(&input.token) + .await + .map_err(|_| ())?; + let Some(grant) = caller.grant else { + return Ok(false); + }; + Ok(grant + .bounds() + .scheduling_permissions() + .is_some_and(|permissions| { + permissions.iter().any(|permission| { + permission.service() == input.service + && permission.location() == input.location + && permission + .actions() + .iter() + .any(|action| action == &input.action) + }) + })) +} diff --git a/crates/registry-scheduling/install.sh b/crates/registry-scheduling/install.sh new file mode 100755 index 0000000000..01bb4bfc4c --- /dev/null +++ b/crates/registry-scheduling/install.sh @@ -0,0 +1,442 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="registrystack/registry-stack" +binaries=(scheduling schedulingctl) +# Publication packaging replaces this empty value with the asset's canonical tag. +default_version="" +script_name="${BASH_SOURCE[0]:-}" +script_name="${script_name##*/}" +filename_version="" +if [[ "$script_name" =~ ^scheduling-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))-install\.sh$ ]]; then + filename_version="${BASH_REMATCH[1]}" +fi +if [ -n "$default_version" ] && + [ -n "$filename_version" ] && + [ "$default_version" != "$filename_version" ]; then + echo "Refusing an installer whose embedded release does not match its filename." >&2 + exit 1 +fi +default_version="${default_version:-$filename_version}" +version="${SCHEDULING_VERSION:-$default_version}" +if [ -n "$default_version" ] && + [ -n "${SCHEDULING_VERSION:-}" ] && + [ "$SCHEDULING_VERSION" != "$default_version" ]; then + echo "Refusing a release override that does not match the released installer asset." >&2 + exit 1 +fi +install_dir="${SCHEDULING_INSTALL_DIR:-$HOME/.local/bin}" +asset_dir="${SCHEDULING_ASSET_DIR:-}" + +usage() { + cat </scheduling--install.sh | bash + +The installer verifies every downloaded release asset against the release's +SHA256SUMS before anything reaches the install directory, and installs the +two binaries together or not at all. It does not verify release authenticity. For +a higher-assurance installation, follow the release verification guide for the +pinned tag, then rerun with SCHEDULING_ASSET_DIR set to the verified +directory: + https://github.com/${repo}/blob//release/VERIFY.md + +Environment: + SCHEDULING_VERSION Registry Scheduling tag to install. A published installer + embeds its tag and refuses a different override. + SCHEDULING_INSTALL_DIR Install directory. Defaults to ~/.local/bin. + SCHEDULING_ASSET_DIR Read already-downloaded release assets from this directory + instead of downloading them. +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Scheduling installer needs '$1'." >&2 + exit 1 + fi +} + +if [ -z "$version" ]; then + echo "No Registry Scheduling tag is pinned for this installer copy." >&2 + echo "Set SCHEDULING_VERSION to a pinned vMAJOR.MINOR.PATCH tag, or run a" >&2 + echo "published scheduling--install.sh asset." >&2 + exit 1 +fi +if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Refusing a non-canonical Registry Scheduling tag." >&2 + echo "Use vMAJOR.MINOR.PATCH." >&2 + exit 1 +fi + +need uname +if [ -z "$asset_dir" ]; then + need curl +fi + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os/$arch" in +Linux/x86_64 | Linux/amd64) + os_label="linux" + arch_label="amd64" + ;; +Linux/arm64 | Linux/aarch64) + os_label="linux" + arch_label="arm64" + ;; +Darwin/arm64 | Darwin/aarch64) + os_label="macos" + arch_label="arm64" + ;; +*) + printf 'No prebuilt Registry Scheduling asset is published for %s/%s.\n' "$os" "$arch" >&2 + printf 'Supported platforms: Linux amd64, Linux arm64, and macOS arm64.\n' >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + ;; +esac + + +# BEGIN generated libc preflight +# Generated from release/glibc-floor.env by +# release/scripts/render-installer-libc-preflight.py. Do not edit by hand. +# +# The published Linux binaries are dynamically linked against GNU libc. On a +# musl system, or on a glibc older than the floor below, they cannot start, and +# the dynamic linker only says so at the first run, long after this installer +# would have reported success. Refuse here instead, before anything downloads. +if [ "$os_label" = "linux" ]; then + libc_floor="2.35" + # musl's ldd exits non-zero when asked for a version, and this installer runs + # under pipefail, so its report is captured once here instead of being read + # through a pipeline whose status would hide the match. + libc_report="" + if command -v ldd >/dev/null 2>&1; then + libc_report="$(ldd --version 2>&1 || true)" + fi + # Only glibc's getconf answers GNU_LIBC_VERSION, so an answer names the libc + # this system runs on, whatever else is installed beside it: a musl loader + # kept for cross builds does not make a musl system. Without an answer, musl + # is recognised by its ldd report or its loader. The report is matched by a + # pattern rather than through grep, which can exit before the report is + # fully written and, under pipefail, lose the match. + detected_libc="" + if command -v getconf >/dev/null 2>&1; then + detected_libc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)" + fi + musl_system=0 + if [ -z "$detected_libc" ]; then + case "$libc_report" in + *[Mm][Uu][Ss][Ll]*) musl_system=1 ;; + esac + if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then + musl_system=1 + fi + fi + if [ "$musl_system" = 1 ]; then + printf 'No musl build of Registry Scheduling is published for this platform.\n' >&2 + printf 'Every published Linux binary needs GNU libc %s or newer, so none of them can start here.\n' \ + "$libc_floor" >&2 + printf 'Install on a GNU libc distribution, or run the published container images.\n' >&2 + printf 'If you need musl builds, ask for them at https://github.com/%s/issues so the demand is recorded.\n' \ + "$repo" >&2 + exit 1 + fi + if [ -z "$detected_libc" ]; then + detected_libc="$(printf '%s\n' "$libc_report" | awk 'NR == 1 {print $NF}')" + fi + case "$detected_libc" in + [0-9]*.[0-9]*) ;; + *) detected_libc="" ;; + esac + if [ -z "$detected_libc" ]; then + printf 'Could not read the GNU libc version of this system, so the %s floor is unchecked.\n' \ + "$libc_floor" >&2 + elif ! awk -v have="$detected_libc" -v floor="$libc_floor" ' + BEGIN { + split(have, h, ".") + split(floor, f, ".") + exit !(h[1] > f[1] || (h[1] == f[1] && h[2] >= f[2])) + } + '; then + printf 'This system has GNU libc %s. The published binaries of Registry Scheduling need %s or newer.\n' \ + "$detected_libc" "$libc_floor" >&2 + printf 'Nothing was installed: the binaries would fail to start with a dynamic linker error.\n' >&2 + printf 'Upgrade the distribution, or run the published container images.\n' >&2 + exit 1 + fi +fi + +# END generated libc preflight +base_url="https://github.com/${repo}/releases/download/${version}" +verify_url="https://github.com/${repo}/blob/${version}/release/VERIFY.md" +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t scheduling)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +download() { + local src="$1" + local dest="$2" + if [ -n "$asset_dir" ]; then + local name="${src##*/}" + if [ ! -f "$asset_dir/$name" ]; then + return 1 + fi + cp "$asset_dir/$name" "$dest" + else + curl -fsSL "$src" -o "$dest" 2>/dev/null + fi +} + +if [ -n "$asset_dir" ]; then + printf 'Installing verified local Registry Scheduling %s assets for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +else + printf 'Downloading Registry Scheduling %s for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +fi + +for binary in "${binaries[@]}"; do + asset="${binary}-${version}-${os_label}-${arch_label}" + if ! download "$base_url/$asset" "$tmpdir/$asset"; then + printf 'Could not read the published %s %s binary for %s/%s.\n' \ + "$binary" "$version" "$os_label" "$arch_label" >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + fi +done +if ! download "$base_url/SHA256SUMS" "$tmpdir/SHA256SUMS"; then + echo "Could not download SHA256SUMS for checksum verification." >&2 + exit 1 +fi + +sha256_file() { + local path="$1" + local result + if command -v shasum >/dev/null 2>&1; then + result="$(shasum -a 256 "$path")" + elif command -v sha256sum >/dev/null 2>&1; then + result="$(sha256sum "$path")" + else + echo "Scheduling installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 + exit 1 + fi + printf '%s\n' "${result%% *}" +} + +verify_asset() { + local name="$1" + local expected_hash actual_hash + expected_hash="$(awk -v asset="$name" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" + if [ -z "$expected_hash" ]; then + echo "SHA256SUMS has no entry for $name" >&2 + exit 1 + fi + actual_hash="$(sha256_file "$tmpdir/$name")" + if [ "$actual_hash" != "$expected_hash" ]; then + echo "Checksum verification failed for $name" >&2 + echo "Expected: $expected_hash" >&2 + echo "Actual: $actual_hash" >&2 + exit 1 + fi +} + +for binary in "${binaries[@]}"; do + verify_asset "${binary}-${version}-${os_label}-${arch_label}" +done +printf 'Integrity checks passed: %s binaries matched SHA256SUMS.\n' "${#binaries[@]}" +cat <&2 + exit 1 +fi + +# A one-time migration keeps existing direct binaries behind the same pointer +# before their stable command links are installed. At every point every command +# therefore resolves to the prior toolset, the new toolset, or neither. +if [ ! -L "$current_link" ]; then + previous_dir="$(mktemp -d "$install_dir/.scheduling-previous.XXXXXX")" + chmod 0755 "$previous_dir" + previous_count=0 + for binary in "${binaries[@]}"; do + if [ -e "$install_dir/$binary" ] && [ ! -d "$install_dir/$binary" ]; then + cp -p "$install_dir/$binary" "$previous_dir/$binary" + previous_count=$((previous_count + 1)) + fi + done + if [ "$previous_count" -gt 0 ]; then + ln -s "${previous_dir##*/}" "$link_stage_dir/current" + replace_path "$link_stage_dir/current" "$current_link" + else + rm -rf "$previous_dir" + fi +fi + +had_current_link=0 +if [ -L "$current_link" ]; then + had_current_link=1 + ln -s "$(readlink "$current_link")" "$link_stage_dir/previous-current" +fi + +for binary in "${binaries[@]}"; do + if [ -d "$install_dir/$binary" ] && [ ! -L "$install_dir/$binary" ]; then + echo "Refusing to replace a directory at $install_dir/$binary." >&2 + exit 1 + fi + command_target=".scheduling-current/$binary" + ln -s "$command_target" "$link_stage_dir/$binary" + if [ -L "$install_dir/$binary" ] && + [ "$(readlink "$install_dir/$binary")" = "$command_target" ]; then + # This command already belongs to Scheduling and changes version through + # the pointer below. Leave it untouched if that pointer switch fails. + rm "$link_stage_dir/$binary" + elif [ -L "$install_dir/$binary" ]; then + # Preserve the target text, including a dangling or relative link, so a + # failed adoption restores the other installer's exact command path. + ln -s "$(readlink "$install_dir/$binary")" "$link_stage_dir/previous-$binary" + elif [ -e "$install_dir/$binary" ]; then + # A regular command may be a local wrapper. Preserve its bytes and mode. + cp -p "$install_dir/$binary" "$link_stage_dir/previous-$binary" + fi +done + +adopted_binaries=() +rollback_adoptions() { + local rollback_failed=0 binary backup + # Clear first so EXIT cleanup never replays a partial rollback whose backup + # entries may already have been moved back into place. + transaction_active=0 + for binary in "${adopted_binaries[@]}"; do + backup="$link_stage_dir/previous-$binary" + if [ -L "$backup" ] || [ -e "$backup" ]; then + if ! replace_path "$backup" "$install_dir/$binary"; then + rollback_failed=1 + fi + elif ! rm -f "$install_dir/$binary"; then + rollback_failed=1 + fi + done + if [ "$had_current_link" -eq 1 ]; then + if ! replace_path "$link_stage_dir/previous-current" "$current_link"; then + rollback_failed=1 + fi + elif ! rm -f "$current_link"; then + rollback_failed=1 + fi + if [ "$rollback_failed" -eq 0 ]; then + # The new toolset is unreachable again and may be removed by cleanup. + install_complete=0 + else + link_stage_cleanup=0 + echo "The failed install could not restore every previous command path; inspect $install_dir and the retained backups under $link_stage_dir." >&2 + fi + return "$rollback_failed" +} + +# Every stable command link the pointer already resolves changes version through +# this one atomic rename. +ln -s "${stage_dir##*/}" "$link_stage_dir/current" +install_complete=1 +transaction_active=1 +replace_path "$link_stage_dir/current" "$current_link" + +# A command not already linked through Scheduling's pointer, such as a shared +# command owned by another product, is linked only now that the switch has made +# its target real. Installing that link earlier would mutate a working command +# even when the final pointer switch fails. Its staged link is still in place +# because the loop above left it there. +for binary in "${binaries[@]}"; do + if [ -L "$link_stage_dir/$binary" ]; then + # Record the attempt before moving: a failing mv may already have changed + # its destination, so rollback must restore this path as well. + adopted_binaries+=("$binary") + if replace_path "$link_stage_dir/$binary" "$install_dir/$binary"; then + : + else + adoption_status=$? + rollback_adoptions || true + exit "$adoption_status" + fi + fi +done +transaction_active=0 + +for binary in "${binaries[@]}"; do + printf '%s installed to %s\n' "$binary" "$install_dir/$binary" +done +cat <&2 ;; +esac diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql new file mode 100644 index 0000000000..4fc10a6d14 --- /dev/null +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -0,0 +1,191 @@ +CREATE TABLE IF NOT EXISTS scheduling_meta ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + scheduling_id text NOT NULL, + policy_revision bigint NOT NULL CHECK (policy_revision > 0), + policy_digest text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +-- The empty scheduling_id marks a database no deployment has adopted yet; +-- `scheduling migrate` claims it for the policy's scheduling id, and every +-- serve refuses both an empty and a mismatched one. +INSERT INTO scheduling_meta (singleton, scheduling_id, policy_revision, policy_digest) +VALUES (true, '', 1, '') +ON CONFLICT (singleton) DO NOTHING; + +CREATE TABLE IF NOT EXISTS scheduling_policy_revisions ( + policy_revision bigint PRIMARY KEY CHECK (policy_revision > 0), + policy_digest text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_locations ( + location_id text PRIMARY KEY, + timezone text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_pools ( + pool_id text PRIMARY KEY, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_pool_members ( + resource_id text PRIMARY KEY, + pool_id text NOT NULL REFERENCES scheduling_pools(pool_id), + capabilities text[] NOT NULL DEFAULT '{}', + available boolean NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS scheduling_pool_members_pool_idx + ON scheduling_pool_members(pool_id); + +CREATE TABLE IF NOT EXISTS scheduling_exceptions ( + exception_id text PRIMARY KEY, + location text NOT NULL, + kind text NOT NULL CHECK (kind IN ('closure','opening')), + date date NOT NULL, + start_time text NOT NULL, + end_time text NOT NULL, + reopens text, + authority text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS scheduling_exceptions_location_idx + ON scheduling_exceptions(location, date); + +-- The capacity transaction's lock anchor. One row per separately locked +-- supply: every resource pool backing exact-time offerings, and every +-- published window. The anchor is the pool, not the offering, because two +-- offerings on one pool sell the same members and must serialize against +-- each other. +CREATE TABLE IF NOT EXISTS scheduling_supply ( + supply_id text PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('pool','window')) +); + +CREATE TABLE IF NOT EXISTS scheduling_claims ( + claim_id uuid PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('booking','hold')), + state text NOT NULL CHECK (state IN ('active','released','cancelled','consumed','expired')), + offering text NOT NULL, + supply_id text NOT NULL, + channel text, + displayed_start timestamptz NOT NULL, + displayed_end timestamptz NOT NULL, + occupied_start timestamptz NOT NULL, + occupied_end timestamptz NOT NULL, + units integer NOT NULL CHECK (units > 0), + -- The caller chooses this value, so the column bounds it: 256 octets, + -- the same ceiling the evaluator gives every other caller-supplied + -- string. The edge refuses the same size first, so the bound is not + -- reached by an ordinary request. + -- + -- The uniqueness the key promises is not a UNIQUE index here. The + -- evaluator refuses a duplicate against the snapshot it locked, scoped to + -- the offering and to the interval under consideration, while a partial + -- UNIQUE over active claims would be a different and stronger rule: it + -- would collide across two offerings that share one pool, and it would + -- fire inside a hold confirmation, which writes the booking while the + -- hold carrying the same key is still active in the same transaction. + duplicate_key text CHECK (duplicate_key IS NULL OR octet_length(duplicate_key) <= 256), + hold_expires_at timestamptz, + revision bigint NOT NULL CHECK (revision > 0), + policy_revision bigint NOT NULL CHECK (policy_revision > 0), + occurrence_id uuid, + actor text NOT NULL, + reason text CHECK (reason IS NULL OR octet_length(reason) <= 4096), + created_at timestamptz NOT NULL DEFAULT now(), + changed_at timestamptz NOT NULL DEFAULT now(), + closed_at timestamptz +); +CREATE INDEX IF NOT EXISTS scheduling_claims_supply_active_idx + ON scheduling_claims(supply_id, occupied_start, occupied_end) + WHERE state = 'active'; +CREATE INDEX IF NOT EXISTS scheduling_claims_duplicate_idx + ON scheduling_claims(duplicate_key) + WHERE state = 'active' AND duplicate_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS scheduling_claims_expiry_idx + ON scheduling_claims(hold_expires_at) + WHERE state = 'active' AND kind = 'hold'; + +CREATE TABLE IF NOT EXISTS scheduling_history ( + event_id uuid PRIMARY KEY, + claim_id uuid NOT NULL REFERENCES scheduling_claims(claim_id), + revision bigint NOT NULL, + kind text NOT NULL, + occurred_at timestamptz NOT NULL, + actor text NOT NULL, + detail jsonb NOT NULL +); +CREATE INDEX IF NOT EXISTS scheduling_history_claim_idx + ON scheduling_history(claim_id, occurred_at, event_id); + +CREATE TABLE IF NOT EXISTS scheduling_outbox ( + outbox_id uuid PRIMARY KEY, + purpose text NOT NULL CHECK (purpose IN ('confirmation','change','cancellation','reminder')), + claim_id uuid NOT NULL REFERENCES scheduling_claims(claim_id), + appointment_revision bigint NOT NULL, + due_at timestamptz NOT NULL, + delivery_state text NOT NULL CHECK (delivery_state IN ('pending','delivered','failed','local')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + delivered_at timestamptz +); +CREATE INDEX IF NOT EXISTS scheduling_outbox_due_idx + ON scheduling_outbox(due_at, next_attempt_at) + WHERE delivery_state = 'pending'; +CREATE INDEX IF NOT EXISTS scheduling_outbox_claim_idx + ON scheduling_outbox(claim_id, purpose); + +CREATE TABLE IF NOT EXISTS scheduling_audit_outbox ( + event_id uuid PRIMARY KEY, + -- The publisher appends every pending record to a hash chain, and a + -- chain is a record of order: publishing in an arbitrary one would have + -- the chain attest to a sequence the deployment never had. The event id + -- is a random UUID and carries no order at all. The sequence orders the + -- rows a publication pass reads; it is allocated at write time, so + -- between two concurrent transactions it can differ from commit + -- visibility order. The chain attests to publication order, not to + -- insertion-sequence order. + recorded_seq bigint GENERATED ALWAYS AS IDENTITY, + audit_record jsonb NOT NULL, + published_at timestamptz +); +CREATE INDEX IF NOT EXISTS scheduling_audit_outbox_pending_idx + ON scheduling_audit_outbox(recorded_seq) + WHERE published_at IS NULL; + +CREATE TABLE IF NOT EXISTS scheduling_attempts ( + attempt_id uuid PRIMARY KEY, + actor_issuer text NOT NULL, + actor_subject text NOT NULL, + scope text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + state text NOT NULL CHECK (state IN ('completed','refused')), + status_code integer NOT NULL, + -- The retention sweep tombstones a receipt rather than deleting it: the + -- row keeps the key and the request it answered, and drops only the + -- answer. A key whose receipt is gone is spent, not free, so a retry + -- after the period is refused instead of executed a second time. + receipt jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + erased_at timestamptz, + CHECK (erased_at IS NOT NULL OR receipt IS NOT NULL), + UNIQUE (actor_issuer, actor_subject, scope, idempotency_key) +); +CREATE INDEX IF NOT EXISTS scheduling_attempts_expiry_idx + ON scheduling_attempts(expires_at) + WHERE erased_at IS NULL; + +CREATE TABLE IF NOT EXISTS scheduling_cursors ( + cursor_id uuid PRIMARY KEY, + context text NOT NULL, + position jsonb NOT NULL, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS scheduling_cursors_expiry_idx + ON scheduling_cursors(expires_at); diff --git a/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql b/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql new file mode 100644 index 0000000000..20574901d2 --- /dev/null +++ b/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql @@ -0,0 +1,14 @@ +-- The records revision. Every wholesale records replacement increments it, +-- and a capacity transaction reads it under the supply anchor it holds: a +-- commitment whose facts were resolved before a replacement is refused +-- rather than allowed to name a resource the deployment no longer carries. +ALTER TABLE scheduling_meta + ADD COLUMN facts_revision bigint NOT NULL DEFAULT 0; + +-- A suppressed reminder is retained rather than deleted. The row keeps +-- accounting for an intent whose appointment moved on, whether or not a +-- dispatch already in flight reached its destination. +ALTER TABLE scheduling_outbox DROP CONSTRAINT scheduling_outbox_delivery_state_check; +ALTER TABLE scheduling_outbox + ADD CONSTRAINT scheduling_outbox_delivery_state_check + CHECK (delivery_state IN ('pending','delivered','failed','local','suppressed')); diff --git a/crates/registry-scheduling/migrations/0004_policy_document.sql b/crates/registry-scheduling/migrations/0004_policy_document.sql new file mode 100644 index 0000000000..d1c71284c0 --- /dev/null +++ b/crates/registry-scheduling/migrations/0004_policy_document.sql @@ -0,0 +1,2 @@ +ALTER TABLE scheduling_policy_revisions + ADD COLUMN IF NOT EXISTS policy_document jsonb; diff --git a/crates/registry-scheduling/migrations/0005_window_records.sql b/crates/registry-scheduling/migrations/0005_window_records.sql new file mode 100644 index 0000000000..02860c7e73 --- /dev/null +++ b/crates/registry-scheduling/migrations/0005_window_records.sql @@ -0,0 +1,19 @@ +-- Published arrival windows are deployment facts applied by the operator, +-- beside locations, pools, members, and dated exceptions. The strict Rust +-- record model validates this JSON before the atomic replacement writes it; +-- the identifier remains a typed column for deterministic replacement and +-- lookup. +CREATE TABLE IF NOT EXISTS scheduling_windows ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- The public revision is the caller's agreement with a window's terms. Keep +-- its last accepted record even while the active window is absent, so a later +-- replacement cannot reuse an observed revision for different terms. +CREATE TABLE IF NOT EXISTS scheduling_window_revision_heads ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/crates/registry-scheduling/migrations/0006_window_revision_heads.sql b/crates/registry-scheduling/migrations/0006_window_revision_heads.sql new file mode 100644 index 0000000000..858fa977fc --- /dev/null +++ b/crates/registry-scheduling/migrations/0006_window_revision_heads.sql @@ -0,0 +1,35 @@ +-- Keep the last accepted record for every published-window identifier, even +-- while that window is absent from the active environment records. A later +-- records replacement can therefore distinguish an exact re-publication from +-- changed terms attempting to reuse an observed public revision. +CREATE TABLE IF NOT EXISTS scheduling_window_revision_heads ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Schema version 5 was never released, but a developer may have exercised it +-- from the review branch. Refuse its formerly accepted overlapping staffing +-- shape during upgrade rather than carrying double-bookable records forward. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM scheduling_windows AS earlier + JOIN scheduling_windows AS later ON earlier.window_id < later.window_id + WHERE earlier.window_record #>> '{staffing,pool}' = + later.window_record #>> '{staffing,pool}' + AND (earlier.window_record ->> 'start')::timestamptz < + (later.window_record ->> 'end')::timestamptz + AND (later.window_record ->> 'start')::timestamptz < + (earlier.window_record ->> 'end')::timestamptz + ) THEN + RAISE EXCEPTION 'overlapping published windows share one staffing pool'; + END IF; +END +$$; + +INSERT INTO scheduling_window_revision_heads(window_id, window_record) +SELECT window_id, window_record +FROM scheduling_windows +ON CONFLICT(window_id) DO NOTHING; diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs new file mode 100644 index 0000000000..3ce25ee291 --- /dev/null +++ b/crates/registry-scheduling/src/auth.rs @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Bearer access-token authentication for the Scheduling HTTP surface. +//! +//! There is no unauthenticated mode: every `/v1` route resolves its caller +//! here first. Authentication answers three questions in order. Is the +//! credential a verifiable access token from the configured issuer, for this +//! deployment's audience, signed by a key the issuer published? Which actor +//! kind does it claim, and does it carry a complete task grant? And does the +//! caller hold the scope the route's authentication profile demands? +//! +//! The three profiles mirror the route families. Readable routes take callers +//! holding the reads scope; the separately authorized explain path takes +//! callers holding its own scope; mutating routes demand no extra scope at +//! all, because their authority is the task grant, whose scheduling +//! permissions the service checks per offering. +//! +//! The task grant is extracted with the platform's contextual-authorization +//! claim profile. A token with no grant claims is an ordinary standing +//! caller: it may read, and the service refuses its mutations. A token with +//! partial grant claims is a malformed credential, refused here rather than +//! half-trusted later. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use registry_platform_authcommon::validate_compact_access_token; +use registry_platform_oidc::{ + actor_kind, grant_claims, ClaimNames, JwksFetcher, OidcError, TokenVerifier, + TokenVerifierConfig, ASSERTION_ISSUER_CLAIM, +}; +use thiserror::Error; + +use crate::config::OidcConfig; +use crate::service::Caller; + +pub struct SchedulingAuthenticator { + verifier: TokenVerifier, + claim_names: ClaimNames, + audience: String, + reads_scope: String, + explain_scope: String, + /// Whether the deployment declared any assertion authority at all. + binds_assertion_issuers: bool, +} + +impl SchedulingAuthenticator { + #[must_use] + pub fn new(oidc: &OidcConfig, verifier: TokenVerifierConfig, keys: Arc) -> Self { + // The default contextual-claim profile is the closed one this product + // speaks; validating it here keeps a future name change honest. + let claim_names = ClaimNames::default(); + claim_names + .validate() + .expect("the default contextual claim names are valid"); + Self { + verifier: TokenVerifier::new(verifier, keys), + claim_names, + audience: oidc.audience.clone(), + reads_scope: oidc.reads_scope.clone(), + explain_scope: oidc.explain_scope.clone(), + binds_assertion_issuers: !oidc.assertion_issuers.is_empty(), + } + } + + /// The profile every catalogue, availability, and owned-record read + /// demands: the caller holds the configured reads scope. + pub async fn authenticate_read(&self, token: &str) -> Result { + let (caller, scopes) = self.verified_caller(token).await?; + scopes + .iter() + .any(|scope| scope == &self.reads_scope) + .then_some(caller) + .ok_or(AuthenticationError::Profile) + } + + /// The profile the separately authorized explain path demands: the caller + /// holds the explain scope, which is never the reads scope. + pub async fn authenticate_explain(&self, token: &str) -> Result { + let (caller, scopes) = self.verified_caller(token).await?; + scopes + .iter() + .any(|scope| scope == &self.explain_scope) + .then_some(caller) + .ok_or(AuthenticationError::Profile) + } + + /// The profile the commitments demand: no product scope, because the + /// authority for a mutation is the task grant the service checks against + /// the offering's service, location, and action. + pub async fn authenticate_mutate(&self, token: &str) -> Result { + self.verified_caller(token).await.map(|(caller, _)| caller) + } + + /// Verify one credential and resolve its caller. A present grant is bound + /// to the verified client and this deployment's audience before the + /// caller ever reaches the service. + async fn verified_caller( + &self, + token: &str, + ) -> Result<(Caller, Vec), AuthenticationError> { + validate_compact_access_token(token).map_err(|_| AuthenticationError::Refused)?; + let verified = self.verifier.verify(token).await.map_err(|error| { + tracing::debug!(error = %error, "the Scheduling bearer credential did not verify"); + verifier_failure(&error) + })?; + // An exchanged token names the authority whose assertion produced it. + // The platform applies no rule while the deployment's map is empty, so + // an empty map would trust every authority the issuer federates. + // Refuse the exchange instead: a deployment that means to accept one + // says which authorities, and which client may present them. + if !self.binds_assertion_issuers + && verified.claims.extra.contains_key(ASSERTION_ISSUER_CLAIM) + { + tracing::debug!( + "an exchanged credential arrived at a deployment that declared no assertion authority" + ); + return Err(AuthenticationError::Profile); + } + let kind = actor_kind(&verified.claims, &self.claim_names) + .map_err(|_| AuthenticationError::Claims)?; + let grant = grant_claims(&verified.claims, &self.claim_names, unix_now()?) + .map_err(|_| AuthenticationError::Claims)?; + if let Some(grant) = &grant { + grant + .verify_context(&verified, &self.audience) + .map_err(|_| AuthenticationError::Profile)?; + } + let issuer = verified + .claims + .iss + .clone() + .filter(|issuer| !issuer.is_empty()) + .ok_or(AuthenticationError::Claims)?; + let subject = verified + .claims + .sub + .clone() + .filter(|subject| !subject.is_empty()) + .ok_or(AuthenticationError::Claims)?; + Ok(( + Caller { + actor_kind: kind.as_str().to_owned(), + issuer, + subject, + grant, + }, + verified.scopes, + )) + } +} + +impl std::fmt::Debug for SchedulingAuthenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SchedulingAuthenticator") + .field("verifier", &"") + .field("audience", &"") + .field("reads_scope", &self.reads_scope) + .field("explain_scope", &self.explain_scope) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum AuthenticationError { + #[error("the bearer credential was refused")] + Refused, + #[error("the verified identity claims are invalid")] + Claims, + #[error("the caller's authentication profile is not authorized for this request")] + Profile, + /// The verifier could not reach a verdict at all. + #[error("the credential could not be verified because this verifier cannot answer")] + Unavailable, +} + +/// Separate a verifier outage from a bad credential. +/// +/// A 401 tells a caller its credential is the problem, so it has to be +/// reserved for credentials. When the issuer's discovery document or key set +/// cannot be fetched, read, parsed or used, nothing has been learned about +/// the credential, and telling the caller to fix it would send it rotating a +/// perfectly good token through an incident that is ours. A deployment whose +/// own OIDC settings are contradictory is the same kind of failure. +fn verifier_failure(error: &OidcError) -> AuthenticationError { + match error { + OidcError::Transport(_) + | OidcError::BoundedRead(_) + | OidcError::FetchUrl(_) + | OidcError::HttpStatus(_) + | OidcError::InvalidUrl + | OidcError::Parse + | OidcError::InvalidJwk + | OidcError::EmptyKeySet + | OidcError::MissingIssuer + | OidcError::ConflictingEndpointConfiguration => AuthenticationError::Unavailable, + // Every other outcome is a judgement about the credential, including + // an unknown `kid`: a key the issuer never published is a forgery + // signal, not an outage, and answering 503 there would let a caller + // probe the key set. A variant added to the platform later is read as + // a credential refusal until it is classified here, so a new outcome + // can never widen the door. + _ => AuthenticationError::Refused, + } +} + +/// The observation of now the grant's expiry is judged against, in the same +/// units the token's own `exp` carries. +fn unix_now() -> Result { + unix_seconds(SystemTime::now()) +} + +/// Read one clock observation as seconds since the epoch. +/// +/// A reading that cannot be taken is refused rather than substituted. Epoch +/// zero is before every `exp` a credential carries, so falling back to it +/// would pass every expiry check at once: an expired grant would keep +/// committing capacity for as long as the clock stayed unreadable, and the +/// deployment would be told nothing. Nothing has been learned about the +/// credential either, so the answer is the outage answer, not a challenge. +fn unix_seconds(observed: SystemTime) -> Result { + observed + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .map_err(|_| { + tracing::error!( + "the system clock reads before the Unix epoch; refusing to judge an expiry" + ); + AuthenticationError::Unavailable + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use chrono::Utc; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use registry_platform_httputil::FetchUrlPolicy; + use registry_platform_oidc::{ActorKind, JwksFetcherConfig, ASSERTION_ISSUER_CLAIM}; + use serde_json::json; + use std::time::Duration; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const ISSUER: &str = "https://task-token.test"; + const AUDIENCE: &str = "urn:registry-scheduling:test"; + const CLIENT: &str = "task-agent"; + const SECRET: &[u8] = b"01234567890123456789012345678901"; + + fn oidc() -> OidcConfig { + OidcConfig { + allowed_clients: vec![CLIENT.to_owned()], + assertion_issuers: std::collections::BTreeMap::new(), + issuer: ISSUER.to_owned(), + audience: AUDIENCE.to_owned(), + jwks_uri: None, + jwks_source: crate::config::OidcJwksSource::Discovery, + scope_claim: "registry_scopes".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + } + } + + fn keys() -> Arc { + Arc::new(JwksFetcher::new_static( + serde_json::from_value(json!({"keys": [{ + "kty": "oct", + "kid": "test", + "alg": "HS256", + "use": "sig", + "k": URL_SAFE_NO_PAD.encode(SECRET), + }]})) + .unwrap(), + JwksFetcherConfig::defaults(), + )) + } + + fn authenticator() -> SchedulingAuthenticator { + authenticator_with(oidc()) + } + + /// Build the authenticator the way the runtime does, so the verifier and + /// the authenticator read the same deployment settings. + fn authenticator_with(oidc: OidcConfig) -> SchedulingAuthenticator { + authenticator_over(oidc, keys()) + } + + /// The same deployment reading a different key source, so a test can put + /// the issuer's key endpoint out of service without changing anything + /// else about the caller or the profile. + fn authenticator_over(oidc: OidcConfig, keys: Arc) -> SchedulingAuthenticator { + let verifier = TokenVerifierConfig::access_token_profile( + ISSUER, + vec![AUDIENCE.to_owned()], + vec![Algorithm::HS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("registry_scopes") + .with_allowed_clients(vec![CLIENT.to_owned()]) + .with_assertion_issuers(oidc.assertion_issuers.clone()); + SchedulingAuthenticator::new(&oidc, verifier, keys) + } + + /// Sign an access token, filling in the claims a real token always + /// carries so a test only has to state what it varies. + fn token(mut claims: serde_json::Value) -> String { + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.entry("iss").or_insert(json!(ISSUER)); + object.entry("aud").or_insert(json!(AUDIENCE)); + object.entry("iat").or_insert(json!(now)); + object.entry("exp").or_insert(json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(SECRET)).unwrap() + } + + /// The complete task-grant claim set a mutating caller carries, with the + /// bounds the test's permission assertions read. + fn grant_claims_value(resource: &str) -> serde_json::Value { + let now = Utc::now().timestamp(); + json!({ + "registry_actor_kind": "agent", + "registry_purpose": "registry-update", + "registry_grant_id": "grant-1", + "registry_grant_source_issuer": "https://authority.test", + "registry_grant_client": CLIENT, + "registry_grant_resource": resource, + "registry_grant_exp": now + 600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "north-counter", + "actions": ["hold.create", "appointment.create"], + }], + }, + "registry_approver": "approver-1", + }) + } + + fn merged(scopes: &str, grant: serde_json::Value) -> serde_json::Value { + let mut claims = json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": scopes, + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + claims + } + + #[tokio::test] + async fn a_read_caller_with_the_reads_scope_authenticates_without_a_grant() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + let caller = authenticator() + .authenticate_read(&credential) + .await + .expect("a standing caller holding the reads scope reads"); + assert_eq!(caller.actor_kind, "service"); + assert_eq!(caller.issuer, ISSUER); + assert_eq!(caller.subject, "principal-1"); + assert!(caller.grant.is_none()); + } + + #[tokio::test] + async fn a_read_without_the_reads_scope_is_a_profile_refusal_but_still_mutates() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "unrelated-scope", + "registry_actor_kind": "service", + })); + let authenticator = authenticator(); + assert!(matches!( + authenticator.authenticate_read(&credential).await, + Err(AuthenticationError::Profile) + )); + // Mutating routes demand no product scope: their authority is the + // task grant, checked per offering in the service. + authenticator + .authenticate_mutate(&credential) + .await + .expect("a caller without the reads scope still reaches a mutation"); + } + + #[tokio::test] + async fn the_explain_path_demands_its_own_scope() { + let authenticator = authenticator(); + let reader = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + assert!(matches!( + authenticator.authenticate_explain(&reader).await, + Err(AuthenticationError::Profile) + )); + let explainer = token(json!({ + "sub": "principal-2", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + })); + authenticator + .authenticate_explain(&explainer) + .await + .expect("the explain scope opens the explain path"); + } + + #[tokio::test] + async fn a_complete_task_grant_is_extracted_and_bound_to_the_deployment() { + let credential = token(merged("scheduling-read", grant_claims_value(AUDIENCE))); + let caller = authenticator() + .authenticate_mutate(&credential) + .await + .expect("a complete grant authenticates"); + let grant = caller.grant.expect("the grant is extracted"); + assert_eq!(caller.actor_kind, "agent"); + let permissions = grant + .bounds() + .scheduling_permissions() + .expect("the scheduling bounds travel with the grant"); + assert_eq!(permissions.len(), 1); + assert_eq!(permissions[0].service(), "registry-update"); + assert_eq!(permissions[0].location(), "north-counter"); + assert!(permissions[0] + .actions() + .iter() + .any(|action| action == "hold.create")); + } + + #[tokio::test] + async fn a_grant_naming_another_resource_is_a_profile_refusal() { + let credential = token(merged( + "scheduling-read", + grant_claims_value("urn:registry:other-product"), + )); + assert!(matches!( + authenticator().authenticate_mutate(&credential).await, + Err(AuthenticationError::Profile) + )); + } + + #[tokio::test] + async fn a_partial_grant_is_refused_as_invalid_claims() { + let mut grant = grant_claims_value(AUDIENCE); + let object = grant.as_object_mut().unwrap(); + object.remove("registry_grant_exp"); + object.remove("registry_grant_client"); + let credential = token(merged("scheduling-read", grant)); + assert!(matches!( + authenticator().authenticate_mutate(&credential).await, + Err(AuthenticationError::Claims) + )); + } + + #[tokio::test] + async fn an_actor_kind_outside_the_vocabulary_is_refused() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "robot", + })); + assert!(matches!( + authenticator().authenticate_read(&credential).await, + Err(AuthenticationError::Claims) + )); + } + + #[tokio::test] + async fn an_exchanged_token_is_refused_until_its_authority_is_declared() { + const AUTHORITY: &str = "https://authority.example.test"; + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + ASSERTION_ISSUER_CLAIM: AUTHORITY, + })); + assert!( + matches!( + authenticator().authenticate_read(&credential).await, + Err(AuthenticationError::Profile) + ), + "an exchanged token was accepted by a deployment that declared no assertion authority" + ); + + let mut declared = oidc(); + declared.assertion_issuers = + std::collections::BTreeMap::from([(CLIENT.to_owned(), vec![AUTHORITY.to_owned()])]); + authenticator_with(declared.clone()) + .authenticate_read(&credential) + .await + .expect("a declared authority's exchanged token reads"); + + // The platform still refuses an authority the client may not exchange + // from once the map is populated. + let other = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + ASSERTION_ISSUER_CLAIM: "https://unrelated.example.test", + })); + assert!(matches!( + authenticator_with(declared).authenticate_read(&other).await, + Err(AuthenticationError::Refused) + )); + } + + #[test] + fn an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it() { + // What the fallback reading would have meant. Epoch zero is before + // every `exp` a credential carries, so a grant that expired an hour + // ago still reads as live, and would keep committing capacity for as + // long as the clock stayed unreadable. + let names = ClaimNames::default(); + let now = Utc::now().timestamp(); + let mut expired = grant_claims_value(AUDIENCE); + expired + .as_object_mut() + .unwrap() + .insert("registry_grant_exp".to_owned(), json!(now - 3_600)); + let mut value = merged("scheduling-read", expired); + let object = value.as_object_mut().unwrap(); + object.insert("iss".to_owned(), json!(ISSUER)); + object.insert("aud".to_owned(), json!(AUDIENCE)); + object.insert("exp".to_owned(), json!(now + 300)); + let claims: registry_platform_oidc::Claims = serde_json::from_value(value).unwrap(); + assert!( + grant_claims(&claims, &names, 0).is_ok(), + "epoch zero would have passed a grant that expired an hour ago" + ); + assert!(grant_claims(&claims, &names, now as u64).is_err()); + + // So a reading that cannot be taken is refused outright. The caller is + // told to come back; it is never told its credential is good. + assert_eq!( + unix_seconds(UNIX_EPOCH + Duration::from_secs(1_700_000_000)), + Ok(1_700_000_000) + ); + assert_eq!( + unix_seconds(UNIX_EPOCH - Duration::from_secs(1)), + Err(AuthenticationError::Unavailable) + ); + } + + #[tokio::test] + async fn a_key_endpoint_outage_is_unavailable_not_a_refusal() { + // The issuer is up enough to answer, and answers that it cannot serve + // its key set. Nothing has been learned about the credential, so + // blaming it would send a caller holding a perfectly good token off + // to rotate credentials during an incident on this side. + let issuer_keys = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/jwks.json")) + .respond_with(ResponseTemplate::new(503)) + .mount(&issuer_keys) + .await; + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + let during_outage = authenticator_over( + oidc(), + Arc::new(JwksFetcher::new_with_fetch_url_policy( + format!("{}/jwks.json", issuer_keys.uri()), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + ); + assert!( + matches!( + during_outage.authenticate_read(&credential).await, + Err(AuthenticationError::Unavailable) + ), + "a key endpoint that cannot answer was reported as a bad credential" + ); + } + + #[test] + fn only_a_verifier_that_reached_no_verdict_is_unavailable() { + for outage in [ + OidcError::HttpStatus(503), + OidcError::InvalidUrl, + OidcError::Parse, + OidcError::InvalidJwk, + OidcError::EmptyKeySet, + OidcError::MissingIssuer, + OidcError::ConflictingEndpointConfiguration, + ] { + assert_eq!( + verifier_failure(&outage), + AuthenticationError::Unavailable, + "{outage} was blamed on the credential" + ); + } + for refusal in [ + OidcError::IssuerMismatch { + expected: ISSUER.to_owned(), + actual: "https://elsewhere.test".to_owned(), + }, + OidcError::MalformedToken, + OidcError::AlgorithmNotAllowed, + OidcError::TokenTypeNotAllowed, + OidcError::MissingKid, + OidcError::KidTooLong, + // An unknown `kid` is a key the issuer never published, which is a + // forgery signal and not an outage. Answering 503 here would also + // hand a caller a probe for the key set. + OidcError::UnknownKid, + OidcError::TokenExpired, + OidcError::TokenNotYetValid, + OidcError::AudienceMismatch, + OidcError::SignatureInvalid, + OidcError::InvalidToken, + OidcError::ClientNotAllowed, + OidcError::AssertionIssuerNotAllowed, + ] { + assert_eq!( + verifier_failure(&refusal), + AuthenticationError::Refused, + "{refusal} was excused as an outage" + ); + } + } + + #[tokio::test] + async fn a_credential_signed_by_an_unknown_key_is_refused() { + let mut claims = json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": ActorKind::Service.as_str(), + }); + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.insert("iss".to_owned(), json!(ISSUER)); + object.insert("aud".to_owned(), json!(AUDIENCE)); + object.insert("iat".to_owned(), json!(now)); + object.insert("exp".to_owned(), json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + let forged = jsonwebtoken::encode( + &header, + &claims, + &EncodingKey::from_secret(b"another-secret-another-secret-another!"), + ) + .unwrap(); + assert!(matches!( + authenticator().authenticate_read(&forged).await, + Err(AuthenticationError::Refused) + )); + } +} diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs new file mode 100644 index 0000000000..38b1a732e0 --- /dev/null +++ b/crates/registry-scheduling/src/config.rs @@ -0,0 +1,1569 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The operator runtime configuration document and the policy package +//! identity it verifies. + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use jsonwebtoken::Algorithm; +use registry_platform_config::{ + SecretError, SecretProvider, SecretReference, SecretResolver, MAX_SECRET_BYTES, +}; +use registry_platform_oidc::{ + access_token_typ_set, fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, + TokenVerifierConfig, +}; +use registry_scheduling_core::{ + parse_policy_yaml, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, + SCHEDULING_RUNTIME_API_VERSION, SCHEDULING_RUNTIME_KIND, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use thiserror::Error; + +/// Explain one refused secret reference without disclosing what it protects. +/// +/// A startup refusal reaches an operator as a single line, and the resolver +/// reports only which rule broke. A valid reference is safe and useful to name, +/// but invalid operator-authored text might itself be a literal credential, so +/// only its field is named. The resolved bytes and opened path never appear. +pub(crate) fn describe_secret_failure( + field: &'static str, + reference: &str, + error: &SecretError, +) -> String { + let reason = match error { + SecretError::InvalidReference => { + "it is not an exact secret:env/NAME or secret:file/name reference".to_owned() + } + SecretError::ProviderDisabled => "its provider is not enabled for this runtime".to_owned(), + SecretError::InvalidProviderConfiguration => { + "the secret provider configuration is invalid".to_owned() + } + SecretError::Unavailable => { + "no readable secret of that name exists under the configured provider".to_owned() + } + SecretError::UnsafeFile => concat!( + "the secret file must be a regular file owned by the runtime user, ", + "with mode 0400 or 0600, and exactly one hard link" + ) + .to_owned(), + SecretError::Read => "the secret could not be read".to_owned(), + SecretError::InvalidValue => format!( + "the secret value must be non-empty text of at most {MAX_SECRET_BYTES} bytes \ + without NUL bytes" + ), + }; + if error == &SecretError::InvalidReference { + format!("the secret reference configured at {field} could not be resolved: {reason}") + } else { + format!("the secret reference {reference} could not be resolved: {reason}") + } +} + +const MAXIMUM_POLICY_FILE_BYTES: usize = 1024 * 1024; +const MAXIMUM_PACKAGE_MANIFEST_BYTES: usize = 1024 * 1024; + +/// apiVersion of the package manifest written beside the authored policy. +/// +/// The manifest names a package identity; the authored policy names a policy. +/// They are two documents with two readers, so they carry two names and +/// neither reader accepts the other's document. +pub const SCHEDULING_PACKAGE_MANIFEST_API_VERSION: &str = + "registry.registrystack.org/scheduling-policy-package-manifest/v1alpha1"; + +/// Kind of the package manifest written beside the authored policy. +pub const SCHEDULING_PACKAGE_MANIFEST_KIND: &str = "SchedulingPolicyPackageManifest"; + +/// The default number of days a stored idempotency receipt is replayable +/// before the retention sweep erases it. The default is a floor, not a +/// recommendation: a jurisdiction's retention schedule approves the deployed +/// value, and the deployed value is what the audit journal records. +pub const DEFAULT_ATTEMPT_RECEIPT_DAYS: u16 = 7; + +/// The operator runtime configuration document. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfig { + pub api_version: String, + pub kind: String, + pub package: RuntimePackageConfig, + pub listener: ListenerConfig, + pub secret_providers: SecretProvidersConfig, + pub database: DatabaseConfig, + pub authentication: AuthenticationConfig, + pub audit: AuditConfig, + #[serde(default)] + pub destinations: DestinationsConfig, + pub retention: RetentionConfig, +} + +/// The root of an authored scheduling project, holding `scheduling.yaml` and +/// its optional package manifest. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimePackageConfig { + pub root: PathBuf, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + #[serde(default = "default_listener_bind")] + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub bind: SocketAddr, + pub tls_termination: TlsTermination, + #[serde(default)] + pub network_exposure: ListenerNetworkExposure, +} + +fn default_listener_bind() -> SocketAddr { + "127.0.0.1:8105" + .parse() + .expect("valid Scheduling listener default") +} + +/// Declares the trusted transport boundary for the runtime's plaintext HTTP +/// listener. Production listeners require operator-controlled upstream TLS +/// termination; direct plaintext is limited to the explicit loopback-only +/// development mode. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum TlsTermination { + OperatorControlledUpstream, + DevelopmentLoopback, +} + +/// The operator-declared private network placement of the HTTP listener. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ListenerNetworkExposure { + #[default] + PrivateAddress, + ContainerPrivate, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SecretProvidersConfig { + #[serde(default)] + pub file: Option, + #[serde(default)] + pub environment: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentSecretProviderConfig {} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileSecretProviderConfig { + pub root: PathBuf, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthenticationConfig { + pub oidc: OidcConfig, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DatabaseConfig { + pub runtime_url_ref: String, + pub migration_url_ref: String, + #[serde(default)] + pub trusted_root_certificate_ref: Option, + #[serde(default)] + pub test_only_plaintext: bool, +} + +impl std::fmt::Debug for DatabaseConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DatabaseConfig") + .field("runtime_url_ref", &"") + .field("migration_url_ref", &"") + .field( + "trusted_root_certificate_ref", + &self + .trusted_root_certificate_ref + .as_ref() + .map(|_| ""), + ) + .field("test_only_plaintext", &self.test_only_plaintext) + .finish() + } +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OidcConfig { + #[serde(default)] + pub allowed_clients: Vec, + /// The assertion authorities each client may exchange a subject token + /// from, keyed by client identifier. + /// + /// A deployment that performs no token exchange leaves this empty. Once a + /// client is listed, a token it exchanged is accepted only for one of that + /// client's declared authorities, so an assertion minted by an unrelated + /// authority the issuer happens to federate cannot become a booking + /// credential here. + #[serde(default)] + pub assertion_issuers: BTreeMap>, + pub issuer: String, + pub audience: String, + #[serde(default)] + pub jwks_uri: Option, + #[serde(default)] + pub jwks_source: OidcJwksSource, + #[serde(default = "default_scope_claim")] + pub scope_claim: String, + /// The scope every listing and availability read requires. + #[serde(default = "default_reads_scope")] + pub reads_scope: String, + /// The scope the separately authorized explain path requires. + #[serde(default = "default_explain_scope")] + pub explain_scope: String, +} + +/// The RFC 9068 access-token media type this runtime verifies. The pair of +/// spellings it admits is derived, never authored, so no deployment can widen +/// it to an ordinary JWT. +const SCHEDULING_ACCESS_TOKEN_TYPE: &str = "at+jwt"; + +/// Bounds on the authored assertion-issuer map, matching the Casework +/// runtime's. They keep one operator document from becoming an unbounded +/// verifier input. +pub(crate) const MAXIMUM_ASSERTION_ISSUER_CLIENTS: usize = 64; +pub(crate) const MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES: usize = 128; +pub(crate) const MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT: usize = 16; +pub(crate) const MAXIMUM_ASSERTION_ISSUER_BYTES: usize = 512; + +fn default_scope_claim() -> String { + "registry_scopes".to_owned() +} +fn default_reads_scope() -> String { + "scheduling-read".to_owned() +} +fn default_explain_scope() -> String { + "scheduling-explain".to_owned() +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum OidcJwksSource { + #[default] + Discovery, + Static { + #[serde(rename = "documentRef")] + document_ref: String, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditConfig { + pub path: PathBuf, + pub hash_key_ref: String, +} + +/// Where due reminder and lifecycle-hook intents are dispatched. An absent +/// reminders destination and an empty hook map are supported: intents stay +/// local, so an operator can adopt the product before wiring delivery buses. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DestinationsConfig { + #[serde(default)] + pub reminders: Option, + /// Logical URL hook destinations. The governed policy names only these + /// ids; URL, signing secret, and retry ceilings stay deployment-owned. + #[serde(default)] + pub hooks: BTreeMap, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReminderDestinationConfig { + pub url: String, + #[serde(default)] + pub bearer_token_ref: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HookDestinationConfig { + pub url: String, + pub hmac_sha256_key_ref: String, + #[serde(default = "default_hook_attempt_timeout_milliseconds")] + pub attempt_timeout_milliseconds: u32, + #[serde(default = "default_hook_maximum_attempts")] + pub maximum_attempts: u8, +} + +const fn default_hook_attempt_timeout_milliseconds() -> u32 { + 5_000 +} + +const fn default_hook_maximum_attempts() -> u8 { + 8 +} + +/// Retention periods the retention sweep enforces. Every value is deployment +/// configuration, never policy: a jurisdiction's retention schedule approves +/// the deployed numbers, and changing one is an operational event. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RetentionConfig { + /// How long a stored idempotency receipt stays replayable. + #[serde(default = "default_attempt_receipt_days")] + pub attempt_receipt_days: u16, + /// Lifetime of canonical hook envelopes retained for retry and + /// dead-letter inspection. + #[serde(default = "default_hook_payload_days")] + pub hook_payload_days: u16, +} + +impl Default for RetentionConfig { + fn default() -> Self { + Self { + attempt_receipt_days: DEFAULT_ATTEMPT_RECEIPT_DAYS, + hook_payload_days: default_hook_payload_days(), + } + } +} + +fn default_attempt_receipt_days() -> u16 { + DEFAULT_ATTEMPT_RECEIPT_DAYS +} + +const fn default_hook_payload_days() -> u16 { + 7 +} + +/// The immutable identity of a packaged policy: the digest of the package +/// envelope over its exact authored files. A scheduling package is one +/// authored policy file, so the manifest is small, but it is still a +/// separate document the runtime verifies rather than trusts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyPackageManifest { + pub api_version: String, + pub kind: String, + pub policy_digest: String, + pub files: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyPackageFile { + pub path: String, + pub sha256: String, + pub bytes: u64, +} + +impl PolicyPackageManifest { + /// Build the immutable identity for an already validated policy text. + pub fn build(policy_text: &str) -> Result { + let bytes = policy_text.as_bytes(); + if bytes.len() > MAXIMUM_POLICY_FILE_BYTES { + return Err(PolicyPackageError::Invalid); + } + let files = vec![PolicyPackageFile { + path: AUTHORED_POLICY_FILE.to_owned(), + sha256: sha256_bytes(bytes), + bytes: u64::try_from(bytes.len()).map_err(|_| PolicyPackageError::Invalid)?, + }]; + Ok(Self { + api_version: SCHEDULING_PACKAGE_MANIFEST_API_VERSION.to_owned(), + kind: SCHEDULING_PACKAGE_MANIFEST_KIND.to_owned(), + policy_digest: package_digest(&files)?, + files, + }) + } + + fn verify(&self, policy_text: &str) -> Result<(), PolicyPackageError> { + let expected = Self::build(policy_text)?; + if self.api_version != expected.api_version + || self.kind != expected.kind + || self.files != expected.files + || self.policy_digest != expected.policy_digest + { + return Err(PolicyPackageError::Invalid); + } + Ok(()) + } +} + +/// Verify the package beside `scheduling.yaml`. An absent manifest is +/// distinguished so local authored development remains usable. +pub fn verify_policy_package( + policy_path: &Path, + policy_text: &str, +) -> Result, PolicyPackageError> { + if policy_path.file_name().and_then(|name| name.to_str()) != Some(AUTHORED_POLICY_FILE) { + return Err(PolicyPackageError::Invalid); + } + let root = policy_path.parent().ok_or(PolicyPackageError::Invalid)?; + let manifest_path = root.join(SCHEDULING_PACKAGE_MANIFEST_FILE); + if !manifest_path.exists() { + return Ok(None); + } + let metadata = std::fs::symlink_metadata(&manifest_path).map_err(PolicyPackageError::Read)?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAXIMUM_PACKAGE_MANIFEST_BYTES as u64 + { + return Err(PolicyPackageError::Invalid); + } + let bytes = std::fs::read(&manifest_path).map_err(PolicyPackageError::Read)?; + let manifest: PolicyPackageManifest = + serde_json::from_slice(&bytes).map_err(|_| PolicyPackageError::Invalid)?; + manifest.verify(policy_text)?; + Ok(Some(manifest.policy_digest)) +} + +fn package_digest(files: &[PolicyPackageFile]) -> Result { + let identity = serde_json::json!({ + "apiVersion": SCHEDULING_PACKAGE_MANIFEST_API_VERSION, + "kind": SCHEDULING_PACKAGE_MANIFEST_KIND, + "files": files, + }); + let canonical = registry_platform_canonical_json::canonicalize_json(&identity) + .map_err(|_| PolicyPackageError::Invalid)?; + Ok(sha256_bytes(&canonical)) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +impl RuntimeConfig { + /// Load and validate the operator document, reading the authored policy + /// beside it. The bytes never re-enter a parser after startup. + pub fn load(path: impl AsRef) -> Result { + if !path.as_ref().is_absolute() { + return Err(RuntimeConfigError::RelativeRuntimePath); + } + let bytes = std::fs::read(path.as_ref()).map_err(RuntimeConfigError::Read)?; + let deserializer = serde_norway::Deserializer::from_slice(&bytes); + let config: Self = serde_path_to_error::deserialize(deserializer).map_err(|error| { + let (path, cause) = refused_yaml(error); + RuntimeConfigError::Parse { path, cause } + })?; + config.check()?; + Ok(config) + } + + #[must_use] + pub fn policy_path(&self) -> PathBuf { + self.package.root.join(AUTHORED_POLICY_FILE) + } + + /// Read and validate the authored policy this deployment runs. + pub fn load_policy(&self) -> Result { + let policy_text = + std::fs::read_to_string(self.policy_path()).map_err(RuntimeConfigError::PolicyRead)?; + let policy = parse_policy_yaml(&policy_text).map_err(|error| { + let (path, cause) = refused_yaml(error); + RuntimeConfigError::PolicyParse { path, cause } + })?; + if !policy.check().is_empty() { + return Err(RuntimeConfigError::PolicyFindings); + } + // A present manifest is verified against the exact policy text; the + // digest the runtime publishes is the policy's own, not the manifest's. + verify_policy_package(&self.policy_path(), &policy_text) + .map_err(RuntimeConfigError::PolicyPackage)?; + Ok(policy) + } + + /// Return the verified package identity, if this is a packaged + /// deployment. Production configurations always have one. + pub fn policy_package_digest(&self) -> Result, RuntimeConfigError> { + let policy_text = + std::fs::read_to_string(self.policy_path()).map_err(RuntimeConfigError::PolicyRead)?; + verify_policy_package(&self.policy_path(), &policy_text) + .map_err(RuntimeConfigError::PolicyPackage) + } + + pub fn check(&self) -> Result<(), RuntimeConfigError> { + if self.api_version != SCHEDULING_RUNTIME_API_VERSION { + return Err(RuntimeConfigError::InvalidApiVersion); + } + if self.kind != SCHEDULING_RUNTIME_KIND { + return Err(RuntimeConfigError::InvalidKind); + } + if !self.package.root.is_absolute() { + return Err(RuntimeConfigError::RelativeOperatedPath("package.root")); + } + if self + .secret_providers + .file + .as_ref() + .is_some_and(|file| !file.root.is_absolute()) + { + return Err(RuntimeConfigError::RelativeOperatedPath( + "secretProviders.file.root", + )); + } + if !self.audit.path.is_absolute() { + return Err(RuntimeConfigError::RelativeOperatedPath("audit.path")); + } + if self.secret_providers.file.is_none() && self.secret_providers.environment.is_none() { + return Err(RuntimeConfigError::InvalidSecretProviders); + } + if !valid_listener( + self.listener.bind.ip(), + self.listener.network_exposure, + self.listener.tls_termination, + ) { + return Err(RuntimeConfigError::InvalidListener); + } + if self.authentication.oidc.issuer.is_empty() + || self.authentication.oidc.audience.is_empty() + || self.authentication.oidc.scope_claim.is_empty() + || self.authentication.oidc.reads_scope.is_empty() + || self.authentication.oidc.explain_scope.is_empty() + || self.authentication.oidc.explain_scope == self.authentication.oidc.reads_scope + { + return Err(RuntimeConfigError::InvalidOidc); + } + self.validate_assertion_issuers()?; + // An empty client list admits every client the issuer verifies, so a + // deployment that simply forgot the field would accept a token minted + // for an unrelated application in the same realm. Development loopback + // keeps that convenience; a deployment behind an operator-controlled + // terminator must name the clients it admits. + if self.listener.tls_termination == TlsTermination::OperatorControlledUpstream + && self.authentication.oidc.allowed_clients.is_empty() + { + return Err(RuntimeConfigError::InvalidOidc); + } + if self.database.runtime_url_ref.is_empty() || self.database.migration_url_ref.is_empty() { + return Err(RuntimeConfigError::InvalidDatabaseReference); + } + if self.audit.hash_key_ref.is_empty() { + return Err(RuntimeConfigError::InvalidAuditReference); + } + if self.retention.attempt_receipt_days == 0 + || !(1..=30).contains(&self.retention.hook_payload_days) + { + return Err(RuntimeConfigError::InvalidRetention); + } + if let Some(reminders) = &self.destinations.reminders { + if !valid_destination_url(&reminders.url) { + return Err(RuntimeConfigError::InvalidDestination); + } + } + if self.destinations.hooks.len() > 128 { + return Err(RuntimeConfigError::InvalidHookDestination); + } + for (id, destination) in &self.destinations.hooks { + if !valid_logical_destination_id(id) + || !valid_destination_url(&destination.url) + || !(100..=10_000).contains(&destination.attempt_timeout_milliseconds) + || !(1..=20).contains(&destination.maximum_attempts) + { + return Err(RuntimeConfigError::InvalidHookDestination); + } + } + self.validate_secret_references()?; + + let policy = self.load_policy()?; + let declared_hook_destinations = policy + .hooks + .iter() + .filter_map(|hook| match &hook.handler { + registry_platform_hooks::HookHandlerSource::Url { destination_id } => { + Some(destination_id.as_str()) + } + _ => None, + }) + .collect::>(); + let configured_hook_destinations = + self.destinations.hooks.keys().map(String::as_str).collect(); + if !declared_hook_destinations.is_subset(&configured_hook_destinations) { + return Err(RuntimeConfigError::HookDestinationInventoryMismatch); + } + if self.listener.tls_termination == TlsTermination::OperatorControlledUpstream + && self.policy_package_digest()?.is_none() + { + return Err(RuntimeConfigError::ProductionPolicyPackageRequired); + } + #[cfg(not(feature = "postgres-test"))] + if self.database.test_only_plaintext { + return Err(RuntimeConfigError::PlaintextDatabase); + } + Ok(()) + } + + /// Refuse an assertion-issuer map with too many clients, an oversized + /// client key or issuer string, too many issuers listed for one client, or + /// a repeated issuer within one client's list. This runs at configuration + /// load, before any verifier is built, so an operator sees the refusal + /// without the runtime ever starting. + fn validate_assertion_issuers(&self) -> Result<(), RuntimeConfigError> { + let assertion_issuers = &self.authentication.oidc.assertion_issuers; + if assertion_issuers.len() > MAXIMUM_ASSERTION_ISSUER_CLIENTS { + return Err(RuntimeConfigError::InvalidOidc); + } + for (client, issuers) in assertion_issuers { + if client.is_empty() + || client.len() > MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES + || issuers.len() > MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT + { + return Err(RuntimeConfigError::InvalidOidc); + } + let mut seen = BTreeSet::new(); + for issuer in issuers { + if issuer.is_empty() + || issuer.len() > MAXIMUM_ASSERTION_ISSUER_BYTES + || !seen.insert(issuer) + { + return Err(RuntimeConfigError::InvalidOidc); + } + } + } + Ok(()) + } + + fn validate_secret_references(&self) -> Result<(), RuntimeConfigError> { + let mut references = vec![ + ( + "database.runtimeUrlRef".to_owned(), + &self.database.runtime_url_ref, + ), + ( + "database.migrationUrlRef".to_owned(), + &self.database.migration_url_ref, + ), + ("audit.hashKeyRef".to_owned(), &self.audit.hash_key_ref), + ]; + if let Some(reference) = &self.database.trusted_root_certificate_ref { + references.push(("database.trustedRootCertificateRef".to_owned(), reference)); + } + if let OidcJwksSource::Static { document_ref } = &self.authentication.oidc.jwks_source { + references.push(( + "authentication.oidc.jwksSource.documentRef".to_owned(), + document_ref, + )); + } + if let Some(reminders) = &self.destinations.reminders { + if let Some(reference) = &reminders.bearer_token_ref { + references.push(( + "destinations.reminders.bearerTokenRef".to_owned(), + reference, + )); + } + } + for (id, destination) in &self.destinations.hooks { + references.push(( + format!("destinations.hooks.{id}.hmacSha256KeyRef"), + &destination.hmac_sha256_key_ref, + )); + } + for (path, raw) in references { + let reference = SecretReference::parse(raw.clone()) + .map_err(|_| RuntimeConfigError::InvalidSecretReference { path: path.clone() })?; + let enabled = match reference.provider() { + SecretProvider::File => self.secret_providers.file.is_some(), + SecretProvider::Environment => self.secret_providers.environment.is_some(), + }; + if !enabled { + return Err(RuntimeConfigError::SecretProviderRequired { path }); + } + } + Ok(()) + } + + pub async fn oidc_verifier( + &self, + secrets: &SecretResolver, + ) -> Result<(TokenVerifierConfig, std::sync::Arc), RuntimeConfigError> { + let discovery_config = OidcDiscoveryConfig { + issuer: self.authentication.oidc.issuer.clone(), + jwks_uri_override: self.authentication.oidc.jwks_uri.clone(), + discovery_timeout: Duration::from_secs(5), + max_doc_bytes: 1024 * 1024, + }; + let fetcher = match &self.authentication.oidc.jwks_source { + OidcJwksSource::Discovery => { + let discovery = fetch_discovery(&discovery_config) + .await + .map_err(|_| RuntimeConfigError::Oidc)?; + JwksFetcher::new(discovery.jwks_uri, JwksFetcherConfig::defaults()) + } + OidcJwksSource::Static { document_ref } => { + let document = secrets.resolve(document_ref).map_err(|error| { + RuntimeConfigError::OidcJwksSecret(describe_secret_failure( + "authentication.oidc.jwksSource.documentRef", + document_ref, + &error, + )) + })?; + let jwks = parse_static_jwks(document.expose_secret())?; + JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults()) + } + }; + Ok((self.verifier_profile(), std::sync::Arc::new(fetcher))) + } + + /// The access-token verifier profile this deployment's OIDC settings + /// describe. + /// + /// RFC 9068 gives the access token one media type spelled two ways, + /// `at+jwt` and `application/at+jwt`, and requires a resource server to + /// accept that pair and refuse every other `typ`. The list comes from + /// [`access_token_typ_set`] so a plain `JWT` stays out: an ID token, a + /// UserInfo JWT or any other JWT minted for this audience is not an + /// access token, and admitting one would let a credential issued for + /// another purpose book, reschedule or cancel an appointment. + pub(crate) fn verifier_profile(&self) -> TokenVerifierConfig { + TokenVerifierConfig::access_token_profile( + self.authentication.oidc.issuer.clone(), + vec![self.authentication.oidc.audience.clone()], + vec![Algorithm::RS256, Algorithm::ES256], + access_token_typ_set(SCHEDULING_ACCESS_TOKEN_TYPE), + ) + .with_scope_claim(self.authentication.oidc.scope_claim.clone()) + .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()) + .with_assertion_issuers(self.authentication.oidc.assertion_issuers.clone()) + } +} + +/// A reminder destination URL is an absolute http(s) URL without userinfo. +/// Plain http is accepted only for an explicit loopback host, so a plaintext +/// destination can never silently point at another machine. Full network +/// egress policy is the deployment's perimeter concern, the same boundary +/// BREG draws for its webhook origins. +fn valid_destination_url(value: &str) -> bool { + let Ok(parsed) = url::Url::parse(value) else { + return false; + }; + if parsed.username().is_empty() + && parsed.password().is_none() + && parsed.host().is_some() + && parsed.port_or_known_default().is_some_and(|port| port != 0) + // The destination is one reviewed endpoint, not a template a + // configuration value may widen; the runtime's frozen transport + // refuses a query or fragment, so the authoring check refuses it + // first. + && parsed.query().is_none() + && parsed.fragment().is_none() + { + match parsed.scheme() { + "https" => true, + "http" => matches!( + parsed.host_str(), + Some("127.0.0.1") | Some("localhost") | Some("[::1]") + ), + _ => false, + } + } else { + false + } +} + +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn valid_listener( + address: IpAddr, + exposure: ListenerNetworkExposure, + tls_termination: TlsTermination, +) -> bool { + if address.is_multicast() { + return false; + } + if tls_termination == TlsTermination::DevelopmentLoopback { + return exposure == ListenerNetworkExposure::PrivateAddress && address.is_loopback(); + } + match (address, exposure) { + (IpAddr::V4(address), ListenerNetworkExposure::PrivateAddress) => { + address.is_loopback() || address.is_private() + } + (IpAddr::V6(address), ListenerNetworkExposure::PrivateAddress) => { + address.is_loopback() || is_unique_local(address) + } + (IpAddr::V4(address), ListenerNetworkExposure::ContainerPrivate) => { + address.is_unspecified() || address.is_loopback() || address.is_private() + } + (IpAddr::V6(address), ListenerNetworkExposure::ContainerPrivate) => { + address.is_unspecified() || address.is_loopback() || is_unique_local(address) + } + } +} + +fn is_unique_local(address: Ipv6Addr) -> bool { + address.octets()[0] & 0xfe == 0xfc +} + +fn parse_static_jwks(bytes: &[u8]) -> Result { + let jwks: JwkSet = serde_json::from_slice(bytes).map_err(|_| RuntimeConfigError::Oidc)?; + let mut kids = BTreeSet::new(); + if jwks.keys.is_empty() + || jwks.keys.iter().any(|key| { + !matches!( + key.algorithm, + AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_) + ) || key + .common + .key_id + .as_ref() + .is_none_or(|kid| kid.is_empty() || !kids.insert(kid.clone())) + }) + { + return Err(RuntimeConfigError::Oidc); + } + Ok(jwks) +} + +/// Name where a YAML document was refused and why, so an operator reading a +/// startup failure can open the document at the place that failed. +/// +/// `serde_path_to_error` renders a refusal it never attributed to a member as +/// `.`, which names nothing an operator can look up, so a document refused +/// whole is reported at its root. The reason is serde's own, and carries the +/// line and column the reader stopped on. +fn refused_yaml(error: serde_path_to_error::Error) -> (String, String) { + let path = error.path().to_string(); + let path = if path == "." { "/".to_owned() } else { path }; + (path, redact_refused_values(&error.into_inner().to_string())) +} + +/// Keep the parts of a refusal an operator acts on, the member, the reason +/// and the location, while the refused value stays out of the message. +/// +/// serde reports the offending value inside an `invalid type:` or an +/// `invalid value:` clause. Only the shape word that opens such a clause +/// survives, so the message still says a string arrived where a number was +/// required without repeating the string. A runtime configuration names +/// secret references, database URLs and destinations, and a startup refusal +/// is written to the operator's log. +fn redact_refused_values(message: &str) -> String { + const CLAUSES: [&str; 2] = ["invalid type: ", "invalid value: "]; + let mut redacted = String::with_capacity(message.len()); + let mut rest = message; + loop { + let Some((start, len)) = CLAUSES + .iter() + .filter_map(|clause| rest.find(clause).map(|start| (start, clause.len()))) + .min_by_key(|(start, _)| *start) + else { + redacted.push_str(rest); + return redacted; + }; + let opened = start + len; + redacted.push_str(&rest[..opened]); + let (shape, tail) = split_refused_value(&rest[opened..]); + redacted.push_str(shape); + rest = tail; + } +} + +/// Split the text after a clause marker into the shape word serde names and +/// the remainder that follows the refused value. +/// +/// serde renders the value with `Debug`, so it opens with a quote or a +/// backtick and may hold the comma that would otherwise end the clause. +fn split_refused_value(clause: &str) -> (&str, &str) { + let bytes = clause.as_bytes(); + let mut index = 0; + let mut shape_end = None; + while index < bytes.len() { + match bytes[index] { + delimiter @ (b'"' | b'`') => { + shape_end.get_or_insert(index); + index = skip_delimited(bytes, index, delimiter); + } + b',' => break, + _ => index += 1, + } + } + let shape_end = shape_end.unwrap_or(index); + (clause[..shape_end].trim_end(), &clause[index..]) +} + +/// Return the offset just past the delimited run that opens at `open`. +/// +/// A delimiter inside a `Debug` rendering arrives escaped, so it does not end +/// the run. +fn skip_delimited(bytes: &[u8], open: usize, delimiter: u8) -> usize { + let mut index = open + 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 2, + byte if byte == delimiter => return index + 1, + _ => index += 1, + } + } + bytes.len() +} + +#[derive(Debug, Error)] +pub enum PolicyPackageError { + #[error("the Scheduling policy package could not be read")] + Read(#[source] std::io::Error), + #[error("the Scheduling policy package is invalid or does not match its exact inputs")] + Invalid, +} + +#[derive(Debug, Error)] +pub enum RuntimeConfigError { + #[error("the Scheduling runtime configuration could not be read")] + Read(#[source] std::io::Error), + #[error("the Scheduling runtime configuration is not valid YAML at {path}: {cause}")] + Parse { path: String, cause: String }, + #[error( + "unsupported Scheduling runtime apiVersion; expected registry.registrystack.org/scheduling-runtime/v1alpha1" + )] + InvalidApiVersion, + #[error("unsupported Scheduling runtime kind; expected SchedulingRuntimeConfig")] + InvalidKind, + #[error("the operated runtime path {0} must be absolute")] + RelativeOperatedPath(&'static str), + #[error("the selected Scheduling runtime configuration path must be absolute")] + RelativeRuntimePath, + #[error("secretProviders must explicitly enable file, environment, or both")] + InvalidSecretProviders, + #[error("{path} is not a valid secret reference")] + InvalidSecretReference { path: String }, + #[error("{path} uses a secret provider that is not explicitly enabled")] + SecretProviderRequired { path: String }, + #[error("the authored scheduling policy could not be read")] + PolicyRead(#[source] std::io::Error), + #[error("the authored scheduling policy is not valid YAML at {path}: {cause}")] + PolicyParse { path: String, cause: String }, + #[error("the authored scheduling policy does not pass its checks")] + PolicyFindings, + #[error("the Scheduling policy package is invalid")] + PolicyPackage(#[source] PolicyPackageError), + #[error("operator-controlled production requires a verified Scheduling policy package")] + ProductionPolicyPackageRequired, + #[error("listener is not valid for its declared TLS termination and network exposure")] + InvalidListener, + #[error("authentication.oidc is invalid")] + InvalidOidc, + #[error( + "database.runtimeUrlRef and database.migrationUrlRef must be non-empty secret references" + )] + InvalidDatabaseReference, + #[error("audit.hashKeyRef must be a non-empty secret reference")] + InvalidAuditReference, + #[error("retention.attemptReceiptDays must be at least one day")] + InvalidRetention, + #[error("destinations.reminders.url is not a valid destination URL")] + InvalidDestination, + #[error("a destinations.hooks binding is invalid")] + InvalidHookDestination, + #[error("destinations.hooks must bind every destination declared by the policy")] + HookDestinationInventoryMismatch, + #[error("plaintext PostgreSQL is test-only")] + PlaintextDatabase, + #[error("the OIDC issuer could not be initialized")] + Oidc, + #[error("the static OIDC signing keys could not be loaded: {0}")] + OidcJwksSecret(String), +} + +impl RuntimeConfigError { + #[must_use] + pub fn path(&self) -> &str { + match self { + Self::InvalidApiVersion => "apiVersion", + Self::InvalidKind => "kind", + Self::RelativeOperatedPath(path) => path, + Self::RelativeRuntimePath | Self::Read(_) => "/", + Self::Parse { path, .. } => path, + Self::InvalidSecretProviders => "secretProviders", + Self::InvalidSecretReference { path } | Self::SecretProviderRequired { path } => path, + Self::InvalidOidc | Self::Oidc => "authentication.oidc", + Self::OidcJwksSecret(_) => "authentication.oidc.jwksSource.documentRef", + Self::InvalidListener => "listener", + Self::InvalidDatabaseReference | Self::PlaintextDatabase => "database", + Self::InvalidAuditReference => "audit.hashKeyRef", + Self::InvalidRetention => "retention", + Self::InvalidDestination => "destinations.reminders.url", + Self::InvalidHookDestination | Self::HookDestinationInventoryMismatch => { + "destinations.hooks" + } + Self::PolicyRead(_) | Self::PolicyParse { .. } => "package.root/scheduling.yaml", + Self::PolicyFindings | Self::PolicyPackage(_) => "package.root", + Self::ProductionPolicyPackageRequired => "package.root", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_oidc::is_access_token_typ_pair; + use registry_scheduling_core::{SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND}; + + const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: {id: standalone-exact-time, version: 1} +services: [{id: registry-update, label: Registry record update}] +holidaySets: [{id: none, revision: 1, because: test, dates: []}] +openings: + - id: weekdays + location: north-counter + holidaySet: none + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:00" + effectiveFrom: "2026-01-01" + effectiveUntil: "2027-01-01" + because: test +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] +holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} +"#; + + fn write_policy(root: &Path) { + std::fs::create_dir_all(root).unwrap(); + std::fs::write(root.join(AUTHORED_POLICY_FILE), POLICY).unwrap(); + } + + fn operator_value(package: &Path, tls: &str) -> serde_json::Value { + let root = package.parent().expect("package parent"); + serde_json::json!({ + "apiVersion": SCHEDULING_RUNTIME_API_VERSION, + "kind": SCHEDULING_RUNTIME_KIND, + "package": {"root": package}, + "listener": {"bind": "127.0.0.1:8105", "tlsTermination": tls}, + "secretProviders": {"file": {"root": root.join("secrets")}, "environment": {}}, + "database": { + "runtimeUrlRef": "secret:env/RUNTIME", + "migrationUrlRef": "secret:env/MIGRATION" + }, + "authentication": {"oidc": { + "issuer": "https://identity.example.test", + "audience": "urn:example:scheduling", + "allowedClients": ["scheduling-booking-agent"] + }}, + "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:file/audit"}, + "destinations": {}, + "retention": {"attemptReceiptDays": 7} + }) + } + + fn write_operator(root: &Path, document: serde_json::Value) -> PathBuf { + let operator = root.join("runtime.yaml"); + std::fs::write(&operator, serde_norway::to_string(&document).unwrap()).unwrap(); + operator + } + + #[test] + fn a_development_configuration_loads_and_exposes_its_defaults() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + let config = RuntimeConfig::load(&operator).expect("configuration is accepted"); + assert_eq!(config.authentication.oidc.scope_claim, "registry_scopes"); + assert_eq!(config.authentication.oidc.reads_scope, "scheduling-read"); + assert_eq!( + config.authentication.oidc.explain_scope, + "scheduling-explain" + ); + assert_eq!(config.retention.attempt_receipt_days, 7); + assert_eq!(config.retention.hook_payload_days, 7); + assert!(config.destinations.reminders.is_none()); + assert!(config.destinations.hooks.is_empty()); + let policy = config.load_policy().expect("policy loads"); + assert_eq!(policy.scheduling.id, "standalone-exact-time"); + } + + #[test] + fn production_requires_a_verified_package_while_loopback_accepts_authoring() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "operator-controlled-upstream"), + ); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::ProductionPolicyPackageRequired) + )); + + let manifest = PolicyPackageManifest::build(POLICY).unwrap(); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + let config = RuntimeConfig::load(&operator).expect("packaged production is accepted"); + assert_eq!( + config.policy_package_digest().unwrap(), + Some(manifest.policy_digest) + ); + + std::fs::write( + package.join(AUTHORED_POLICY_FILE), + POLICY.replace("30-minute", "31-minute"), + ) + .unwrap(); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::PolicyPackage(_)) + )); + } + + #[test] + fn explain_and_read_scopes_must_differ_and_retention_must_be_positive() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + for (patch, expected) in [ + ( + serde_json::json!({"explainScope": "scheduling-read"}), + RuntimeConfigError::InvalidOidc, + ), + ( + serde_json::json!({"attemptReceiptDays": 0}), + RuntimeConfigError::InvalidRetention, + ), + ( + serde_json::json!({"reminders": {"url": "not-a-url"}}), + RuntimeConfigError::InvalidDestination, + ), + ] { + let mut document = operator_value(&package, "development-loopback"); + if patch.get("attemptReceiptDays").is_some() { + document["retention"] = patch; + } else if patch.get("reminders").is_some() { + document["destinations"] = patch; + } else { + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .extend( + patch + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.clone())), + ); + } + let operator = write_operator(root.path(), document); + let outcome = RuntimeConfig::load(&operator); + assert!( + matches!(&outcome, Err(error) if std::mem::discriminant(error) == std::mem::discriminant(&expected)), + "expected {expected:?}, got {outcome:?}" + ); + } + } + + #[test] + fn a_hook_policy_requires_and_accepts_its_deployment_binding() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let hooked = format!( + "{POLICY}hooks:\n - id: appointment-observer\n phase: after\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: url\n destinationId: appointment-events\n" + ); + std::fs::write(package.join(AUTHORED_POLICY_FILE), hooked).unwrap(); + let mut document = operator_value(&package, "development-loopback"); + let operator = write_operator(root.path(), document.clone()); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::HookDestinationInventoryMismatch) + )); + + document["destinations"]["hooks"] = serde_json::json!({ + "appointment-events": { + "url": "https://events.example.test/scheduling", + "hmacSha256KeyRef": "secret:file/hook-key" + } + }); + let operator = write_operator(root.path(), document); + let config = RuntimeConfig::load(&operator).expect("the hook destination is bound"); + assert!(config.destinations.hooks.contains_key("appointment-events")); + } + + #[test] + fn typed_parse_path_does_not_echo_the_rejected_value() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + let mut document = operator_value(&package, "development-loopback"); + document["database"]["testOnlyPlaintext"] = serde_json::json!(canary); + // The refusal this pins exists only without the test feature: with + // it, plaintext is the configuration the suite itself runs under. + #[cfg(not(feature = "postgres-test"))] + let operator = write_operator(root.path(), document); + #[cfg(not(feature = "postgres-test"))] + { + let error = RuntimeConfig::load(&operator).unwrap_err(); + assert_eq!(error.path(), "database.testOnlyPlaintext"); + assert!(!error.to_string().contains(canary)); + } + } + + #[test] + fn a_runtime_document_refused_whole_is_reported_at_the_root() { + let root = tempfile::tempdir().unwrap(); + let operator = root.path().join("runtime.yaml"); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + std::fs::write(&operator, format!("{canary}\n")).unwrap(); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + // A refusal the reader never attributed to a member belongs to the + // document, and "." names nothing an operator can look up. + assert_eq!(error.path(), "/"); + assert!(!message.contains(" at ."), "{message}"); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(!message.contains(canary), "{message}"); + } + + #[test] + fn a_refused_value_never_survives_the_clause_that_names_it() { + // The value serde renders may hold the comma that ends the clause, + // so the shape word is where the rendering opens, not the comma. + assert_eq!( + redact_refused_values( + "invalid type: string \"secret:env/URL, and more\", expected u16 at line 3 column 5" + ), + "invalid type: string, expected u16 at line 3 column 5" + ); + // A refusal naming a member rather than a value is carried whole: the + // member name is what the operator has to correct. + assert_eq!( + redact_refused_values("unknown field `bnd`, expected `bind` at line 4 column 3"), + "unknown field `bnd`, expected `bind` at line 4 column 3" + ); + } + + #[test] + fn a_runtime_document_the_reader_stops_on_names_the_line_and_column() { + let root = tempfile::tempdir().unwrap(); + let operator = root.path().join("runtime.yaml"); + // A tab can never open an indented line, so the reader stops on it. + std::fs::write(&operator, "apiVersion: v1\n\tkind: x\n").unwrap(); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("line 2"), "{message}"); + assert!(message.contains("column 1"), "{message}"); + } + + #[test] + fn a_rejected_runtime_member_names_the_cause_without_the_value() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + let mut document = operator_value(&package, "development-loopback"); + document["retention"]["attemptReceiptDays"] = serde_json::json!(canary); + let operator = write_operator(root.path(), document); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert_eq!(error.path(), "retention.attemptReceiptDays"); + assert!( + message.contains("retention.attemptReceiptDays"), + "{message}" + ); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(message.contains("expected u16"), "{message}"); + assert!(!message.contains(canary), "{message}"); + } + + #[test] + fn a_refused_authored_policy_names_the_member_and_the_cause() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write( + package.join(AUTHORED_POLICY_FILE), + "scheduling: {id: standalone-exact-time, version: one}\n", + ) + .unwrap(); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert_eq!(error.path(), "package.root/scheduling.yaml"); + assert!(message.contains("scheduling.version"), "{message}"); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(message.contains("line"), "{message}"); + assert!(message.contains("column"), "{message}"); + } + + // The package manifest and the policy it identifies are two documents + // with two readers: the manifest is verified by the runtime at startup, + // the policy is parsed by the authoring grammar. A reader that accepts + // one must be able to refuse the other on its declared names alone. + #[test] + fn a_package_manifest_is_a_different_document_from_the_policy_it_identifies() { + let manifest = PolicyPackageManifest::build(POLICY).expect("the policy is packaged"); + assert_ne!(manifest.api_version, SCHEDULING_POLICY_API_VERSION); + assert_ne!(manifest.kind, SCHEDULING_POLICY_KIND); + assert_eq!( + manifest.api_version, + SCHEDULING_PACKAGE_MANIFEST_API_VERSION + ); + assert_eq!(manifest.kind, SCHEDULING_PACKAGE_MANIFEST_KIND); + } + + // A deployment identity is only an identity if a document of another kind + // cannot stand in for it. The manifest is verified against its own names, + // so an authored policy's names never carry a package identity. + #[test] + fn a_manifest_wearing_the_authored_policy_names_is_refused() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let manifest = PolicyPackageManifest::build(POLICY).expect("the policy is packaged"); + let mut document = serde_json::to_value(&manifest).unwrap(); + document["apiVersion"] = serde_json::json!(SCHEDULING_POLICY_API_VERSION); + document["kind"] = serde_json::json!(SCHEDULING_POLICY_KIND); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&document).unwrap(), + ) + .unwrap(); + assert!(matches!( + verify_policy_package(&package.join(AUTHORED_POLICY_FILE), POLICY), + Err(PolicyPackageError::Invalid) + )); + } + + #[test] + fn a_production_deployment_must_name_the_clients_it_admits() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let manifest = PolicyPackageManifest::build(POLICY).unwrap(); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let mut document = operator_value(&package, "operator-controlled-upstream"); + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .remove("allowedClients"); + let operator = write_operator(root.path(), document); + assert!( + matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::InvalidOidc) + ), + "a production deployment with no allowedClients was accepted" + ); + + let operator = write_operator( + root.path(), + operator_value(&package, "operator-controlled-upstream"), + ); + RuntimeConfig::load(&operator).expect("a named client list is accepted"); + + // Development loopback keeps the convenience: it is not a deployment + // an unrelated client can reach. + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .remove("allowedClients"); + let operator = write_operator(root.path(), document); + RuntimeConfig::load(&operator).expect("development loopback stays permissive"); + } + + #[test] + fn a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"]["assertionIssuers"] = serde_json::json!({ + "scheduling-booking-agent": ["https://authority.example.test"] + }); + let operator = write_operator(root.path(), document); + let config = RuntimeConfig::load(&operator).expect("an assertion authority is declarable"); + assert_eq!( + config.verifier_profile().assertion_issuers, + BTreeMap::from([( + "scheduling-booking-agent".to_owned(), + vec!["https://authority.example.test".to_owned()] + )]), + "the declared assertion authorities never reached the verifier" + ); + } + + #[test] + fn an_assertion_issuer_map_outside_its_bounds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let oversized_client = "c".repeat(MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES + 1); + let oversized_issuer = format!("https://{}", "a".repeat(MAXIMUM_ASSERTION_ISSUER_BYTES)); + let too_many_clients: serde_json::Map = (0 + ..=MAXIMUM_ASSERTION_ISSUER_CLIENTS) + .map(|index| { + ( + format!("client-{index}"), + serde_json::json!(["https://a.test"]), + ) + }) + .collect(); + let too_many_issuers: Vec = (0..=MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT) + .map(|index| format!("https://authority-{index}.test")) + .collect(); + for refused in [ + serde_json::json!({"": ["https://a.test"]}), + serde_json::json!({oversized_client: ["https://a.test"]}), + serde_json::json!({"client": [""]}), + serde_json::json!({"client": [oversized_issuer]}), + serde_json::json!({"client": ["https://a.test", "https://a.test"]}), + serde_json::Value::Object(too_many_clients), + serde_json::json!({"client": too_many_issuers}), + ] { + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"]["assertionIssuers"] = refused.clone(); + let operator = write_operator(root.path(), document); + let outcome = RuntimeConfig::load(&operator); + assert!( + matches!(outcome, Err(RuntimeConfigError::InvalidOidc)), + "an assertion-issuer map outside its bounds was accepted: {refused}" + ); + } + } + + #[test] + fn the_access_token_profile_admits_only_the_rfc_9068_pair() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + let config = RuntimeConfig::load(&operator).expect("configuration is accepted"); + let profile = config.verifier_profile(); + assert!( + is_access_token_typ_pair(&profile.allowed_typ), + "the access-token profile is not the RFC 9068 pair: {:?}", + profile.allowed_typ + ); + assert!( + !profile + .allowed_typ + .iter() + .any(|value| value.eq_ignore_ascii_case("JWT")), + "a plain JWT reaches the access-token profile: {:?}", + profile.allowed_typ + ); + } + + #[test] + fn listener_requires_a_private_address_or_explicit_container_network() { + use std::net::{Ipv4Addr, Ipv6Addr}; + assert!(valid_listener( + IpAddr::V4(Ipv4Addr::LOCALHOST), + ListenerNetworkExposure::PrivateAddress, + TlsTermination::OperatorControlledUpstream, + )); + assert!(!valid_listener( + "203.0.113.10".parse::().unwrap(), + ListenerNetworkExposure::ContainerPrivate, + TlsTermination::OperatorControlledUpstream, + )); + assert!(valid_listener( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + ListenerNetworkExposure::ContainerPrivate, + TlsTermination::OperatorControlledUpstream, + )); + assert!(!valid_listener( + "10.20.30.40".parse::().unwrap(), + ListenerNetworkExposure::PrivateAddress, + TlsTermination::DevelopmentLoopback, + )); + } + + #[test] + fn static_jwks_requires_unique_named_asymmetric_keys() { + assert!(parse_static_jwks( + br#"{"keys":[{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"}]}"# + ) + .is_ok()); + for invalid in [ + br#"{}"#.as_slice(), + br#"{"keys":[]}"#, + br#"{"keys":[{"kty":"oct","kid":"one","k":"AA"}]}"#, + br#"{"keys":[{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"},{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"}]}"#, + b"not-json", + ] { + assert!(parse_static_jwks(invalid).is_err()); + } + } +} diff --git a/crates/registry-scheduling/src/cursors.rs b/crates/registry-scheduling/src/cursors.rs new file mode 100644 index 0000000000..e02a040c2c --- /dev/null +++ b/crates/registry-scheduling/src/cursors.rs @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The 15-minute context-bound listing cursors. +//! +//! A cursor is an opaque token carrying one server-stored cursor id. The row +//! it names holds the listing context it was minted for and the position the +//! next page resumes from, and it expires after fifteen minutes: a listing is +//! a bounded view of a live ledger, not a snapshot a caller may walk forever. +//! +//! Context binding is the load-bearing part. The context string names the +//! exact listing and every parameter that defines its result set, so a cursor +//! replayed against a different listing, a different offering, or a different +//! range is refused as invalid rather than silently returning rows from the +//! wrong view. +//! +//! ## The actor-binding contract +//! +//! A stored cursor is deliberately not keyed by the caller: it holds its +//! identity, its context, its position and its expiry, and nothing else. That +//! is sound only because every listing that mints one is, at the moment the +//! cursor is honoured, one of two things. +//! +//! Either the listing is public to any caller holding the read scope, as the +//! catalogue and availability listings are, in which case a cursor discloses +//! nothing its holder could not ask for outright; or the listing re-applies +//! its own authorization on the continuing request before the cursor is +//! resolved, as appointment history does by re-running the ownership check +//! against the caller of the page, never against the caller who minted the +//! token. +//! +//! The obligation is therefore on the call site, and it is a real one: a +//! listing that is neither public nor re-authorized on every page must bind +//! the actor into the cursor rather than assume this module did it. Nothing +//! here can enforce that, which is why it is written where whoever adds the +//! third listing will read it. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +/// How long a cursor stays resolvable. The bound is part of the listing +/// contract: past it, a caller restarts the listing from its first page. +pub const CURSOR_LIFETIME_MINUTES: i64 = 15; + +/// The most bytes of one encoded cursor a caller may present. +const MAXIMUM_CURSOR_BYTES: usize = 256; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum CursorError { + #[error("the cursor is not a valid cursor of this listing")] + Invalid, + #[error("the cursor expired; restart the listing from its first page")] + Expired, +} + +/// The decoded wire form of one cursor. +#[derive(Serialize, Deserialize)] +struct CursorToken { + v: u8, + id: Uuid, +} + +/// One stored cursor row. +#[derive(Clone, Debug)] +pub struct StoredCursor { + pub cursor_id: Uuid, + pub context: String, + pub position: Value, + pub expires_at: DateTime, +} + +/// Encode a cursor id as the opaque token a caller carries. +#[must_use] +pub fn encode_cursor(cursor_id: Uuid) -> String { + let token = CursorToken { + v: 1, + id: cursor_id, + }; + let bytes = serde_json::to_vec(&token).expect("a cursor token always serializes"); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Decode an opaque token back to its cursor id, refusing anything that is +/// not exactly a cursor of this wire form. +pub fn decode_cursor(encoded: &str) -> Result { + if encoded.len() > MAXIMUM_CURSOR_BYTES { + return Err(CursorError::Invalid); + } + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| CursorError::Invalid)?; + let token: CursorToken = serde_json::from_slice(&bytes).map_err(|_| CursorError::Invalid)?; + if token.v != 1 { + return Err(CursorError::Invalid); + } + Ok(token.id) +} + +/// The expiry a newly minted cursor carries. +#[must_use] +pub fn cursor_expiry(now: DateTime) -> DateTime { + now.checked_add_signed(TimeDelta::minutes(CURSOR_LIFETIME_MINUTES)) + .expect("fifteen minutes is always representable") +} + +/// The position a listing resumes from, typed by what each listing walks. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ListingPosition { + /// Resume strictly after this catalogue id. + AfterId { last_id: String }, + /// Resume an availability walk after this instant, carrying the + /// effective interval the walk was minted for. The interval rides with + /// the cursor because a request that omitted its bounds has none of its + /// own to repeat: the continuation restores them from here rather than + /// deriving a different interval from its own request time. + AvailabilityAfter { + from: DateTime, + to: DateTime, + after: DateTime, + }, + /// Resume strictly before this instant and id, walking newest first: the + /// position one history page leaves behind. + BeforeInstantAndId { + before: DateTime, + last_id: String, + }, +} + +impl ListingPosition { + /// The JSON the stored row carries. The shape is internal: callers only + /// ever see the opaque token. + #[must_use] + pub fn to_json(&self) -> Value { + match self { + Self::AfterId { last_id } => serde_json::json!({"afterId": last_id}), + Self::AvailabilityAfter { from, to, after } => serde_json::json!({ + "from": from.to_rfc3339(), + "to": to.to_rfc3339(), + "after": after.to_rfc3339(), + }), + Self::BeforeInstantAndId { before, last_id } => { + serde_json::json!({"before": before.to_rfc3339(), "lastId": last_id}) + } + } + } + + /// Read back a stored position, refusing one a different listing shape + /// wrote. A position that does not parse is a corrupt row, not a caller + /// error, so it surfaces as invalid the same way a foreign cursor does. + #[must_use] + pub fn from_json(value: &Value) -> Option { + if let Some(last_id) = value.get("afterId").and_then(Value::as_str) { + return Some(Self::AfterId { + last_id: last_id.to_owned(), + }); + } + if let (Some(before), Some(last_id)) = ( + value.get("before").and_then(Value::as_str), + value.get("lastId").and_then(Value::as_str), + ) { + return DateTime::parse_from_rfc3339(before).ok().map(|before| { + Self::BeforeInstantAndId { + before: before.with_timezone(&Utc), + last_id: last_id.to_owned(), + } + }); + } + let instant = |key: &str| { + value + .get(key) + .and_then(Value::as_str) + .and_then(|text| DateTime::parse_from_rfc3339(text).ok()) + .map(|instant| instant.with_timezone(&Utc)) + }; + match (instant("from"), instant("to"), instant("after")) { + (Some(from), Some(to), Some(after)) => { + Some(Self::AvailabilityAfter { from, to, after }) + } + _ => None, + } + } +} + +/// Check a resolved cursor row against the listing trying to continue it. +pub fn bind_stored( + stored: &StoredCursor, + context: &str, + now: DateTime, +) -> Result { + if stored.context != context { + return Err(CursorError::Invalid); + } + if stored.expires_at <= now { + return Err(CursorError::Expired); + } + ListingPosition::from_json(&stored.position).ok_or(CursorError::Invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stored(context: &str, position: Value, minutes_left: i64) -> StoredCursor { + StoredCursor { + cursor_id: Uuid::new_v4(), + context: context.to_owned(), + position, + expires_at: Utc::now() + TimeDelta::minutes(minutes_left), + } + } + + #[test] + fn a_cursor_round_trips_through_its_opaque_form() { + let cursor_id = Uuid::new_v4(); + let encoded = encode_cursor(cursor_id); + assert_eq!(decode_cursor(&encoded), Ok(cursor_id)); + // Overwriting the final character with a byte outside the base64url + // alphabet corrupts the token deterministically, whatever the id. + let corrupted = format!("{}!", &encoded[..encoded.len() - 1]); + for foreign in ["", "not-a-cursor", "AAAA", &"A".repeat(512), &corrupted] { + assert_eq!(decode_cursor(foreign), Err(CursorError::Invalid)); + } + } + + #[test] + fn a_cursor_binds_to_its_listing_context_and_expires() { + let now = Utc::now(); + let position = ListingPosition::AfterId { + last_id: "registry-update-30".to_owned(), + }; + let row = stored("offerings", position.to_json(), 10); + assert_eq!( + bind_stored(&row, "offerings", now), + Ok(ListingPosition::AfterId { + last_id: "registry-update-30".to_owned() + }) + ); + assert_eq!( + bind_stored(&row, "services", now), + Err(CursorError::Invalid) + ); + assert_eq!( + bind_stored(&row, "offerings", row.expires_at), + Err(CursorError::Expired) + ); + } + + #[test] + fn positions_round_trip_and_refuse_foreign_shapes() { + for position in [ + ListingPosition::AfterId { + last_id: "w1".to_owned(), + }, + ListingPosition::AvailabilityAfter { + from: Utc::now(), + to: Utc::now(), + after: Utc::now(), + }, + ListingPosition::BeforeInstantAndId { + before: Utc::now(), + last_id: "event-1".to_owned(), + }, + ] { + assert_eq!( + ListingPosition::from_json(&position.to_json()), + Some(position.clone()) + ); + } + assert_eq!( + ListingPosition::from_json(&serde_json::json!({"x": 1})), + None + ); + } + + #[test] + fn cursor_expiry_is_fifteen_minutes_out() { + let now = Utc::now(); + assert_eq!( + cursor_expiry(now).signed_duration_since(now).num_minutes(), + CURSOR_LIFETIME_MINUTES + ); + } +} diff --git a/crates/registry-scheduling/src/hooks.rs b/crates/registry-scheduling/src/hooks.rs new file mode 100644 index 0000000000..42f6d6d51a --- /dev/null +++ b/crates/registry-scheduling/src/hooks.rs @@ -0,0 +1,1204 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Scheduling's closed after-commit appointment observer runtime. +//! +//! The shared hook crate owns the envelope and delivery state machine. This +//! module owns Scheduling's policy: three appointment lifecycle triggers, +//! caller-selected projections over a closed set of non-attributable claim +//! fields, URL handlers only, exact deployment destination activation, and a +//! worker that can observe an answer but can never apply a proposal. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{ProtectedSecret, SecretResolver, MAX_SECRET_BYTES}; +use registry_platform_crypto::delivery_signature::{sign_v1, SignatureFields}; +use registry_platform_hooks::delivery::{ + insert_delivery, DeliveryAuditDisposition, DeliveryAuditOutcome, DeliveryAuditPhase, + DeliveryAuditRecord, DeliveryCapture, DeliveryConfig, DeliveryConnection, DeliveryError, + DeliveryOperationalEvent, DeliveryOutcome, DeliverySeams, DeliveryService, + DeliverySignatureFields, DeliverySignatureRefused, DeliveryWorker, DestinationAnswer, + HandlerRunFailure, HookDestination, HookHandler, HookHandlerBinding, ProposalApplication, + ProposalOutcome, ProposalReceiptRecovery, +}; +use registry_platform_hooks::{ + validate_hooks, BoundedText, Causation, EnvelopeLimits, EventSubject, HookDeclaration, + HookEnvelope, HookHandlerKind, HookHandlerSource, HookPhase, MAX_OUTPUT_BYTES, + MAX_REFUSAL_CODE_BYTES, MAX_REFUSAL_SUMMARY_BYTES, +}; +use registry_platform_httputil::destination::{ + DestinationProfile, DestinationRequestError, DestinationResponseError, DestinationSendError, + EventDeliveryHeaders, EventDestinationPolicy, EventDestinationRequest, + EventDestinationRequestTemplate, MAX_DESTINATION_REQUEST_BODY_BYTES, + MAX_DESTINATION_REQUEST_HEADER_BYTES, MAX_DESTINATION_TARGET_BYTES, +}; +use registry_scheduling_core::{ + valid_identifier, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, + APPOINTMENT_RESCHEDULED_TRIGGER, +}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::watch; +use tokio_postgres::Transaction; +use url::Url; +use uuid::Uuid; + +use crate::config::HookDestinationConfig; +use crate::store::{ClaimRow, PostgresStore}; + +const DESTINATION_BINDING_SCHEMA: &str = "registry.scheduling-hook-destinations/v1"; +const COMPILED_HOOK_SCHEMA: &str = "registry.scheduling-hooks/v1"; +const DATA_SCHEMA_BINDING: &str = "registry.scheduling-hook-data/v1"; +const IDEMPOTENCY_DOMAIN: &[u8] = b"scheduling-hook-idempotency-v1"; +const ENTITY_ID: &str = "appointment"; + +const MIN_HMAC_SHA256_KEY_BYTES: usize = 32; +const MAX_ATTEMPT_TIMEOUT_MS: u32 = 10_000; +const MAXIMUM_ATTEMPTS: u8 = 20; +const INITIAL_BACKOFF_MS: i64 = 30_000; +const MAXIMUM_BACKOFF_MS: i64 = 3_600_000; +const BACKOFF_MULTIPLIER: i16 = 2; +const MAXIMUM_PAYLOAD_BYTES: usize = 16 * 1024; +const MAXIMUM_REQUEST_BYTES: usize = + MAX_DESTINATION_TARGET_BYTES + MAX_DESTINATION_REQUEST_HEADER_BYTES + MAXIMUM_PAYLOAD_BYTES; +const MINIMUM_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); +const MAXIMUM_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const IDENTITY_LOCK_TIMEOUT_MS: i32 = 5_000; + +const RETRY_DELAYS_MS: [i64; 19] = [ + 30_000, 60_000, 120_000, 240_000, 480_000, 960_000, 1_920_000, 3_600_000, 3_600_000, 3_600_000, + 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, + 3_600_000, +]; + +const APPOINTMENT_FIELDS: &[&str] = &[ + "appointmentId", + "end", + "offering", + "policyRevision", + "revision", + "start", + "state", +]; +const CANCELLATION_FIELDS: &[&str] = &["appointmentId", "revision", "state"]; + +const _: () = assert!(MAXIMUM_PAYLOAD_BYTES <= MAX_DESTINATION_REQUEST_BODY_BYTES); + +/// Value-free refusal from hook compilation or deployment activation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum HookActivationError { + #[error("the Scheduling hook declaration is invalid")] + InvalidDeclaration, + #[error("the Scheduling hook contract is unsupported")] + UnsupportedContract, + #[error("the Scheduling hook destination inventory is incomplete")] + DestinationInventoryMismatch, + #[error("a Scheduling hook destination binding is invalid")] + InvalidDestination, + #[error("a Scheduling hook destination widens the fixed delivery budget")] + DeliveryBudgetWidening, + #[error("a Scheduling hook signing secret is unavailable or invalid")] + InvalidSigningMaterial, + #[error("the Scheduling hook runtime identity is invalid")] + InvalidIdentity, +} + +/// Value-free refusal while capturing a committed appointment event. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum HookCaptureError { + #[error("the Scheduling hook capture is invalid")] + InvalidCapture, + #[error("the Scheduling hook outbox is unavailable")] + Unavailable, +} + +/// The immutable Scheduling identity a hook worker must observe in every +/// transaction before it reads or changes delivery state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HookRuntimeIdentity { + pub scheduling_id: String, + pub policy_revision: i64, + pub policy_digest: String, +} + +/// Validated and compiled Scheduling observer declarations. +#[derive(Clone, Debug)] +pub struct CompiledHooks { + hooks: Vec, + destination_ids: BTreeSet, + schema_fingerprint: String, +} + +#[derive(Clone, Debug)] +struct CompiledHook { + id: String, + trigger: String, + projection: Vec, + destination_id: String, + data_schema: String, +} + +impl CompiledHooks { + /// Compile the shared declaration shape into Scheduling's closed observer + /// contract. Callers may safely compile again at runtime even when the + /// authored policy was checked earlier; no unchecked declaration reaches + /// capture. + pub fn compile(declarations: &[HookDeclaration]) -> Result { + validate_hooks(declarations).map_err(|_| HookActivationError::InvalidDeclaration)?; + + let mut hooks = Vec::with_capacity(declarations.len()); + let mut destination_ids = BTreeSet::new(); + for declaration in declarations { + if !valid_identifier(&declaration.id) + || declaration.phase != HookPhase::After + || declaration.when.is_some() + || declaration.principal.is_some() + { + return Err(HookActivationError::UnsupportedContract); + } + let destination_id = match &declaration.handler { + HookHandlerSource::Url { destination_id } + if valid_logical_destination_id(destination_id) => + { + destination_id.clone() + } + _ => return Err(HookActivationError::UnsupportedContract), + }; + let allowed = projection_fields(&declaration.trigger) + .ok_or(HookActivationError::UnsupportedContract)?; + if declaration + .projection + .iter() + .any(|field| !allowed.contains(&field.as_str())) + { + return Err(HookActivationError::UnsupportedContract); + } + let projection = declaration.projection.iter().cloned().collect::>(); + let data_schema = data_schema(&declaration.trigger, &projection)?; + destination_ids.insert(destination_id.clone()); + hooks.push(CompiledHook { + id: declaration.id.clone(), + trigger: declaration.trigger.clone(), + projection, + destination_id, + data_schema, + }); + } + + let fingerprint_value = json!({ + "schemaVersion": COMPILED_HOOK_SCHEMA, + "hooks": hooks.iter().map(|hook| json!({ + "id": hook.id, + "trigger": hook.trigger, + "projection": hook.projection, + "destinationId": hook.destination_id, + "dataSchema": hook.data_schema, + })).collect::>(), + }); + let schema_fingerprint = canonical_digest(&fingerprint_value) + .map_err(|_| HookActivationError::InvalidDeclaration)?; + Ok(Self { + hooks, + destination_ids, + schema_fingerprint, + }) + } + + #[must_use] + pub fn schema_fingerprint(&self) -> &str { + &self.schema_fingerprint + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.hooks.is_empty() + } +} + +/// Activated observer hooks and their exact deployment bindings. +#[derive(Clone)] +pub struct ActivatedHooks { + compiled: Arc, + destinations: Arc, + identity: HookRuntimeIdentity, + schema: String, + delivery_source: String, + payload_retention: Duration, +} + +impl std::fmt::Debug for ActivatedHooks { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivatedHooks") + .field("hook_count", &self.compiled.hooks.len()) + .field("destination_count", &self.destinations.bindings.len()) + .field("schema_fingerprint", &self.compiled.schema_fingerprint) + .field("identity", &self.identity) + .field("payload_retention", &self.payload_retention) + .finish() + } +} + +impl ActivatedHooks { + /// Compile and activate the supplied destination inventory for one + /// deployed Scheduling policy. It must cover every current declaration; + /// additional explicit bindings remain available for retained delivery + /// rows captured under an earlier policy. + #[allow(clippy::too_many_arguments)] + pub fn activate( + declarations: &[HookDeclaration], + configured: &BTreeMap, + secrets: &SecretResolver, + identity: HookRuntimeIdentity, + database_schema: String, + payload_retention: Duration, + ) -> Result { + let compiled = Arc::new(CompiledHooks::compile(declarations)?); + validate_identity(&identity, &database_schema, payload_retention)?; + let destinations = Arc::new(ActivatedDestinations::activate( + &compiled.destination_ids, + configured, + secrets, + )?); + let delivery_source = format!("urn:registrystack:scheduling:{}", identity.scheduling_id); + Ok(Self { + compiled, + destinations, + identity, + schema: database_schema, + delivery_source, + payload_retention, + }) + } + + #[must_use] + pub fn schema_fingerprint(&self) -> &str { + self.compiled.schema_fingerprint() + } + + #[must_use] + pub fn destination_binding_digest(&self) -> &str { + &self.destinations.binding_digest + } + + /// Capture one canonical envelope and one platform delivery row for every + /// declaration matching `trigger`, in declaration order and inside the + /// caller's appointment transaction. + pub async fn capture( + &self, + transaction: &Transaction<'_>, + trigger: &str, + claim: &ClaimRow, + ) -> Result { + if projection_fields(trigger).is_none() || claim.revision <= 0 { + return Err(HookCaptureError::InvalidCapture); + } + let matching = self + .compiled + .hooks + .iter() + .filter(|hook| hook.trigger == trigger) + .collect::>(); + if matching.is_empty() { + return Ok(0); + } + + let created_at = transaction + .query_one( + "SELECT date_trunc('milliseconds', transaction_timestamp())", + &[], + ) + .await + .map_err(|_| HookCaptureError::Unavailable)? + .try_get::<_, SystemTime>(0) + .map_err(|_| HookCaptureError::Unavailable)?; + let retention_seconds = i64::try_from(self.payload_retention.as_secs()) + .map_err(|_| HookCaptureError::InvalidCapture)?; + let record_reference = format!("/v1/appointments/{}", claim.claim_id); + + for hook in matching { + let event_id = Uuid::new_v4(); + let id = event_id.to_string(); + let data = projected_claim( + claim, + self.identity.policy_revision, + &hook.trigger, + &hook.projection, + )?; + let envelope = HookEnvelope { + id: id.clone(), + event_type: hook.id.clone(), + source: self.delivery_source.clone(), + time: created_at.into(), + subject: EventSubject { + record_reference: record_reference.clone(), + record_revision: claim.revision, + }, + dataschema: hook.data_schema.clone(), + data, + causation: Causation::root(&id), + }; + let payload = envelope + .to_canonical_bytes(&EnvelopeLimits::tightened_to(MAXIMUM_PAYLOAD_BYTES)) + .map_err(|_| HookCaptureError::InvalidCapture)?; + self.insert_outbox( + transaction, + event_id, + hook, + claim.revision, + &record_reference, + &payload, + created_at, + retention_seconds, + ) + .await?; + let destination = self + .destinations + .bindings + .get(&hook.destination_id) + .ok_or(HookCaptureError::InvalidCapture)?; + insert_delivery( + transaction, + &self.schema, + event_id, + DeliveryCapture { + compiled_delivery_id: &hook.id, + handler_kind: HookHandlerKind::Url, + logical_destination_id: Some(&hook.destination_id), + destination_binding_digest: &destination.binding_digest, + package_revision: &self.identity.policy_digest, + schema_fingerprint: &self.compiled.schema_fingerprint, + data_schema: &hook.data_schema, + classification_ceiling: "restricted", + authentication_profile: "hmac_sha256_v1", + delivery_mode: "after_commit", + attempt_timeout_ms: i64::from(MAX_ATTEMPT_TIMEOUT_MS), + initial_backoff_ms: INITIAL_BACKOFF_MS, + maximum_backoff_ms: MAXIMUM_BACKOFF_MS, + exponential_backoff_multiplier: BACKOFF_MULTIPLIER, + maximum_attempts: i16::from(MAXIMUM_ATTEMPTS), + retry_delays_ms: &RETRY_DELAYS_MS, + maximum_payload_bytes: MAXIMUM_PAYLOAD_BYTES as i64, + payload: &payload, + deployed_attempt_timeout_ms: i64::from(destination.attempt_timeout_ms), + deployed_maximum_attempts: i16::from(destination.maximum_attempts), + dead_letter: "required", + operator_replay: false, + }, + ) + .await + .map_err(|_| HookCaptureError::Unavailable)?; + } + Ok(self + .compiled + .hooks + .iter() + .filter(|hook| hook.trigger == trigger) + .count()) + } + + #[allow(clippy::too_many_arguments)] + async fn insert_outbox( + &self, + transaction: &Transaction<'_>, + event_id: Uuid, + hook: &CompiledHook, + revision: i64, + record_reference: &str, + payload: &[u8], + created_at: SystemTime, + retention_seconds: i64, + ) -> Result<(), HookCaptureError> { + let sql = format!( + "INSERT INTO {}.registry_outbox + (event_id, event_type, trigger, entity_id, record_reference, + record_revision, application_reference, package_revision, + schema_fingerprint, payload, payload_expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, $8, $9, + $10::timestamptz + $11::bigint * interval '1 second', $10)", + self.schema + ); + let changed = transaction + .execute( + &sql, + &[ + &event_id, + &hook.id, + &hook.trigger, + &ENTITY_ID, + &record_reference, + &revision, + &self.identity.policy_digest, + &self.compiled.schema_fingerprint, + &payload, + &created_at, + &retention_seconds, + ], + ) + .await + .map_err(|_| HookCaptureError::Unavailable)?; + if changed == 1 { + Ok(()) + } else { + Err(HookCaptureError::Unavailable) + } + } + + /// Bind the generic platform service to Scheduling's database identity, + /// audit outbox, destinations, and fixed wire constants. + #[must_use] + pub fn delivery_service(&self, store: PostgresStore) -> HookDeliveryService { + let seams = SchedulingDeliverySeams { + store, + destinations: Arc::clone(&self.destinations), + identity: self.identity.clone(), + schema: self.schema.clone(), + }; + HookDeliveryService { + inner: DeliveryService::new( + seams, + DeliveryConfig { + schema: self.schema.clone(), + idempotency_domain: IDEMPOTENCY_DOMAIN.to_vec(), + delivery_source: self.delivery_source.clone(), + }, + ), + } + } +} + +#[derive(Clone)] +struct ActivatedDestinations { + bindings: BTreeMap>, + binding_digest: String, +} + +impl ActivatedDestinations { + fn activate( + expected: &BTreeSet, + configured: &BTreeMap, + secrets: &SecretResolver, + ) -> Result { + let configured_ids = configured.keys().cloned().collect::>(); + if !expected.is_subset(&configured_ids) { + return Err(HookActivationError::DestinationInventoryMismatch); + } + let mut bindings = BTreeMap::new(); + let mut digest_values = Vec::with_capacity(configured.len()); + for (logical_id, config) in configured { + let destination = ActivatedDestination::activate(logical_id, config, secrets)?; + digest_values.push(destination.digest_value.clone()); + bindings.insert(logical_id.clone(), Arc::new(destination)); + } + let binding_digest = canonical_digest(&json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA, + "destinations": digest_values, + })) + .map_err(|_| HookActivationError::InvalidDestination)?; + Ok(Self { + bindings, + binding_digest, + }) + } +} + +struct ActivatedDestination { + binding_digest: String, + digest_value: Value, + request_target: String, + policy: Arc, + request_template: EventDestinationRequestTemplate, + hmac_sha256_key: ProtectedSecret, + attempt_timeout_ms: u32, + maximum_attempts: u8, +} + +impl ActivatedDestination { + fn activate( + logical_id: &str, + config: &HookDestinationConfig, + secrets: &SecretResolver, + ) -> Result { + if !valid_logical_destination_id(logical_id) + || !(100..=MAX_ATTEMPT_TIMEOUT_MS).contains(&config.attempt_timeout_milliseconds) + || !(1..=MAXIMUM_ATTEMPTS).contains(&config.maximum_attempts) + { + return Err(HookActivationError::DeliveryBudgetWidening); + } + let (origin, request_target, profile) = destination_parts(&config.url)?; + let policy = EventDestinationPolicy::new(logical_id, &origin, profile, &[]) + .map_err(|_| HookActivationError::InvalidDestination)?; + let request_template = EventDestinationRequestTemplate::event_delivery( + &request_target, + MAXIMUM_PAYLOAD_BYTES, + MAXIMUM_REQUEST_BYTES, + ) + .map_err(|_| HookActivationError::InvalidDestination)?; + let hmac_sha256_key = secrets + .resolve(&config.hmac_sha256_key_ref) + .map_err(|_| HookActivationError::InvalidSigningMaterial)?; + if !(MIN_HMAC_SHA256_KEY_BYTES..=MAX_SECRET_BYTES).contains(&hmac_sha256_key.len()) { + return Err(HookActivationError::InvalidSigningMaterial); + } + let digest_value = json!({ + "logicalId": logical_id, + "url": config.url, + "hmacSha256KeyRef": config.hmac_sha256_key_ref, + "attemptTimeoutMilliseconds": config.attempt_timeout_milliseconds, + "maximumAttempts": config.maximum_attempts, + }); + let binding_digest = canonical_digest(&json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA, + "destination": digest_value, + })) + .map_err(|_| HookActivationError::InvalidDestination)?; + Ok(Self { + binding_digest, + digest_value, + request_target, + policy: Arc::new(policy), + request_template, + hmac_sha256_key, + attempt_timeout_ms: config.attempt_timeout_milliseconds, + maximum_attempts: config.maximum_attempts, + }) + } +} + +impl std::fmt::Debug for ActivatedDestination { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivatedDestination") + .field("binding_digest", &self.binding_digest) + .field("request_target", &"[REDACTED]") + .field("policy", &self.policy) + .field("request_template", &self.request_template) + .field("hmac_sha256_key", &"[REDACTED]") + .field("attempt_timeout_ms", &self.attempt_timeout_ms) + .field("maximum_attempts", &self.maximum_attempts) + .finish() + } +} + +#[derive(Clone)] +struct DestinationBinding(Arc); + +#[async_trait::async_trait] +impl HookDestination for DestinationBinding { + fn binding_digest(&self) -> &str { + &self.0.binding_digest + } + + fn attempt_timeout(&self) -> Duration { + Duration::from_millis(u64::from(self.0.attempt_timeout_ms)) + } + + fn maximum_attempts(&self) -> u8 { + self.0.maximum_attempts + } + + fn sign_delivery( + &self, + fields: DeliverySignatureFields<'_>, + ) -> Result { + sign_v1( + self.0.hmac_sha256_key.expose_secret(), + SignatureFields { + id: fields.id, + source: fields.source, + event_type: fields.event_type, + time: fields.time, + data_schema: fields.data_schema, + generation: fields.generation, + attempt: fields.attempt, + delivery_time: fields.delivery_time, + method: "POST", + request_target: &self.0.request_target, + content_type: "application/json", + idempotency_key: fields.idempotency_key, + body: fields.body, + }, + ) + .map_err(|_| DeliverySignatureRefused) + } + + fn render_delivery( + &self, + headers: EventDeliveryHeaders<'_>, + body: Vec, + ) -> Result { + self.0.request_template.render_event(headers, body) + } + + async fn send_delivery( + &self, + request: EventDestinationRequest, + remaining: Duration, + ) -> Result { + let response = self.0.policy.send(request, remaining).await?; + let status = response.status().as_u16(); + if !response.status().is_success() { + return Ok(DestinationAnswer::NonSuccess { status }); + } + match response.read_bounded(MAX_OUTPUT_BYTES).await { + Ok(body) => Ok(DestinationAnswer::Delivered { + body: body.to_event_answer(), + }), + Err(error) => Ok(DestinationAnswer::AnswerRefused(HandlerRunFailure::new( + match error { + DestinationResponseError::BodyTooLarge + | DestinationResponseError::BodyLimitTooHigh => { + registry_platform_hooks::ErrorCategory::Resource + } + DestinationResponseError::DeadlineExceeded => { + registry_platform_hooks::ErrorCategory::Deadline + } + DestinationResponseError::BodyReadFailed => { + registry_platform_hooks::ErrorCategory::Unavailable + } + }, + ))), + } + } +} + +#[derive(Clone)] +struct SchedulingDeliverySeams { + store: PostgresStore, + destinations: Arc, + identity: HookRuntimeIdentity, + schema: String, +} + +#[async_trait::async_trait] +impl DeliverySeams for SchedulingDeliverySeams { + type Destination = DestinationBinding; + type Handler = NoLocalHandler; + + async fn connection(&self) -> Result { + let client = self + .store + .hook_delivery_client() + .await + .map_err(|_| DeliveryError::Unavailable)?; + Ok(Box::new(PooledClient(client))) + } + + async fn verify_transaction(&self, transaction: &Transaction<'_>) -> Result<(), DeliveryError> { + transaction + .execute( + "SELECT set_config('lock_timeout', $1::text, true)", + &[&format!("{IDENTITY_LOCK_TIMEOUT_MS}ms")], + ) + .await + .map_err(|_| DeliveryError::Unavailable)?; + let identity_query = format!( + "SELECT scheduling_id, policy_revision, policy_digest + FROM {}.scheduling_meta WHERE singleton FOR SHARE", + self.schema + ); + let row = transaction + .query_opt(&identity_query, &[]) + .await + .map_err(|_| DeliveryError::Unavailable)? + .ok_or(DeliveryError::Unavailable)?; + let matches = row.try_get::<_, String>(0).ok().as_deref() + == Some(self.identity.scheduling_id.as_str()) + && row.try_get::<_, i64>(1).ok() == Some(self.identity.policy_revision) + && row.try_get::<_, String>(2).ok().as_deref() + == Some(self.identity.policy_digest.as_str()); + matches.then_some(()).ok_or(DeliveryError::Unavailable) + } + + fn destination(&self, logical_destination_id: &str) -> Option { + self.destinations + .bindings + .get(logical_destination_id) + .cloned() + .map(DestinationBinding) + } + + fn handler(&self, _binding: HookHandlerBinding<'_>) -> Option { + None + } + + async fn apply_proposal( + &self, + _application: ProposalApplication<'_>, + ) -> Result { + Ok(ProposalOutcome::Refused { + code: bounded_proposal_code("scheduling.hook.proposal_unsupported"), + summary: bounded_proposal_summary( + "Scheduling appointment observer hooks cannot propose changes", + ), + }) + } + + async fn recover_proposal_receipt( + &self, + _recovery: ProposalReceiptRecovery<'_>, + ) -> Result, DeliveryError> { + Ok(None) + } + + async fn recover_proposal_receipt_in_transaction( + &self, + _transaction: &Transaction<'_>, + _recovery: ProposalReceiptRecovery<'_>, + ) -> Result, DeliveryError> { + Ok(None) + } + + async fn record_audit( + &self, + transaction: &Transaction<'_>, + record: DeliveryAuditRecord<'_>, + ) -> Result<(), DeliveryError> { + let audit = json!({ + "event": "scheduling.hook-delivery", + "hookEventId": record.event_id, + "compiledDeliveryId": record.compiled_delivery_id, + "policyDigest": record.package_revision, + "generation": record.generation, + "attempt": record.attempt, + "phase": audit_phase(record.phase), + "outcome": audit_outcome(record.outcome), + "disposition": audit_disposition(record.disposition), + }); + let sql = format!( + "INSERT INTO {}.scheduling_audit_outbox(event_id, audit_record) VALUES($1, $2)", + self.schema + ); + let changed = transaction + .execute(&sql, &[&Uuid::new_v4(), &audit]) + .await + .map_err(|_| DeliveryError::Unavailable)?; + if changed == 1 { + Ok(()) + } else { + Err(DeliveryError::Unavailable) + } + } + + fn operational_event(&self, event: DeliveryOperationalEvent) { + match event { + DeliveryOperationalEvent::IterationFailed => { + tracing::warn!(event = "scheduling_hook_delivery_iteration_failed"); + } + DeliveryOperationalEvent::TransitionFailed(code) => { + tracing::warn!( + event = "scheduling_hook_delivery_transition_failed", + code = transition_code(code) + ); + } + } + } +} + +#[derive(Clone, Copy)] +enum NoLocalHandler {} + +#[async_trait::async_trait] +impl HookHandler for NoLocalHandler { + fn handler_digest(&self) -> &str { + match *self {} + } + + fn attempt_timeout(&self) -> Duration { + match *self {} + } + + fn maximum_attempts(&self) -> u8 { + match *self {} + } + + async fn run( + &self, + _envelope: &[u8], + _remaining: Duration, + ) -> Result, HandlerRunFailure> { + match *self {} + } +} + +struct PooledClient(deadpool_postgres::Client); + +impl Deref for PooledClient { + type Target = tokio_postgres::Client; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for PooledClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Scheduling's usable wrapper around the generic delivery service. +#[derive(Clone)] +pub struct HookDeliveryService { + inner: DeliveryService, +} + +impl HookDeliveryService { + pub async fn deliver_once(&self) -> Result { + self.inner.deliver_once().await + } + + pub async fn verify_retained_bindings(&self) -> Result<(), DeliveryError> { + self.inner.verify_retained_bindings().await + } + + #[must_use] + pub fn worker(&self) -> HookDeliveryWorker { + HookDeliveryWorker { + inner: DeliveryWorker::new(self.inner.clone()), + } + } +} + +/// Scheduling's post-commit hook delivery loop. +pub struct HookDeliveryWorker { + inner: DeliveryWorker, +} + +impl HookDeliveryWorker { + pub async fn run(self, shutdown: watch::Receiver) { + self.inner.run(shutdown).await; + } +} + +fn projected_claim( + claim: &ClaimRow, + policy_revision: i64, + trigger: &str, + projection: &[String], +) -> Result { + let mut values = Map::new(); + for field in projection { + let value = match field.as_str() { + "appointmentId" => json!(claim.claim_id), + "offering" => json!(claim.offering), + "start" => json!(claim.displayed_start), + "end" => json!(claim.displayed_end), + "revision" => json!(claim.revision), + // Bind the event to the policy revision that authorized the + // operation. A cancellation closes a claim minted under an older + // revision, so the retained claim's creation revision is not the + // relevant authority here. + "policyRevision" => json!(policy_revision), + "state" => match trigger { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => { + json!("confirmed") + } + APPOINTMENT_CANCELLED_TRIGGER => json!("cancelled"), + _ => return Err(HookCaptureError::InvalidCapture), + }, + // Actor, reason, duplicate keys, and every credential-adjacent + // caller value are intentionally not representable here. + _ => return Err(HookCaptureError::InvalidCapture), + }; + values.insert(field.clone(), value); + } + Ok(Value::Object(values)) +} + +fn projection_fields(trigger: &str) -> Option<&'static [&'static str]> { + match trigger { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => Some(APPOINTMENT_FIELDS), + APPOINTMENT_CANCELLED_TRIGGER => Some(CANCELLATION_FIELDS), + _ => None, + } +} + +fn data_schema(trigger: &str, projection: &[String]) -> Result { + let digest = canonical_digest(&json!({ + "schemaVersion": DATA_SCHEMA_BINDING, + "trigger": trigger, + "projection": projection, + })) + .map_err(|_| HookActivationError::InvalidDeclaration)?; + Ok(format!("urn:registrystack:scheduling:hook-data:{digest}")) +} + +fn destination_parts( + configured: &str, +) -> Result<(String, String, DestinationProfile), HookActivationError> { + let parsed = Url::parse(configured).map_err(|_| HookActivationError::InvalidDestination)?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.host().is_none() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(HookActivationError::InvalidDestination); + } + let profile = match parsed.scheme() { + "https" => DestinationProfile::ProductionHttps, + "http" + if matches!( + parsed.host_str(), + Some("127.0.0.1") | Some("localhost") | Some("[::1]") + ) => + { + DestinationProfile::LoopbackDevelopmentHttp + } + _ => return Err(HookActivationError::InvalidDestination), + }; + let request_target = if parsed.path().is_empty() { + "/".to_owned() + } else { + parsed.path().to_owned() + }; + let mut origin = parsed; + origin.set_path(""); + origin.set_query(None); + origin.set_fragment(None); + Ok((origin.to_string(), request_target, profile)) +} + +fn validate_identity( + identity: &HookRuntimeIdentity, + schema: &str, + payload_retention: Duration, +) -> Result<(), HookActivationError> { + if !valid_identifier(&identity.scheduling_id) + || identity.policy_revision <= 0 + || !valid_sha256_digest(&identity.policy_digest) + || !valid_schema_name(schema) + || !(MINIMUM_RETENTION..=MAXIMUM_RETENTION).contains(&payload_retention) + || payload_retention.subsec_nanos() != 0 + { + return Err(HookActivationError::InvalidIdentity); + } + Ok(()) +} + +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn valid_schema_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn valid_sha256_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn canonical_digest(value: &Value) -> Result { + let canonical = canonicalize_json(value).map_err(|_| ())?; + let digest = Sha256::digest(canonical); + Ok(format!("sha256:{}", hex(&digest))) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + +fn bounded_proposal_code(value: &'static str) -> BoundedText { + BoundedText::new(value.to_owned()).expect("static proposal code fits its bound") +} + +fn bounded_proposal_summary(value: &'static str) -> BoundedText { + BoundedText::new(value.to_owned()).expect("static proposal summary fits its bound") +} + +fn audit_phase(value: DeliveryAuditPhase) -> &'static str { + match value { + DeliveryAuditPhase::Attempt => "attempt", + DeliveryAuditPhase::Terminal => "terminal", + DeliveryAuditPhase::Replay => "replay", + } +} + +fn audit_outcome(value: DeliveryAuditOutcome) -> &'static str { + match value { + DeliveryAuditOutcome::AttemptStarted => "attempt_started", + DeliveryAuditOutcome::Delivered => "delivered", + DeliveryAuditOutcome::HttpNonSuccess => "http_non_success", + DeliveryAuditOutcome::DestinationTimeout => "destination_timeout", + DeliveryAuditOutcome::DestinationResolutionRefused => "destination_resolution_refused", + DeliveryAuditOutcome::DestinationTransportUnavailable => { + "destination_transport_unavailable" + } + DeliveryAuditOutcome::DestinationPolicyRefused => "destination_policy_refused", + DeliveryAuditOutcome::DestinationBindingRefused => "destination_binding_refused", + DeliveryAuditOutcome::HandlerBindingRefused => "handler_binding_refused", + DeliveryAuditOutcome::HandlerDeadline => "handler_deadline", + DeliveryAuditOutcome::HandlerResource => "handler_resource", + DeliveryAuditOutcome::HandlerExecution => "handler_execution", + DeliveryAuditOutcome::HandlerSource => "handler_source", + DeliveryAuditOutcome::HandlerUnavailable => "handler_unavailable", + DeliveryAuditOutcome::PayloadRefused => "payload_refused", + DeliveryAuditOutcome::PayloadExpired => "payload_expired", + DeliveryAuditOutcome::WorkerInterrupted => "worker_interrupted", + DeliveryAuditOutcome::ReplayRequested => "replay_requested", + } +} + +fn audit_disposition(value: DeliveryAuditDisposition) -> &'static str { + match value { + DeliveryAuditDisposition::Leased => "leased", + DeliveryAuditDisposition::Delivered => "delivered", + DeliveryAuditDisposition::RetryPending => "retry_pending", + DeliveryAuditDisposition::DeadLettered => "dead_lettered", + DeliveryAuditDisposition::Expired => "expired", + DeliveryAuditDisposition::ReplayPending => "replay_pending", + } +} + +fn transition_code( + value: registry_platform_hooks::delivery::DeliveryTransitionCode, +) -> &'static str { + use registry_platform_hooks::delivery::DeliveryTransitionCode; + match value { + DeliveryTransitionCode::ClaimIdentityRefused => "claim_identity_refused", + DeliveryTransitionCode::ClaimRecoveryFailed => "claim_recovery_failed", + DeliveryTransitionCode::ClaimSelectFailed => "claim_select_failed", + DeliveryTransitionCode::ClaimPolicyRefused => "claim_policy_refused", + DeliveryTransitionCode::ClaimUpdateFailed => "claim_update_failed", + DeliveryTransitionCode::ClaimAuditFailed => "claim_audit_failed", + DeliveryTransitionCode::ClaimCommitFailed => "claim_commit_failed", + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeDelta, Utc}; + use registry_scheduling_core::LedgerKind; + + use super::*; + use crate::store::ClaimState; + + #[test] + fn projections_disclose_only_the_requested_closed_fields() { + let now = Utc::now(); + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: "consultation".to_owned(), + supply_id: "secret-resource".to_owned(), + channel: Some("secret-channel".to_owned()), + displayed_start: now, + displayed_end: now + TimeDelta::minutes(30), + occupied_start: now - TimeDelta::minutes(5), + occupied_end: now + TimeDelta::minutes(35), + units: 1, + duplicate_key: Some("secret-duplicate-key".to_owned()), + hold_expires_at: None, + revision: 4, + policy_revision: 2, + actor: "secret-actor".to_owned(), + reason: Some("secret-reason".to_owned()), + created_at: now, + closed_at: None, + }; + let projection = [ + "appointmentId", + "offering", + "start", + "end", + "revision", + "policyRevision", + "state", + ] + .map(str::to_owned); + + let projected = projected_claim(&claim, 9, APPOINTMENT_CONFIRMED_TRIGGER, &projection) + .expect("the closed projection is valid"); + + assert_eq!(projected["policyRevision"], json!(9)); + assert_eq!(projected["state"], json!("confirmed")); + let serialized = projected.to_string(); + for canary in [ + "secret-resource", + "secret-channel", + "secret-duplicate-key", + "secret-actor", + "secret-reason", + ] { + assert!(!serialized.contains(canary), "projection leaked {canary}"); + } + } + + #[test] + fn cancellation_projection_uses_the_public_terminal_state() { + let now = Utc::now(); + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: ClaimState::Cancelled, + offering: "consultation".to_owned(), + supply_id: "resource".to_owned(), + channel: None, + displayed_start: now, + displayed_end: now + TimeDelta::minutes(30), + occupied_start: now, + occupied_end: now + TimeDelta::minutes(30), + units: 1, + duplicate_key: None, + hold_expires_at: None, + revision: 5, + policy_revision: 2, + actor: "actor".to_owned(), + reason: Some("private reason".to_owned()), + created_at: now, + closed_at: Some(now), + }; + let projection = ["appointmentId", "revision", "state"].map(str::to_owned); + + let projected = projected_claim(&claim, 9, APPOINTMENT_CANCELLED_TRIGGER, &projection) + .expect("the cancellation projection is valid"); + + assert_eq!(projected["state"], json!("cancelled")); + assert!(!projected.to_string().contains("private reason")); + } + + #[test] + fn destination_parsing_accepts_only_explicit_loopback_http() { + for url in [ + "http://localhost:8080/hooks", + "http://127.0.0.1:8080/hooks", + "http://[::1]:8080/hooks", + "https://receiver.example/hooks", + ] { + destination_parts(url).expect("the reviewed destination is valid"); + } + assert!(destination_parts("http://receiver.example/hooks").is_err()); + assert!(destination_parts("https://receiver.example/hooks?token=secret").is_err()); + } +} diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs new file mode 100644 index 0000000000..a0ac48883d --- /dev/null +++ b/crates/registry-scheduling/src/http.rs @@ -0,0 +1,1193 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP surface: the pinned routes, the request boundary, and the one +//! problem rendering every refusal flows through. +//! +//! Every refusal leaves this edge as one problem shape: a code from the closed +//! vocabulary, the product refusals (authentication, authorization, +//! admission, idempotency, cursors, availability) and the `request.*` family +//! carrying the request-edge rejections (a route that does not exist, a +//! method the route refuses, a body too large or not JSON), under the +//! Scheduling problem base, with the answering request's trace id. No shared +//! platform problem prefix exists in the Registry Stack catalog, so the edge +//! codes live in this product's own vocabulary exactly as Casework's do. +//! +//! Mutations answer through the service's commitment verdict. A minted +//! commitment answers with the route's success status and its document. A +//! replayed idempotency key answers as the stored attempt first did: a +//! success receipt projects through the same document, a refusal receipt +//! re-renders its pinned problem under the answering request's trace, and a +//! release's empty receipt answers as an empty 204 again. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::header::{ + AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE, +}; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use registry_platform_authcommon::parse_bearer_token; +use registry_platform_httpsec::{ + request_body_limit_default, security_headers, CspBuilder, ProblemBody, TraceContext, +}; +use registry_scheduling_core::{ + type_uri, valid_identifier, valid_reference, AdmissionRequest, CancelAppointmentRequest, + CreateAppointmentRequest, ProblemCode, RescheduleAppointmentRequest, APPOINTMENTS_PATH, + AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, + LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, + RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, +}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::{AuthenticationError, SchedulingAuthenticator}; +use crate::service::{Caller, CommitmentAnswer, SchedulingService, ServiceError}; +use crate::store::PostgresStore; + +tokio::task_local! { + static REQUEST_TRACE: TraceContext; +} + +#[derive(Clone)] +pub struct HttpState { + pub service: Arc, + pub authenticator: Arc, + pub store: PostgresStore, +} + +const HOLD_ROUTE: &str = "/v1/holds/{hold_id}"; +const APPOINTMENT_ROUTE: &str = "/v1/appointments/{appointment_id}"; +const APPOINTMENT_RESCHEDULE_ROUTE: &str = "/v1/appointments/{appointment_id}/reschedule"; +const APPOINTMENT_CANCEL_ROUTE: &str = "/v1/appointments/{appointment_id}/cancel"; +const APPOINTMENT_HISTORY_ROUTE: &str = "/v1/appointments/{appointment_id}/history"; + +pub fn router(state: HttpState) -> Router { + http_edge( + Router::new() + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)) + .route(SCHEDULING_PATH, get(scheduling)) + .route(SERVICES_PATH, get(list_services)) + .route(OFFERINGS_PATH, get(list_offerings)) + .route(RESOURCES_PATH, get(list_resources)) + .route(LOCATIONS_PATH, get(list_locations)) + .route(AVAILABILITY_PATH, get(availability)) + .route(AVAILABILITY_EXPLAIN_PATH, get(explain)) + .route(HOLDS_PATH, post(create_hold)) + .route(HOLD_ROUTE, delete(release_hold)) + .route(APPOINTMENTS_PATH, post(create_appointment)) + .route(APPOINTMENT_ROUTE, get(get_appointment)) + .route(APPOINTMENT_RESCHEDULE_ROUTE, post(reschedule_appointment)) + .route(APPOINTMENT_CANCEL_ROUTE, post(cancel_appointment)) + .route(APPOINTMENT_HISTORY_ROUTE, get(appointment_history)), + ) + .with_state(state) +} + +fn http_edge(router: Router) -> Router +where + S: Clone + Send + Sync + 'static, +{ + router + .fallback(route_not_found) + .method_not_allowed_fallback(method_not_allowed) + .layer(request_body_limit_default()) + .layer(middleware::from_fn(http_boundary)) + .layer(security_headers(CspBuilder::restrictive()).without_hsts()) +} + +async fn http_boundary(request: axum::http::Request, next: Next) -> Response { + let trace = TraceContext::from_headers(request.headers()); + let mut response = REQUEST_TRACE + .scope(trace.clone(), async move { + normalize_framework_rejection(next.run(request).await) + }) + .await; + trace.apply(response.headers_mut()); + response.headers_mut().insert( + CACHE_CONTROL, + "no-store" + .parse() + .expect("the no-store policy is a valid header value"), + ); + response +} + +/// Framework rejections carry axum's own bodies, which no Scheduling caller +/// was promised. The statuses are kept; the body becomes the vocabulary's +/// `request.*` problem for that status, so every answer still reads as one +/// problem shape. +fn normalize_framework_rejection(response: Response) -> Response { + let is_problem = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + == Some("application/problem+json"); + if is_problem { + return response; + } + let problem = match response.status() { + StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid), + StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge), + StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType), + StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable), + _ => None, + }; + problem.map_or(response, problem_response) +} + +async fn route_not_found() -> Response { + problem_response(ProblemCode::RequestNotFound) +} + +async fn method_not_allowed() -> Response { + problem_response(ProblemCode::RequestMethodNotAllowed) +} + +async fn healthz() -> StatusCode { + StatusCode::OK +} + +async fn readyz(State(state): State) -> Result { + if state.store.ready().await.is_ok() { + Ok(StatusCode::OK) + } else { + Err(HttpError(ProblemCode::ServiceUnavailable)) + } +} + +async fn scheduling( + State(state): State, + headers: HeaderMap, +) -> Result, HttpError> { + authenticate_read(&state, &headers).await?; + Ok(Json(state.service.scheduling())) +} + +async fn list_services( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_services(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_offerings( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_offerings(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_resources( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_resources(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_locations( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_locations(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn availability( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + bounded_identifier(&query.offering)?; + Ok(Json( + state + .service + .availability( + &query.offering, + query.start, + query.end, + query.cursor.as_deref(), + query.limit, + Utc::now(), + ) + .await?, + )) +} + +async fn explain( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, HttpError> { + authenticate_explain(&state, &headers).await?; + bounded_identifier(&query.offering)?; + Ok(Json( + state + .service + .explain(&query.offering, query.start, Utc::now()) + .await?, + )) +} + +async fn create_hold( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + bounded_admission(&request)?; + let answer = state + .service + .create_hold(&caller, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(hold) => (StatusCode::CREATED, Json(hold)).into_response(), + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn release_hold( + State(state): State, + headers: HeaderMap, + Path(hold_id): Path, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let answer = state + .service + .release_hold(&caller, hold_id, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(()) => StatusCode::NO_CONTENT.into_response(), + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn create_appointment( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + if let Some(admission) = &request.admission { + bounded_admission(admission)?; + } + let answer = state + .service + .create_appointment(&caller, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::CREATED, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn get_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, +) -> Result, HttpError> { + let caller = authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .get_appointment(&caller, appointment_id) + .await?, + )) +} + +async fn reschedule_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + bounded_admission(&request.admission)?; + let answer = state + .service + .reschedule_appointment(&caller, appointment_id, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::OK, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn cancel_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + bounded_reason(request.reason.as_deref())?; + let answer = state + .service + .cancel_appointment(&caller, appointment_id, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::OK, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn appointment_history( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Query(page): Query, +) -> Result< + Json< + registry_scheduling_core::PageDocument< + registry_scheduling_core::AppointmentHistoryEntryDocument, + >, + >, + HttpError, +> { + let caller = authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .appointment_history( + &caller, + appointment_id, + page.cursor.as_deref(), + page.limit, + Utc::now(), + ) + .await?, + )) +} + +#[derive(Debug, Default, Deserialize)] +struct ListingQuery { + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct AvailabilityQuery { + offering: String, + #[serde(default)] + start: Option>, + #[serde(default)] + end: Option>, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct ExplainQuery { + offering: String, + start: DateTime, +} + +async fn authenticate_read(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_read(token) + .await + .map_err(authentication_problem) +} + +async fn authenticate_explain(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_explain(token) + .await + .map_err(authentication_problem) +} + +async fn authenticate_mutate(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_mutate(token) + .await + .map_err(authentication_problem) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, HttpError> { + let authorization = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or(HttpError(ProblemCode::AuthenticationRefused))?; + parse_bearer_token(authorization).map_err(|_| HttpError(ProblemCode::AuthenticationRefused)) +} + +fn authentication_problem(error: AuthenticationError) -> HttpError { + match error { + AuthenticationError::Refused | AuthenticationError::Claims => { + HttpError(ProblemCode::AuthenticationRefused) + } + AuthenticationError::Profile => HttpError(ProblemCode::ProfileNotAuthorized), + // The verifier reached no verdict, so the credential is not what is + // wrong. Answering 401 here would challenge a caller holding a good + // token and invite it to rotate one during an outage that is ours; + // `service.unavailable` carries `Retry-After` instead. + AuthenticationError::Unavailable => HttpError(ProblemCode::ServiceUnavailable), + } +} + +/// The most octets of a cancellation reason. +/// +/// The column the reason lands in refuses more, and a database CHECK that +/// fires during a commitment reaches the caller as service.unavailable: the +/// service would be reported down for a request only the caller can fix. The +/// edge refuses the same size first, and answers request.invalid. +const MAXIMUM_CANCELLATION_REASON_BYTES: usize = 4096; + +/// Bound every caller-supplied string an admission request carries. +/// +/// All of them are stored: the ledger entry, the idempotency receipt and the +/// audit journal keep them verbatim, so an unbounded string is an unbounded +/// write for anyone holding a credential. The bounds are the authoring +/// grammar's own, not new numbers: an offering and a channel are policy +/// identifiers, a capability, a prerequisite and a duplicate key are scoped +/// references, and a request may name no more references than a policy +/// collection may hold. A value outside them could never have matched +/// anything the deployment published. +fn bounded_admission(request: &AdmissionRequest) -> Result<(), HttpError> { + bounded_identifier(&request.offering)?; + if let Some(channel) = &request.channel { + bounded_identifier(channel)?; + } + if let Some(duplicate_key) = &request.duplicate_key { + bounded_reference(duplicate_key)?; + } + bounded_references(&request.capabilities)?; + bounded_references(&request.prerequisites) +} + +/// One policy identifier as the authored grammar admits it. +fn bounded_identifier(value: &str) -> Result<(), HttpError> { + valid_identifier(value) + .then_some(()) + .ok_or(HttpError(ProblemCode::RequestInvalid)) +} + +/// One scoped reference as the authored grammar admits it. +fn bounded_reference(value: &str) -> Result<(), HttpError> { + valid_reference(value) + .then_some(()) + .ok_or(HttpError(ProblemCode::RequestInvalid)) +} + +/// A list of scoped references, no longer than a policy collection may be. +fn bounded_references(values: &[String]) -> Result<(), HttpError> { + if values.len() > MAXIMUM_COLLECTION_ENTRIES { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + values.iter().try_for_each(|value| bounded_reference(value)) +} + +/// The free text a cancellation may carry, bounded to what the column holds. +fn bounded_reason(reason: Option<&str>) -> Result<(), HttpError> { + match reason { + Some(reason) if reason.len() > MAXIMUM_CANCELLATION_REASON_BYTES => { + Err(HttpError(ProblemCode::RequestInvalid)) + } + _ => Ok(()), + } +} + +/// The idempotency key every mutating command carries: present, bounded, and +/// graphical, so it can never smuggle a header-breaking byte. +fn idempotency_key(headers: &HeaderMap) -> Result<&str, HttpError> { + let value = headers + .get(IDEMPOTENCY_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .ok_or(HttpError(ProblemCode::RequestInvalid))?; + if value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + Ok(value) +} + +/// One refusal from the closed vocabulary, as an HTTP answer: every product +/// decision and every request-edge rejection renders through the same pinned +/// document. +#[derive(Debug)] +pub struct HttpError(pub ProblemCode); + +impl From for HttpError { + fn from(error: ServiceError) -> Self { + match error { + ServiceError::Problem(problem) => Self(problem), + } + } +} + +impl IntoResponse for HttpError { + fn into_response(self) -> Response { + problem_response(self.0) + } +} + +fn current_trace() -> TraceContext { + REQUEST_TRACE + .try_with(Clone::clone) + .unwrap_or_else(|_| TraceContext::server_created()) +} + +/// Render one problem: the pinned code, title, status, and detail +/// from the closed vocabulary, under the answering request's trace. +fn problem_response(problem: ProblemCode) -> Response { + let trace = current_trace(); + let status = problem.http_status(); + let body = ProblemBody { + type_uri: type_uri(problem.code()), + title: problem.title(), + status, + detail: problem.detail(), + code: problem.code(), + trace_id: trace.trace_id.clone(), + }; + finish_problem(status, body, &trace) +} + +fn finish_problem(status: u16, body: ProblemBody, trace: &TraceContext) -> Response { + let status = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response = ( + status, + [(CONTENT_TYPE, "application/problem+json")], + Json(body), + ) + .into_response(); + trace.apply(response.headers_mut()); + if status == StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + WWW_AUTHENTICATE, + "Bearer" + .parse() + .expect("the bearer challenge is a valid header value"), + ); + } + if status == StatusCode::SERVICE_UNAVAILABLE { + response.headers_mut().insert( + RETRY_AFTER, + "5".parse() + .expect("the retry interval is a valid header value"), + ); + } + response +} + +/// Answer a replayed idempotency key exactly as the stored attempt first +/// answered. A release's empty receipt answers as an empty 204 again. A +/// refusal receipt carries its problem without a trace id, so it re-renders +/// from its pinned code under the answering request's trace: the first +/// answer's words, this answer's correlation. A receipt in no known shape is +/// corrupt stored state: the caller learns nothing from it. +fn replay_response(status_code: u16, receipt: Value) -> Response { + if receipt.is_null() { + return StatusCode::NO_CONTENT.into_response(); + } + let Some(code) = receipt + .get("problem") + .and_then(|problem| problem.get("code")) + .and_then(Value::as_str) + else { + tracing::error!("a stored attempt receipt carries no recognizable problem"); + return problem_response(ProblemCode::ServiceUnavailable); + }; + match ProblemCode::from_code(code) { + Some(problem) => { + if problem.http_status() != status_code { + tracing::warn!( + stored = status_code, + pinned = problem.http_status(), + "a stored attempt receipt disagrees with its pinned status" + ); + } + problem_response(problem) + } + None => { + tracing::error!(code, "a stored attempt receipt carries an unknown code"); + problem_response(ProblemCode::ServiceUnavailable) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{to_bytes, Body}; + use axum::http::header::IntoHeaderName; + use axum::http::Request; + use registry_scheduling_core::PartyCounts; + use tower::ServiceExt; + + const TRACEPARENT: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; + const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + + fn traced_request(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("traceparent", TRACEPARENT) + .body(Body::empty()) + .expect("a traced test request") + } + + /// Drive one in-process request to its answer; an infallible router + /// always answers. + async fn send(router: Router, request: Request) -> Response { + router + .oneshot(request) + .await + .expect("an in-process request always answers") + } + + async fn body_json(response: Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), 1 << 20) + .await + .expect("a bounded test body"); + serde_json::from_slice(&bytes).expect("a JSON test body") + } + + /// The edge under test, without a service behind it: the boundary layers + /// and the fallbacks are the surface these tests pin. + /// + /// The identified-resource routes are carried under their own templates, + /// reading their segment with the extractor the real handlers use, so a + /// segment the extractor refuses is refused here exactly as it is in the + /// assembled router. + fn edge_router() -> Router { + async fn echo(Json(_): Json) -> StatusCode { + StatusCode::OK + } + async fn identified(Path(_): Path) -> StatusCode { + StatusCode::NO_CONTENT + } + http_edge( + Router::new() + .route("/v1/echo", post(echo)) + .route(HOLD_ROUTE, delete(identified)) + .route(APPOINTMENT_ROUTE, get(identified)), + ) + } + + #[tokio::test] + async fn an_unknown_route_answers_the_pinned_problem_under_the_callers_trace() { + let response = send(edge_router(), traced_request("GET", "/v1/nothing")).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + assert_eq!(response.headers().get("traceparent").unwrap(), TRACEPARENT); + let body = body_json(response).await; + assert_eq!( + body["type"], + "https://id.registrystack.org/problems/registry-scheduling/request/not-found" + ); + assert_eq!(body["title"], "Route not found"); + assert_eq!(body["status"], 404); + assert_eq!(body["detail"], "The requested route does not exist."); + assert_eq!(body["code"], "request.not-found"); + assert_eq!(body["traceId"], TRACE_ID); + } + + #[tokio::test] + async fn a_known_route_with_a_foreign_method_is_method_not_allowed() { + let response = send(edge_router(), traced_request("POST", "/v1/nothing")).await; + // The method fallback wins over the route fallback for a path no + // route claims at all. + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = send(edge_router(), traced_request("GET", "/v1/echo")).await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + let body = body_json(response).await; + assert_eq!(body["code"], "request.method-not-allowed"); + assert_eq!(body["status"], 405); + } + + #[tokio::test] + async fn framework_rejections_normalize_to_request_problems() { + // Malformed JSON is a 400, a type mismatch a 422, and a non-JSON + // content type a 415: three framework answers, one problem vocabulary. + let malformed = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "application/json") + .body(Body::from("this is not json")) + .unwrap(); + let response = send(edge_router(), malformed).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await; + assert_eq!(body["code"], "request.invalid"); + + let mismatched = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "application/json") + .body(Body::from(r#"{"observedRevision": "not a number"}"#)) + .unwrap(); + let response = send(edge_router(), mismatched).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = body_json(response).await; + assert_eq!(body["code"], "request.unprocessable"); + + let wrong_media = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "text/plain") + .body(Body::from(r#"{"x":1}"#)) + .unwrap(); + let response = send(edge_router(), wrong_media).await; + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let body = body_json(response).await; + assert_eq!(body["code"], "request.unsupported-media-type"); + } + + #[tokio::test] + async fn a_path_segment_that_is_not_an_identifier_is_a_request_problem() { + // The two operations whose only caller-controlled input is the path + // segment. The segment is read before the handler runs, so a segment + // that is not an identifier never reaches authentication and never + // reaches the store: the edge answers it, and the OpenAPI document + // declares that answer for every operation carrying a path parameter. + for (method, uri) in [ + ("DELETE", "/v1/holds/not-an-identifier"), + ("GET", "/v1/appointments/not-an-identifier"), + ] { + let response = send(edge_router(), traced_request(method, uri)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + let body = body_json(response).await; + assert_eq!(body["status"], 400); + assert_eq!(body["code"], "request.invalid"); + assert_eq!( + body["type"], + "https://id.registrystack.org/problems/registry-scheduling/request/invalid" + ); + assert_eq!(body["traceId"], TRACE_ID); + } + } + + #[tokio::test] + async fn answers_carry_no_store_and_the_callers_trace() { + let response = send( + edge_router(), + Request::builder() + .method("POST") + .uri("/v1/echo") + .header("traceparent", TRACEPARENT) + .header("content-type", "application/json") + .body(Body::from(r#"{"observedRevision": 1}"#)) + .unwrap(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + // The answering traceparent keeps the caller's trace id under a fresh + // server span id: pin the trace id segment, not the whole header. + let answered = response + .headers() + .get("traceparent") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(&answered[3..35], TRACE_ID); + } + + #[test] + fn every_problem_of_the_closed_vocabulary_renders_its_pinned_document() { + for problem in ProblemCode::ALL { + let response = problem_response(*problem); + let status = StatusCode::from_u16(problem.http_status()).unwrap(); + assert_eq!(response.status(), status, "the code {}", problem.code()); + } + } + + #[tokio::test] + async fn product_problems_carry_the_vocabulary_dialect() { + let trace = TraceContext::from_headers(&{ + let mut headers = HeaderMap::new(); + headers.insert( + "traceparent", + TRACEPARENT.parse().expect("a valid traceparent"), + ); + headers + }); + let response = REQUEST_TRACE + .scope(trace.clone(), async { + problem_response(ProblemCode::CapacityExhausted) + }) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = body_json(response).await; + assert_eq!(body["type"], type_uri("capacity.exhausted")); + assert_eq!(body["title"], "Capacity exhausted"); + assert_eq!(body["status"], 409); + assert_eq!(body["detail"], ProblemCode::CapacityExhausted.detail()); + assert_eq!(body["code"], "capacity.exhausted"); + assert_eq!( + body["traceId"], + serde_json::to_value(&trace.trace_id).unwrap() + ); + } + + #[tokio::test] + async fn an_unauthenticated_problem_challenges_for_a_bearer() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(ProblemCode::AuthenticationRefused) + }) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers().get(WWW_AUTHENTICATE).unwrap(), "Bearer"); + } + + #[tokio::test] + async fn an_unavailable_problem_names_a_retry_interval() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(ProblemCode::ServiceUnavailable) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "5"); + } + + #[tokio::test] + async fn a_verifier_that_cannot_answer_is_unavailable_not_a_challenge() { + // A 401 tells a caller its credential is wrong. When the issuer's key + // material cannot be reached, nothing has been learned about the + // credential, so the caller is told to come back instead of being sent + // to rotate a working token. + let problem = authentication_problem(AuthenticationError::Unavailable); + assert!(matches!( + problem, + HttpError(ProblemCode::ServiceUnavailable) + )); + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(problem.0) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "5"); + assert!(response.headers().get(WWW_AUTHENTICATE).is_none()); + let body = body_json(response).await; + assert_eq!(body["code"], "service.unavailable"); + } + + #[test] + fn a_refused_credential_still_answers_a_challenge() { + for refusal in [AuthenticationError::Refused, AuthenticationError::Claims] { + assert!(matches!( + authentication_problem(refusal), + HttpError(ProblemCode::AuthenticationRefused) + )); + } + assert!(matches!( + authentication_problem(AuthenticationError::Profile), + HttpError(ProblemCode::ProfileNotAuthorized) + )); + } + + fn stored_refusal(problem: ProblemCode) -> Value { + // The receipt shape the service records under a caller's refused + // idempotency key: the pinned problem, deliberately without a trace. + serde_json::json!({ + "problem": { + "type": type_uri(problem.code()), + "title": problem.title(), + "status": problem.http_status(), + "detail": problem.detail(), + "code": problem.code(), + } + }) + } + + #[tokio::test] + async fn a_replayed_refusal_re_renders_its_pinned_code_under_the_current_trace() { + let trace = TraceContext::from_headers(&{ + let mut headers = HeaderMap::new(); + headers.insert( + "traceparent", + TRACEPARENT.parse().expect("a valid traceparent"), + ); + headers + }); + let response = REQUEST_TRACE + .scope(trace.clone(), async { + replay_response(409, stored_refusal(ProblemCode::CapacityExhausted)) + }) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = body_json(response).await; + assert_eq!(body["code"], "capacity.exhausted"); + assert_eq!(body["status"], 409); + assert_eq!( + body["traceId"], + serde_json::to_value(&trace.trace_id).unwrap() + ); + } + + #[tokio::test] + async fn a_replayed_release_answers_an_empty_204_again() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + replay_response(204, Value::Null) + }) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let bytes = to_bytes(response.into_body(), 1 << 10).await.unwrap(); + assert!(bytes.is_empty()); + } + + #[tokio::test] + async fn a_receipt_in_no_known_shape_is_never_shown_to_the_caller() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + replay_response(409, serde_json::json!({"surprise": true})) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = body_json(response).await; + assert_eq!(body["code"], "service.unavailable"); + } + + fn admission() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: Utc::now(), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + #[test] + fn an_admission_request_is_bounded_before_it_reaches_the_store() { + bounded_admission(&admission()).expect("an ordinary admission request passes the edge"); + + // Every one of these lands in the ledger, the idempotency receipt and + // the audit journal verbatim, so a caller with a body allowance of a + // megabyte could write a megabyte into each of them. + let oversized = [ + AdmissionRequest { + offering: "o".repeat(65), + ..admission() + }, + AdmissionRequest { + channel: Some("c".repeat(65)), + ..admission() + }, + AdmissionRequest { + duplicate_key: Some("k".repeat(257)), + ..admission() + }, + AdmissionRequest { + capabilities: vec!["c".repeat(257)], + ..admission() + }, + AdmissionRequest { + prerequisites: vec!["p".repeat(257)], + ..admission() + }, + AdmissionRequest { + capabilities: vec!["cap".to_owned(); MAXIMUM_COLLECTION_ENTRIES + 1], + ..admission() + }, + AdmissionRequest { + prerequisites: vec!["req".to_owned(); MAXIMUM_COLLECTION_ENTRIES + 1], + ..admission() + }, + // A reference is a stable scoped identifier, so an empty one and + // one carrying a control byte are both malformed. + AdmissionRequest { + duplicate_key: Some(String::new()), + ..admission() + }, + AdmissionRequest { + capabilities: vec!["cap\u{7}a".to_owned()], + ..admission() + }, + ]; + for request in oversized { + assert!( + matches!( + bounded_admission(&request), + Err(HttpError(ProblemCode::RequestInvalid)) + ), + "an unbounded caller string reached the store: {request:?}" + ); + } + } + + #[test] + fn a_cancellation_reason_is_bounded_below_the_column_that_stores_it() { + bounded_reason(None).expect("a cancellation needs no reason"); + bounded_reason(Some(&"r".repeat(MAXIMUM_CANCELLATION_REASON_BYTES))) + .expect("a reason the column accepts passes the edge"); + // Past this the column's own CHECK refuses the write, and a database + // refusal projects to service.unavailable: the caller would be told + // the service is down for a request only the caller can fix. + assert!(matches!( + bounded_reason(Some(&"r".repeat(MAXIMUM_CANCELLATION_REASON_BYTES + 1))), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + } + + #[test] + fn a_listing_offering_is_bounded_the_same_way_a_body_is() { + bounded_identifier("registry-update-30").expect("an authored offering reads"); + assert!(matches!( + bounded_identifier(&"o".repeat(65)), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + } + + fn headers_with(key: K, value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(key, value.parse().expect("a valid header value")); + headers + } + + #[test] + fn the_idempotency_key_is_present_bounded_and_graphical() { + assert!(matches!( + idempotency_key(&HeaderMap::new()), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "")), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "a key with spaces")), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + let oversized = "k".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES + 1); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, &oversized)), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert_eq!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "hold-7")).unwrap(), + "hold-7" + ); + } + + #[test] + fn the_item_routes_sit_on_the_pinned_collection_paths() { + assert!(HOLD_ROUTE.starts_with(HOLDS_PATH)); + assert!(APPOINTMENT_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_RESCHEDULE_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_CANCEL_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_HISTORY_ROUTE.starts_with(APPOINTMENTS_PATH)); + } + + #[test] + fn a_bearer_header_is_parsed_and_anything_else_is_a_refusal() { + assert_eq!( + bearer_token(&headers_with(AUTHORIZATION, "Bearer fixture-secret")).unwrap(), + "fixture-secret" + ); + assert!(matches!( + bearer_token(&headers_with(AUTHORIZATION, "Basic dXNlcjpwYXNz")), + Err(HttpError(ProblemCode::AuthenticationRefused)) + )); + assert!(matches!( + bearer_token(&HeaderMap::new()), + Err(HttpError(ProblemCode::AuthenticationRefused)) + )); + } +} diff --git a/crates/registry-scheduling/src/lib.rs b/crates/registry-scheduling/src/lib.rs new file mode 100644 index 0000000000..d75a328627 --- /dev/null +++ b/crates/registry-scheduling/src/lib.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Registry Scheduling runtime: the PostgreSQL store, the HTTP surface, and +//! the supervised workers. +//! +//! The runtime owns commitment: every booking, hold, release, and cancellation +//! lands through one capacity-transaction shape: lock the supply row, read a +//! ledger snapshot whose hold expiries are evaluated in the query, run the +//! pure evaluators from `registry-scheduling-core` in memory, and write the +//! claim, the idempotency attempt, the history event, and the outbox rows in +//! the same transaction. Three supervised workers run beside it: hold expiry, +//! reminder-intent dispatch, and retention sweeps. A dead worker stops the +//! process rather than silently overselling. +//! +//! The dependency runs one way: this crate builds on the source-neutral core +//! and shared `registry-platform-*` primitives, and nothing here may grow a +//! second product's protocol types. + +pub mod auth; +pub mod config; +pub mod cursors; +pub mod hooks; +pub mod http; +pub mod runtime; +#[cfg(feature = "schema")] +pub mod schema; +pub mod service; +pub mod store; diff --git a/crates/registry-scheduling/src/main.rs b/crates/registry-scheduling/src/main.rs new file mode 100644 index 0000000000..37475cb4d0 --- /dev/null +++ b/crates/registry-scheduling/src/main.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The `scheduling` binary entry point. + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + let matches = registry_scheduling::runtime::command().get_matches(); + if let Err(error) = registry_scheduling::runtime::run(&matches).await { + eprintln!("scheduling: {error}"); + std::process::exit(1); + } +} diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs new file mode 100644 index 0000000000..336004d7b6 --- /dev/null +++ b/crates/registry-scheduling/src/runtime.rs @@ -0,0 +1,1160 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Service assembly and the supervised background loops: `scheduling serve` +//! builds the store connection, the authenticator, the audit chain, and the +//! scheduling service, then runs the HTTP listener beside five workers: hold +//! expiry, reminder-intent dispatch, hook delivery, retention sweeps, and +//! audit publication. +//! A worker that dies stops the process rather than letting the runtime keep +//! selling capacity its clocks no longer guard. +//! +//! Reminder dispatch and declared appointment observers are the places the +//! runtime speaks to another system. Reminder dispatch renders due intents; +//! observer delivery sends canonical envelopes captured with the appointment +//! transaction and can never mutate Scheduling from a receiver's response. +//! +//! For reminders, +//! each due outbox intent is rendered as one CloudEvents 1.0 JSON event and +//! POSTed to the operator-configured destination, exactly once per claim, with +//! the transport and the notification bus wholly external (INT-02). When no +//! destination is configured the outbox is the boundary: intents are recorded +//! and marked local, never pretended delivered. + +use std::future::Future; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use clap::{Arg, Command}; +use registry_platform_audit::{AuditEnvelope, AuditProfile, ChainState, JsonlFileSink}; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{ProtectedSecret, SecretProvider, SecretResolver}; +use registry_platform_httputil::destination::{ + DataDestinationPolicy, DataDestinationRequestTemplate, DestinationAuthorizationTemplate, + DestinationAuthorizationValue, DestinationBodyTemplate, DestinationMethod, DestinationProfile, + FixedDestinationPolicy, +}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::{Interval, MissedTickBehavior}; +use url::Url; +use uuid::Uuid; + +use crate::auth::SchedulingAuthenticator; +use crate::config::{ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError}; +use crate::hooks::{ActivatedHooks, HookActivationError, HookRuntimeIdentity}; +use crate::http::{router, HttpState}; +use crate::service::SchedulingService; +use crate::store::{OutboxRow, PostgresStore, StoreError, REMINDER_SEND_TIMEOUT}; + +/// How often each background loop wakes. The intervals are fixed constants, +/// not configuration: they are internal mechanics of one deployment, not an +/// operator-tunable contract. +const WORKER_INTERVAL: Duration = Duration::from_secs(2); +/// How often the audit publication loop wakes; publication is the loop whose +/// lag directly bounds the accountability record's durability. +const AUDIT_PUBLICATION_INTERVAL: Duration = Duration::from_secs(1); +/// Retention sweeps run every thirtieth maintenance tick. +const RETENTION_TICKS: u8 = 30; +/// How many holds one expiry pass may retire, and how many intents one +/// dispatch pass may claim. +const HOLD_EXPIRY_BATCH: i64 = 100; +const REMINDER_BATCH: i64 = 100; +/// The delivery attempts an intent may consume before it is held as failed for +/// the operator to see. +const REMINDER_ATTEMPTS_CEILING: i32 = 8; +/// Exponential backoff between reminder attempts, from half a minute to an +/// hour. +const REMINDER_RETRY_BASE_SECONDS: i64 = 30; +const REMINDER_RETRY_MAX_SECONDS: i64 = 3600; +/// The lease one dispatch pass holds its claimed intents for. The worst +/// case of one whole claimed batch is every intent sending to its full +/// timeout, 100 x 5 s = 500 s, plus the database round trips around each +/// send; 600 s covers both, so a second dispatcher never re-sends what a +/// slow first one still owns, and a dispatcher that dies delays its +/// intents by exactly one lease. +const INTENT_DISPATCH_LEASE_SECONDS: i64 = 600; +/// One rendered reminder event is bounded, as is the whole request. +const REMINDER_MAXIMUM_BODY_BYTES: usize = 16 * 1024; +const REMINDER_MAXIMUM_REQUEST_BYTES: usize = 32 * 1024; +const REMINDER_BEARER_MAXIMUM_BYTES: usize = 8192; + +#[must_use] +pub fn command() -> Command { + Command::new("scheduling") + .version(registry_platform_buildinfo::DISPLAY_VERSION) + .about("Run and maintain Registry Scheduling") + .arg( + Arg::new("runtime-config") + .long("runtime-config") + .value_name("FILE") + .help("Absolute path to the runtime configuration file.") + .required(true), + ) + .subcommand_required(true) + .subcommand(Command::new("migrate").about("Apply Scheduling database migrations")) + .subcommand(Command::new("serve").about("Run the Scheduling HTTP service")) +} + +pub async fn run(matches: &clap::ArgMatches) -> Result<(), RuntimeError> { + let path = matches + .get_one::("runtime-config") + .ok_or(RuntimeError::Arguments)?; + match matches.subcommand_name() { + Some("migrate") => migrate_from_path(path).await, + Some("serve") => serve_from_path(path).await, + _ => Err(RuntimeError::Arguments), + } +} + +/// Name the provisioning or startup act a store failure happened in. +fn database_step(stage: &'static str) -> impl Fn(StoreError) -> RuntimeError { + move |source| RuntimeError::Database { stage, source } +} + +pub async fn migrate_from_path(path: impl AsRef) -> Result<(), RuntimeError> { + let config = RuntimeConfig::load(path)?; + let policy = config.load_policy()?; + let secrets = secret_resolver(&config)?; + let store = PostgresStore::connect_migration(&config.database, &secrets) + .map_err(database_step("migration database configuration"))?; + store + .migrate() + .await + .map_err(database_step("schema migration"))?; + // Migrations leave the deployment identity unset; adopting it here is the + // provisioning act that binds this database to the policy's scheduling + // id, which every serve verifies before it writes anything. + store + .adopt(&policy.scheduling.id) + .await + .map_err(database_step("deployment adoption"))?; + Ok(()) +} + +pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> { + let config = RuntimeConfig::load(path)?; + match config.policy_package_digest()? { + Some(digest) => tracing::info!( + policy_package_digest = %digest, + "verified Scheduling policy package" + ), + None => tracing::info!("loading authored Scheduling policy for loopback development"), + } + let policy = config.load_policy()?; + let scheduling_id = policy.scheduling.id.clone(); + let policy_digest = policy.policy_digest(); + let secrets = secret_resolver(&config)?; + let store = PostgresStore::connect_runtime(&config.database, &secrets) + .map_err(database_step("runtime database configuration"))?; + store + .ready() + .await + .map_err(database_step("schema readiness check"))?; + + // The deployment identity is read before anything is written: the store + // refuses to apply a policy under a scheduling id the deployment does not + // carry, and an empty one is a deployment that has not been adopted yet. + let (stored_id, stored_revision, stored_digest) = store + .scheduling_meta() + .await + .map_err(database_step("deployment identity read"))?; + if stored_id.is_empty() { + return Err(RuntimeError::Unbootstrapped); + } + if stored_id != scheduling_id { + return Err(RuntimeError::DeploymentIdentity); + } + let database_schema = store + .schema_name() + .await + .map_err(database_step("hook schema discovery"))?; + let hook_payload_retention = + Duration::from_secs(u64::from(config.retention.hook_payload_days) * 24 * 60 * 60); + let expected_policy_revision = if stored_digest == policy_digest { + stored_revision + } else { + stored_revision + .checked_add(1) + .ok_or(RuntimeError::HookActivation( + HookActivationError::InvalidIdentity, + ))? + }; + + // Resolve and validate every destination, including its signing material, + // before policy publication. A first start with a bad secret must not + // advance the durable policy revision and then fail to serve it. + let hooks = ActivatedHooks::activate( + &policy.hooks, + &config.destinations.hooks, + &secrets, + HookRuntimeIdentity { + scheduling_id: scheduling_id.clone(), + policy_revision: expected_policy_revision, + policy_digest: policy_digest.clone(), + }, + database_schema.clone(), + hook_payload_retention, + )?; + + // Before publishing a changed policy, prove that every retained event can + // still use the exact destination binding captured for it. A deployment + // may retain extra explicit bindings while old deliveries drain. + if stored_revision > 0 && !stored_digest.is_empty() { + let retained_hooks = ActivatedHooks::activate( + &policy.hooks, + &config.destinations.hooks, + &secrets, + HookRuntimeIdentity { + scheduling_id: stored_id.clone(), + policy_revision: stored_revision, + policy_digest: stored_digest.clone(), + }, + database_schema.clone(), + hook_payload_retention, + )?; + retained_hooks + .delivery_service(store.clone()) + .verify_retained_bindings() + .await + .map_err(|_| RuntimeError::HookDelivery)?; + } + + let (verifier, keys) = config.oidc_verifier(&secrets).await?; + let authenticator = Arc::new(SchedulingAuthenticator::new( + &config.authentication.oidc, + verifier, + keys, + )); + + // The journal is keyed and verified before the policy revision can move, + // so a mis-provisioned deployment never advances state it cannot hold to + // account. + let audit_secret = resolve_audit_secret(&secrets, &config.audit.hash_key_ref)?; + // One master secret, two independently HKDF-derived sub-keys: the chain + // integrity key and the identifier-hash key never share key material + // (AUDIT-03), so one leaked sub-key reveals neither its sibling nor the + // master. + let audit_profile = + AuditProfile::production_from_secret_bytes(audit_secret.expose_secret().to_vec().into()) + .map_err(|_| RuntimeError::Audit)?; + let audit_sink = Arc::new( + JsonlFileSink::new_single_writer(&config.audit.path).map_err(|_| RuntimeError::Audit)?, + ); + let audit_chain = Arc::new( + audit_profile + .bootstrap_or_start_empty(audit_sink.as_ref()) + .await + .map_err(|_| RuntimeError::Audit)?, + ); + // The keyed bootstrap above authenticates the retained chain before its + // tail identity is used to reconcile a possible append/mark crash gap. + let mut audit_publication_state = AuditPublicationState::from_verified_tail( + audit_sink + .last_envelope() + .await + .map_err(|_| RuntimeError::Audit)? + .as_ref(), + ); + + let reminders = match &config.destinations.reminders { + Some(destination) => { + Some(reminder_transport(destination, &secrets).map_err(RuntimeError::Destination)?) + } + None => None, + }; + + // Publishing a policy may bump the revision every process start only when + // the digest changed; the anchors the policy names must exist for any + // offering to resolve supply. + let pool_ids = offering_pool_ids(&policy); + let policy_revision = store + .apply_policy(&scheduling_id, &policy_digest, &pool_ids, &policy) + .await + .map_err(database_step("policy publication"))?; + + if policy_revision != expected_policy_revision { + return Err(RuntimeError::HookActivation( + HookActivationError::InvalidIdentity, + )); + } + let hook_delivery = hooks.delivery_service(store.clone()); + hook_delivery + .verify_retained_bindings() + .await + .map_err(|_| RuntimeError::HookDelivery)?; + + let service = Arc::new( + SchedulingService::new( + store.clone(), + policy, + scheduling_id.clone(), + policy_revision, + policy_digest, + audit_profile.key_hasher(), + config.retention.attempt_receipt_days, + ) + .with_hooks(hooks), + ); + + let (worker_stopped, worker_stops) = mpsc::channel(WORKER_STOP_CAPACITY); + let mut workers = Vec::new(); + + let expiry_store = store.clone(); + workers.push(supervise( + "hold expiry", + worker_stopped.clone(), + async move { + let mut interval = worker_timer(); + loop { + interval.tick().await; + let now = Utc::now(); + match expiry_store.expire_due_holds(now, HOLD_EXPIRY_BATCH).await { + Ok(expired) if expired > 0 => { + tracing::info!(expired, "Scheduling hold expiry pass retired due holds"); + } + Ok(_) => {} + Err(error) => tracing::warn!( + error = %error, + "Scheduling hold expiry pass did not complete" + ), + } + } + }, + )); + + let dispatch_store = store.clone(); + let dispatch_id = scheduling_id.clone(); + let dispatch_destination = reminders; + workers.push(supervise( + "reminder dispatch", + worker_stopped.clone(), + async move { + let mut interval = worker_timer(); + loop { + interval.tick().await; + if let Err(error) = dispatch_due_intents( + &dispatch_store, + &dispatch_id, + dispatch_destination.as_ref(), + ) + .await + { + tracing::warn!( + error = %error, + "Scheduling reminder dispatch pass did not complete" + ); + } + } + }, + )); + + let (hook_shutdown, hook_shutdown_rx) = tokio::sync::watch::channel(false); + workers.push(supervise( + "hook delivery", + worker_stopped.clone(), + async move { + // Keep the sender for the lifetime of the supervised worker. Runtime + // shutdown aborts this future and releases the shared delivery loop. + let _keepalive = hook_shutdown; + hook_delivery.worker().run(hook_shutdown_rx).await; + }, + )); + + // The whole of retention, and deliberately not all of retention. This + // sweep erases two things: idempotency attempt receipts, after the + // configured `retention.attemptReceiptDays`, and listing cursors, after + // the fifteen minutes the listing contract gives them. Appointments, + // their history, the delivery outbox and the audit journal are never + // swept, by decision and not by omission: committed scheduling data has + // no retention period in this milestone, and one knob must not read as a + // promise to sweep it. A future period for those is new configuration and + // new passes here, not a wider reading of this one. + let retention_store = store.clone(); + workers.push(supervise("retention", worker_stopped.clone(), async move { + let mut interval = worker_timer(); + let mut ticks = 0_u8; + loop { + interval.tick().await; + ticks = (ticks + 1) % RETENTION_TICKS; + if ticks != 0 { + continue; + } + let now = Utc::now(); + if let Err(error) = retention_store.erase_expired_cursors(now).await { + tracing::warn!(error = %error, "Scheduling cursor retention pass did not complete"); + } + if let Err(error) = retention_store.erase_expired_attempts(now).await { + tracing::warn!( + error = %error, + "Scheduling attempt receipt retention pass did not complete" + ); + } + } + })); + + let audit_publisher = AuditPublisher { + store: store.clone(), + chain: audit_chain, + sink: audit_sink, + }; + workers.push(supervise("audit publication", worker_stopped, async move { + let mut interval = tokio::time::interval(AUDIT_PUBLICATION_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + if let Err(stage) = + publish_audit_pass(&audit_publisher, &mut audit_publication_state).await + { + tracing::warn!(stage, "Scheduling audit publication pass did not complete"); + } + } + })); + + let app = router(HttpState { + service, + authenticator, + store, + }); + let listener = tokio::net::TcpListener::bind(config.listener.bind).await?; + let served = serve_until_worker_stops(listener, app, worker_stops).await; + for worker in workers { + worker.abort(); + } + served +} + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("the scheduling command arguments are invalid")] + Arguments, + #[error(transparent)] + Config(#[from] RuntimeConfigError), + #[error("the Scheduling secret configuration is invalid")] + SecretConfiguration, + #[error("the Scheduling audit journal could not be opened or extended")] + Audit, + #[error("{0}")] + AuditSecret(String), + /// A database step of provisioning or startup failed. The step is named + /// because a bare store error says the database refused and not which + /// act it refused, and the acts have different remedies: an unreachable + /// server, a database nobody migrated, an identity nobody adopted, and a + /// policy that cannot be published are four different operator tasks. + #[error("the Scheduling {stage} failed: {source}")] + Database { + stage: &'static str, + #[source] + source: StoreError, + }, + #[error("the Scheduling listener could not bind or serve")] + Listen(#[from] std::io::Error), + #[error("the Scheduling reminder destination is invalid: {0}")] + Destination(String), + #[error(transparent)] + HookActivation(#[from] HookActivationError), + #[error("the Scheduling retained hook delivery bindings are unavailable")] + HookDelivery, + #[error("a Scheduling background worker stopped")] + WorkerStopped, + #[error( + "the deployment identity is unset: adopt this database before serving, by running \ + `scheduling migrate` with this runtime configuration" + )] + Unbootstrapped, + #[error("the deployment's scheduling id does not match the authored policy")] + DeploymentIdentity, + #[error("a due reminder intent could not be rendered as an event")] + ReminderEvent, +} + +/// Serve until a supervised background loop stops. The listener never stops on +/// its own, so a clean return means a worker stopped, and the process reports +/// that as a failure for whatever supervises it to restart. +async fn serve_until_worker_stops( + listener: tokio::net::TcpListener, + app: axum::Router, + stops: mpsc::Receiver<&'static str>, +) -> Result<(), RuntimeError> { + axum::serve(listener, app) + .with_graceful_shutdown(worker_stop(stops)) + .await?; + Err(RuntimeError::WorkerStopped) +} + +/// One slot per supervised loop, so a stopping worker never blocks on the +/// listener reading its report. +const WORKER_STOP_CAPACITY: usize = 16; + +/// Run a background loop under a task that outlives it. The loops never return +/// on their own, so a supervised task that finishes carries a panic, and its +/// name travels to the listener. +fn supervise( + name: &'static str, + stopped: mpsc::Sender<&'static str>, + worker: impl Future + Send + 'static, +) -> JoinHandle<()> { + tokio::spawn(async move { + match tokio::spawn(worker).await { + Ok(()) => tracing::error!(worker = name, "a Scheduling background worker returned"), + Err(error) => { + tracing::error!(worker = name, error = %error, "a Scheduling background worker panicked"); + } + } + if stopped.send(name).await.is_err() { + tracing::debug!(worker = name, "the Scheduling listener had already stopped"); + } + }) +} + +/// Resolve once a supervised background loop has stopped. A process whose +/// clocks no longer fire keeps neither its listener nor its readiness. +async fn worker_stop(mut stopped: mpsc::Receiver<&'static str>) { + match stopped.recv().await { + Some(worker) => tracing::error!( + worker, + "stopping the Scheduling listener after a background worker stopped" + ), + None => { + tracing::error!( + "stopping the Scheduling listener after every background worker stopped" + ) + } + } +} + +fn worker_timer() -> Interval { + let mut interval = tokio::time::interval(WORKER_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + interval +} + +/// Resolve the audit journal's keying secret, naming the reference on refusal. +/// The journal is keyed before the listener binds, so this refusal is the +/// first line an operator sees on a mis-provisioned deployment. It carries a +/// valid configured reference and the rule that broke, never the key bytes. +fn resolve_audit_secret( + secrets: &SecretResolver, + reference: &str, +) -> Result { + secrets.resolve(reference).map_err(|error| { + RuntimeError::AuditSecret(crate::config::describe_secret_failure( + "audit.hashKeyRef", + reference, + &error, + )) + }) +} + +pub fn secret_resolver(config: &RuntimeConfig) -> Result { + let mut providers = Vec::new(); + if config.secret_providers.file.is_some() { + providers.push(SecretProvider::File); + } + if config.secret_providers.environment.is_some() { + providers.push(SecretProvider::Environment); + } + SecretResolver::new( + providers, + config + .secret_providers + .file + .as_ref() + .map_or_else(|| Path::new(""), |file| file.root.as_path()), + ) + .map_err(|_| RuntimeError::SecretConfiguration) +} + +/// The pool anchors the policy's exact-time offerings name. +fn offering_pool_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> Vec { + let mut ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +// --------------------------------------------------------------------------- +// Reminder dispatch +// --------------------------------------------------------------------------- + +/// The frozen outbound transport for due reminder intents: one fixed +/// destination, one reviewed request shape, and an operator-configured bearer +/// credential that never reaches a log line. +/// +/// Public, with its fields kept private, so the database suite can point a +/// dispatch pass at a local destination it controls. What the deployment +/// sends, and how it reads each answer back, is only observable against a +/// destination that answers. +pub struct ReminderTransport { + policy: DataDestinationPolicy, + template: DataDestinationRequestTemplate, + bearer: Option, +} + +/// Split an operator-configured destination URL into the origin a frozen +/// destination policy holds and the fixed path its request template compiles. +/// A URL carrying a query or fragment is refused: the destination is one +/// reviewed endpoint, not a template a configuration value may widen. +fn destination_origin_and_path(configured: &str) -> Result<(String, String), &'static str> { + let parsed = Url::parse(configured).map_err(|_| "the destination URL does not parse")?; + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err("the destination URL may not carry a query or fragment"); + } + let path = parsed.path().to_owned(); + let mut origin = parsed; + origin.set_path(""); + origin.set_query(None); + origin.set_fragment(None); + Ok((origin.to_string(), path)) +} + +/// Compile one operator-configured destination into the frozen transport the +/// dispatch loop sends through. +/// +/// # Errors +/// +/// Returns the operator-facing sentence naming what about the configured +/// destination could not be frozen. +pub fn reminder_transport( + destination: &ReminderDestinationConfig, + secrets: &SecretResolver, +) -> Result { + let (origin, path) = + destination_origin_and_path(&destination.url).map_err(|detail| detail.to_owned())?; + let scheme = origin + .split_once("://") + .map(|(scheme, _)| scheme.to_owned()) + .unwrap_or_default(); + // The configuration check already confines plain http to loopback hosts; + // the transport pins the same rule into the frozen policy. + let profile = match scheme.as_str() { + "https" => DestinationProfile::ProductionHttps, + "http" => DestinationProfile::LoopbackDevelopmentHttp, + _ => return Err("the destination URL scheme is not http(s)".to_owned()), + }; + let path = if path.is_empty() { + "/".to_owned() + } else { + path + }; + let policy = FixedDestinationPolicy::new("scheduling-reminders", &origin, profile, &[]) + .map_err(|_| "the destination could not be frozen as a fixed origin".to_owned())?; + let authorization = match &destination.bearer_token_ref { + Some(reference) => { + let secret = secrets.resolve(reference).map_err(|error| { + crate::config::describe_secret_failure( + "destinations.reminders.bearerTokenRef", + reference, + &error, + ) + })?; + return Ok(ReminderTransport { + policy, + template: reminder_template( + &path, + DestinationAuthorizationTemplate::Bearer { + max_value_bytes: REMINDER_BEARER_MAXIMUM_BYTES, + }, + )?, + bearer: Some(secret), + }); + } + None => DestinationAuthorizationTemplate::Forbidden, + }; + Ok(ReminderTransport { + policy, + template: reminder_template(&path, authorization)?, + bearer: None, + }) +} + +fn reminder_template( + path: &str, + authorization: DestinationAuthorizationTemplate, +) -> Result { + DataDestinationRequestTemplate::new_with_exact_headers( + DestinationMethod::ReviewedReadOnlyPost, + path, + &[], + &[ + ("accept", b"application/json".as_slice()), + ("content-type", b"application/cloudevents+json".as_slice()), + ], + authorization, + DestinationBodyTemplate::Required { + max_bytes: REMINDER_MAXIMUM_BODY_BYTES, + }, + REMINDER_MAXIMUM_REQUEST_BYTES, + ) + .map_err(|_| "the reminder request shape could not be compiled".to_owned()) +} + +/// Render one due intent as a CloudEvents 1.0 event in canonical JSON: the +/// intent's own id, the deployment it came from, the purpose it was minted +/// under, the instant it fell due, and the payload the runtime committed. +fn reminder_event(scheduling_id: &str, row: &OutboxRow) -> Result, RuntimeError> { + let event = serde_json::json!({ + "specversion": "1.0", + "id": row.outbox_id.to_string(), + "source": scheduling_id, + "type": format!("org.registrystack.scheduling.{}", row.purpose), + "time": row.due_at.to_rfc3339(), + "data": row.payload, + }); + canonicalize_json(&event).map_err(|_| RuntimeError::ReminderEvent) +} + +/// How one dispatch attempt's outcome is held, before it is written back. +enum Delivery { + /// The destination took the event. + Delivered, + /// The attempt proved nothing either way: try again after a backoff. + Transient, + /// The destination refused the event outright: hold it for the operator + /// instead of retrying a refusal forever. + Refused, +} + +fn delivery_of(status: u16) -> Delivery { + match status { + 200..=299 => Delivery::Delivered, + 408 | 429 | 500..=599 => Delivery::Transient, + _ => Delivery::Refused, + } +} + +/// The backoff before the next attempt of one intent, exponential from the +/// base and capped at the hour. +fn next_attempt(attempts: i32) -> DateTime { + let shift = (attempts.max(1) - 1).min(7) as u32; + let seconds = (REMINDER_RETRY_BASE_SECONDS << shift).min(REMINDER_RETRY_MAX_SECONDS); + Utc::now() + TimeDelta::seconds(seconds) +} + +/// Claim every due intent and give each one dispatch attempt. A transport +/// failure proves nothing and schedules a retry; a refusal outside the retry +/// classes is held as failed for the operator; with no destination +/// configured the intent is marked local: recorded, never pretended +/// delivered. +/// +/// A claimed intent is leased for the pass that claimed it and fenced by +/// its attempt number, so a concurrent or restarted dispatcher cannot +/// re-send it or overwrite a newer outcome. Reminders whose appointment has +/// been cancelled or moved to a newer revision are skipped before the send +/// rather than delivered against a decision the caller already changed; the +/// residual race, a suppression landing while the send itself is in flight, +/// is reported as a lost outcome rather than erased, and the destination +/// deduplicates by the event's stable identifier. +/// +/// # Errors +/// +/// Returns the store failure that stopped the pass. A destination's answer, +/// whatever it is, is an outcome written back and not an error. +pub async fn dispatch_due_intents( + store: &PostgresStore, + scheduling_id: &str, + transport: Option<&ReminderTransport>, +) -> Result<(), StoreError> { + let now = Utc::now(); + let lease = TimeDelta::seconds(INTENT_DISPATCH_LEASE_SECONDS); + for row in store.claim_due_intents(now, REMINDER_BATCH, lease).await? { + let Some(transport) = transport else { + let owned = store.hold_intent_local(row.outbox_id, row.attempts).await?; + if !owned { + // Nothing was sent: no destination exists. The intent was + // suppressed or taken over between the claim and this write, + // and its owner holds the record. + tracing::warn!( + outbox_id = %row.outbox_id, + attempts = row.attempts, + "a local hold arrived after its attempt lost the intent; nothing was sent" + ); + } + continue; + }; + // The claim's own liveness: a suppressed reminder, or one whose + // appointment moved to a newer revision, is not sent. + if !store + .intent_still_dispatchable(row.outbox_id, row.attempts) + .await? + { + tracing::info!( + outbox_id = %row.outbox_id, + "a claimed intent was suppressed or superseded before its dispatch" + ); + continue; + } + let body = match reminder_event(scheduling_id, &row) { + Ok(body) => body, + Err(error) => { + tracing::error!( + error = %error, + outbox_id = %row.outbox_id, + "a due reminder intent could not be rendered" + ); + // An unrenderable intent is held, not retried: no amount of + // retrying fixes a payload that cannot become an event. + store + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + row.attempts, + row.attempts, + ) + .await?; + continue; + } + }; + let authorization = transport + .bearer + .as_ref() + .map(|secret| DestinationAuthorizationValue::bearer(secret.expose_secret().to_vec())) + .transpose(); + let request = match authorization { + Ok(authorization) => transport.template.render(&[], &[], authorization, Some(body)), + Err(_) => Err(registry_platform_httputil::destination::DestinationRequestError::InvalidAuthorization), + }; + let outcome = match request { + Ok(request) => match transport.policy.send(request, REMINDER_SEND_TIMEOUT).await { + Ok(response) => Some(delivery_of(response.status().as_u16())), + Err(error) => { + tracing::warn!( + error = %error, + outbox_id = %row.outbox_id, + "a reminder delivery attempt did not reach a verdict" + ); + Some(Delivery::Transient) + } + }, + Err(error) => { + tracing::error!( + error = %error, + outbox_id = %row.outbox_id, + "a reminder delivery request could not be rendered" + ); + None + } + }; + match outcome.unwrap_or(Delivery::Transient) { + Delivery::Delivered => { + let owned = store + .mark_intent_delivered(row.outbox_id, row.attempts) + .await?; + if !owned { + // The send completed, but suppression or a superseding + // attempt owns the row now. The row records its own + // state; this line is the delivery's accounting. + report_lost_outcome(&row); + } + } + Delivery::Transient => { + // A retry write that loses its claim needs no report: the + // attempt that took the intent over writes the outcome that + // counts, and a back-off nobody owes is nobody's news. + store + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + REMINDER_ATTEMPTS_CEILING, + row.attempts, + ) + .await?; + } + // A refusal the retry classes do not cover (an authorization + // refusal, a routing refusal) is terminal: the ceiling set to the + // attempt count holds the intent as failed on this very write. + Delivery::Refused => { + tracing::warn!( + outbox_id = %row.outbox_id, + status = row.attempts, + "a reminder destination refused an intent outright" + ); + store + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + row.attempts, + row.attempts, + ) + .await?; + } + } + } + Ok(()) +} + +/// Report an outcome write that landed on nobody: the intent was +/// suppressed, or a superseding attempt took it over, while this attempt +/// was in flight. The row keeps the state its owner left it in; what this +/// records is that a send this attempt made may have reached a destination +/// that no outbox state will name. +fn report_lost_outcome(row: &OutboxRow) { + tracing::warn!( + outbox_id = %row.outbox_id, + attempts = row.attempts, + "a dispatch outcome arrived after its attempt lost the intent; a send may have completed" + ); +} + +// --------------------------------------------------------------------------- +// Audit publication +// --------------------------------------------------------------------------- + +/// What one publication pass writes through: the journal it drains, the keyed +/// chain it extends, and the sink that retains the envelopes. +/// +/// Public so the database suite can run a pass against a real deployment, +/// stop, and start again the way a restart does. Continuity across that +/// boundary is the property the chain exists to carry, and it cannot be +/// observed from inside one process that never puts the chain down. +pub struct AuditPublisher { + pub store: PostgresStore, + pub chain: Arc, + pub sink: Arc, +} + +/// The one record whose append may have landed without its mark, carried +/// across passes and across a restart. +#[derive(Default)] +pub struct AuditPublicationState { + unconfirmed: Option, +} + +impl AuditPublicationState { + /// Recover the append/mark gap from the retained tail. The caller + /// authenticates the chain first: this reads identity only. + #[must_use] + pub fn from_verified_tail(tail: Option<&AuditEnvelope>) -> Self { + let unconfirmed = tail + .and_then(|envelope| envelope.record.as_object()) + .and_then(|record| record.get("eventId")) + .and_then(Value::as_str) + .and_then(|event_id| Uuid::parse_str(event_id).ok()); + Self { unconfirmed } + } +} + +/// Publish every pending audit record: append it to the keyed chain, then mark +/// it published. The one record whose append may have landed before its mark +/// is remembered and re-marked first on the next pass, so a crash between the +/// two writes never duplicates an envelope. +pub async fn publish_audit_pass( + publisher: &AuditPublisher, + state: &mut AuditPublicationState, +) -> Result<(), &'static str> { + if let Some(event_id) = state.unconfirmed { + publisher + .store + .mark_audit_published(event_id) + .await + .map_err(|_| "published-mark")?; + state.unconfirmed = None; + } + let records = publisher + .store + .pending_audit(100) + .await + .map_err(|_| "pending-read")?; + for (event_id, record) in records { + let record = audit_record_with_event_id(event_id, record).ok_or("record-identity")?; + publisher + .chain + .append(publisher.sink.as_ref(), record) + .await + .map_err(|_| "sink-append")?; + state.unconfirmed = Some(event_id); + publisher + .store + .mark_audit_published(event_id) + .await + .map_err(|_| "published-mark")?; + state.unconfirmed = None; + } + Ok(()) +} + +/// Stamp the pending record with its audit identity. The records the service +/// writes are already pseudonymized; publication adds only the event id, and +/// refuses a record that already carries a different one. +fn audit_record_with_event_id(event_id: Uuid, mut record: Value) -> Option { + let fields = record.as_object_mut()?; + let event_id = event_id.to_string(); + match fields.get("eventId") { + Some(Value::String(existing)) if existing == &event_id => {} + Some(_) => return None, + None => { + fields.insert("eventId".to_owned(), Value::String(event_id)); + } + } + Some(record) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn intent(purpose: &str) -> OutboxRow { + let due = Utc.with_ymd_and_hms(2026, 10, 5, 8, 0, 0).unwrap(); + OutboxRow { + outbox_id: Uuid::new_v4(), + purpose: purpose.to_owned(), + claim_id: Uuid::new_v4(), + appointment_revision: 3, + due_at: due, + attempts: 1, + payload: serde_json::json!({"appointment": "a-1", "minutesBefore": 60}), + } + } + + #[test] + fn a_due_intent_renders_as_one_canonical_cloudevents_event() { + let row = intent("reminder-60"); + let body = reminder_event("registry-north", &row).unwrap(); + let event: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(event["specversion"], "1.0"); + assert_eq!(event["id"], row.outbox_id.to_string()); + assert_eq!(event["source"], "registry-north"); + assert_eq!(event["type"], "org.registrystack.scheduling.reminder-60"); + assert_eq!(event["time"], "2026-10-05T08:00:00+00:00"); + assert_eq!(event["data"]["minutesBefore"], 60); + // Canonical JSON carries no insignificant whitespace. + assert!(!String::from_utf8(body.clone()).unwrap().contains(' ')); + } + + #[test] + fn the_delivery_verdict_partitions_the_status_line() { + assert!(matches!(delivery_of(200), Delivery::Delivered)); + assert!(matches!(delivery_of(204), Delivery::Delivered)); + assert!(matches!(delivery_of(408), Delivery::Transient)); + assert!(matches!(delivery_of(429), Delivery::Transient)); + assert!(matches!(delivery_of(503), Delivery::Transient)); + assert!(matches!(delivery_of(301), Delivery::Refused)); + assert!(matches!(delivery_of(401), Delivery::Refused)); + assert!(matches!(delivery_of(422), Delivery::Refused)); + } + + #[test] + fn retry_backoff_grows_exponentially_and_caps_at_the_hour() { + let now = Utc::now(); + let first = next_attempt(1).signed_duration_since(now).num_seconds(); + let second = next_attempt(2).signed_duration_since(now).num_seconds(); + let ceiling = next_attempt(50).signed_duration_since(now).num_seconds(); + assert_eq!(first, REMINDER_RETRY_BASE_SECONDS); + assert_eq!(second, 2 * REMINDER_RETRY_BASE_SECONDS); + assert_eq!(ceiling, REMINDER_RETRY_MAX_SECONDS); + } + + #[test] + fn a_destination_url_splits_into_origin_and_fixed_path() { + let (origin, path) = destination_origin_and_path("https://bus.example/reminders").unwrap(); + assert_eq!(origin, "https://bus.example/"); + assert_eq!(path, "/reminders"); + let (origin, path) = destination_origin_and_path("http://127.0.0.1:9000/outbox").unwrap(); + assert_eq!(origin, "http://127.0.0.1:9000/"); + assert_eq!(path, "/outbox"); + assert!(destination_origin_and_path("https://bus.example/r?wide=1").is_err()); + assert!(destination_origin_and_path("https://bus.example/r#f").is_err()); + assert!(destination_origin_and_path("not a url").is_err()); + } + + #[test] + fn the_offering_pool_anchors_are_collected_without_duplicates() { + use registry_scheduling_core::{ + ArrivalOffering, ExactTimeOffering, HoldPolicy, OfferingPolicy, PolicyIdentity, + SchedulingMode, SchedulingPolicy, ServicePolicy, + }; + let exact_time = || { + Some(ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "north".to_owned(), + start_increment_minutes: 30, + max_recipients: 1, + }) + }; + let offering = + |id: &str, exact: Option, arrival: Option| { + OfferingPolicy { + id: id.to_owned(), + service: "s1".to_owned(), + label: id.to_owned(), + mode: if exact.is_some() { + SchedulingMode::ExactTime + } else { + SchedulingMode::ArrivalWindow + }, + location: "l1".to_owned(), + because: "test".to_owned(), + exact_time: exact, + arrival, + cancellation_cutoff_minutes: 240, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + }; + let policy = SchedulingPolicy { + api_version: "test".to_owned(), + kind: "test".to_owned(), + scheduling: PolicyIdentity { + id: "standalone".to_owned(), + version: 1, + }, + services: vec![ServicePolicy { + id: "s1".to_owned(), + label: "Service".to_owned(), + }], + offerings: vec![ + offering("o1", exact_time(), None), + // The same pool again: one anchor, not two. + offering("o2", exact_time(), None), + offering( + "o3", + None, + Some(ArrivalOffering { + window: "w-morning".to_owned(), + lead_time_minutes: 60, + horizon_days: 30, + }), + ), + ], + holiday_sets: Vec::new(), + openings: Vec::new(), + channels: Vec::new(), + hold_policy: HoldPolicy { + ttl_minutes: 10, + max_per_caller: 2, + because: "test".to_owned(), + }, + hooks: Vec::new(), + }; + assert_eq!(offering_pool_ids(&policy), vec!["north".to_owned()]); + } + + #[test] + fn a_pending_audit_record_is_stamped_with_its_identity_exactly_once() { + let event_id = Uuid::new_v4(); + let record = + audit_record_with_event_id(event_id, serde_json::json!({"event": "x"})).unwrap(); + assert_eq!(record["eventId"], event_id.to_string()); + // An agreeing stamp is accepted; a disagreeing one is refused. + assert!(audit_record_with_event_id(event_id, record.clone()).is_some()); + assert!(audit_record_with_event_id( + Uuid::new_v4(), + serde_json::json!({"eventId": "another"}) + ) + .is_none()); + } +} diff --git a/crates/registry-scheduling/src/schema.rs b/crates/registry-scheduling/src/schema.rs new file mode 100644 index 0000000000..0bf47cb422 --- /dev/null +++ b/crates/registry-scheduling/src/schema.rs @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Generated JSON Schema for the Scheduling runtime configuration. +//! +//! The schema is derived from the strict `RuntimeConfig` types, never written +//! by hand. Regenerate the committed document with: +//! +//! ```bash +//! cargo run -p registry-scheduling --features schema --example runtime-schema -- \ +//! --output products/scheduling/generated/runtime +//! ``` +//! +//! The derived schema states the bounds `RuntimeConfig::check` enforces, so +//! a document an operator's editor accepts is one the runtime starts on +//! rather than one it refuses after the operator has already written it. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use registry_scheduling_core::{ + RUNTIME_SCHEMA_FILE, SCHEDULING_RUNTIME_API_VERSION, SCHEDULING_RUNTIME_KIND, + SCHEDULING_RUNTIME_SCHEMA_ID, +}; + +use crate::config::{ + RuntimeConfig, MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT, MAXIMUM_ASSERTION_ISSUER_BYTES, + MAXIMUM_ASSERTION_ISSUER_CLIENTS, MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES, +}; + +const SECRET_REFERENCE_SCHEMA_PATTERN: &str = + "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$"; + +pub fn runtime_documents() -> Result, serde_json::Error> { + let mut derived = serde_json::to_value(schemars::schema_for!(RuntimeConfig))?; + set_const(&mut derived, "apiVersion", SCHEDULING_RUNTIME_API_VERSION); + set_const(&mut derived, "kind", SCHEDULING_RUNTIME_KIND); + install_runtime_constraints(&mut derived); + let mut object = match derived { + Value::Object(object) => object, + _ => unreachable!("schemars derives a schema object for RuntimeConfig"), + }; + object.insert( + "$schema".to_owned(), + Value::String("https://json-schema.org/draft/2020-12/schema".to_owned()), + ); + object.insert( + "$id".to_owned(), + Value::String(SCHEDULING_RUNTIME_SCHEMA_ID.to_owned()), + ); + object.insert( + "title".to_owned(), + Value::String("Registry Scheduling runtime configuration".to_owned()), + ); + let mut rendered = serde_json::to_string_pretty(&Value::Object(object))?; + rendered.push('\n'); + Ok([(RUNTIME_SCHEMA_FILE, rendered)].into()) +} + +/// State in the schema the bounds `RuntimeConfig::check` and +/// `validate_secret_references` enforce at load: operated paths are absolute, +/// every secret field is a secret reference, a static JWKS document names its +/// provider, and at least one secret provider is configured. +fn install_runtime_constraints(schema: &mut Value) { + for (definition, property) in [ + ("RuntimePackageConfig", "root"), + ("FileSecretProviderConfig", "root"), + ("AuditConfig", "path"), + ] { + set_definition_property( + schema, + definition, + property, + "pattern", + Value::String("^/".to_owned()), + ); + } + for (definition, property) in [ + ("DatabaseConfig", "runtimeUrlRef"), + ("DatabaseConfig", "migrationUrlRef"), + ("DatabaseConfig", "trustedRootCertificateRef"), + ("AuditConfig", "hashKeyRef"), + ("ReminderDestinationConfig", "bearerTokenRef"), + ] { + set_definition_property( + schema, + definition, + property, + "pattern", + Value::String("^secret:(?:env|file)/".to_owned()), + ); + } + set_jwks_document_reference_constraints(schema); + set_assertion_issuer_bounds(schema); + if let Some(providers) = schema + .get_mut("$defs") + .and_then(|definitions| definitions.get_mut("SecretProvidersConfig")) + .and_then(Value::as_object_mut) + { + providers.insert( + "anyOf".to_owned(), + serde_json::json!([ + {"required": ["file"], "properties": {"file": {"$ref": "#/$defs/FileSecretProviderConfig"}}}, + {"required": ["environment"], "properties": {"environment": {"$ref": "#/$defs/EnvironmentSecretProviderConfig"}}} + ]), + ); + } + if let Some(root) = schema.as_object_mut() { + root.insert( + "allOf".to_owned(), + serde_json::json!([ + secret_provider_requirement("^secret:env/", "environment"), + secret_provider_requirement("^secret:file/", "file") + ]), + ); + } +} + +/// A static JWKS document reference is the one secret field whose full +/// reference grammar the schema states: the other fields need only name a +/// provider, while this one an operator authors directly. +fn set_jwks_document_reference_constraints(schema: &mut Value) { + if let Some(variants) = schema + .pointer_mut("/$defs/OidcJwksSource/oneOf") + .and_then(Value::as_array_mut) + { + for variant in variants { + if let Some(document_reference) = variant + .pointer_mut("/properties/documentRef") + .and_then(Value::as_object_mut) + { + document_reference.insert( + "pattern".to_owned(), + Value::String(SECRET_REFERENCE_SCHEMA_PATTERN.to_owned()), + ); + } + } + } +} + +/// State the assertion-issuer map's authored bounds, the ones +/// `RuntimeConfig::check` refuses a document for exceeding. +fn set_assertion_issuer_bounds(schema: &mut Value) { + if let Some(property) = schema + .pointer_mut("/$defs/OidcConfig/properties/assertionIssuers") + .and_then(Value::as_object_mut) + { + property.insert( + "maxProperties".to_owned(), + Value::from(MAXIMUM_ASSERTION_ISSUER_CLIENTS), + ); + property.insert( + "propertyNames".to_owned(), + serde_json::json!({ + "minLength": 1, + "maxLength": MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES, + }), + ); + property.insert( + "additionalProperties".to_owned(), + serde_json::json!({ + "type": "array", + "maxItems": MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": MAXIMUM_ASSERTION_ISSUER_BYTES, + }, + }), + ); + } +} + +/// A static JWKS document reference names its provider by its prefix, so a +/// configuration carrying one must enable that provider. +fn secret_provider_requirement(reference_pattern: &str, provider: &str) -> Value { + serde_json::json!({ + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": {"pattern": reference_pattern} + }, + "required": ["documentRef"] + } + }, + "required": ["jwksSource"] + } + }, + "required": ["oidc"] + } + }, + "required": ["authentication"] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + provider: { + "$ref": format!("#/$defs/{}SecretProviderConfig", match provider { + "environment" => "Environment", + "file" => "File", + _ => unreachable!("closed secret provider schema"), + }) + } + }, + "required": [provider] + } + } + } + }) +} + +fn set_definition_property( + schema: &mut Value, + definition: &str, + property: &str, + keyword: &str, + value: Value, +) { + if let Some(member) = schema + .get_mut("$defs") + .and_then(|definitions| definitions.get_mut(definition)) + .and_then(|definition| definition.get_mut("properties")) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + { + member.insert(keyword.to_owned(), value); + } +} + +fn set_const(schema: &mut Value, property: &str, expected: &str) { + if let Some(member) = schema + .get_mut("properties") + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + { + member.insert("const".to_owned(), Value::String(expected.to_owned())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_schema_is_deterministic_and_versioned() { + let first = runtime_documents().unwrap(); + let second = runtime_documents().unwrap(); + assert_eq!(first, second); + let document: Value = serde_json::from_str(&first[RUNTIME_SCHEMA_FILE]).unwrap(); + assert_eq!(document["$id"], SCHEDULING_RUNTIME_SCHEMA_ID); + assert_eq!( + document["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!( + document["title"], + "Registry Scheduling runtime configuration" + ); + assert_eq!( + document["properties"]["apiVersion"]["const"], + SCHEDULING_RUNTIME_API_VERSION + ); + assert_eq!( + document["properties"]["kind"]["const"], + SCHEDULING_RUNTIME_KIND + ); + } + + #[test] + fn the_schema_states_the_bounds_the_runtime_enforces() { + let documents = runtime_documents().unwrap(); + let document: Value = serde_json::from_str(&documents[RUNTIME_SCHEMA_FILE]).unwrap(); + for (definition, property) in [ + ("RuntimePackageConfig", "root"), + ("FileSecretProviderConfig", "root"), + ("AuditConfig", "path"), + ] { + assert_eq!( + document["$defs"][definition]["properties"][property]["pattern"], "^/", + "{definition}.{property} must be absolute" + ); + } + for (definition, property) in [ + ("DatabaseConfig", "runtimeUrlRef"), + ("DatabaseConfig", "migrationUrlRef"), + ("DatabaseConfig", "trustedRootCertificateRef"), + ("AuditConfig", "hashKeyRef"), + ("ReminderDestinationConfig", "bearerTokenRef"), + ] { + assert_eq!( + document["$defs"][definition]["properties"][property]["pattern"], + "^secret:(?:env|file)/", + "{definition}.{property} must be a secret reference" + ); + } + assert_eq!( + document["$defs"]["SecretProvidersConfig"]["anyOf"], + serde_json::json!([ + {"required": ["file"], "properties": {"file": {"$ref": "#/$defs/FileSecretProviderConfig"}}}, + {"required": ["environment"], "properties": {"environment": {"$ref": "#/$defs/EnvironmentSecretProviderConfig"}}} + ]), + "at least one secret provider must be configured" + ); + assert_eq!( + document["$defs"]["OidcJwksSource"]["oneOf"][1]["properties"]["documentRef"]["pattern"], + SECRET_REFERENCE_SCHEMA_PATTERN + ); + } + + #[test] + fn committed_runtime_schema_matches_generated_bytes() { + let generated = runtime_documents().unwrap(); + let committed = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/scheduling/generated/runtime") + .join(RUNTIME_SCHEMA_FILE); + assert_eq!( + std::fs::read_to_string(committed).unwrap(), + generated[RUNTIME_SCHEMA_FILE] + ); + } +} diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs new file mode 100644 index 0000000000..eb1ed5c49a --- /dev/null +++ b/crates/registry-scheduling/src/service.rs @@ -0,0 +1,2508 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP-facing service: catalogue projections, availability, explain, and +//! the six commitments, each driving the store's one capacity transaction. +//! +//! The service owns three decisions the store cannot. Authorization: a +//! mutating call must carry a task grant whose scheduling permissions cover +//! the offering's service, location, and the operation, and the store +//! re-checks the grant's expiry inside the transaction. Disclosure: every +//! refusal projects to its public problem code here, and only the separately +//! authorized explain path sees the detailed one. Attributability: the +//! commitment's audit record is built here with pseudonymized principal, +//! client, grant, and approver references, and refused commitments record +//! their receipt so a replayed idempotency key answers as it first did. + +use chrono::{DateTime, TimeDelta, Utc}; +use registry_platform_audit::{AuditKeyHasher, AuthorizationAuditEvent, AuthorizationOutcome}; +use registry_platform_calendar::CalendarInterval; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_oidc::GrantClaims; +use registry_scheduling_core::{ + admission_request_hash, evaluate_exact_time_admission, evaluate_window_admission, + location_closure_intervals, location_open_intervals, type_uri, AdmissionRequest, + AppointmentDocument, AppointmentHistoryEntryDocument, AppointmentStateDocument, + AvailabilityEntry, CancelAppointmentRequest, Channel, CreateAppointmentRequest, + ExactTimeContext, ExactTimeOffering, LedgerKind, LedgerSnapshot, OfferingDocument, + OfferingPolicy, PageDocument, PoolMember, ProblemCode, PublishedWindow, ReminderDocument, + RescheduleAppointmentRequest, ResourceDocument, SchedulingFacts, SchedulingMode, + SchedulingModeDocument, SchedulingPolicy, SchedulingServiceDocument, ServiceDocument, + WindowContext, WindowDocument, +}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; + +use crate::cursors::{ + bind_stored, cursor_expiry, decode_cursor, encode_cursor, CursorError, ListingPosition, + StoredCursor, +}; +use crate::hooks::ActivatedHooks; +use crate::store::{ + ClaimRow, CommitError, CommitOutcome, Commitment, PostgresStore, StoreError, SupplyContext, +}; + +/// The closed action vocabulary a task grant's scheduling permissions carry. +/// A permission names a service, a location, and the actions it allows there; +/// these are the actions the runtime knows. +pub const HOLD_CREATE_ACTION: &str = "hold.create"; +pub const HOLD_RELEASE_ACTION: &str = "hold.release"; +pub const APPOINTMENT_CREATE_ACTION: &str = "appointment.create"; +pub const APPOINTMENT_RESCHEDULE_ACTION: &str = "appointment.reschedule"; +pub const APPOINTMENT_CANCEL_ACTION: &str = "appointment.cancel"; + +/// The default and maximum page sizes for every listing. +pub const DEFAULT_PAGE_LIMIT: usize = 50; +pub const MAXIMUM_PAGE_LIMIT: usize = 200; + +/// How far one availability search may span. A search is bounded (BKG-01); +/// a wider ask is served from the first 62 days and continues by cursor. +const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62; + +/// The audit reference class for a booking caller, hashed over the verified +/// issuer and subject pair. Claims, history, and the idempotency ceiling all +/// key on this pseudonym, never on the raw identity. +const PRINCIPAL_CLASS: &str = "scheduling-principal-v1"; + +/// The audit reference class for the authority that minted a credential +/// carrying no task grant. It exists only so a refusal decided before any +/// grant was resolved still names an accountable second party; a different +/// class is a different keyed domain, so a digest written under it can never +/// be mistaken for the pseudonym of a client that actually presented a grant. +const ISSUER_CLASS: &str = "scheduling-issuer-v1"; + +/// One verified caller, as the authenticator resolved them. +pub struct Caller { + /// The verified actor kind: `human`, `agent`, or `service`. + pub actor_kind: String, + /// The verified token issuer and subject. The idempotency attempts key + /// on this pair; every other record carries the pseudonym. + pub issuer: String, + pub subject: String, + /// The verified task grant, when the token carried one. + pub grant: Option, +} + +impl Caller { + /// The pseudonymized actor reference claims and history carry. + pub fn actor_pseudonym( + &self, + hasher: &AuditKeyHasher, + scope: &str, + ) -> Result { + let canonical = serde_json::to_string(&(&self.issuer, &self.subject)).map_err(|_| { + ServiceError::internal("the caller identity could not be canonicalized") + })?; + hasher + .audit_reference_hash(PRINCIPAL_CLASS, scope, &canonical) + .map_err(|_| ServiceError::internal("the caller identity could not be pseudonymized")) + } +} + +/// What a commitment answered: a minted document, or the stored receipt a +/// replayed idempotency key must receive again. +pub enum CommitmentAnswer { + Minted(T), + Replay { status_code: u16, receipt: Value }, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + /// The refusal the caller sees, in its public projection. Every service + /// failure, internal bound, and cursor verdict lands here too: the edge + /// renders one problem shape. + #[error("the request was refused: {}", .0.code())] + Problem(ProblemCode), +} + +impl ServiceError { + fn internal(reason: &'static str) -> Self { + tracing::error!(reason, "the Scheduling service hit an internal bound"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +impl From for ServiceError { + fn from(error: StoreError) -> Self { + tracing::error!(%error, "the Scheduling store failed"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +impl From for ServiceError { + fn from(error: CursorError) -> Self { + match error { + CursorError::Invalid => Self::Problem(ProblemCode::CursorInvalid), + CursorError::Expired => Self::Problem(ProblemCode::CursorExpired), + } + } +} + +impl From for ServiceError { + fn from(error: registry_scheduling_core::ResolveError) -> Self { + // The policy and the facts do not fit together: an operator state + // mismatch, never a caller defect, and never disclosed in detail. + tracing::error!(%error, "the policy and the environment records do not fit together"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +pub struct SchedulingService { + store: PostgresStore, + policy: SchedulingPolicy, + scheduling_id: String, + policy_revision: u64, + policy_digest: String, + hasher: AuditKeyHasher, + attempt_receipt_days: i64, + hooks: Option, +} + +impl SchedulingService { + #[allow(clippy::too_many_arguments)] + pub fn new( + store: PostgresStore, + policy: SchedulingPolicy, + scheduling_id: String, + policy_revision: i64, + policy_digest: String, + hasher: AuditKeyHasher, + attempt_receipt_days: u16, + ) -> Self { + Self { + store, + policy, + scheduling_id, + policy_revision: u64::try_from(policy_revision).unwrap_or(u64::MAX), + policy_digest, + hasher, + attempt_receipt_days: i64::from(attempt_receipt_days), + hooks: None, + } + } + + #[must_use] + pub fn with_hooks(mut self, hooks: ActivatedHooks) -> Self { + self.hooks = Some(hooks); + self + } + + fn revision(&self) -> u64 { + self.policy_revision + } + + /// Which deployment and which policy this service answers with. + pub fn scheduling(&self) -> SchedulingServiceDocument { + SchedulingServiceDocument { + scheduling_id: self.scheduling_id.clone(), + policy_revision: self.policy_revision, + policy_digest: self.policy_digest.clone(), + } + } + + pub async fn list_services( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "services", now).await?; + let mut services: Vec<_> = self + .policy + .services + .iter() + .map(|service| ServiceDocument { + id: service.id.clone(), + label: service.label.clone(), + }) + .collect(); + services.sort_by(|a, b| a.id.cmp(&b.id)); + page_of(self, services, position, limit, "services", now).await + } + + pub async fn list_offerings( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "offerings", now).await?; + let (facts, _) = self.store.facts().await?; + let mut offerings: Vec<_> = self + .policy + .offerings + .iter() + .map(|offering| self.offering_document(offering, &facts)) + .collect::>()?; + offerings.sort_by(|a, b| a.id.cmp(&b.id)); + page_of(self, offerings, position, limit, "offerings", now).await + } + + pub async fn list_resources( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "resources", now).await?; + let after = id_position(position)?; + let rows = self + .store + .list_resources(after.as_deref(), limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let items: Vec<_> = rows + .into_iter() + .take(limit) + .map( + |(resource_id, pool, capabilities, available)| ResourceDocument { + resource_id, + pool, + capabilities, + available, + }, + ) + .collect(); + let next_cursor = if more { + Some( + self.mint_cursor("resources", &position_of_last_id(&items), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + pub async fn list_locations( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "locations", now).await?; + let after = id_position(position)?; + let rows = self + .store + .list_locations(after.as_deref(), limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let items: Vec<_> = rows + .into_iter() + .take(limit) + .map( + |(location_id, timezone)| registry_scheduling_core::LocationDocument { + location_id, + timezone, + }, + ) + .collect(); + let next_cursor = if more { + Some( + self.mint_cursor("locations", &position_of_last_id(&items), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// Bounded availability. Exact-time offerings answer in grid slots, + /// arrival-window offerings in windows. Only entries with capacity left + /// are listed: a slot nobody can serve or a window with no units left is + /// not availability. + pub async fn availability( + &self, + offering_id: &str, + start: Option>, + end: Option>, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let offering = self.published_offering(offering_id)?; + let span = TimeDelta::days(MAXIMUM_AVAILABILITY_SPAN_DAYS); + // A continuation restores the effective interval of the request that + // minted its cursor. A default availability request, with neither + // start nor end, has no bounds of its own to repeat, so it reads + // them from the cursor rather than deriving a different interval + // from this request's own now; bounds a caller does supply must + // repeat the minted interval exactly, and anything else is a + // different listing the cursor refuses. + let stored = match cursor { + Some(encoded) => Some(self.stored_cursor(encoded).await?), + None => None, + }; + let minted_interval = match &stored { + Some(row) => match ListingPosition::from_json(&row.position) { + Some(ListingPosition::AvailabilityAfter { from, to, .. }) => Some((from, to)), + _ => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + }, + None => None, + }; + let from = start + .or(minted_interval.map(|(from, _)| from)) + .unwrap_or(now); + let to = match end { + Some(end) => end.max(from + TimeDelta::minutes(1)).min(from + span), + None => minted_interval.map(|(_, to)| to).unwrap_or(from + span), + }; + let context = format!("availability:{offering_id}:{}:{to}", from.to_rfc3339()); + let position = match stored { + Some(ref row) => Some(bind_stored(row, &context, now)?), + None => None, + }; + let after = match position { + Some(ListingPosition::AvailabilityAfter { after, .. }) => Some(after), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let entries = match self.supply(offering).await?.0 { + ResolvedSupply::ExactTime { + exact, + members, + open, + .. + } => { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let buffer = TimeDelta::minutes(i64::from( + exact.buffer_before_minutes + exact.buffer_after_minutes, + )); + let ids: Vec = members.iter().map(|m| m.resource_id.clone()).collect(); + // The snapshot window widens by the duration and both buffers: + // a booking that intrudes on any listed slot's occupied span + // must be in the snapshot, wherever it starts. + let snapshot = self + .store + .member_snapshot(&ids, from - buffer, to + duration + buffer, now) + .await?; + exact_time_slots( + &exact, + &members, + &offering.requires_capabilities, + &open, + &snapshot, + from, + to, + now, + ) + } + ResolvedSupply::Window { + window, + lead_time_minutes, + horizon_days, + open, + .. + } => { + let snapshot = self.store.window_snapshot(&window.id, now).await?; + window_entries( + &window, + &snapshot, + from, + to, + now, + lead_time_minutes, + horizon_days, + &open, + ) + } + }; + let selected: Vec<_> = entries + .into_iter() + .filter(|entry| match &after { + Some(after) => entry_instant(entry) > *after, + None => true, + }) + .collect(); + let more = selected.len() > limit; + let items: Vec<_> = selected.into_iter().take(limit).collect(); + let next_cursor = if more { + let last = entry_instant(items.last().expect("a limited page is not empty")); + let position = ListingPosition::AvailabilityAfter { + from, + to, + after: last, + }; + Some(self.mint_cursor(&context, &position, now).await?) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// The separately authorized explanation of one start: the public and the + /// detailed code of the refusal a booking would receive, or no codes at + /// all when the start admits as things stand. The probe is a minimal + /// party that already meets the offering's declared input requirements, + /// so the codes explain the calendar and capacity rather than repeating + /// requirements the operator already knows this synthetic probe must + /// carry. It never names another caller's booking. + pub async fn explain( + &self, + offering_id: &str, + start: DateTime, + now: DateTime, + ) -> Result { + let offering = self.published_offering(offering_id)?; + let probe = AdmissionRequest { + offering: offering.id.clone(), + start, + party: registry_scheduling_core::PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: self.revision(), + window_revision: None, + capabilities: offering.requires_capabilities.clone(), + prerequisites: offering.prerequisites.clone(), + }; + let refusal = match self.supply(offering).await?.0 { + ResolvedSupply::ExactTime { + exact, + members, + open, + closures, + } => { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let buffer = TimeDelta::minutes(i64::from( + exact.buffer_before_minutes + exact.buffer_after_minutes, + )); + let ids: Vec = members.iter().map(|m| m.resource_id.clone()).collect(); + let snapshot = self + .store + .member_snapshot( + &ids, + start - buffer - duration, + start + duration + buffer, + now, + ) + .await?; + evaluate_exact_time_admission( + &ExactTimeContext { + offering, + exact: &exact, + members: &members, + open: &open, + closures: &closures, + snapshot: &snapshot, + policy_revision: self.revision(), + now, + }, + &probe, + // A probe replaces nothing; it explains the start as an + // ordinary booking would meet it. + None, + ) + .err() + } + ResolvedSupply::Window { + window, + lead_time_minutes, + horizon_days, + channels, + open, + closures, + } => { + // The probe carries the window's current revision, the way a + // booking request would: window admission checks capacity + // only under a revision the caller actually observed, and an + // explain that never names one answers every window with a + // revision mismatch instead of explaining the start. + let mut probe = probe.clone(); + probe.window_revision = Some(window.revision); + let snapshot = self.store.window_snapshot(&window.id, now).await?; + evaluate_window_admission( + &WindowContext { + offering, + window: &window, + open: &open, + closures: &closures, + lead_time_minutes, + horizon_days, + snapshot: &snapshot, + policy_revision: self.revision(), + channels: &channels, + now, + }, + &probe, + // A probe replaces nothing here either. + None, + ) + .err() + } + }; + Ok(registry_scheduling_core::ExplainDocument { + offering: offering.id.clone(), + start, + public_code: refusal + .as_ref() + .map(|refusal| refusal.public_code().code().to_owned()), + detailed_code: refusal + .as_ref() + .map(|refusal| refusal.detailed_code().code().to_owned()), + explanation: refusal.as_ref().map(ToString::to_string), + }) + } + + /// Reserve an admission instead of committing it. + pub async fn create_hold( + &self, + caller: &Caller, + idempotency_key: &str, + request: &AdmissionRequest, + now: DateTime, + ) -> Result, ServiceError> { + let offering = self.published_offering(&request.offering)?; + let grant = self + .require_permission(caller, offering, HOLD_CREATE_ACTION) + .await?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let (supply, facts_revision) = self.supply(offering).await?; + let request_hash = admission_request_hash(request); + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + HOLD_CREATE_ACTION, + now, + facts_revision, + )?; + let outcome = self + .store + .create_hold( + offering, + &supply.context(), + request, + self.policy.hold_policy.ttl_minutes, + self.policy.hold_policy.max_per_caller, + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + "hold:create", + HOLD_CREATE_ACTION, + now, + facts_revision, + ) + .await?; + Ok(claim_or_replay(answer, |claim, receipt| { + hold_document(claim, &self.policy, self.revision(), receipt) + })) + } + + /// Give a hold's capacity back. The release carries no caller-chosen + /// idempotency key on the wire, so the key is the hold's own id: a + /// retried release of the same hold replays the first answer. + pub async fn release_hold( + &self, + caller: &Caller, + hold_id: Uuid, + now: DateTime, + ) -> Result, ServiceError> { + let hold = self + .store + .claim(hold_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::HoldReleased))?; + if hold.kind != LedgerKind::Hold { + return Err(ServiceError::Problem(ProblemCode::HoldReleased)); + } + let offering = self.policy.offering(&hold.offering).ok_or_else(|| { + ServiceError::internal("a committed hold names no offering in the policy") + })?; + let grant = self + .require_permission(caller, offering, HOLD_RELEASE_ACTION) + .await?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let request_hash = canonical_hash(&json!({"hold": hold_id}))?; + // The release carries no caller-chosen key, so the hold's own id is + // the idempotency key: a retried release replays the first answer. + let release_key = hold_id.to_string(); + // A release evaluates no supply, so no records revision guards it: + // closing a hold cannot name a resource. + let commitment = self.commitment( + caller, + &actor, + &grant, + &release_key, + &request_hash, + HOLD_RELEASE_ACTION, + now, + 0, + )?; + let outcome = self.store.release_hold(hold_id, commitment).await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + &release_key, + &request_hash, + &format!("hold:{hold_id}:release"), + HOLD_RELEASE_ACTION, + now, + 0, + ) + .await + } + + /// Confirm a held allocation, or create an appointment directly. + pub async fn create_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + request: &CreateAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + match (&request.hold, &request.admission) { + (Some(_), Some(_)) | (None, None) => { + return Err(ServiceError::Problem(ProblemCode::RequestInvalid)); + } + _ => {} + } + let answer = match &request.hold { + Some(hold) => { + let hold_id = Uuid::parse_str(hold) + .map_err(|_| ServiceError::Problem(ProblemCode::RequestInvalid))?; + self.confirm_appointment(caller, idempotency_key, hold_id, now) + .await + } + None => { + let admission = request.admission.as_ref().expect("checked above"); + self.direct_create_appointment(caller, idempotency_key, admission, now) + .await + } + }?; + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) + })) + } + + async fn confirm_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + hold_id: Uuid, + now: DateTime, + ) -> Result, ServiceError> { + let hold = self + .store + .claim(hold_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::HoldReleased))?; + if hold.kind != LedgerKind::Hold { + return Err(ServiceError::Problem(ProblemCode::HoldReleased)); + } + let offering = self.policy.offering(&hold.offering).ok_or_else(|| { + ServiceError::internal("a committed hold names no offering in the policy") + })?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) + .await?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let (supply, facts_revision) = self.supply(offering).await?; + let request_hash = canonical_hash(&json!({"hold": hold_id}))?; + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CREATE_ACTION, + now, + facts_revision, + )?; + let outcome = self + .store + .confirm_hold(hold_id, offering, &supply.context(), commitment) + .await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("hold:{hold_id}:confirm"), + APPOINTMENT_CREATE_ACTION, + now, + facts_revision, + ) + .await + } + + async fn direct_create_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + request: &AdmissionRequest, + now: DateTime, + ) -> Result, ServiceError> { + let offering = self.published_offering(&request.offering)?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) + .await?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let (supply, facts_revision) = self.supply(offering).await?; + let request_hash = admission_request_hash(request); + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CREATE_ACTION, + now, + facts_revision, + )?; + let outcome = self + .store + .create_appointment(offering, &supply.context(), request, commitment) + .await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + "appointment:create", + APPOINTMENT_CREATE_ACTION, + now, + facts_revision, + ) + .await + } + + /// One appointment by id, visible to the caller that owns it. + pub async fn get_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + ) -> Result { + let claim = self + .owned_booking(caller, appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + Ok(appointment_document( + &claim, + &self.policy, + self.revision(), + None, + )) + } + + pub async fn reschedule_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + idempotency_key: &str, + request: &RescheduleAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + // The pre-read resolves the offering for the permission check; kind, + // state, revision, and ownership are re-checked inside the + // transaction, where a refusal is an auditable decision. + let appointment = self + .booking(appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { + ServiceError::internal("a committed appointment names no offering in the policy") + })?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION) + .await?; + if request.admission.offering != appointment.offering { + return Err(ServiceError::Problem(ProblemCode::RequestUnprocessable)); + } + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let (supply, facts_revision) = self.supply(offering).await?; + let request_hash = canonical_hash(&json!({ + "observedRevision": request.observed_revision, + "admissionHash": admission_request_hash(&request.admission), + }))?; + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_RESCHEDULE_ACTION, + now, + facts_revision, + )?; + let outcome = self + .store + .reschedule_appointment( + appointment_id, + offering, + &supply.context(), + &request.admission, + request.observed_revision, + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("appointment:{appointment_id}:reschedule"), + APPOINTMENT_RESCHEDULE_ACTION, + now, + facts_revision, + ) + .await?; + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) + })) + } + + pub async fn cancel_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + idempotency_key: &str, + request: &CancelAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + let appointment = self + .booking(appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { + ServiceError::internal("a committed appointment names no offering in the policy") + })?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CANCEL_ACTION) + .await?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let request_hash = canonical_hash(&json!({ + "observedRevision": request.observed_revision, + "reason": request.reason, + }))?; + // A cancellation evaluates no supply, so no records revision guards + // it: closing an appointment cannot name a resource. + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CANCEL_ACTION, + now, + 0, + )?; + let outcome = self + .store + .cancel_appointment( + appointment_id, + request.observed_revision, + Some(offering.cancellation_cutoff_minutes), + offering.exact_time.is_some(), + request.reason.as_deref(), + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("appointment:{appointment_id}:cancel"), + APPOINTMENT_CANCEL_ACTION, + now, + 0, + ) + .await?; + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) + })) + } + + /// The attributable history of one appointment, newest first, visible to + /// the caller that owns it. + pub async fn appointment_history( + &self, + caller: &Caller, + appointment_id: Uuid, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + self.owned_booking(caller, appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let context = format!("history:{appointment_id}"); + let position = self.resolve_position(cursor, &context, now).await?; + let before = match position { + Some(ListingPosition::BeforeInstantAndId { before, last_id }) => Some(( + before, + Uuid::parse_str(&last_id) + .map_err(|_| ServiceError::internal("a stored history position is corrupt"))?, + )), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let rows = self + .store + .claim_history(appointment_id, before, limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let page: Vec<_> = rows.into_iter().take(limit).collect(); + let items = page + .iter() + .map(history_entry) + .collect::, _>>()?; + let next_cursor = if more { + let last = items.last().expect("a limited page is not empty"); + let position = ListingPosition::BeforeInstantAndId { + before: last.occurred_at, + last_id: last.event_id.clone(), + }; + Some(self.mint_cursor(&context, &position, now).await?) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// The booking a claim names, when it is one. `None` covers a claim the + /// caller may not act on as a booking: unknown, not a booking, or + /// another caller's. Mutating paths take the ownership refusal from the + /// store, where it is auditable; read paths take it here, where hiding + /// existence is the contract. + async fn booking(&self, appointment_id: Uuid) -> Result, ServiceError> { + let Some(claim) = self.store.claim(appointment_id).await? else { + return Ok(None); + }; + if claim.kind != LedgerKind::Booking { + return Ok(None); + } + Ok(Some(claim)) + } + + /// `booking`, and the caller owns it. + async fn owned_booking( + &self, + caller: &Caller, + appointment_id: Uuid, + ) -> Result, ServiceError> { + let Some(claim) = self.booking(appointment_id).await? else { + return Ok(None); + }; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + if claim.actor != actor { + return Ok(None); + } + Ok(Some(claim)) + } + + fn offering_document( + &self, + offering: &OfferingPolicy, + facts: &SchedulingFacts, + ) -> Result { + let mode = match offering.mode { + SchedulingMode::ExactTime => SchedulingModeDocument::ExactTime, + SchedulingMode::ArrivalWindow => SchedulingModeDocument::ArrivalWindow, + }; + let (duration, buffer_before, buffer_after, increment, max_recipients) = + match &offering.exact_time { + Some(exact) => ( + Some(exact.duration_minutes), + Some(exact.buffer_before_minutes), + Some(exact.buffer_after_minutes), + Some(exact.start_increment_minutes), + Some(exact.max_recipients), + ), + None => (None, None, None, None, None), + }; + let window = offering + .arrival + .as_ref() + .map(|arrival| { + let window = bound_window(facts, offering, &arrival.window)?; + Ok::(WindowDocument { + id: window.id.clone(), + revision: window.revision, + start: window.start, + end: window.end, + units: window.units, + }) + }) + .transpose()?; + let (lead_time_minutes, horizon_days) = match (&offering.exact_time, &offering.arrival) { + (Some(exact), None) => (exact.lead_time_minutes, exact.horizon_days), + (None, Some(arrival)) => (arrival.lead_time_minutes, arrival.horizon_days), + _ => (0, 0), + }; + Ok(OfferingDocument { + id: offering.id.clone(), + service: offering.service.clone(), + label: offering.label.clone(), + mode, + location: offering.location.clone(), + lead_time_minutes, + horizon_days, + cancellation_cutoff_minutes: offering.cancellation_cutoff_minutes, + duration_minutes: duration, + buffer_before_minutes: buffer_before, + buffer_after_minutes: buffer_after, + start_increment_minutes: increment, + max_recipients, + window, + reminders: offering + .reminders + .iter() + .map(|reminder| ReminderDocument { + minutes_before: reminder.minutes_before, + }) + .collect(), + requires_capabilities: offering.requires_capabilities.clone(), + prerequisites: offering.prerequisites.clone(), + }) + } + + /// The offering the deployed policy publishes under `offering_id`. + /// + /// An offering the policy does not publish is not a precondition the + /// caller can satisfy by reloading and retrying, which is what + /// `precondition.failed` invites: it does not exist, and the offering + /// listing is where a caller learns what does. That listing is readable by + /// every caller holding the reads scope, so naming the absence discloses + /// nothing the listing would not. + fn published_offering(&self, offering_id: &str) -> Result<&OfferingPolicy, ServiceError> { + self.policy + .offering(offering_id) + .ok_or(ServiceError::Problem(ProblemCode::RequestNotFound)) + } + + /// A grant that covers this offering's service, location, and action, or + /// the refusal. Readable availability is not authority to book: the + /// permission must name all three. + /// + /// Both refusals are audited. A refusal decided here never opens the + /// capacity transaction, so nothing further in the request would record + /// that it happened, and a caller probing which services and locations its + /// grant reaches would leave the journal empty. What the journal gains is + /// the attribution: the answer stays the same closed code, naming neither + /// the bound that failed nor whether a grant was carried at all. + async fn require_permission( + &self, + caller: &Caller, + offering: &OfferingPolicy, + action: &str, + ) -> Result { + let Some(grant) = &caller.grant else { + self.record_refusal(grantless_refusal_record( + &self.hasher, + &self.scheduling_id, + caller, + action, + )) + .await; + return Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)); + }; + let allowed = grant + .bounds() + .scheduling_permissions() + .is_some_and(|permissions| { + permissions.iter().any(|permission| { + permission.service() == offering.service.as_str() + && permission.location() == offering.location.as_str() + && permission + .actions() + .iter() + .any(|allowed| allowed.as_str() == action) + }) + }); + if allowed { + Ok(grant.clone()) + } else { + self.record_refusal(audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + action, + AuthorizationOutcome::Denied, + "authorization.refused", + )) + .await; + Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)) + } + } + + /// Write one authorization refusal to the journal. A journal that cannot + /// be written must not change the caller's answer: the decision is already + /// made and the caller is refused either way, so the failure is logged + /// loudly and the refusal stands. + async fn record_refusal(&self, record: Result) { + match record { + Ok(record) => { + if let Err(failure) = self + .store + .record_refusal_audit(Uuid::new_v4(), record) + .await + { + tracing::error!(%failure, "the refusal audit row could not be recorded"); + } + } + Err(refused) => { + tracing::error!(error = %refused, "the refusal audit row could not be built"); + } + } + } + + /// The policy-resolved supply an offering runs against, read from the + /// live facts the operator's records apply wrote, with the records + /// revision the read observed. The revision travels into the capacity + /// transaction, which refuses to commit against records a replacement + /// has since superseded. + async fn supply( + &self, + offering: &OfferingPolicy, + ) -> Result<(ResolvedSupply, i64), ServiceError> { + let (facts, revision) = self.store.facts().await?; + Ok(( + ResolvedSupply::resolve(&self.policy, &facts, offering)?, + revision, + )) + } + + #[allow(clippy::too_many_arguments)] + fn commitment<'a>( + &'a self, + caller: &'a Caller, + actor: &'a str, + grant: &GrantClaims, + idempotency_key: &'a str, + request_hash: &'a str, + operation: &str, + now: DateTime, + facts_revision: i64, + ) -> Result, ServiceError> { + let attempt_expires_at = now + .checked_add_signed(TimeDelta::days(self.attempt_receipt_days)) + .ok_or_else(|| { + ServiceError::internal("the attempt retention horizon is not representable") + })?; + let audit_record = audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + operation, + AuthorizationOutcome::Allowed, + "authorization.allowed", + )?; + Ok(Commitment { + now, + policy_revision: i64::try_from(self.policy_revision).unwrap_or(i64::MAX), + facts_revision, + actor, + actor_issuer: &caller.issuer, + actor_subject: &caller.subject, + idempotency_key, + request_hash, + attempt_expires_at, + grant_exp_unix: Some(grant.exp()), + audit_event: Uuid::new_v4(), + audit_record, + hooks: self.hooks.as_ref(), + }) + } + + /// Translate one store outcome: minted, replayed, or refused. A refusal + /// writes its receipt under the caller's idempotency key so a replay of + /// that key answers the same, and writes its audit row when the refusal + /// was an authorization decision. + #[allow(clippy::too_many_arguments)] + async fn commitment_outcome( + &self, + outcome: Result, + caller: &Caller, + grant: &GrantClaims, + actor: &str, + idempotency_key: &str, + request_hash: &str, + scope: &str, + operation: &str, + now: DateTime, + facts_revision: i64, + ) -> Result, ServiceError> + where + T: FromMinted, + { + match outcome { + Ok(CommitOutcome::Replay { + status_code, + receipt, + }) => Ok(CommitmentAnswer::Replay { + status_code, + receipt, + }), + Ok(minted) => Ok(CommitmentAnswer::Minted(T::from_minted(minted))), + Err(error) => { + if matches!( + error, + CommitError::Store(_) | CommitError::Query(_) | CommitError::Hooks(_) + ) { + // Nothing was decided: no receipt, no audit row, and the + // caller sees no detail. + tracing::error!(%error, "the Scheduling store failed mid-commitment"); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } + if matches!(error, CommitError::FactsStale) { + // A records replacement moved under this request. Nothing + // was decided and the caller retries; the swap is an + // expected operator act, so this is a warning, not a + // failure. + tracing::warn!( + %error, + "the environment records were replaced while a commitment was in flight" + ); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } + let problem = problem_of(&error); + // Key misuse keeps the stored attempt as the answer and + // records nothing new. + if !matches!(error, CommitError::KeyReused | CommitError::KeyExpired) { + let receipt = self.commitment( + caller, + actor, + grant, + idempotency_key, + request_hash, + operation, + now, + facts_revision, + ); + match receipt { + Ok(commitment) => { + match self + .store + .record_refused_attempt( + &commitment, + scope, + problem.http_status(), + problem_receipt(problem), + ) + .await + { + Ok(Some((status_code, receipt))) => { + return Ok(CommitmentAnswer::Replay { + status_code, + receipt, + }); + } + Ok(None) => {} + Err( + failure @ (CommitError::KeyReused | CommitError::KeyExpired), + ) => { + return Err(ServiceError::Problem(problem_of(&failure))); + } + Err(failure) => { + tracing::error!(%failure, "the refused attempt receipt could not be recorded"); + } + } + } + Err(refused) => { + tracing::error!(error = %refused, "the refused attempt could not be recorded"); + } + } + } + if matches!( + error, + CommitError::Refused(_) | CommitError::HoldCeiling | CommitError::Unauthorized + ) { + let reason = match error { + CommitError::Unauthorized => "authorization.refused", + _ => "authorization.profile", + }; + self.record_refusal(audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + operation, + AuthorizationOutcome::Denied, + reason, + )) + .await; + } + Err(ServiceError::Problem(problem)) + } + } + } + + /// Decode and load one stored cursor row. Binding to a listing is the + /// caller's act: the availability walk reads the stored interval before + /// it can compute the context it binds against. + async fn stored_cursor(&self, encoded: &str) -> Result { + let cursor_id = decode_cursor(encoded)?; + let stored = self + .store + .cursor(cursor_id) + .await? + .ok_or(CursorError::Invalid)?; + Ok(StoredCursor { + cursor_id, + context: stored["context"].as_str().unwrap_or_default().to_owned(), + position: stored["position"].clone(), + expires_at: serde_json::from_value(stored["expiresAt"].clone()) + .map_err(|_| CursorError::Invalid)?, + }) + } + + async fn resolve_position( + &self, + cursor: Option<&str>, + context: &str, + now: DateTime, + ) -> Result, ServiceError> { + let Some(encoded) = cursor else { + return Ok(None); + }; + let stored = self.stored_cursor(encoded).await?; + Ok(Some(bind_stored(&stored, context, now)?)) + } + + async fn mint_cursor( + &self, + context: &str, + position: &ListingPosition, + now: DateTime, + ) -> Result { + let cursor_id = Uuid::new_v4(); + self.store + .insert_cursor(cursor_id, context, position.to_json(), cursor_expiry(now)) + .await?; + Ok(encode_cursor(cursor_id)) + } +} + +/// The minted-claim projection each commitment answer carries. +pub trait FromMinted { + fn from_minted(outcome: CommitOutcome) -> Self; +} + +impl FromMinted for ClaimRow { + fn from_minted(outcome: CommitOutcome) -> Self { + match outcome { + CommitOutcome::Booking(claim) | CommitOutcome::Hold(claim) => claim, + CommitOutcome::Cancelled(claim) => claim, + CommitOutcome::Released => unreachable!("a release is not a minted claim"), + CommitOutcome::Replay { .. } => unreachable!("a replay is not a minted claim"), + } + } +} + +impl FromMinted for () { + fn from_minted(outcome: CommitOutcome) -> Self { + match outcome { + CommitOutcome::Released => (), + _ => unreachable!("a claim minting is not an empty answer"), + } + } +} + +/// A stored success receipt carries the claim the first answer minted; the +/// replay answers with that same claim through the same projection, so a +/// retried request reads the original response, not a re-derivation. A +/// refusal receipt carries no claim and flows to the edge untouched. +fn claim_or_replay( + answer: CommitmentAnswer, + project: impl FnOnce(&ClaimRow, Option<&Value>) -> T, +) -> CommitmentAnswer { + match answer { + CommitmentAnswer::Minted(claim) => CommitmentAnswer::Minted(project(&claim, None)), + CommitmentAnswer::Replay { + status_code, + receipt, + } => { + if let Some(claim) = receipt + .get("claim") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + { + CommitmentAnswer::Minted(project(&claim, Some(&receipt))) + } else { + CommitmentAnswer::Replay { + status_code, + receipt, + } + } + } + } +} + +/// The resolved supply an offering runs against, owned here and borrowed into +/// the store's context. +enum ResolvedSupply { + ExactTime { + exact: ExactTimeOffering, + members: Vec, + open: Vec, + closures: Vec, + }, + Window { + window: Box, + lead_time_minutes: u32, + horizon_days: u32, + channels: Vec, + open: Vec, + closures: Vec, + }, +} + +impl ResolvedSupply { + fn resolve( + policy: &SchedulingPolicy, + facts: &SchedulingFacts, + offering: &OfferingPolicy, + ) -> Result { + // Every reference the policy names must exist in the live records. + // A gap is operator state the caller cannot see or fix, disclosed as + // no availability and logged for the operator. + let operator_gap = |reference: &str| { + tracing::error!( + offering = %offering.id, + reference, + "the offering names a reference the records do not carry" + ); + ServiceError::Problem(ProblemCode::ServiceUnavailable) + }; + let location = facts + .location(&offering.location) + .ok_or_else(|| operator_gap(&offering.location))?; + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + match (&offering.exact_time, &offering.arrival) { + (Some(exact), None) => { + let pool = facts + .pool(&exact.pool) + .ok_or_else(|| operator_gap(&exact.pool))?; + Ok(Self::ExactTime { + exact: exact.clone(), + members: pool.members.clone(), + open: location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + )?, + closures: location_closure_intervals( + facts, + &offering.location, + &location.timezone, + )?, + }) + } + (None, Some(arrival)) => { + let window = bound_window(facts, offering, &arrival.window)?; + Ok(Self::Window { + window: Box::new(window.clone()), + lead_time_minutes: arrival.lead_time_minutes, + horizon_days: arrival.horizon_days, + channels: policy.channels.clone(), + open: location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + )?, + closures: location_closure_intervals( + facts, + &offering.location, + &location.timezone, + )?, + }) + } + _ => Err(operator_gap("one scheduling mode")), + } + } + + fn context(&self) -> SupplyContext<'_> { + match self { + Self::ExactTime { + exact, + members, + open, + closures, + } => SupplyContext::ExactTime { + exact, + members, + open, + closures, + }, + Self::Window { + window, + lead_time_minutes, + horizon_days, + channels, + open, + closures, + } => SupplyContext::Window { + window, + lead_time_minutes: *lead_time_minutes, + horizon_days: *horizon_days, + channels: channels.as_slice(), + open, + closures, + }, + } + } +} + +/// Resolve a published window only when its operator record belongs to the +/// offering and location whose authority the service checked. A malformed or +/// stale records set is an operator gap, never capacity a caller may consume. +fn bound_window<'a>( + facts: &'a SchedulingFacts, + offering: &OfferingPolicy, + reference: &str, +) -> Result<&'a PublishedWindow, ServiceError> { + let window = facts.window(reference).ok_or_else(|| { + tracing::error!( + offering = %offering.id, + reference, + "the offering names a window the records do not carry" + ); + ServiceError::Problem(ProblemCode::ServiceUnavailable) + })?; + if window.offering != offering.id || window.location != offering.location { + tracing::error!( + offering = %offering.id, + reference, + "the window record is not bound to the offering and location" + ); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } + Ok(window) +} + +/// The exact-time availability walk: one entry per grid slot that lies inside +/// the effective openings, inside the booking horizon, and that at least one +/// available, capable member can still serve. +/// +/// `open` is the effective-open truth: closures already removed from it, and +/// an authorized reopening already restored into it. Filtering by raw +/// closures again here would strike out the time a reopening put back, so +/// discovery and admission would disagree; they read the same intervals +/// instead. +#[allow(clippy::too_many_arguments)] +fn exact_time_slots( + exact: &ExactTimeOffering, + members: &[PoolMember], + requires_capabilities: &[String], + open: &[CalendarInterval], + snapshot: &LedgerSnapshot, + from: DateTime, + to: DateTime, + now: DateTime, +) -> Vec { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let increment = TimeDelta::minutes(i64::from(exact.start_increment_minutes).max(1)); + let earliest = now + TimeDelta::minutes(i64::from(exact.lead_time_minutes)); + let latest = now + TimeDelta::days(i64::from(exact.horizon_days)); + let mut entries = Vec::new(); + for interval in open { + // Slots anchor on each published opening's own start. Jump directly + // to the first grid point in the requested/horizon intersection so a + // short page never walks years of an otherwise valid pattern. + let lower = interval.start.max(from).max(earliest); + let delta_seconds = (lower - interval.start).num_seconds().max(0); + let increment_seconds = increment.num_seconds(); + let steps = (delta_seconds + increment_seconds - 1) / increment_seconds; + let Some(mut slot_start) = interval + .start + .checked_add_signed(TimeDelta::seconds(steps.saturating_mul(increment_seconds))) + else { + continue; + }; + while slot_start + duration <= interval.end && slot_start < to && slot_start <= latest { + let slot_end = slot_start + duration; + let occupied_start = + slot_start - TimeDelta::minutes(i64::from(exact.buffer_before_minutes)); + let occupied_end = slot_end + TimeDelta::minutes(i64::from(exact.buffer_after_minutes)); + let free = members + .iter() + .filter(|member| member.available) + .filter(|member| member.serves(requires_capabilities)) + .filter(|member| { + snapshot.claims.iter().all(|claim| { + claim.supply_id != member.resource_id + || claim.end <= occupied_start + || occupied_end <= claim.start + }) + }) + .count(); + if free > 0 { + entries.push(AvailabilityEntry::Slot { + start: slot_start, + end: slot_end, + free: u32::try_from(free).unwrap_or(u32::MAX), + }); + } + slot_start += increment; + if slot_start >= interval.end { + break; + } + } + } + entries.sort_by_key(entry_instant); + entries.dedup_by_key(|entry| entry_instant(entry)); + entries +} + +/// The arrival-window availability walk: one entry per published window in +/// range with units left, after lead time and inside the horizon. +#[allow(clippy::too_many_arguments)] +fn window_entries( + window: &PublishedWindow, + snapshot: &LedgerSnapshot, + from: DateTime, + to: DateTime, + now: DateTime, + lead_time_minutes: u32, + horizon_days: u32, + open: &[CalendarInterval], +) -> Vec { + let consumed: u32 = snapshot.claims.iter().map(|claim| claim.units).sum(); + let remaining = window.units.saturating_sub(consumed); + let earliest = now + TimeDelta::minutes(i64::from(lead_time_minutes)); + let latest = now + TimeDelta::days(i64::from(horizon_days)); + let in_range = window.start >= from && window.start < to; + let in_horizon = window.start >= earliest && window.start <= latest; + let is_open = open + .iter() + .any(|interval| interval.start <= window.start && window.end <= interval.end); + if in_range && in_horizon && is_open && remaining > 0 { + vec![AvailabilityEntry::Window { + window: window.id.clone(), + start: window.start, + end: window.end, + remaining, + // The caller's channel slice is a booking-time fact; a public + // availability read carries no channel. + channel_remaining: None, + }] + } else { + Vec::new() + } +} + +fn entry_instant(entry: &AvailabilityEntry) -> DateTime { + match entry { + AvailabilityEntry::Slot { start, .. } | AvailabilityEntry::Window { start, .. } => *start, + } +} + +fn page_limit(limit: Option) -> usize { + limit + .unwrap_or(DEFAULT_PAGE_LIMIT) + .clamp(1, MAXIMUM_PAGE_LIMIT) +} + +/// The `after` id of a position a by-id listing carries, or the invalid-cursor +/// refusal for a foreign position shape. +fn id_position(position: Option) -> Result, ServiceError> { + match position { + Some(ListingPosition::AfterId { last_id }) => Ok(Some(last_id)), + Some(_) => Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => Ok(None), + } +} + +trait LastId { + fn last_id(&self) -> String; +} + +macro_rules! last_id_via { + ($name:ident) => { + impl LastId for $name { + fn last_id(&self) -> String { + self.id.clone() + } + } + }; +} +last_id_via!(ServiceDocument); +last_id_via!(OfferingDocument); + +impl LastId for ResourceDocument { + fn last_id(&self) -> String { + self.resource_id.clone() + } +} + +impl LastId for registry_scheduling_core::LocationDocument { + fn last_id(&self) -> String { + self.location_id.clone() + } +} + +fn position_of_last_id(items: &[T]) -> ListingPosition { + ListingPosition::AfterId { + last_id: items.last().expect("a limited page is not empty").last_id(), + } +} + +async fn page_of( + service: &SchedulingService, + items: Vec, + position: Option, + limit: usize, + context: &str, + now: DateTime, +) -> Result, ServiceError> { + let after = match position { + Some(ListingPosition::AfterId { last_id }) => Some(last_id), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let selected: Vec<_> = items + .into_iter() + .filter(|item| after.as_ref().is_none_or(|after| &item.last_id() > after)) + .collect(); + let more = selected.len() > limit; + let page: Vec<_> = selected.into_iter().take(limit).collect(); + let next_cursor = if more { + Some( + service + .mint_cursor(context, &position_of_last_id(&page), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { + items: page, + next_cursor, + }) +} + +/// One stored history row as its published document. Every field is strict: +/// a row that does not carry the shape the store writes is corruption, and +/// the caller sees no part of it. +fn history_entry(row: &Value) -> Result { + let corrupt = || ServiceError::internal("a stored history row is corrupt"); + Ok(AppointmentHistoryEntryDocument { + event_id: row["eventId"].as_str().ok_or_else(corrupt)?.to_owned(), + kind: row["kind"].as_str().ok_or_else(corrupt)?.to_owned(), + revision: u64::try_from(row["revision"].as_i64().ok_or_else(corrupt)?) + .map_err(|_| corrupt())?, + occurred_at: serde_json::from_value(row["occurredAt"].clone()).map_err(|_| corrupt())?, + actor: row["actor"].as_str().map(str::to_owned), + detail: row.get("detail").cloned().unwrap_or(Value::Null), + }) +} + +fn hold_document( + claim: &ClaimRow, + policy: &SchedulingPolicy, + policy_revision: u64, + receipt: Option<&Value>, +) -> registry_scheduling_core::HoldDocument { + registry_scheduling_core::HoldDocument { + hold_id: claim.claim_id.to_string(), + offering: claim.offering.clone(), + start: claim.displayed_start, + end: claim.displayed_end, + resource: projected_resource(claim, policy, receipt), + units: u32::try_from(claim.units).unwrap_or(u32::MAX), + expires_at: claim.hold_expires_at.unwrap_or(claim.created_at), + policy_revision: projected_policy_revision(policy_revision, receipt), + } +} + +fn appointment_document( + claim: &ClaimRow, + policy: &SchedulingPolicy, + policy_revision: u64, + receipt: Option<&Value>, +) -> AppointmentDocument { + AppointmentDocument { + appointment_id: claim.claim_id.to_string(), + offering: claim.offering.clone(), + start: claim.displayed_start, + end: claim.displayed_end, + resource: projected_resource(claim, policy, receipt), + units: u32::try_from(claim.units).unwrap_or(u32::MAX), + channel: claim.channel.clone(), + revision: u64::try_from(claim.revision).unwrap_or(u64::MAX), + state: if claim.state == crate::store::ClaimState::Cancelled { + AppointmentStateDocument::Cancelled + } else { + AppointmentStateDocument::Confirmed + }, + policy_revision: projected_policy_revision(policy_revision, receipt), + created_at: claim.created_at, + cancelled_at: claim.closed_at, + } +} + +fn projected_policy_revision(current: u64, receipt: Option<&Value>) -> u64 { + receipt + .and_then(|value| value.get("policyRevision")) + .and_then(Value::as_i64) + .and_then(|revision| u64::try_from(revision).ok()) + .unwrap_or(current) +} + +fn projected_resource( + claim: &ClaimRow, + policy: &SchedulingPolicy, + receipt: Option<&Value>, +) -> Option { + match receipt.and_then(|value| value.get("resource")) { + Some(Value::String(resource)) => Some(resource.clone()), + Some(Value::Null) => None, + _ => claim + .supply_id_is_member(policy) + .then(|| claim.supply_id.clone()), + } +} + +impl ClaimRow { + /// Whether the claim's supply id names a pool member (an exact-time + /// booking) rather than a window. + /// + /// The ledger keeps one supply column for both: an exact-time claim + /// occupies the pool member it was assigned, and a window claim occupies + /// the window itself. Only the offering's mode says which of the two the + /// column holds, so an offering the policy no longer carries names no + /// member: a supply id that cannot be shown to be one is not published + /// through a field that promises one. + fn supply_id_is_member(&self, policy: &SchedulingPolicy) -> bool { + policy + .offering(&self.offering) + .is_some_and(|offering| offering.mode == SchedulingMode::ExactTime) + } +} + +fn problem_of(error: &CommitError) -> ProblemCode { + match error { + CommitError::Store(_) | CommitError::Query(_) | CommitError::Hooks(_) => { + ProblemCode::ServiceUnavailable + } + CommitError::Refused(refusal) => refusal.public_code(), + CommitError::KeyReused => ProblemCode::IdempotencyKeyReused, + CommitError::KeyExpired => ProblemCode::IdempotencyExpired, + CommitError::HoldCeiling => ProblemCode::CapacityExhausted, + CommitError::Unauthorized => ProblemCode::OperationNotAuthorized, + CommitError::RevisionMismatch => ProblemCode::RevisionMismatch, + CommitError::CutoffPassed => ProblemCode::CancellationCutoffPassed, + // Never reached: the outcome handler intercepts a stale-facts + // refusal before it projects, because nothing was decided. + CommitError::FactsStale => ProblemCode::ServiceUnavailable, + } +} + +/// The stored body a replayed refusal answers with: the pinned problem +/// without a trace id, so the edge stamps the answering request's own trace. +fn problem_receipt(problem: ProblemCode) -> Value { + json!({ + "problem": { + "type": type_uri(problem.code()), + "title": problem.title(), + "status": problem.http_status(), + "detail": problem.detail(), + "code": problem.code(), + } + }) +} + +fn canonical_hash(value: &Value) -> Result { + let canonical = canonicalize_json(value) + .map_err(|_| ServiceError::internal("a request body could not be canonicalized"))?; + let digest = Sha256::digest(&canonical); + Ok(format!("sha256:{}", hex(&digest))) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +/// The attributable record of a refusal decided before any grant was +/// resolved. The caller authenticated, so there is a principal to attribute +/// the attempt to, but no grant, approver, or purpose exists to record and +/// none is invented. +/// +/// The shape still demands a client. Without a grant the deployment verified +/// no client identity, so the field carries the credential's issuer under the +/// issuer reference class, and the absent grant pseudonym is what tells a +/// reader of the journal which of the two it is looking at. +fn grantless_refusal_record( + hasher: &AuditKeyHasher, + scope: &str, + caller: &Caller, + operation: &str, +) -> Result { + let issuer = hasher + .audit_reference_hash(ISSUER_CLASS, scope, &caller.issuer) + .map_err(|_| ServiceError::internal("an audit reference could not be pseudonymized"))?; + let event = AuthorizationAuditEvent::denied_without_purpose( + caller.actor_kind.clone(), + caller.actor_pseudonym(hasher, scope)?, + issuer, + None, + None, + operation, + "authorization.no-grant", + ) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + serde_json::to_value(event) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable")) +} + +/// The attributable authorization record of one commitment, with the +/// principal, client, grant, and approver pseudonymized and the purpose +/// recorded as presence only, the way the stack's other runtimes publish it. +#[allow(clippy::too_many_arguments)] +fn audit_record( + hasher: &AuditKeyHasher, + scope: &str, + caller: &Caller, + grant: &GrantClaims, + operation: &str, + outcome: AuthorizationOutcome, + reason: &str, +) -> Result { + let pseudonym = |class: &str, value: &str| -> Result { + hasher + .audit_reference_hash(class, scope, value) + .map_err(|_| ServiceError::internal("an audit reference could not be pseudonymized")) + }; + let event = AuthorizationAuditEvent::new( + caller.actor_kind.clone(), + pseudonym("scheduling-principal-v1", grant.principal())?, + pseudonym("scheduling-client-v1", grant.client())?, + Some(pseudonym("scheduling-grant-v1", grant.id())?), + Some(pseudonym("scheduling-approver-v1", grant.approver())?), + grant.purpose(), + operation, + outcome, + reason, + ) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + let mut value = serde_json::to_value(event) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + let Some(object) = value.as_object_mut() else { + return Err(ServiceError::internal( + "the authorization audit event is not an object", + )); + }; + // Purpose presence is recorded, never the purpose value. + object.remove("purpose"); + value["sourceIssuer"] = json!(grant.source_issuer()); + value["expiresAt"] = json!(grant.exp()); + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn interval(start_hour: u32, end_hour: u32) -> CalendarInterval { + let day = Utc.with_ymd_and_hms(2026, 10, 5, 0, 0, 0).unwrap(); + CalendarInterval { + start: day + TimeDelta::hours(i64::from(start_hour)), + end: day + TimeDelta::hours(i64::from(end_hour)), + } + } + + fn exact() -> ExactTimeOffering { + ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "north-stations".to_owned(), + start_increment_minutes: 30, + max_recipients: 2, + } + } + + fn members(count: usize) -> Vec { + (1..=count) + .map(|index| PoolMember { + resource_id: format!("station-{index}"), + capabilities: Vec::new(), + available: true, + }) + .collect() + } + + fn at(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + } + + fn booking_of( + id: &str, + supply_id: &str, + start: u32, + end: u32, + ) -> registry_scheduling_core::LedgerClaim { + registry_scheduling_core::LedgerClaim { + id: id.to_owned(), + offering: "registry-update-30".to_owned(), + supply_id: supply_id.to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: at(start, 50), + end: at(end, 10), + units: 1, + duplicate_key: None, + expires_at: None, + } + } + + fn window_offering() -> OfferingPolicy { + OfferingPolicy { + id: "review-arrivals".to_owned(), + service: "registry-review".to_owned(), + label: "Review arrivals".to_owned(), + mode: SchedulingMode::ArrivalWindow, + location: "north-counter".to_owned(), + because: "test".to_owned(), + exact_time: None, + arrival: Some(registry_scheduling_core::ArrivalOffering { + window: "morning-arrivals".to_owned(), + lead_time_minutes: 60, + horizon_days: 30, + }), + cancellation_cutoff_minutes: 240, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn window_facts() -> SchedulingFacts { + SchedulingFacts { + windows: vec![PublishedWindow { + id: "morning-arrivals".to_owned(), + revision: 1, + offering: "review-arrivals".to_owned(), + location: "north-counter".to_owned(), + start: at(2, 0), + end: at(4, 0), + units: 2, + units_policy: registry_scheduling_core::RequiredUnitsPolicy::Fixed { + units: 1, + because: "test".to_owned(), + }, + subquotas: Vec::new(), + leftover: None, + staffing: None, + because: "test".to_owned(), + }], + ..SchedulingFacts::default() + } + } + + #[test] + fn a_window_resolves_only_for_its_own_offering_and_location() { + let offering = window_offering(); + let mut facts = window_facts(); + assert!(bound_window(&facts, &offering, "morning-arrivals").is_ok()); + + facts.windows[0].offering = "other-arrivals".to_owned(); + assert!(matches!( + bound_window(&facts, &offering, "morning-arrivals"), + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + )); + + facts.windows[0].offering = offering.id.clone(); + facts.windows[0].location = "south-counter".to_owned(); + assert!(matches!( + bound_window(&facts, &offering, "morning-arrivals"), + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + )); + } + + #[test] + fn slots_walk_the_grid_inside_the_opening() { + let now = at(0, 0); + let entries = exact_time_slots( + &exact(), + &members(2), + &[], + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + now, + ); + let starts: Vec<_> = entries.iter().map(entry_instant).collect(); + assert_eq!(starts, vec![at(2, 0), at(2, 30), at(3, 0), at(3, 30)]); + assert!(entries.iter().all(|entry| match entry { + AvailabilityEntry::Slot { free, .. } => *free == 2, + _ => false, + })); + } + + #[test] + fn a_slot_the_lead_time_excludes_is_not_listed() { + let now = at(2, 15); + let entries = exact_time_slots( + &exact(), + &members(1), + &[], + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + now, + ); + // The earliest bookable start is 03:15 on the grid, so 03:30 is first. + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(3, 30)] + ); + } + + #[test] + fn a_booking_occupying_a_member_reduces_its_free_count() { + let snapshot = LedgerSnapshot { + claims: vec![booking_of("booking-1", "station-1", 2, 3)], + }; + let entries = exact_time_slots( + &exact(), + &members(2), + &[], + &[interval(2, 4)], + &snapshot, + at(1, 0), + at(5, 0), + at(0, 0), + ); + let by_start = |start: DateTime| { + entries + .iter() + .find(|entry| entry_instant(entry) == start) + .expect("the slot is listed") + }; + match by_start(at(3, 0)) { + AvailabilityEntry::Slot { free, .. } => assert_eq!(*free, 1), + _ => panic!("expected a slot"), + } + match by_start(at(2, 0)) { + AvailabilityEntry::Slot { free, .. } => assert_eq!(*free, 2), + _ => panic!("expected a slot"), + } + } + + #[test] + fn slots_follow_the_effective_openings_a_reopening_restored() { + // The openings are the effective-open truth: a closure that an + // authorized reopening partly restores has already been subtracted + // and added back here, so the walk does not filter by raw closures + // again and strike out the restored time. + let entries = exact_time_slots( + &exact(), + &members(1), + &[], + &[ + interval(2, 3), + CalendarInterval { + start: at(3, 30), + end: at(4, 0), + }, + ], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(2, 0), at(2, 30), at(3, 30)] + ); + } + + #[test] + fn a_slot_without_a_free_capable_member_is_not_availability() { + let capable = [PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: true, + }]; + let requires = vec!["interpreter".to_owned()]; + // Only an incapable member is free: the slot is not listed, because + // admission would refuse every start it advertised. + let entries = exact_time_slots( + &exact(), + &members(1), + &requires, + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert!(entries.is_empty()); + // One capable member among two: the free count names only it. + let mut mixed = members(2); + mixed.extend_from_slice(&capable); + let entries = exact_time_slots( + &exact(), + &mixed, + &requires, + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert!(entries.iter().all(|entry| match entry { + AvailabilityEntry::Slot { free, .. } => *free == 1, + _ => false, + })); + } + + #[test] + fn a_fully_occupied_slot_is_not_availability() { + let snapshot = LedgerSnapshot { + claims: vec![ + booking_of("booking-1", "station-1", 2, 3), + booking_of("booking-2", "station-2", 2, 3), + ], + }; + let entries = exact_time_slots( + &exact(), + &members(2), + &[], + &[interval(2, 4)], + &snapshot, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert!(entries.iter().all(|entry| entry_instant(entry) != at(3, 0))); + } + + #[test] + fn slots_anchor_on_each_opening_not_a_global_grid() { + // Two openings whose starts are off each other's grid: each anchors + // its own slots. + let entries = exact_time_slots( + &exact(), + &members(1), + &[], + &[ + interval(2, 3), + CalendarInterval { + start: at(3, 15), + end: at(4, 15), + }, + ], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(2, 0), at(2, 30), at(3, 15), at(3, 45)] + ); + } + + #[test] + fn slots_jump_to_the_requested_part_of_a_long_opening() { + let opening = CalendarInterval { + start: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2035, 12, 31, 0, 0, 0).unwrap(), + }; + let now = Utc.with_ymd_and_hms(2035, 12, 1, 0, 0, 0).unwrap(); + let from = Utc.with_ymd_and_hms(2035, 12, 2, 10, 7, 0).unwrap(); + let to = Utc.with_ymd_and_hms(2035, 12, 2, 11, 0, 0).unwrap(); + let entries = exact_time_slots( + &exact(), + &members(1), + &[], + &[opening], + &LedgerSnapshot { claims: Vec::new() }, + from, + to, + now, + ); + + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![Utc.with_ymd_and_hms(2035, 12, 2, 10, 30, 0).unwrap()] + ); + } + + #[test] + fn page_limits_clamp_to_the_published_bound() { + assert_eq!(page_limit(None), DEFAULT_PAGE_LIMIT); + assert_eq!(page_limit(Some(0)), 1); + assert_eq!(page_limit(Some(10_000)), MAXIMUM_PAGE_LIMIT); + } + + #[test] + fn problem_receipts_carry_the_pinned_problem_without_a_trace() { + let receipt = problem_receipt(ProblemCode::CapacityExhausted); + let problem = &receipt["problem"]; + assert_eq!(problem["code"], "capacity.exhausted"); + assert_eq!(problem["status"], 409); + assert!(problem.get("traceId").is_none()); + } + + #[test] + fn commit_errors_project_to_their_public_codes() { + assert_eq!( + problem_of(&CommitError::HoldCeiling), + ProblemCode::CapacityExhausted + ); + assert_eq!( + problem_of(&CommitError::Unauthorized), + ProblemCode::OperationNotAuthorized + ); + assert_eq!( + problem_of(&CommitError::CutoffPassed), + ProblemCode::CancellationCutoffPassed + ); + assert_eq!( + problem_of(&CommitError::KeyReused), + ProblemCode::IdempotencyKeyReused + ); + } + + #[test] + fn the_detailed_code_stays_behind_the_explain_path() { + let refusal = registry_scheduling_core::AdmissionRefusal::ResourceUnavailable; + assert_eq!(refusal.public_code(), ProblemCode::CapacityExhausted); + assert_eq!(refusal.detailed_code(), ProblemCode::ResourceUnavailable); + // Every other refusal discloses nothing extra. + assert_eq!( + registry_scheduling_core::AdmissionRefusal::CapacityExhausted.detailed_code(), + registry_scheduling_core::AdmissionRefusal::CapacityExhausted.public_code() + ); + } + + #[test] + fn a_success_replay_receipt_projects_through_the_same_document() { + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: crate::store::ClaimState::Active, + offering: "registry-update-30".to_owned(), + supply_id: "station-1".to_owned(), + channel: None, + displayed_start: at(2, 0), + displayed_end: at(2, 30), + occupied_start: at(1, 55), + occupied_end: at(2, 35), + units: 1, + duplicate_key: None, + hold_expires_at: None, + revision: 1, + policy_revision: 4, + actor: "actor-pseudonym".to_owned(), + reason: None, + created_at: at(0, 0), + closed_at: None, + }; + let receipt = json!({ + "kind": "booking", + "claim": claim.clone(), + "resource": "station-1", + "policyRevision": 4 + }); + let answer: CommitmentAnswer = CommitmentAnswer::Replay { + status_code: 201, + receipt, + }; + let replayed = claim_or_replay(answer, |replayed, receipt| { + ( + replayed.clone(), + receipt + .and_then(|value| value["resource"].as_str()) + .map(str::to_owned), + ) + }); + match replayed { + CommitmentAnswer::Minted((replayed, resource)) => { + assert_eq!(replayed, claim); + assert_eq!(resource.as_deref(), Some("station-1")); + } + CommitmentAnswer::Replay { .. } => panic!("a claim receipt is a minted answer"), + } + + let current_policy: SchedulingPolicy = serde_json::from_value(json!({ + "apiVersion": "registry.registrystack.org/scheduling-policy-package/v1alpha1", + "kind": "SchedulingPolicyPackage", + "scheduling": {"id": "test", "version": 99}, + "services": [], + "offerings": [], + "holidaySets": [], + "openings": [], + "holdPolicy": {"ttlMinutes": 5, "maxPerCaller": 1, "because": "test"} + })) + .expect("a policy used only for replay projection"); + let replay_receipt = json!({ + "kind": "booking", + "claim": claim.clone(), + "resource": "station-1", + "policyRevision": 4 + }); + let document = appointment_document(&claim, ¤t_policy, 99, Some(&replay_receipt)); + assert_eq!(document.resource.as_deref(), Some("station-1")); + assert_eq!(document.policy_revision, 4); + + let refused: CommitmentAnswer = CommitmentAnswer::Replay { + status_code: 409, + receipt: problem_receipt(ProblemCode::CapacityExhausted), + }; + assert!(matches!( + claim_or_replay(refused, |claim, _| claim.clone()), + CommitmentAnswer::Replay { + status_code: 409, + .. + } + )); + } +} diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs new file mode 100644 index 0000000000..6eb0e5d489 --- /dev/null +++ b/crates/registry-scheduling/src/store.rs @@ -0,0 +1,3293 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The PostgreSQL store: schema, the capacity transactions, and the worker +//! queries. +//! +//! Every commitment lands through one transaction shape: +//! +//! 1. `SELECT ... FOR UPDATE` on the supply row that anchors the decision: +//! the resource pool for an exact-time offering, the published window for +//! an arrival window. +//! 2. A ledger snapshot whose hold expiry is evaluated *in the query*: a +//! claim consumes capacity when it is an active booking, or an active +//! hold whose `hold_expires_at` is still ahead of the observed now. A +//! delayed cleanup worker therefore cannot keep an expired hold alive. +//! 3. The revision guards, with `policy.changed` winning: the revision of +//! the policy this process actually evaluated must still be the stored +//! one, and the facts the request resolved must not have been replaced +//! by a records swap. Both are checked under the anchor, after every +//! lock wait, so a commitment never carries a stale evaluation forward. +//! 4. The pure evaluators from `registry-scheduling-core`, in memory, inside +//! the lock but never doing I/O. The task grant's expiry is re-checked +//! here too, against a fresh observation of the clock taken after the +//! waits, so a grant that lapses before the commit never books. +//! 5. The claim, the idempotency attempt, the history event, and the outbox +//! rows, written in that same transaction. The lifecycle writes carry +//! the observed revision and the active state in their own predicates, +//! so a second writer that accepted the same observed state changes +//! nothing. +//! 6. Commit. +//! +//! Workers claim due work with `FOR UPDATE SKIP LOCKED`, a limit, and a +//! dispatch lease: a claimed intent is not claimable again until its lease +//! passes, and an outcome write only lands for the attempt that owns it. + +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; +use registry_platform_calendar::CalendarInterval; +use registry_platform_config::SecretResolver; +use registry_scheduling_core::{ + assess_window_record_impact, evaluate_exact_time_admission, evaluate_hold_state, + evaluate_window_admission, AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, + LedgerSnapshot, PoolMember, SchedulingFacts, SchedulingPolicy, APPOINTMENT_CANCELLED_TRIGGER, + APPOINTMENT_CONFIRMED_TRIGGER, APPOINTMENT_RESCHEDULED_TRIGGER, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; +use tokio_postgres::{Config as PgConfig, Row}; +use uuid::Uuid; + +use crate::config::{describe_secret_failure, DatabaseConfig}; +use crate::hooks::{ActivatedHooks, HookCaptureError}; + +const SCHEDULING_MIGRATION: &str = include_str!("../migrations/0001_scheduling.sql"); +const FACTS_REVISION_MIGRATION: &str = + include_str!("../migrations/0002_facts_revision_and_suppressed.sql"); +const HOOK_DELIVERY_MIGRATION_VERSION: i64 = 3; +const POLICY_DOCUMENT_MIGRATION: &str = include_str!("../migrations/0004_policy_document.sql"); +const POLICY_DOCUMENT_MIGRATION_VERSION: i64 = 4; +const WINDOW_RECORDS_MIGRATION: &str = include_str!("../migrations/0005_window_records.sql"); +const WINDOW_RECORDS_MIGRATION_VERSION: i64 = 5; +const WINDOW_REVISION_HEADS_MIGRATION: &str = + include_str!("../migrations/0006_window_revision_heads.sql"); +const WINDOW_REVISION_HEADS_MIGRATION_VERSION: i64 = 6; + +/// Every schema version in ledger order. +const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; +const SCHEMA_VERSIONS: [i64; 6] = [ + 1, + 2, + HOOK_DELIVERY_MIGRATION_VERSION, + POLICY_DOCUMENT_MIGRATION_VERSION, + WINDOW_RECORDS_MIGRATION_VERSION, + WINDOW_REVISION_HEADS_MIGRATION_VERSION, +]; + +/// Serializes operator-run migrations on one session lock. A second migrator +/// waits here instead of racing the ledger primary key. The key spells the +/// ASCII bytes of "sched". +const MIGRATION_LOCK_KEY: i64 = 0x7363_6865_6475_6c65; + +/// The advisory-lock namespace the hold ceiling serializes one caller in. The +/// second half of the key is the caller's pseudonym, which is already keyed to +/// this deployment, so two deployments sharing a database do not serialize each +/// other. The lock is a transaction lock: PostgreSQL releases it when the +/// capacity transaction ends, committed or rolled back. The namespace spells +/// the ASCII bytes of "SCHD". +const HOLD_CEILING_LOCK_NAMESPACE: i32 = 0x5343_4844; + +/// The send deadline must fit inside the remaining dispatch lease. +pub(crate) const REMINDER_SEND_TIMEOUT: Duration = Duration::from_secs(5); + +/// The clock the store reads when it re-checks authorization currency. +/// Production observes the system clock; the database suite pins one to +/// cross an expiry boundary deterministically inside a transaction. The +/// clock sits behind a shared lock so every handle cloned from one store +/// reads the same time. +pub type Clock = std::sync::Arc DateTime + Send + Sync>; +type SharedClock = std::sync::Arc>; + +fn system_clock() -> SharedClock { + std::sync::Arc::new(std::sync::RwLock::new(std::sync::Arc::new(Utc::now))) +} + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("the Scheduling database configuration is invalid")] + Configuration, + #[error("the Scheduling database secret could not be resolved: {0}")] + SecretConfiguration(String), + #[error("the Scheduling database is not in the state this runtime expects")] + Corrupt, + #[error("the Scheduling database belongs to another deployment")] + DeploymentIdentity, + /// The environment records would retire or reduce supply that live + /// appointments or holds still occupy. The swap is refused whole, so the + /// operator either keeps the supply or closes what stands on it first. + #[error( + "the environment records retire, move, or reduce {0}, which live appointments or holds still occupy" + )] + FactsInUse(String), + #[error( + "window {window} changed without advancing its revision beyond {current}; proposed revision is {proposed}" + )] + WindowRevision { + window: String, + current: u64, + proposed: u64, + }, + #[error("the proposed policy would strand standing commitments: {0}")] + PolicyInUse(String), + // Both carry the driver's own account of what went wrong. Neither the + // pool nor the driver repeats the connection string in its message, so + // naming the cause costs no credential. + #[error("the Scheduling query failed: {0}")] + Query(#[from] tokio_postgres::Error), + #[error("the Scheduling database connection could not be established: {0}")] + Pool(#[from] deadpool_postgres::PoolError), +} + +#[derive(Clone)] +pub struct PostgresStore { + pool: Pool, + clock: SharedClock, +} + +impl PostgresStore { + /// One observation of the store's clock: the time an authorization + /// re-check inside a transaction is decided under. + fn observed_now(&self) -> DateTime { + let clock = self + .clock + .read() + .expect("the store clock is never held across a panic"); + clock() + } + + /// The task-grant re-check at decision time: one fresh observation of + /// the store's clock, taken after every wait and immediately before the + /// final writes, so a grant that lapsed while this transaction waited + /// for a lock never commits. The rest of the transaction keeps the + /// request's own single `now`; this is the one authorization-currency + /// exception to it. + fn recheck_grant(&self, commitment: &Commitment<'_>) -> Result<(), CommitError> { + check_grant_current_at(commitment, self.observed_now()) + } + + /// Pin the clock the store's authorization re-checks read. Test-only: + /// production always observes the system clock. Every handle cloned + /// from this store reads the pinned time. + #[cfg(feature = "postgres-test")] + pub fn pin_clock(&self, clock: Clock) { + *self + .clock + .write() + .expect("the store clock is never held across a panic") = clock; + } +} + +impl std::fmt::Debug for PostgresStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PostgresStore") + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptState { + Completed, + Refused, +} + +impl AttemptState { + const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Refused => "refused", + } + } +} + +/// One appointment or hold claim row. This is also the replay receipt body: +/// a stored attempt carries these fields, so a replayed response is the +/// original response, not a re-derivation from live state. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimRow { + pub claim_id: Uuid, + pub kind: LedgerKind, + pub state: ClaimState, + pub offering: String, + pub supply_id: String, + pub channel: Option, + pub displayed_start: DateTime, + pub displayed_end: DateTime, + pub occupied_start: DateTime, + pub occupied_end: DateTime, + pub units: i32, + pub duplicate_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_expires_at: Option>, + pub revision: i64, + pub policy_revision: i64, + pub actor: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_at: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClaimState { + Active, + Released, + Cancelled, + Consumed, + Expired, +} + +impl ClaimState { + const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Released => "released", + Self::Cancelled => "cancelled", + Self::Consumed => "consumed", + Self::Expired => "expired", + } + } +} + +/// The caller-side facts every commitment carries. `now` is observed once +/// per request and used for every decision the caller reads inside it, so +/// a transaction never mixes two observations of the time it answers with. +/// Authorization currency and transfer of a hold are the two deliberate +/// exceptions. The grant's expiry is re-checked against a fresh observation +/// taken inside the transaction, after every wait and immediately before the +/// final write. A hold's expiry is re-checked after its supply anchor is +/// locked, so capacity another transaction reclaimed after expiry cannot also +/// be transferred into an appointment by an older confirmation request. +pub struct Commitment<'c> { + pub now: DateTime, + /// The revision of the policy this process evaluated, not the stored + /// current one: the capacity transaction verifies the two still agree + /// before it commits, so a claim never carries a revision that names + /// rules it was not evaluated under. + pub policy_revision: i64, + /// The records revision the request resolved its supply under. A + /// wholesale records replacement increments it; a transaction whose + /// resolved facts are older than the stored revision is refused. + pub facts_revision: i64, + /// The pseudonymized actor reference history and audit carry. + pub actor: &'c str, + pub actor_issuer: &'c str, + pub actor_subject: &'c str, + pub idempotency_key: &'c str, + /// The idempotent hash of the request payload. + pub request_hash: &'c str, + pub attempt_expires_at: DateTime, + /// The task grant's `exp`, re-checked inside the capacity transaction. + pub grant_exp_unix: Option, + /// The audit identity of the commitment, written in the same + /// transaction as the state change. + pub audit_event: Uuid, + pub audit_record: Value, + pub hooks: Option<&'c ActivatedHooks>, +} + +/// The policy-resolved supply an admission runs against. +pub enum SupplyContext<'p> { + ExactTime { + exact: &'p registry_scheduling_core::ExactTimeOffering, + members: &'p [PoolMember], + open: &'p [CalendarInterval], + closures: &'p [CalendarInterval], + }, + Window { + window: &'p registry_scheduling_core::PublishedWindow, + lead_time_minutes: u32, + horizon_days: u32, + channels: &'p [registry_scheduling_core::Channel], + open: &'p [CalendarInterval], + closures: &'p [CalendarInterval], + }, +} + +/// What one committed operation produced. +#[derive(Clone, Debug)] +pub enum CommitOutcome { + Booking(ClaimRow), + Hold(ClaimRow), + Cancelled(ClaimRow), + Released, + /// A stored attempt answered instead: replay its receipt exactly. + Replay { + status_code: u16, + receipt: Value, + }, +} + +/// What resolving a stored attempt decided. +enum ReplayOutcome { + Replay { + status_code: u16, + receipt: Value, + }, + /// The key is being misused, or its receipt has been erased past + /// retention. Either way this request does not proceed to a commitment. + Refused(CommitError), +} + +#[derive(Debug, Error)] +pub enum CommitError { + #[error(transparent)] + Store(#[from] StoreError), + /// A statement inside the capacity transaction itself failed. + #[error("the Scheduling query failed")] + Query(#[from] tokio_postgres::Error), + #[error("the Scheduling hook event could not be captured")] + Hooks(#[from] HookCaptureError), + #[error("the admission was refused")] + Refused(#[from] AdmissionRefusal), + #[error("the idempotency key was reused with a different request")] + KeyReused, + #[error("the stored response for this idempotency key has expired")] + KeyExpired, + #[error("the caller holds more active holds than the hold policy allows")] + HoldCeiling, + #[error("the task grant lapsed before the commitment")] + Unauthorized, + #[error("the observed revision does not match the current revision")] + RevisionMismatch, + #[error("the cancellation cutoff has passed")] + CutoffPassed, + /// The environment records were replaced after this request resolved + /// its supply. Nothing was decided; the caller retries against the + /// current records. + #[error("the environment records were replaced while the request was in flight")] + FactsStale, +} + +/// One due or delivered outbox intent. +#[derive(Clone, Debug)] +pub struct OutboxRow { + pub outbox_id: Uuid, + pub purpose: String, + pub claim_id: Uuid, + pub appointment_revision: i64, + pub due_at: DateTime, + pub attempts: i32, + pub payload: Value, +} + +/// One delivery intent no sweep will carry any further, as an operator reads +/// it. The delivery state is what separates the two ways that happens: `local` +/// is a deployment that declares no destination, and `failed` is a destination +/// that refused every attempt the ceiling allowed. +#[derive(Clone, Debug)] +pub struct UndeliveredIntent { + pub outbox_id: Uuid, + pub purpose: String, + pub claim_id: Uuid, + pub appointment_revision: i64, + pub due_at: DateTime, + pub delivery_state: String, + pub attempts: i32, + pub payload: Value, +} + +impl PostgresStore { + pub fn connect_runtime( + config: &DatabaseConfig, + secrets: &SecretResolver, + ) -> Result { + Self::connect_reference( + config, + secrets, + "database.runtimeUrlRef", + &config.runtime_url_ref, + ) + } + + pub fn connect_migration( + config: &DatabaseConfig, + secrets: &SecretResolver, + ) -> Result { + Self::connect_reference( + config, + secrets, + "database.migrationUrlRef", + &config.migration_url_ref, + ) + } + + fn connect_reference( + config: &DatabaseConfig, + secrets: &SecretResolver, + field: &'static str, + reference: &str, + ) -> Result { + let protected = secrets.resolve(reference).map_err(|error| { + StoreError::SecretConfiguration(describe_secret_failure(field, reference, &error)) + })?; + let url = std::str::from_utf8(protected.expose_secret()) + .map_err(|_| StoreError::Configuration)?; + let mut postgres = PgConfig::from_str(url).map_err(|_| StoreError::Configuration)?; + if postgres.get_user().is_none() || postgres.get_dbname().is_none() { + return Err(StoreError::Configuration); + } + let manager_config = ManagerConfig { + recycling_method: RecyclingMethod::Verified, + }; + #[cfg(feature = "postgres-test")] + let manager = if config.test_only_plaintext { + postgres.ssl_mode(tokio_postgres::config::SslMode::Disable); + Manager::from_config(postgres, tokio_postgres::NoTls, manager_config) + } else { + postgres.ssl_mode(tokio_postgres::config::SslMode::Require); + let connector = tls_connector(config, secrets)?; + Manager::from_config(postgres, connector, manager_config) + }; + #[cfg(not(feature = "postgres-test"))] + let manager = { + if config.test_only_plaintext { + return Err(StoreError::Configuration); + } + postgres.ssl_mode(tokio_postgres::config::SslMode::Require); + let connector = tls_connector(config, secrets)?; + Manager::from_config(postgres, connector, manager_config) + }; + let pool = Pool::builder(manager) + .max_size(32) + .wait_timeout(Some(Duration::from_secs(5))) + .create_timeout(Some(Duration::from_secs(5))) + .recycle_timeout(Some(Duration::from_secs(5))) + .runtime(Runtime::Tokio1) + .build() + .map_err(|_| StoreError::Configuration)?; + Ok(Self { + pool, + clock: system_clock(), + }) + } + + async fn client(&self) -> Result { + self.pool.get().await.map_err(StoreError::Pool) + } + + pub(crate) async fn hook_delivery_client( + &self, + ) -> Result { + self.client().await + } + + pub(crate) async fn schema_name(&self) -> Result { + let client = self.client().await?; + let schema: String = client + .query_one("SELECT current_schema()", &[]) + .await? + .get(0); + let valid = !schema.is_empty() + && schema.len() <= 63 + && schema + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && schema + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if valid { + Ok(schema) + } else { + Err(StoreError::Corrupt) + } + } + + pub async fn migrate(&self) -> Result<(), StoreError> { + let mut client = self.client().await?; + client + .query_one("SELECT pg_advisory_lock($1)", &[&MIGRATION_LOCK_KEY]) + .await?; + let applied = Self::apply_migrations(&mut client).await; + let released = client + .query_one("SELECT pg_advisory_unlock($1)", &[&MIGRATION_LOCK_KEY]) + .await; + applied?; + if released?.get::<_, bool>(0) { + Ok(()) + } else { + Err(StoreError::Corrupt) + } + } + + async fn apply_migrations(client: &mut deadpool_postgres::Client) -> Result<(), StoreError> { + let transaction = client.transaction().await?; + transaction + .batch_execute( + "CREATE TABLE IF NOT EXISTS scheduling_schema_migrations (\ + version bigint PRIMARY KEY CHECK (version > 0),\ + applied_at timestamptz NOT NULL);", + ) + .await?; + transaction.commit().await?; + + for (version, migration) in MIGRATIONS { + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&version], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(migration).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&version], + ) + .await?; + } + transaction.commit().await?; + } + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&HOOK_DELIVERY_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + let schema: String = transaction + .query_one("SELECT current_schema()", &[]) + .await? + .get(0); + let valid = !schema.is_empty() + && schema.len() <= 63 + && schema + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && schema + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + return Err(StoreError::Corrupt); + } + registry_platform_hooks::delivery_schema::install(&*transaction, &schema).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&HOOK_DELIVERY_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&POLICY_DOCUMENT_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(POLICY_DOCUMENT_MIGRATION).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&POLICY_DOCUMENT_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&WINDOW_RECORDS_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(WINDOW_RECORDS_MIGRATION).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&WINDOW_RECORDS_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&WINDOW_REVISION_HEADS_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction + .batch_execute(WINDOW_REVISION_HEADS_MIGRATION) + .await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&WINDOW_REVISION_HEADS_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; + Ok(()) + } + + pub async fn ready(&self) -> Result<(), StoreError> { + let client = self.client().await?; + let applied = client + .query( + "SELECT version FROM scheduling_schema_migrations ORDER BY version", + &[], + ) + .await?; + let schema_is_current = applied.len() == SCHEMA_VERSIONS.len() + && applied + .iter() + .zip(SCHEMA_VERSIONS.iter()) + .all(|(row, expected)| { + row.try_get::<_, i64>(0) + .is_ok_and(|version| version == *expected) + }); + if schema_is_current { + Ok(()) + } else { + Err(StoreError::Corrupt) + } + } + + /// Adopt this database for one deployment identity. A fresh database + /// carries the empty id the migration seeds; the first adopt claims it + /// for the policy's scheduling id, an adopt under the same id again is a + /// no-op, and an adopt under a different id is refused: two deployments + /// never share one database silently. + pub async fn adopt(&self, scheduling_id: &str) -> Result<(), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let row = transaction + .query_one( + "SELECT scheduling_id FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await?; + let stored: String = row.get(0); + if stored.is_empty() { + transaction + .execute( + "UPDATE scheduling_meta SET scheduling_id=$1, updated_at=now() WHERE singleton", + &[&scheduling_id], + ) + .await?; + } else if stored != scheduling_id { + return Err(StoreError::Corrupt); + } + transaction.commit().await?; + Ok(()) + } + + /// The deployment identity and current policy revision. + pub async fn scheduling_meta(&self) -> Result<(String, i64, String), StoreError> { + let client = self.client().await?; + let row = client + .query_one( + "SELECT scheduling_id, policy_revision, policy_digest FROM scheduling_meta WHERE singleton", + &[], + ) + .await?; + Ok(( + row.get::<_, String>(0), + row.get::<_, i64>(1), + row.get::<_, String>(2), + )) + } + + /// Publish a policy after proving it does not strand standing commitments + /// or move their offering to different supply. Re-applying the same digest + /// backfills its retained policy document and remains a no-op for the + /// revision. + /// + /// Lock order, load-bearing: policy publication takes every existing + /// supply anchor in order before `scheduling_meta`, the same order as the + /// records swap. Capacity transactions take one anchor and then the meta + /// row, so an older runtime cannot add a commitment between this method's + /// impact assessment and publication. + pub async fn apply_policy( + &self, + scheduling_id: &str, + policy_digest: &str, + pool_ids: &[String], + policy: &SchedulingPolicy, + ) -> Result { + if policy.policy_digest() != policy_digest { + return Err(StoreError::Corrupt); + } + let policy_document = serde_json::to_value(policy).map_err(|_| StoreError::Corrupt)?; + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .execute( + "SELECT supply_id FROM scheduling_supply ORDER BY supply_id FOR UPDATE", + &[], + ) + .await?; + let row = transaction + .query_one( + "SELECT scheduling_id, policy_revision, policy_digest FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await?; + if row.get::<_, String>(0) != scheduling_id { + return Err(StoreError::Corrupt); + } + let revision; + let stored_revision = row.get::<_, i64>(1); + let stored_digest = row.get::<_, String>(2); + if stored_digest != policy_digest { + if !stored_digest.is_empty() { + let current_document: Option = transaction + .query_opt( + "SELECT policy_document FROM scheduling_policy_revisions \ + WHERE policy_revision=$1 AND policy_digest=$2", + &[&stored_revision, &stored_digest], + ) + .await? + .and_then(|stored| stored.get(0)); + let Some(mut current_document) = current_document else { + return Err(StoreError::PolicyInUse( + "the current policy document is unavailable; reapply the current policy before publishing a change" + .to_owned(), + )); + }; + // Earlier branch builds retained mutable window records inside + // the policy document. Preserve that historical document and + // digest, but ignore its legacy member while comparing the + // lifecycle terms governed by the current policy shape. + if let Some(document) = current_document.as_object_mut() { + document.remove("windows"); + } + let current: SchedulingPolicy = + serde_json::from_value(current_document).map_err(|_| StoreError::Corrupt)?; + let now = self.observed_now(); + let active_offerings = transaction + .query( + "SELECT DISTINCT offering FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $1)) \ + ORDER BY offering", + &[&now], + ) + .await?; + for active in active_offerings { + let offering_id: String = active.get(0); + let Some(current_offering) = current.offering(&offering_id) else { + return Err(StoreError::PolicyInUse(format!( + "offering {offering_id} has active commitments but is absent from the retained current policy" + ))); + }; + let retained = policy.offering(&offering_id).is_some_and(|proposed| { + let same_supply = match ( + ¤t_offering.exact_time, + &proposed.exact_time, + ¤t_offering.arrival, + &proposed.arrival, + ) { + (Some(current), Some(next), None, None) => current.pool == next.pool, + (None, None, Some(current), Some(next)) => { + current.window == next.window + } + _ => false, + }; + proposed.service == current_offering.service + && proposed.location == current_offering.location + && proposed.mode == current_offering.mode + && same_supply + }); + if !retained { + return Err(StoreError::PolicyInUse(format!( + "offering {offering_id} has active commitments and must retain its service, location, mode, and supply" + ))); + } + } + } + revision = row.get::<_, i64>(1) + 1; + transaction + .execute( + "INSERT INTO scheduling_policy_revisions(\ + policy_revision, policy_digest, policy_document) VALUES($1,$2,$3)", + &[&revision, &policy_digest, &policy_document], + ) + .await?; + transaction + .execute( + "UPDATE scheduling_meta SET policy_revision=$1, policy_digest=$2, \ + updated_at=now() WHERE singleton", + &[&revision, &policy_digest], + ) + .await?; + } else { + revision = stored_revision; + transaction + .execute( + "UPDATE scheduling_policy_revisions SET policy_document=$2 \ + WHERE policy_revision=$1 AND policy_document IS NULL", + &[&revision, &policy_document], + ) + .await?; + } + for id in pool_ids { + transaction + .execute( + "INSERT INTO scheduling_supply(supply_id, kind) VALUES($1,$2) \ + ON CONFLICT(supply_id) DO NOTHING", + &[id, &"pool"], + ) + .await?; + } + transaction.commit().await?; + Ok(revision) + } + + /// The environment records an admission runs against, with the records + /// revision they were read under. The whole read shares one repeatable + /// snapshot, so no records replacement can land between the members and + /// the exceptions and hand a caller a torn combination; the revision + /// travels with the facts into the capacity transaction, which refuses + /// to commit against records a replacement has since superseded. + pub async fn facts(&self) -> Result<(SchedulingFacts, i64), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .batch_execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .await?; + let revision: i64 = transaction + .query_one( + "SELECT facts_revision FROM scheduling_meta WHERE singleton", + &[], + ) + .await? + .get(0); + let locations = transaction + .query( + "SELECT location_id, timezone FROM scheduling_locations", + &[], + ) + .await?; + let members = transaction + .query( + "SELECT resource_id, pool_id, capabilities, available \ + FROM scheduling_pool_members ORDER BY pool_id, resource_id", + &[], + ) + .await?; + let pools = transaction + .query("SELECT pool_id FROM scheduling_pools ORDER BY pool_id", &[]) + .await?; + let windows = transaction + .query( + "SELECT window_record FROM scheduling_windows ORDER BY window_id", + &[], + ) + .await?; + let exceptions = transaction + .query( + "SELECT exception_id, location, kind, date::text, start_time, end_time, \ + reopens, authority FROM scheduling_exceptions", + &[], + ) + .await?; + let windows = windows + .iter() + .map(|row| { + serde_json::from_value::(row.get(0)) + .map_err(|_| StoreError::Corrupt) + }) + .collect::, _>>()?; + let facts = SchedulingFacts { + locations: locations + .iter() + .map(|row| registry_scheduling_core::LocationRecord { + id: row.get(0), + timezone: row.get(1), + }) + .collect(), + pools: pools + .iter() + .map(|pool| registry_scheduling_core::ResourcePool { + id: pool.get(0), + members: members + .iter() + .filter(|member| member.get::<_, String>(1) == pool.get::<_, String>(0)) + .map(|member| registry_scheduling_core::PoolMember { + resource_id: member.get(0), + capabilities: member.get(2), + available: member.get(3), + }) + .collect(), + }) + .collect(), + windows, + exceptions: exceptions + .iter() + .map(|row| registry_scheduling_core::CalendarExceptionRecord { + id: row.get(0), + location: row.get(1), + kind: match row.get::<_, String>(2).as_str() { + "closure" => registry_scheduling_core::ExceptionRecordKind::Closure, + _ => registry_scheduling_core::ExceptionRecordKind::Opening, + }, + date: row.get(3), + start_time: row.get(4), + end_time: row.get(5), + reopens: row.get(6), + authority: row.get(7), + }) + .collect(), + }; + Ok((facts, revision)) + } + + /// Replace the environment records wholesale. This is the operator + /// tooling path (`schedulingctl records apply`): one attributable write + /// that swaps locations, pools, members, windows, and exceptions atomically. + pub async fn replace_facts( + &self, + scheduling_id: &str, + facts: &SchedulingFacts, + audit_event: Uuid, + audit_record: Value, + ) -> Result<(), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + replace_facts_in_transaction( + &transaction, + scheduling_id, + facts, + audit_event, + audit_record, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + /// Mint a listing cursor. + pub async fn insert_cursor( + &self, + cursor_id: Uuid, + context: &str, + position: Value, + expires_at: DateTime, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "INSERT INTO scheduling_cursors(cursor_id, context, position, expires_at) \ + VALUES($1,$2,$3,$4)", + &[&cursor_id, &context, &position, &expires_at], + ) + .await?; + Ok(()) + } + + /// Resolve one listing cursor by id. + pub async fn cursor(&self, cursor_id: Uuid) -> Result, StoreError> { + let client = self.client().await?; + let row = client + .query_opt( + "SELECT context, position, expires_at FROM scheduling_cursors WHERE cursor_id=$1", + &[&cursor_id], + ) + .await?; + Ok(row.map(|row| { + json!({ + "context": row.get::<_, String>(0), + "position": row.get::<_, Value>(1), + "expiresAt": row.get::<_, DateTime>(2), + }) + })) + } + + /// One appointment or hold claim by id. + pub async fn claim(&self, claim_id: Uuid) -> Result, StoreError> { + let client = self.client().await?; + let row = client.query_opt(SELECT_CLAIM, &[&claim_id]).await?; + row.map(map_claim_row).transpose() + } + + /// The history of one claim, newest first, bounded by the listing limit. + /// A page resumes strictly before the (occurred_at, event_id) pair its + /// last row carries, so two events sharing an instant still page + /// deterministically. + pub async fn claim_history( + &self, + claim_id: Uuid, + before: Option<(DateTime, Uuid)>, + limit: i64, + ) -> Result, StoreError> { + let (before_at, before_id) = match before { + Some((at, id)) => (Some(at), Some(id)), + None => (None, None), + }; + let client = self.client().await?; + let rows = client + .query( + "SELECT event_id, revision, kind, occurred_at, actor, detail \ + FROM scheduling_history WHERE claim_id=$1 \ + AND ($3::timestamptz IS NULL OR (occurred_at, event_id) < ($3::timestamptz, $4::uuid)) \ + ORDER BY occurred_at DESC, event_id DESC LIMIT $2", + &[&claim_id, &limit, &before_at, &before_id], + ) + .await?; + Ok(rows + .iter() + .map(|row| { + json!({ + "eventId": row.get::<_, Uuid>(0), + "revision": row.get::<_, i64>(1), + "kind": row.get::<_, String>(2), + "occurredAt": row.get::<_, DateTime>(3), + "actor": row.get::<_, String>(4), + "detail": row.get::<_, Value>(5), + }) + }) + .collect()) + } + + /// List pool members page-wise, resuming after `after_id`. + pub async fn list_resources( + &self, + after_id: Option<&str>, + limit: i64, + ) -> Result, bool)>, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT resource_id, pool_id, capabilities, available \ + FROM scheduling_pool_members \ + WHERE ($1::text IS NULL OR resource_id > $1) \ + ORDER BY resource_id LIMIT $2", + &[&after_id, &limit], + ) + .await?; + Ok(rows + .iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3))) + .collect()) + } + + /// List locations page-wise, resuming after `after_id`. + pub async fn list_locations( + &self, + after_id: Option<&str>, + limit: i64, + ) -> Result, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT location_id, timezone FROM scheduling_locations \ + WHERE ($1::text IS NULL OR location_id > $1) \ + ORDER BY location_id LIMIT $2", + &[&after_id, &limit], + ) + .await?; + Ok(rows.iter().map(|row| (row.get(0), row.get(1))).collect()) + } + + /// A read-only member snapshot for availability and explain. No lock: + /// nothing here commits, so committed rows are read as they stand. + pub async fn member_snapshot( + &self, + members: &[String], + from: DateTime, + to: DateTime, + now: DateTime, + ) -> Result { + let client = self.client().await?; + let rows = client + .query(CONSUMING_CLAUSES, &[&members, &from, &to, &now]) + .await?; + Ok(snapshot_from_rows(rows)) + } + + /// A read-only window snapshot for availability and explain. + pub async fn window_snapshot( + &self, + window_id: &str, + now: DateTime, + ) -> Result { + let client = self.client().await?; + let rows = client + .query( + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, occupied_end, \ + units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $2)) \ + AND supply_id = $1 \ + ORDER BY occupied_start", + &[&window_id, &now], + ) + .await?; + Ok(snapshot_from_rows(rows)) + } + + /// Mint a hold through the full capacity transaction. + pub async fn create_hold( + &self, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + ttl_minutes: u32, + max_per_caller: u32, + commitment: Commitment<'_>, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, "hold:create").await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + // The caller lock is taken after the supply lock, never before: every + // hold transaction acquires the two in that one order, so no pair of + // them can hold what the other is waiting for. + if transaction + .lock_caller_and_count_active_holds(commitment.actor, commitment.now) + .await? + >= i64::from(max_per_caller) + { + return Err(CommitError::HoldCeiling); + } + guard_revisions(&transaction, &commitment).await?; + let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; + // The last authorization fact before the writes: the grant must + // still stand at the time this transaction decides, observed after + // every wait rather than at the door. + self.recheck_grant(&commitment)?; + let expires_at = commitment + .now + .checked_add_signed(TimeDelta::minutes(i64::from(ttl_minutes))) + .ok_or(AdmissionRefusal::ScheduleUnpublished)?; + let hold_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: hold_id, + kind: LedgerKind::Hold, + state: ClaimState::Active, + offering: &admission.offering, + supply_id: admission + .resource + .as_deref() + .unwrap_or(window_supply_id(supply)), + channel: request.channel.as_deref(), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + units: i32::try_from(admission.units).unwrap_or(i32::MAX), + duplicate_key: request.duplicate_key.as_deref(), + hold_expires_at: Some(expires_at), + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + transaction + .insert_history( + hold_id, + 1, + "held", + commitment.now, + commitment.actor, + json!({"expiresAt": expires_at, "offering": admission.offering}), + ) + .await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + "hold:create", + AttemptState::Completed, + 201, + claim_receipt( + "hold", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Hold(claim)) + } + + /// Create an appointment directly, through the full capacity + /// transaction. + pub async fn create_appointment( + &self, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + commitment: Commitment<'_>, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, "appointment:create").await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + guard_revisions(&transaction, &commitment).await?; + let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; + self.recheck_grant(&commitment)?; + let appointment_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: appointment_id, + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: &admission.offering, + supply_id: admission + .resource + .as_deref() + .unwrap_or(window_supply_id(supply)), + channel: request.channel.as_deref(), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + units: i32::try_from(admission.units).unwrap_or(i32::MAX), + duplicate_key: request.duplicate_key.as_deref(), + hold_expires_at: None, + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + transaction + .insert_history( + appointment_id, + 1, + "confirmed", + commitment.now, + commitment.actor, + json!({"offering": admission.offering, "start": admission.start}), + ) + .await?; + transaction + .insert_outbox( + "confirmation", + appointment_id, + 1, + commitment.now, + confirmation_payload(&claim, commitment.policy_revision), + ) + .await?; + mint_reminders(&transaction, &claim, offering, commitment.now).await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CONFIRMED_TRIGGER, &claim) + .await?; + } + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + "appointment:create", + AttemptState::Completed, + 201, + claim_receipt( + "booking", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(claim)) + } + + /// Confirm a held allocation: the hold converts to an appointment in one + /// transaction. The hold's own allocation is the reservation; the + /// confirm re-checks expiry, policy identity, and the grant, then + /// consumes the hold and writes the booking beside it. + pub async fn confirm_hold( + &self, + hold_id: Uuid, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("hold:{hold_id}:confirm"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + // The snapshot is read under the lock for serialization order, but + // admission is not re-evaluated: the hold's own reservation transfers + // to the booking in this same transaction, so capacity does not + // change. What must hold is identity (the policy revision the hold + // was admitted under is still current), checked with the other + // revision guards below, under the anchor. + let _snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let hold = transaction + .claim_in_transaction(hold_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if hold.kind != LedgerKind::Hold || hold.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + guard_revisions(&transaction, &commitment).await?; + // A confirmation request may have entered while the hold was live but + // reached this anchor after its capacity was reclaimed. Re-read the + // clock only after taking the anchor: accepting the older request time + // here could transfer capacity that a later transaction already + // booked. Other caller-visible decisions keep the request's single + // clock observation. + evaluate_hold_state(&hold_ledger_claim(&hold), self.observed_now())?; + if hold.policy_revision != commitment.policy_revision { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if hold.actor != commitment.actor { + // Confirming another caller's hold is never a state error: it is + // an authorization refusal the audit journal records. + return Err(CommitError::Unauthorized); + } + self.recheck_grant(&commitment)?; + let appointment_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: appointment_id, + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: &hold.offering, + supply_id: &hold.supply_id, + channel: hold.channel.as_deref(), + displayed_start: hold.displayed_start, + displayed_end: hold.displayed_end, + occupied_start: hold.occupied_start, + occupied_end: hold.occupied_end, + units: hold.units, + duplicate_key: hold.duplicate_key.as_deref(), + hold_expires_at: None, + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + if !transaction + .close_claim(hold_id, ClaimState::Consumed, None, hold.revision) + .await? + { + // The hold changed state between the read and this write; the + // whole transaction, booking included, rolls back behind the + // refusal. + return Err(AdmissionRefusal::HoldReleased.into()); + } + transaction + .insert_history( + appointment_id, + 1, + "confirmed", + commitment.now, + commitment.actor, + json!({"confirmedFrom": hold_id, "start": claim.displayed_start}), + ) + .await?; + transaction + .insert_history( + hold_id, + hold.revision + 1, + "consumed", + commitment.now, + commitment.actor, + json!({"appointment": appointment_id}), + ) + .await?; + transaction + .insert_outbox( + "confirmation", + appointment_id, + 1, + commitment.now, + confirmation_payload(&claim, commitment.policy_revision), + ) + .await?; + mint_reminders(&transaction, &claim, offering, commitment.now).await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CONFIRMED_TRIGGER, &claim) + .await?; + } + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 201, + claim_receipt( + "booking", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(claim)) + } + + /// Release a hold early. + pub async fn release_hold( + &self, + hold_id: Uuid, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("hold:{hold_id}:release"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let hold = transaction + .claim_in_transaction(hold_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if hold.kind != LedgerKind::Hold || hold.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + if hold.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + self.recheck_grant(&commitment)?; + if !transaction + .close_claim(hold_id, ClaimState::Released, None, hold.revision) + .await? + { + return Err(AdmissionRefusal::HoldReleased.into()); + } + transaction + .insert_history( + hold_id, + hold.revision + 1, + "released", + commitment.now, + commitment.actor, + json!({}), + ) + .await?; + transaction.suppress_pending_reminders(hold_id).await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 204, + Value::Null, + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Released) + } + + /// Reschedule an appointment to a new time, keeping its identity. + pub async fn reschedule_appointment( + &self, + appointment_id: Uuid, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + observed_revision: u64, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("appointment:{appointment_id}:reschedule"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let appointment = transaction + .claim_in_transaction(appointment_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + guard_revisions(&transaction, &commitment).await?; + // The policy guard wins over the revision guard: a caller whose + // observed revision is also stale learns the policy moved first. + if request.policy_revision != u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX) + { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if appointment.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } + // The appointment's own allocation is the exclusion: a reschedule + // never competes with the booking it moves. It is read from the row + // this transaction locked, never from the request. + let own_claim_id = appointment.claim_id.to_string(); + let admission = evaluate( + offering, + supply, + request, + &snapshot, + &commitment, + Some(&own_claim_id), + )?; + self.recheck_grant(&commitment)?; + let next_revision = appointment.revision + 1; + if !transaction + .move_claim( + &ClaimMove { + claim_id: appointment_id, + supply_id: admission + .resource + .as_deref() + .unwrap_or(&appointment.supply_id), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + policy_revision: commitment.policy_revision, + next_revision, + }, + appointment.revision, + ) + .await? + { + // A concurrent lifecycle writer moved the appointment first; + // this transaction's whole decision rolls back behind the + // refusal. + return Err(CommitError::RevisionMismatch); + } + transaction + .insert_history(appointment_id, next_revision, "rescheduled", commitment.now, + commitment.actor, + json!({ + "from": {"start": appointment.displayed_start, "end": appointment.displayed_end}, + "to": {"start": admission.start, "end": admission.end}, + })) + .await?; + // A stale unsent reminder describes an appointment that no longer + // exists in this form; it is suppressed, never sent (INT-02). + transaction + .suppress_pending_reminders(appointment_id) + .await?; + let moved = ClaimRow { + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + supply_id: admission.resource.unwrap_or(appointment.supply_id.clone()), + revision: next_revision, + policy_revision: commitment.policy_revision, + ..appointment.clone() + }; + mint_reminders(&transaction, &moved, offering, commitment.now).await?; + transaction + .insert_outbox( + "change", + appointment_id, + next_revision, + commitment.now, + confirmation_payload(&moved, commitment.policy_revision), + ) + .await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_RESCHEDULED_TRIGGER, &moved) + .await?; + } + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 200, + claim_receipt( + "booking", + &moved, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(moved)) + } + + /// Cancel an appointment. Cancellation is a release of capacity: it is + /// guarded by the observed revision and the cutoff, never by the policy + /// revision, so an appointment is always cancellable under the policy + /// that currently governs its offering. + pub async fn cancel_appointment( + &self, + appointment_id: Uuid, + observed_revision: u64, + cancellation_cutoff_minutes: Option, + resource_is_member: bool, + reason: Option<&str>, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("appointment:{appointment_id}:cancel"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let appointment = transaction + .claim_in_transaction(appointment_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + if appointment.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } + if let Some(cutoff) = cancellation_cutoff_minutes { + let earliest_cancel_end = appointment + .displayed_start + .checked_sub_signed(TimeDelta::minutes(i64::from(cutoff))) + .ok_or(CommitError::CutoffPassed)?; + if commitment.now >= earliest_cancel_end { + return Err(CommitError::CutoffPassed); + } + } + self.recheck_grant(&commitment)?; + let next_revision = appointment.revision + 1; + if !transaction + .close_claim( + appointment_id, + ClaimState::Cancelled, + reason, + appointment.revision, + ) + .await? + { + // A concurrent lifecycle writer closed or moved the appointment + // first; nothing this transaction decided stands. + return Err(CommitError::RevisionMismatch); + } + transaction + .insert_history( + appointment_id, + next_revision, + "cancelled", + commitment.now, + commitment.actor, + json!({"reason": reason}), + ) + .await?; + transaction + .suppress_pending_reminders(appointment_id) + .await?; + transaction + .insert_outbox( + "cancellation", + appointment_id, + next_revision, + commitment.now, + json!({ + "appointmentId": appointment_id, + "revision": next_revision, + "reason": reason, + }), + ) + .await?; + let cancelled = ClaimRow { + state: ClaimState::Cancelled, + revision: next_revision, + reason: reason.map(str::to_owned), + closed_at: Some(commitment.now), + ..appointment + }; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CANCELLED_TRIGGER, &cancelled) + .await?; + } + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 200, + claim_receipt( + "booking", + &cancelled, + resource_is_member, + commitment.policy_revision, + ), + ) + .await?; + self.recheck_grant(&commitment)?; + transaction.commit().await?; + Ok(CommitOutcome::Cancelled(cancelled)) + } + + /// Expire holds whose TTL has passed. Each expiry is a state change with + /// its own history event, so the ledger never depends on the sweeper + /// having run for capacity to be free: the snapshot query already + /// discounts expired holds. + pub async fn expire_due_holds( + &self, + now: DateTime, + limit: i64, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let rows = transaction + .query( + "UPDATE scheduling_claims SET state='expired', closed_at=now(), changed_at=now() \ + WHERE claim_id IN (\ + SELECT claim_id FROM scheduling_claims \ + WHERE kind='hold' AND state='active' AND hold_expires_at <= $1 \ + ORDER BY hold_expires_at LIMIT $2 FOR UPDATE SKIP LOCKED)\ + RETURNING claim_id, revision", + &[&now, &limit], + ) + .await?; + for row in &rows { + let claim_id: Uuid = row.get(0); + transaction + .execute( + "INSERT INTO scheduling_history(event_id, claim_id, revision, kind, \ + occurred_at, actor, detail) \ + VALUES($1,$2,$3,'expired',$4,'system','{}')", + &[&Uuid::new_v4(), &claim_id, &row.get::<_, i64>(1), &now], + ) + .await?; + } + transaction.commit().await?; + Ok(rows.len() as u64) + } + + /// Claim due outbox intents for dispatch, marking the attempt and + /// holding each intent for the lease a dispatch pass needs. An intent + /// is due when its own time has come and the back-off a failed attempt + /// wrote has passed: without the second half a failing intent would be + /// re-claimed on every tick and burn its attempts ceiling in seconds. + /// + /// The claim is a durable reservation. `next_attempt_at` moves to the + /// end of the lease, so a second dispatcher, or the same one after a + /// crash, cannot claim the intent again until the lease passes; the + /// outcome writes that follow are fenced by the attempt number this + /// claim stamped, so only the attempt that owns the intent can settle + /// it. The lease must therefore cover a whole claimed batch's worst + /// case, and a dispatcher that dies delays its intents by exactly one + /// lease. + pub async fn claim_due_intents( + &self, + now: DateTime, + limit: i64, + lease: TimeDelta, + ) -> Result, StoreError> { + let leased_until = now + .checked_add_signed(lease) + .ok_or(StoreError::Configuration)?; + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let rows = transaction + .query( + "UPDATE scheduling_outbox SET attempts = attempts + 1, next_attempt_at = $3 \ + WHERE outbox_id IN (\ + SELECT outbox_id FROM scheduling_outbox \ + WHERE delivery_state='pending' AND due_at <= $1 \ + AND next_attempt_at <= $1 \ + ORDER BY due_at LIMIT $2 FOR UPDATE SKIP LOCKED) \ + RETURNING outbox_id, purpose, claim_id, appointment_revision, due_at, attempts, payload", + &[&now, &limit, &leased_until], + ) + .await?; + transaction.commit().await?; + Ok(rows + .iter() + .map(|row| OutboxRow { + outbox_id: row.get(0), + purpose: row.get(1), + claim_id: row.get(2), + appointment_revision: row.get(3), + due_at: row.get(4), + attempts: row.get(5), + payload: row.get(6), + }) + .collect()) + } + + /// Whether the attempt that claimed an intent still owns a dispatch it + /// should carry out: the row must still be pending at that attempt, the + /// remaining lease must cover one more send deadline, and a reminder + /// must still describe its appointment's current revision. A + /// cancellation or reschedule suppresses the row, a superseding attempt + /// means this one no longer speaks for the intent, and a lease that + /// expired means another pass is entitled to reclaim the intent; any of + /// the three skips the send. + pub async fn intent_still_dispatchable( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { + let client = self.client().await?; + let row = client + .query_opt( + "SELECT o.next_attempt_at FROM scheduling_outbox AS o \ + JOIN scheduling_claims AS c ON c.claim_id = o.claim_id \ + WHERE o.outbox_id=$1 AND o.attempts=$2 AND o.attempts > 0 \ + AND o.delivery_state='pending' \ + AND (o.purpose <> 'reminder' \ + OR (c.state='active' AND c.revision = o.appointment_revision))", + &[&outbox_id, &attempts], + ) + .await?; + let send_deadline = self + .observed_now() + .checked_add_signed( + TimeDelta::from_std(REMINDER_SEND_TIMEOUT) + .map_err(|_| StoreError::Configuration)?, + ) + .ok_or(StoreError::Configuration)?; + Ok(row.is_some_and(|row| row.get::<_, DateTime>(0) > send_deadline)) + } + + /// The intents nothing will deliver without an operator, oldest first. + /// + /// A deployment that declares no reminders destination holds every intent + /// locally, and a destination that refused every attempt leaves the intent + /// failed. Both are recorded and then waited on by nobody, so without a + /// read path an operator cannot tell either has happened. Delivered + /// intents are not listed, and neither are pending ones: the sweep is + /// still carrying those. + pub async fn undelivered_intents( + &self, + limit: i64, + ) -> Result, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT outbox_id, purpose, claim_id, appointment_revision, due_at, \ + delivery_state, attempts, payload FROM scheduling_outbox \ + WHERE delivery_state IN ('local','failed') \ + ORDER BY due_at, outbox_id LIMIT $1", + &[&limit], + ) + .await?; + Ok(rows + .iter() + .map(|row| UndeliveredIntent { + outbox_id: row.get(0), + purpose: row.get(1), + claim_id: row.get(2), + appointment_revision: row.get(3), + due_at: row.get(4), + delivery_state: row.get(5), + attempts: row.get(6), + payload: row.get(7), + }) + .collect()) + } + + /// Mark one intent delivered, for the attempt that delivered it. + /// + /// `false` means the write changed nothing: the intent was suppressed + /// or a superseding attempt took it over while this one was in flight. + /// The caller reports that outcome explicitly, because a send whose + /// acknowledgement is lost is a delivery the accounting must still talk + /// about, not a silence. + pub async fn mark_intent_delivered( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { + let client = self.client().await?; + let delivered = client + .execute( + "UPDATE scheduling_outbox SET delivery_state='delivered', delivered_at=now(), \ + next_attempt_at=now() \ + WHERE outbox_id=$1 AND attempts=$2 AND delivery_state='pending'", + &[&outbox_id, &attempts], + ) + .await?; + Ok(delivered == 1) + } + + /// Schedule one intent's retry, or fail it once its attempts reach the + /// ceiling, for the attempt that owns it. + pub async fn retry_intent( + &self, + outbox_id: Uuid, + next_attempt_at: DateTime, + attempts_ceiling: i32, + attempts: i32, + ) -> Result { + let client = self.client().await?; + let written = client + .execute( + "UPDATE scheduling_outbox \ + SET delivery_state = CASE WHEN attempts >= $3 THEN 'failed' ELSE 'pending' END, \ + next_attempt_at = CASE WHEN attempts >= $3 THEN now() ELSE $2 END \ + WHERE outbox_id=$1 AND attempts=$4 AND delivery_state='pending'", + &[&outbox_id, &next_attempt_at, &attempts_ceiling, &attempts], + ) + .await?; + Ok(written == 1) + } + + /// Hold one intent locally, for the attempt that claimed it: the + /// deployment has no destination configured, so the intent stays + /// recorded instead of pretending a delivery happened. Nothing is + /// delivered, so no delivery instant is stamped either. + pub async fn hold_intent_local( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { + let client = self.client().await?; + let held = client + .execute( + "UPDATE scheduling_outbox SET delivery_state='local', next_attempt_at=now() \ + WHERE outbox_id=$1 AND attempts=$2 AND delivery_state='pending'", + &[&outbox_id, &attempts], + ) + .await?; + Ok(held == 1) + } + + /// Erase cursors whose fifteen minutes have passed. + pub async fn erase_expired_cursors(&self, now: DateTime) -> Result { + let client = self.client().await?; + Ok(client + .execute( + "DELETE FROM scheduling_cursors WHERE expires_at <= $1", + &[&now], + ) + .await?) + } + + /// Record a refused attempt outside the capacity transaction: the store + /// refused the admission and rolled back, so the service writes the + /// receipt a replay of the same key must answer with. A concurrent writer + /// of the same key already stored one; the conflict is not an error, + /// because either receipt answers the same replay. + pub async fn record_refused_attempt( + &self, + commitment: &Commitment<'_>, + scope: &str, + status_code: u16, + receipt: Value, + ) -> Result, CommitError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let written = transaction + .execute( + "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", + &[ + &Uuid::new_v4(), + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + &commitment.request_hash, + &i32::from(status_code), + &receipt, + &commitment.attempt_expires_at, + ], + ) + .await?; + if written == 1 { + transaction.commit().await?; + return Ok(None); + } + match replay_stored_attempt(&transaction, commitment, scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + Ok(Some((status_code, receipt))) + } + Some(ReplayOutcome::Refused(error)) => Err(error), + None => Err(StoreError::Corrupt.into()), + } + } + + /// Record an authorization refusal in the audit journal. The capacity + /// transactions write their audit rows in-transaction; a grant that lapsed + /// or an actor that did not own the claim changes nothing, so its audit + /// row is written beside the refusal instead. + pub async fn record_refusal_audit( + &self, + audit_event: Uuid, + audit_record: Value, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&audit_event, &audit_record], + ) + .await?; + Ok(()) + } + + /// Erase idempotency receipts past their retention period. The answer is + /// dropped, the row is kept and stamped erased: a key that answered once + /// stays spent, so a retry after the period is refused as expired rather + /// than executed again as a fresh request. + /// + /// This is the only retention the sweep enforces beside listing cursors. + /// Appointments, history, the delivery outbox and the audit journal are + /// not swept. + pub async fn erase_expired_attempts(&self, now: DateTime) -> Result { + let client = self.client().await?; + Ok(client + .execute( + "UPDATE scheduling_attempts SET erased_at=$1, receipt=NULL \ + WHERE expires_at <= $1 AND erased_at IS NULL", + &[&now], + ) + .await?) + } + + /// The pending audit journal, oldest first. + /// + /// The chain the publisher extends attests to *publication order*: the + /// order in which rows became visible and were appended, with each + /// batch internally ordered by the recorded sequence. The sequence is + /// allocated when a row is written, not when its transaction commits, so + /// two concurrent transactions can become visible in the opposite order + /// from their sequence numbers; the chain does not claim + /// insertion-sequence order, and a verifier of the chain verifies what + /// the deployment published, in the order it published it. + pub async fn pending_audit(&self, limit: i64) -> Result, StoreError> { + let client = self.client().await?; + Ok(client + .query( + "SELECT event_id, audit_record FROM scheduling_audit_outbox \ + WHERE published_at IS NULL ORDER BY recorded_seq LIMIT $1", + &[&limit], + ) + .await? + .into_iter() + .map(|row| (row.get(0), row.get(1))) + .collect()) + } + + pub async fn mark_audit_published(&self, event_id: Uuid) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "UPDATE scheduling_audit_outbox SET published_at=now() \ + WHERE event_id=$1 AND published_at IS NULL", + &[&event_id], + ) + .await?; + Ok(()) + } +} + +const SELECT_CLAIM: &str = "SELECT claim_id, kind, state, offering, supply_id, channel, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, duplicate_key, \ + hold_expires_at, revision, policy_revision, actor, reason, created_at, closed_at \ + FROM scheduling_claims WHERE claim_id=$1"; + +/// The consuming-claim filter, with hold expiry evaluated in the query. This +/// is the sentence the whole capacity contract turns on: an expired hold +/// stops consuming capacity at its expiry, whether or not any worker has +/// touched it. +const CONSUMING_CLAUSES: &str = + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, \ + occupied_end, units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $4)) \ + AND supply_id = ANY($1) \ + AND occupied_start < $3::timestamptz AND $2::timestamptz < occupied_end \ + ORDER BY occupied_start"; + +fn map_claim_row(row: Row) -> Result { + Ok(ClaimRow { + claim_id: row.get(0), + kind: match row.get::<_, String>(1).as_str() { + "booking" => LedgerKind::Booking, + _ => LedgerKind::Hold, + }, + state: match row.get::<_, String>(2).as_str() { + "active" => ClaimState::Active, + "released" => ClaimState::Released, + "cancelled" => ClaimState::Cancelled, + "consumed" => ClaimState::Consumed, + _ => ClaimState::Expired, + }, + offering: row.get(3), + supply_id: row.get(4), + channel: row.get(5), + displayed_start: row.get(6), + displayed_end: row.get(7), + occupied_start: row.get(8), + occupied_end: row.get(9), + units: row.get(10), + duplicate_key: row.get(11), + hold_expires_at: row.get(12), + revision: row.get(13), + policy_revision: row.get(14), + actor: row.get(15), + reason: row.get(16), + created_at: row.get(17), + closed_at: row.get(18), + }) +} + +fn snapshot_from_rows(rows: Vec) -> LedgerSnapshot { + LedgerSnapshot { + claims: rows + .iter() + .map(|row| LedgerClaim { + id: row.get::<_, Uuid>(0).to_string(), + offering: row.get(1), + supply_id: row.get(2), + kind: match row.get::<_, String>(3).as_str() { + "booking" => LedgerKind::Booking, + _ => LedgerKind::Hold, + }, + channel: row.get(4), + start: row.get(5), + end: row.get(6), + units: u32::try_from(row.get::<_, i32>(7)).unwrap_or(u32::MAX), + duplicate_key: row.get(8), + expires_at: row.get(9), + }) + .collect(), + } +} + +/// The window id a window admission's claim occupies. +fn window_supply_id<'a>(supply: &'a SupplyContext<'a>) -> &'a str { + match supply { + SupplyContext::Window { window, .. } => &window.id, + SupplyContext::ExactTime { .. } => "", + } +} + +/// Steps 1 and 2: lock the anchor row, then read the ledger snapshot with +/// expiry evaluated in the query. +async fn lock_and_snapshot( + transaction: &deadpool_postgres::Transaction<'_>, + supply: &SupplyContext<'_>, + now: DateTime, +) -> Result { + match supply { + SupplyContext::ExactTime { + exact, + members, + open, + .. + } => { + // The anchor is the pool, not the members: two offerings on one + // pool sell the same members and must serialize against each + // other, and `apply_policy` anchors pool ids only. + transaction + .lock_supply(std::slice::from_ref(&exact.pool)) + .await?; + // Claims occupy a member, so the snapshot reads member ids even + // though the lock is the pool's. + let member_ids: Vec = members + .iter() + .map(|member| member.resource_id.clone()) + .collect(); + let earliest = open + .iter() + .map(|interval| interval.start) + .min() + .unwrap_or(now); + let latest = open + .iter() + .map(|interval| interval.end) + .max() + .unwrap_or(now); + let buffer = TimeDelta::minutes( + i64::from(exact.buffer_before_minutes) + i64::from(exact.buffer_after_minutes), + ); + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let earliest = earliest + .checked_sub_signed(buffer) + .ok_or(StoreError::Corrupt)?; + let latest = latest + .checked_add_signed(duration + buffer) + .ok_or(StoreError::Corrupt)?; + let rows = transaction + .query(CONSUMING_CLAUSES, &[&member_ids, &earliest, &latest, &now]) + .await?; + Ok(snapshot_from_rows(rows)) + } + SupplyContext::Window { window, .. } => { + transaction + .lock_supply(std::slice::from_ref(&window.id)) + .await?; + let rows = transaction + .query( + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, occupied_end, \ + units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $2)) \ + AND supply_id = $1 \ + ORDER BY occupied_start", + &[&window.id, &now], + ) + .await?; + Ok(snapshot_from_rows(rows)) + } + } +} + +/// The task-grant re-check at the door of the transaction: a request whose +/// grant already lapsed fails before any lock is taken. +fn check_grant_current(commitment: &Commitment<'_>) -> Result<(), CommitError> { + check_grant_current_at(commitment, commitment.now) +} + +/// The same expiry rule against an explicitly supplied observation of now, +/// which is how the store re-checks it at decision time. +fn check_grant_current_at( + commitment: &Commitment<'_>, + now: DateTime, +) -> Result<(), CommitError> { + match commitment.grant_exp_unix { + Some(exp) if now.timestamp() < i64::try_from(exp).unwrap_or(i64::MAX) => Ok(()), + Some(_) => Err(CommitError::Unauthorized), + // A mutating commitment without a grant never reaches the store: the + // service refuses it before the transaction opens. + None => Ok(()), + } +} + +/// The in-memory evaluator call, step 4. +/// +/// `exclude` is the one standing claim this commitment replaces, supplied +/// only by the reschedule transaction from the appointment row it locks. A +/// create path passes `None`: the wire request cannot carry an exclusion, so +/// no caller can name another party's allocation out of the check. +fn evaluate( + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + snapshot: &LedgerSnapshot, + commitment: &Commitment<'_>, + exclude: Option<&str>, +) -> Result { + match supply { + SupplyContext::ExactTime { + exact, + members, + open, + closures, + } => evaluate_exact_time_admission( + &ExactTimeContext { + offering, + exact, + members, + open, + closures, + snapshot, + policy_revision: u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX), + now: commitment.now, + }, + request, + exclude, + ), + SupplyContext::Window { + window, + lead_time_minutes, + horizon_days, + channels, + open, + closures, + } => evaluate_window_admission( + ®istry_scheduling_core::WindowContext { + offering, + window, + open, + closures, + lead_time_minutes: *lead_time_minutes, + horizon_days: *horizon_days, + snapshot, + policy_revision: u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX), + channels, + now: commitment.now, + }, + request, + exclude, + ), + } +} + +fn hold_ledger_claim(hold: &ClaimRow) -> LedgerClaim { + LedgerClaim { + id: hold.claim_id.to_string(), + offering: hold.offering.clone(), + supply_id: hold.supply_id.clone(), + kind: hold.kind, + channel: hold.channel.clone(), + start: hold.occupied_start, + end: hold.occupied_end, + units: u32::try_from(hold.units).unwrap_or(u32::MAX), + duplicate_key: hold.duplicate_key.clone(), + expires_at: hold.hold_expires_at, + } +} + +/// The revision guards, read under the transaction after the supply anchor +/// it holds is locked. +/// +/// A commitment may only land under the policy revision this process +/// actually evaluated and the records revision the request actually +/// resolved. The stored revisions are the deployment's current truth: a +/// policy published by another process, or a records swap another operator +/// wrote, makes the caller's whole evaluation stale, and the commitment is +/// refused rather than stamped with a revision whose rules it never saw. +/// The share lock holds both revisions still for the life of the +/// transaction, so they cannot move again between the guard and the commit. +async fn guard_revisions( + transaction: &deadpool_postgres::Transaction<'_>, + commitment: &Commitment<'_>, +) -> Result<(), CommitError> { + let row = transaction + .query_one( + "SELECT policy_revision, facts_revision FROM scheduling_meta WHERE singleton FOR SHARE", + &[], + ) + .await?; + let current_policy: i64 = row.get(0); + let current_facts: i64 = row.get(1); + if current_policy != commitment.policy_revision { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if current_facts != commitment.facts_revision { + return Err(CommitError::FactsStale); + } + Ok(()) +} + +/// Replay a stored attempt: the same key with a different payload is +/// refused as reused, an erased receipt as expired, and a retained one is +/// answered exactly as it was. +async fn replay_stored_attempt( + transaction: &deadpool_postgres::Transaction<'_>, + commitment: &Commitment<'_>, + scope: &str, +) -> Result, StoreError> { + let row = transaction + .query_opt( + "SELECT request_hash, state, status_code, receipt, expires_at, erased_at \ + FROM scheduling_attempts \ + WHERE actor_issuer=$1 AND actor_subject=$2 AND scope=$3 AND idempotency_key=$4", + &[ + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + ], + ) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let stored_hash: String = row.get(0); + let expires_at: DateTime = row.get(4); + let erased_at: Option> = row.get(5); + let receipt: Option = row.get(3); + // A different payload under a stored key is a reuse whether or not the + // answer is still held: the caller is told the key is not theirs to + // re-aim before being told the answer is gone. + if stored_hash != commitment.request_hash { + return Ok(Some(ReplayOutcome::Refused(CommitError::KeyReused))); + } + let Some(receipt) = receipt.filter(|_| erased_at.is_none() && expires_at > commitment.now) + else { + return Ok(Some(ReplayOutcome::Refused(CommitError::KeyExpired))); + }; + Ok(Some(ReplayOutcome::Replay { + status_code: u16::try_from(row.get::<_, i32>(2)).unwrap_or(500), + receipt, + })) +} + +/// Mint reminder intents for a freshly committed appointment: one outbox +/// row per authored offset whose due time is still ahead. An offset already +/// past mints nothing, because a reminder about the past is noise, not a +/// notice. +async fn mint_reminders( + transaction: &deadpool_postgres::Transaction<'_>, + claim: &ClaimRow, + offering: ®istry_scheduling_core::OfferingPolicy, + now: DateTime, +) -> Result<(), StoreError> { + for reminder in &offering.reminders { + let minutes = reminder.minutes_before; + let Some(due_at) = claim + .displayed_start + .checked_sub_signed(TimeDelta::minutes(i64::from(minutes))) + else { + continue; + }; + if due_at <= now { + continue; + } + transaction + .execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, \ + appointment_revision, due_at, delivery_state, next_attempt_at, payload) \ + VALUES($1,'reminder',$2,$3,$4,'pending',$4,$5)", + &[ + &Uuid::new_v4(), + &claim.claim_id, + &claim.revision, + &due_at, + &json!({ + "appointmentId": claim.claim_id, + "revision": claim.revision, + "offering": offering.id, + "start": claim.displayed_start, + "minutesBefore": minutes, + }), + ], + ) + .await?; + } + Ok(()) +} + +fn confirmation_payload(claim: &ClaimRow, policy_revision: i64) -> Value { + json!({ + "appointmentId": claim.claim_id, + "revision": claim.revision, + "offering": claim.offering, + "start": claim.displayed_start, + "end": claim.displayed_end, + "policyRevision": policy_revision, + }) +} + +/// The durable success receipt retains every value the public projection +/// cannot recover from a claim alone. In particular, whether `supply_id` was +/// a pool member must survive a later policy mode change or removal. +fn claim_receipt( + kind: &str, + claim: &ClaimRow, + resource_is_member: bool, + policy_revision: i64, +) -> Value { + json!({ + "kind": kind, + "claim": claim, + "resource": resource_is_member.then(|| claim.supply_id.clone()), + "policyRevision": policy_revision, + }) +} + +pub(crate) async fn replace_facts_in_transaction( + transaction: &deadpool_postgres::Transaction<'_>, + scheduling_id: &str, + facts: &SchedulingFacts, + audit_event: Uuid, + audit_record: Value, +) -> Result<(), StoreError> { + // Take every supply anchor before reading a claim. A capacity + // transaction holds its own anchor from its snapshot until it commits, + // so holding all of them means no commitment is mid-evaluation against + // members this swap is about to delete, and a claim written a moment + // later is seen by the guard below rather than stranded behind it. A + // capacity transaction takes exactly one anchor and this one takes them + // in the anchor's own order, so the two cannot cycle. + transaction + .execute( + "SELECT supply_id FROM scheduling_supply ORDER BY supply_id FOR UPDATE", + &[], + ) + .await?; + let stored_id: String = transaction + .query_one( + "SELECT scheduling_id FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await? + .get(0); + if stored_id != scheduling_id { + return Err(StoreError::DeploymentIdentity); + } + let occupied = occupied_resources_changed_by(transaction, facts).await?; + if !occupied.is_empty() { + return Err(StoreError::FactsInUse(occupied.join(", "))); + } + let current_windows = transaction + .query( + "SELECT window_record FROM scheduling_windows ORDER BY window_id", + &[], + ) + .await? + .iter() + .map(|row| { + serde_json::from_value::(row.get(0)) + .map_err(|_| StoreError::Corrupt) + }) + .collect::, _>>()?; + let revision_heads = transaction + .query( + "SELECT window_id, window_record FROM scheduling_window_revision_heads ORDER BY window_id", + &[], + ) + .await? + .iter() + .map(|row| { + let window_id: String = row.get(0); + let window = serde_json::from_value::( + row.get(1), + ) + .map_err(|_| StoreError::Corrupt)?; + if window.id != window_id { + return Err(StoreError::Corrupt); + } + Ok(window) + }) + .collect::, _>>()?; + for proposed in &facts.windows { + let Some(current) = revision_heads + .iter() + .find(|current| current.id == proposed.id) + else { + continue; + }; + if proposed != current && proposed.revision <= current.revision { + return Err(StoreError::WindowRevision { + window: proposed.id.clone(), + current: current.revision, + proposed: proposed.revision, + }); + } + } + let now = Utc::now(); + let rows = transaction + .query( + "SELECT c.claim_id, c.offering, c.supply_id, c.kind, c.channel, \ + c.occupied_start, c.occupied_end, c.units, c.duplicate_key, c.hold_expires_at \ + FROM scheduling_claims AS c \ + JOIN scheduling_supply AS s ON s.supply_id=c.supply_id \ + WHERE s.kind='window' AND c.state='active' \ + AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > $1)) \ + ORDER BY c.occupied_start", + &[&now], + ) + .await?; + let reductions = assess_window_record_impact( + ¤t_windows, + &facts.windows, + &snapshot_from_rows(rows), + now, + ); + if !reductions.is_empty() { + let details = reductions + .iter() + .map(|reduction| { + format!( + "{}:{} has {} committed units but proposes {}", + reduction.window, + reduction + .channel + .map_or("total", |channel| channel.as_str()), + reduction.committed_units, + reduction.proposed_units + ) + }) + .collect::>() + .join(", "); + return Err(StoreError::FactsInUse(details)); + } + transaction + .execute("DELETE FROM scheduling_pool_members", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_pools", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_locations", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_exceptions", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_windows", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_supply WHERE kind='window'", &[]) + .await?; + for location in &facts.locations { + transaction + .execute( + "INSERT INTO scheduling_locations(location_id, timezone) VALUES($1,$2)", + &[&location.id, &location.timezone], + ) + .await?; + } + for pool in &facts.pools { + transaction + .execute( + "INSERT INTO scheduling_pools(pool_id) VALUES($1)", + &[&pool.id], + ) + .await?; + for member in &pool.members { + transaction + .execute( + "INSERT INTO scheduling_pool_members(resource_id, pool_id, capabilities, \ + available) VALUES($1,$2,$3,$4)", + &[ + &member.resource_id, + &pool.id, + &member.capabilities, + &member.available, + ], + ) + .await?; + } + } + for window in &facts.windows { + let record = serde_json::to_value(window).map_err(|_| StoreError::Corrupt)?; + transaction + .execute( + "INSERT INTO scheduling_windows(window_id, window_record) VALUES($1,$2)", + &[&window.id, &record], + ) + .await?; + transaction + .execute( + "INSERT INTO scheduling_supply(supply_id, kind) VALUES($1,'window')", + &[&window.id], + ) + .await?; + transaction + .execute( + "INSERT INTO scheduling_window_revision_heads(window_id, window_record, updated_at) \ + VALUES($1,$2,now()) \ + ON CONFLICT(window_id) DO UPDATE SET window_record=EXCLUDED.window_record, \ + updated_at=EXCLUDED.updated_at", + &[&window.id, &record], + ) + .await?; + } + for exception in &facts.exceptions { + let kind = match exception.kind { + registry_scheduling_core::ExceptionRecordKind::Closure => "closure", + registry_scheduling_core::ExceptionRecordKind::Opening => "opening", + }; + transaction + .execute( + "INSERT INTO scheduling_exceptions(exception_id, location, kind, date, \ + start_time, end_time, reopens, authority) \ + VALUES($1,$2,$3,$4::text::date,$5,$6,$7,$8)", + &[ + &exception.id, + &exception.location, + &kind, + &exception.date, + &exception.start_time, + &exception.end_time, + &exception.reopens, + &exception.authority, + ], + ) + .await?; + } + // The records revision moves last, inside the same transaction that + // holds every supply anchor: a capacity transaction that acquires its + // anchor after this commit reads the new revision and refuses to + // evaluate against the resolved-before facts it carried in. + transaction + .execute( + "UPDATE scheduling_meta SET facts_revision = facts_revision + 1, \ + updated_at=now() WHERE singleton", + &[], + ) + .await?; + transaction + .execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&audit_event, &audit_record], + ) + .await?; + Ok(()) +} + +/// The resources live claims occupy that the incoming records retire or move. +/// +/// An exact-time claim names its member in `supply_id`, so a swap that drops +/// a member out from under a live booking would leave that booking pointing +/// at a resource the deployment no longer has: invisible to every pool +/// snapshot, counted against nothing, and still promised to its caller. A +/// Window claims are assessed separately against the incoming window records, +/// so this helper reports only moved or retired pool members. +async fn occupied_resources_changed_by( + transaction: &deadpool_postgres::Transaction<'_>, + facts: &SchedulingFacts, +) -> Result, StoreError> { + let incoming: HashMap<&str, &str> = facts + .pools + .iter() + .flat_map(|pool| { + pool.members + .iter() + .map(move |member| (member.resource_id.as_str(), pool.id.as_str())) + }) + .collect(); + let rows = transaction + .query( + "SELECT DISTINCT c.supply_id, m.pool_id FROM scheduling_claims AS c \ + JOIN scheduling_pool_members AS m ON m.resource_id = c.supply_id \ + WHERE c.state='active' \ + AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > now())) \ + ORDER BY c.supply_id", + &[], + ) + .await?; + Ok(rows + .iter() + .filter_map(|row| { + let resource: String = row.get(0); + let current_pool: String = row.get(1); + match incoming.get(resource.as_str()) { + Some(incoming_pool) if *incoming_pool == current_pool => None, + Some(incoming_pool) => { + Some(format!("{resource} from {current_pool} to {incoming_pool}")) + } + None => Some(resource), + } + }) + .collect()) +} + +/// A claim about to be written. +struct NewClaim<'c> { + claim_id: Uuid, + kind: LedgerKind, + state: ClaimState, + offering: &'c str, + supply_id: &'c str, + channel: Option<&'c str>, + displayed_start: DateTime, + displayed_end: DateTime, + occupied_start: DateTime, + occupied_end: DateTime, + units: i32, + duplicate_key: Option<&'c str>, + hold_expires_at: Option>, + revision: i64, + policy_revision: i64, + actor: &'c str, + reason: Option<&'c str>, +} + +/// A claim about to move to a new time or resource. +struct ClaimMove<'c> { + claim_id: Uuid, + supply_id: &'c str, + displayed_start: DateTime, + displayed_end: DateTime, + occupied_start: DateTime, + occupied_end: DateTime, + policy_revision: i64, + next_revision: i64, +} + +/// The private statement-set the capacity transactions run. Holding these on +/// a trait keeps every multi-step method on `Transaction` without exposing +/// the transaction handle itself. +#[async_trait::async_trait] +trait CapacityStatements { + async fn lock_supply(&self, supply_ids: &[String]) -> Result<(), StoreError>; + async fn insert_claim(&self, claim: &NewClaim<'_>) -> Result; + async fn close_claim( + &self, + claim_id: Uuid, + state: ClaimState, + reason: Option<&str>, + observed_revision: i64, + ) -> Result; + /// Move an active booking to a new time or resource under the revision + /// the mover observed. `false` means a concurrent writer changed the + /// claim first and this transaction's whole decision is superseded. + async fn move_claim( + &self, + movement: &ClaimMove<'_>, + observed_revision: i64, + ) -> Result; + async fn insert_history( + &self, + claim_id: Uuid, + revision: i64, + kind: &str, + occurred_at: DateTime, + actor: &str, + detail: Value, + ) -> Result<(), StoreError>; + async fn insert_outbox( + &self, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + payload: Value, + ) -> Result<(), StoreError>; + async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError>; + /// Write the receipt this commitment answers a replay with. The key is + /// claimed here, at the end of the transaction, so a writer who finds it + /// already taken is refused rather than failed. + async fn insert_attempt( + &self, + attempt_id: Uuid, + commitment: &Commitment<'_>, + scope: &str, + state: AttemptState, + status_code: u16, + receipt: Value, + ) -> Result<(), CommitError>; + async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError>; + async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError>; + /// Take the caller's hold-ceiling lock, then count the holds it has open. + /// + /// The lock is the reason the count means anything. A commitment locks the + /// supply anchor it is about to draw from, but the ceiling bounds the + /// caller across every pool, and two holds on two pools lock two different + /// rows. Without a lock scoped to the caller, two concurrent holds each + /// read the same under-ceiling count and both write, so a caller admitted + /// two holds can end up holding three. The count and the lock are one + /// method so neither can be taken without the other. + async fn lock_caller_and_count_active_holds( + &self, + actor: &str, + now: DateTime, + ) -> Result; +} + +#[async_trait::async_trait] +impl CapacityStatements for deadpool_postgres::Transaction<'_> { + async fn lock_supply(&self, supply_ids: &[String]) -> Result<(), StoreError> { + let rows = self + .query( + "SELECT supply_id FROM scheduling_supply WHERE supply_id = ANY($1) FOR UPDATE", + &[&supply_ids], + ) + .await?; + if rows.len() != supply_ids.len() { + return Err(StoreError::Corrupt); + } + Ok(()) + } + + async fn insert_claim(&self, claim: &NewClaim<'_>) -> Result { + let kind = match claim.kind { + LedgerKind::Booking => "booking", + LedgerKind::Hold => "hold", + }; + let row = self + .query_one( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + channel, displayed_start, displayed_end, occupied_start, occupied_end, units, \ + duplicate_key, hold_expires_at, revision, policy_revision, actor, reason) \ + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) \ + RETURNING created_at", + &[ + &claim.claim_id, + &kind, + &claim.state.as_str(), + &claim.offering, + &claim.supply_id, + &claim.channel, + &claim.displayed_start, + &claim.displayed_end, + &claim.occupied_start, + &claim.occupied_end, + &claim.units, + &claim.duplicate_key, + &claim.hold_expires_at, + &claim.revision, + &claim.policy_revision, + &claim.actor, + &claim.reason, + ], + ) + .await?; + Ok(ClaimRow { + claim_id: claim.claim_id, + kind: claim.kind, + state: claim.state, + offering: claim.offering.to_owned(), + supply_id: claim.supply_id.to_owned(), + channel: claim.channel.map(str::to_owned), + displayed_start: claim.displayed_start, + displayed_end: claim.displayed_end, + occupied_start: claim.occupied_start, + occupied_end: claim.occupied_end, + units: claim.units, + duplicate_key: claim.duplicate_key.map(str::to_owned), + hold_expires_at: claim.hold_expires_at, + revision: claim.revision, + policy_revision: claim.policy_revision, + actor: claim.actor.to_owned(), + reason: claim.reason.map(str::to_owned), + created_at: row.get(0), + closed_at: None, + }) + } + + /// Close the claim under the revision its caller observed. The + /// predicate, not an earlier read, decides: a concurrent lifecycle + /// writer that already moved the claim makes this affect zero rows, and + /// the caller rolls its whole transaction back behind the conflict. + async fn close_claim( + &self, + claim_id: Uuid, + state: ClaimState, + reason: Option<&str>, + observed_revision: i64, + ) -> Result { + let closed = self + .execute( + "UPDATE scheduling_claims SET state=$2, reason=$3, \ + revision=$4 + 1, closed_at=now(), changed_at=now() \ + WHERE claim_id=$1 AND state='active' AND revision=$4", + &[&claim_id, &state.as_str(), &reason, &observed_revision], + ) + .await?; + Ok(closed == 1) + } + + async fn move_claim( + &self, + movement: &ClaimMove<'_>, + observed_revision: i64, + ) -> Result { + let moved = self + .execute( + "UPDATE scheduling_claims SET supply_id=$2, displayed_start=$3, displayed_end=$4, \ + occupied_start=$5, occupied_end=$6, policy_revision=$7, revision=$8, \ + changed_at=now() WHERE claim_id=$1 AND state='active' AND revision=$9", + &[ + &movement.claim_id, + &movement.supply_id, + &movement.displayed_start, + &movement.displayed_end, + &movement.occupied_start, + &movement.occupied_end, + &movement.policy_revision, + &movement.next_revision, + &observed_revision, + ], + ) + .await?; + Ok(moved == 1) + } + + async fn insert_history( + &self, + claim_id: Uuid, + revision: i64, + kind: &str, + occurred_at: DateTime, + actor: &str, + detail: Value, + ) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_history(event_id, claim_id, revision, kind, occurred_at, \ + actor, detail) VALUES($1,$2,$3,$4,$5,$6,$7)", + &[ + &Uuid::new_v4(), + &claim_id, + &revision, + &kind, + &occurred_at, + &actor, + &detail, + ], + ) + .await?; + Ok(()) + } + + async fn insert_outbox( + &self, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + payload: Value, + ) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, appointment_revision, \ + due_at, delivery_state, next_attempt_at, payload) \ + VALUES($1,$2,$3,$4,$5,'pending',$5,$6)", + &[ + &Uuid::new_v4(), + &purpose, + &claim_id, + &appointment_revision, + &due_at, + &payload, + ], + ) + .await?; + Ok(()) + } + + async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError> { + // Suppression marks rather than deletes: the row keeps accounting + // for the intent, and a dispatch already in flight reports its + // outcome as lost to suppression instead of vanishing. + self.execute( + "UPDATE scheduling_outbox SET delivery_state='suppressed' \ + WHERE claim_id=$1 AND purpose='reminder' AND delivery_state='pending'", + &[&claim_id], + ) + .await?; + Ok(()) + } + + async fn insert_attempt( + &self, + attempt_id: Uuid, + commitment: &Commitment<'_>, + scope: &str, + state: AttemptState, + status_code: u16, + receipt: Value, + ) -> Result<(), CommitError> { + let written = self + .execute( + "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,$7,$8,$9,$10) ON CONFLICT DO NOTHING", + &[ + &attempt_id, + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + &commitment.request_hash, + &state.as_str(), + &i32::from(status_code), + &receipt, + &commitment.attempt_expires_at, + ], + ) + .await?; + if written == 0 { + // The replay read at the head of this transaction saw no stored + // attempt because the writer that owns the key had not committed + // yet. The key is not this caller's to answer under, and the + // whole transaction rolls back behind the refusal, so nothing was + // decided. + return Err(CommitError::KeyReused); + } + Ok(()) + } + + async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&event_id, &record], + ) + .await?; + Ok(()) + } + + async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError> { + let row = self.query_opt(SELECT_CLAIM, &[&claim_id]).await?; + row.map(map_claim_row).transpose() + } + + async fn lock_caller_and_count_active_holds( + &self, + actor: &str, + now: DateTime, + ) -> Result { + self.execute( + "SELECT pg_advisory_xact_lock($1, hashtext($2))", + &[&HOLD_CEILING_LOCK_NAMESPACE, &actor], + ) + .await?; + let row = self + .query_one( + "SELECT count(*) FROM scheduling_claims \ + WHERE actor=$1 AND kind='hold' AND state='active' AND hold_expires_at > $2", + &[&actor, &now], + ) + .await?; + Ok(row.get(0)) + } +} + +fn tls_connector( + config: &DatabaseConfig, + secrets: &SecretResolver, +) -> Result { + let provider = rustls::crypto::ring::default_provider(); + if provider.install_default().is_err() + && rustls::crypto::CryptoProvider::get_default().is_none() + { + return Err(StoreError::Configuration); + } + if let Some(reference) = &config.trusted_root_certificate_ref { + let certificate = secrets.resolve(reference).map_err(|error| { + StoreError::SecretConfiguration(describe_secret_failure( + "database.trustedRootCertificateRef", + reference, + &error, + )) + })?; + let mut roots = rustls::RootCertStore::empty(); + use rustls::pki_types::pem::PemObject as _; + let mut count = 0usize; + for certificate in + rustls::pki_types::CertificateDer::pem_slice_iter(certificate.expose_secret()) + { + roots + .add(certificate.map_err(|_| StoreError::Configuration)?) + .map_err(|_| StoreError::Configuration)?; + count += 1; + } + if count == 0 { + return Err(StoreError::Configuration); + } + let client = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(tokio_postgres_rustls::MakeRustlsConnect::new(client)) + } else { + tokio_postgres_rustls::MakeRustlsConnect::with_native_certs() + .map(|value| value.0) + .map_err(|_| StoreError::Configuration) + } +} diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs new file mode 100644 index 0000000000..cfdf913ce4 --- /dev/null +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -0,0 +1,5362 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Database-backed commitment tests: the six commitments, availability, +//! cursors, the hold-expiry cycle, and the unanchored-supply refusal, +//! exercised through the pinned HTTP surface against a real PostgreSQL +//! deployment. +//! +//! Every test runs in its own schema inside the database named by +//! `SCHEDULING_TEST_DATABASE_URL`, adopted under one scheduling id, seeded +//! with one location, two pools of one member each, and driven through the +//! same router the runtime serves. A test binary that passes because its +//! database URL is absent is not database verification, so the variable is +//! required: without it every test in this file fails on the spot. + +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use axum::Router; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Timelike, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use registry_platform_audit::{ + AuditEnvelope, AuditHashSecret, AuditKeyHasher, AuditProfile, JsonlFileSink, +}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_hooks::delivery::DeliveryOutcome; +use registry_platform_hooks::{EnvelopeLimits, HookEnvelope, HookHandlerSource}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_scheduling::auth::SchedulingAuthenticator; +use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; +use registry_scheduling::config::{HookDestinationConfig, ReminderDestinationConfig}; +use registry_scheduling::hooks::{ActivatedHooks, HookRuntimeIdentity}; +use registry_scheduling::http::{router, HttpState}; +use registry_scheduling::runtime::{ + dispatch_due_intents, publish_audit_pass, reminder_transport, AuditPublicationState, + AuditPublisher, +}; +use registry_scheduling::service::SchedulingService; +use registry_scheduling::store::{ + CommitError, CommitOutcome, Commitment, PostgresStore, StoreError, SupplyContext, +}; +use registry_scheduling_core::{ + location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRefusal, + AdmissionRequest, CalendarExceptionRecord, Channel, ExceptionRecordKind, LocationRecord, + OfferingPolicy, PartyCounts, PoolMember, PublishedWindow, RequiredUnitsPolicy, ResourcePool, + SchedulingFacts, SchedulingPolicy, WindowSubquota, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tower::ServiceExt; +use uuid::Uuid; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ISSUER: &str = "https://task-token.test"; +const AUDIENCE: &str = "urn:registry-scheduling:test"; +const CLIENT: &str = "task-agent"; +const SECRET: &[u8] = b"01234567890123456789012345678901"; +const SCHEDULING_ID: &str = "commitments-test"; +const OFFERING: &str = "registry-update-30"; +const SECOND_OFFERING: &str = "registry-review-45"; +/// An offering whose service runs twice its start increment, so consecutive +/// published starts overlap each other. It is the only shape that can move an +/// appointment inside the interval it already holds. +const OVERLAPPING_OFFERING: &str = "registry-update-60"; + +fn observer_policy() -> String { + format!( + "{POLICY}hooks:\n - id: confirmed-observer\n phase: after\n trigger: appointment.confirmed\n projection: [appointmentId, offering, start, end, revision, policyRevision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n - id: rescheduled-observer\n phase: after\n trigger: appointment.rescheduled\n projection: [appointmentId, offering, start, end, revision, policyRevision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n - id: cancelled-observer\n phase: after\n trigger: appointment.cancelled\n projection: [appointmentId, revision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n" + ) +} + +/// One plain booking grid: slots every 30 minutes around the clock, every +/// day, one hour of lead time, a four-hour cancellation cutoff, and a +/// ten-minute hold. The wide opening keeps every query window these tests +/// use, all at least 110 minutes wide, certain to contain a bookable start +/// regardless of when the test runs, and the single member per pool makes +/// every commitment's capacity effect total: a booked or held slot is not +/// availability. A third offering serves an hour on the same pool at the same +/// half-hour increment, so its published starts overlap one another. The last +/// offering sells a second pool so one fixture can also pin a commitment +/// against a pool the publication never anchored. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: {id: commitments-test, version: 1} +services: + - {id: registry-update, label: Registry record update} + - {id: registry-review, label: Registry record review} +holidaySets: [{id: none, revision: 1, because: test, dates: []}] +openings: + - id: all-day + location: north-counter + holidaySet: none + weekdays: [mon, tue, wed, thu, fri, sat, sun] + startTime: "00:00" + endTime: "23:30" + effectiveFrom: "2026-01-01" + effectiveUntil: "2035-06-30" + because: test +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + reminders: [{minutesBefore: 60, because: test}] + requiresCapabilities: [] + prerequisites: [] + - id: registry-update-60 + service: registry-update + label: 60-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 60 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] + - id: registry-review-45 + service: registry-review + label: 45-minute record review + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: two-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] +holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} +"#; + +struct Fixture { + http: Router, + store: PostgresStore, + hooks: ActivatedHooks, + hook_secret_ref: String, + schema: String, + /// A session on the test's own schema, for the facts operator tooling + /// owns and for the rows a test needs to observe or hold directly. + admin: tokio_postgres::Client, + revision: u64, + /// A standing reader: the reads scope, no task grant. + reader: String, + /// The booking agent: reads and explain scopes plus the full task grant. + agent: String, +} + +/// Drive one request through the router. The free function takes the router +/// by value so a test can hold two requests in flight at once. +async fn send( + http: Router, + method: String, + uri: String, + token: String, + idempotency: Option, + body: Option, +) -> (StatusCode, Value) { + let mut builder = Request::builder() + .method(method.as_str()) + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json"); + if let Some(key) = idempotency { + builder = builder.header("idempotency-key", key); + } + let body = match body { + Some(value) => Body::from(value.to_string()), + None => Body::empty(), + }; + let response = http + .oneshot(builder.body(body).expect("a well-formed test request")) + .await + .expect("an in-process request always answers"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1 << 20) + .await + .expect("a bounded test body"); + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("a JSON test body") + }; + (status, value) +} + +impl Fixture { + async fn request( + &self, + method: &str, + uri: &str, + token: &str, + idempotency: Option<&str>, + body: Option, + ) -> (StatusCode, Value) { + send( + self.http.clone(), + method.to_owned(), + uri.to_owned(), + token.to_owned(), + idempotency.map(str::to_owned), + body, + ) + .await + } + + async fn get(&self, uri: &str, token: &str) -> (StatusCode, Value) { + self.request("GET", uri, token, None, None).await + } + + async fn post(&self, uri: &str, token: &str, key: &str, body: Value) -> (StatusCode, Value) { + self.request("POST", uri, token, Some(key), Some(body)) + .await + } + + async fn delete(&self, uri: &str, token: &str) -> (StatusCode, Value) { + self.request("DELETE", uri, token, None, None).await + } +} + +/// A fresh deployment in its own schema: migrated, adopted, seeded, published +/// with every pool the policy names, and serving through the same router the +/// runtime serves. +async fn fixture() -> Fixture { + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let mut pool_ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + fixture_anchoring(&pool_ids).await +} + +/// A fresh deployment whose publication anchored exactly `pool_ids`, so a +/// test can pin what happens when a policy names a pool no anchor covers. +async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { + fixture_publishing(POLICY, pool_ids).await +} + +/// A fresh deployment publishing `policy_yaml` and anchoring exactly +/// `pool_ids`. Window anchors are owned by the records replacement path. +async fn fixture_publishing(policy_yaml: &str, pool_ids: &[String]) -> Fixture { + fixture_publishing_with_hook_url(policy_yaml, pool_ids, "http://127.0.0.1:9/scheduling-hooks") + .await +} + +async fn fixture_publishing_with_hook_url( + policy_yaml: &str, + pool_ids: &[String], + hook_url: &str, +) -> Fixture { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("scheduling_{}", Uuid::new_v4().simple()); + let separator = if base.contains('?') { '&' } else { '?' }; + let scoped = format!("{base}{separator}options=-csearch_path%3D{schema}"); + + // The admin connection creates the isolated schema before anything + // resolves into it: table creation follows search_path, and the scoped + // URL every store connection carries points at this schema, so it must + // exist before the migrations run. + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("connect the scheduling test database"); + tokio::spawn(async move { connection.await.expect("the scheduling admin connection") }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("an isolated scheduling schema"); + admin + .batch_execute(&format!("SET search_path TO {schema}")) + .await + .expect("the scheduling test schema"); + + let secret_name = format!("SCHEDULING_SCHEMA_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + let hook_secret_name = + format!("SCHEDULING_HOOK_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&secret_name, &scoped); + std::env::set_var(&hook_secret_name, "0123456789abcdef0123456789abcdef"); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") + .expect("the scheduling test secret resolver"); + let database = DatabaseConfig { + runtime_url_ref: format!("secret:env/{secret_name}"), + migration_url_ref: format!("secret:env/{secret_name}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + let migration_store = + PostgresStore::connect_migration(&database, &secrets).expect("the migration store"); + migration_store + .migrate() + .await + .expect("the scheduling migrations"); + let store = PostgresStore::connect_runtime(&database, &secrets).expect("the runtime store"); + + // The admin connection seeds the facts the policy resolves supply + // against (the path operator tooling owns), and the store adopts the + // deployment identity, the same provisioning verb `scheduling migrate` + // runs on a fresh database. + for statement in [ + "INSERT INTO scheduling_locations(location_id, timezone) VALUES('north-counter','UTC')", + "INSERT INTO scheduling_pools(pool_id) VALUES('north-counter')", + "INSERT INTO scheduling_pools(pool_id) VALUES('two-counter')", + "INSERT INTO scheduling_pool_members(resource_id, pool_id, available) \ + VALUES('station-1','north-counter',true)", + "INSERT INTO scheduling_pool_members(resource_id, pool_id, available) \ + VALUES('station-2','two-counter',true)", + ] { + admin + .batch_execute(statement) + .await + .expect("seed the scheduling facts"); + } + store + .adopt(SCHEDULING_ID) + .await + .expect("adopt the scheduling deployment"); + + let policy = parse_policy_yaml(policy_yaml).expect("the scheduling test policy"); + let digest = policy.policy_digest(); + let revision = store + .apply_policy(SCHEDULING_ID, &digest, pool_ids, &policy) + .await + .expect("publish the scheduling policy"); + let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); + let mut hook_destinations = BTreeMap::new(); + for destination_id in policy.hooks.iter().filter_map(|hook| match &hook.handler { + HookHandlerSource::Url { destination_id } => Some(destination_id), + HookHandlerSource::Rhai { .. } | HookHandlerSource::Wasm { .. } => None, + }) { + hook_destinations.insert( + destination_id.clone(), + HookDestinationConfig { + url: hook_url.to_owned(), + hmac_sha256_key_ref: format!("secret:env/{hook_secret_name}"), + attempt_timeout_milliseconds: 5_000, + maximum_attempts: 1, + }, + ); + } + let hooks = ActivatedHooks::activate( + &policy.hooks, + &hook_destinations, + &secrets, + HookRuntimeIdentity { + scheduling_id: SCHEDULING_ID.to_owned(), + policy_revision: revision, + policy_digest: digest.clone(), + }, + schema.clone(), + Duration::from_secs(7 * 24 * 60 * 60), + ) + .expect("activate the scheduling test hooks"); + let service = Arc::new( + SchedulingService::new( + store.clone(), + policy, + SCHEDULING_ID.to_owned(), + revision, + digest, + AuditKeyHasher::Keyed(keying), + 7, + ) + .with_hooks(hooks.clone()), + ); + let http = router(HttpState { + service, + authenticator: Arc::new(authenticator()), + store: store.clone(), + }); + Fixture { + http, + store, + hooks, + hook_secret_ref: format!("secret:env/{hook_secret_name}"), + schema, + admin, + revision: u64::try_from(revision).expect("a bounded policy revision"), + reader: reader_token(), + agent: agent_token(), + } +} + +fn authenticator() -> SchedulingAuthenticator { + let oidc = OidcConfig { + allowed_clients: vec![CLIENT.to_owned()], + assertion_issuers: std::collections::BTreeMap::new(), + issuer: ISSUER.to_owned(), + audience: AUDIENCE.to_owned(), + jwks_uri: None, + jwks_source: OidcJwksSource::Discovery, + scope_claim: "registry_scopes".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + }; + let verifier = TokenVerifierConfig::access_token_profile( + ISSUER, + vec![AUDIENCE.to_owned()], + vec![Algorithm::HS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("registry_scopes") + .with_allowed_clients(vec![CLIENT.to_owned()]); + let keys = Arc::new(JwksFetcher::new_static( + serde_json::from_value(json!({"keys": [{ + "kty": "oct", + "kid": "test", + "alg": "HS256", + "use": "sig", + "k": URL_SAFE_NO_PAD.encode(SECRET), + }]})) + .expect("the test signing keys"), + JwksFetcherConfig::defaults(), + )); + SchedulingAuthenticator::new(&oidc, verifier, keys) +} + +/// Resolve the owned exact-time supply pieces a direct store commitment can +/// borrow. The HTTP service performs the same resolution before it opens the +/// capacity transaction. +fn exact_supply_parts( + policy: &SchedulingPolicy, + facts: &SchedulingFacts, + offering: &OfferingPolicy, +) -> ( + Vec, + Vec, + Vec, +) { + let exact = offering + .exact_time + .as_ref() + .expect("the test offering is exact-time"); + let members = facts + .pool(&exact.pool) + .expect("the seeded pool") + .members + .clone(); + let timezone = facts + .location(&offering.location) + .expect("the seeded location") + .timezone + .clone(); + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals(policy, &offering.location, &timezone, &exceptions) + .expect("resolve the openings"); + let closures = location_closure_intervals(facts, &offering.location, &timezone) + .expect("resolve the closures"); + (members, open, closures) +} + +/// Sign an access token, filling in the claims a real token always carries. +fn token(mut claims: Value) -> String { + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.entry("iss").or_insert(json!(ISSUER)); + object.entry("aud").or_insert(json!(AUDIENCE)); + object.entry("iat").or_insert(json!(now)); + object.entry("exp").or_insert(json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(SECRET)).unwrap() +} + +fn reader_token() -> String { + token(json!({ + "sub": "principal-read", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })) +} + +/// The task grant the booking agent carries: every action over the test +/// policy's services and location. +fn grant_claims() -> Value { + json!({ + "registry_actor_kind": "agent", + "registry_purpose": "registry-update", + "registry_grant_id": "grant-1", + "registry_grant_source_issuer": "https://authority.test", + "registry_grant_client": CLIENT, + "registry_grant_resource": AUDIENCE, + "registry_grant_exp": Utc::now().timestamp() + 600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "north-counter", + "actions": [ + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, { + "service": "registry-review", + "location": "north-counter", + "actions": ["appointment.create"], + }], + }, + "registry_approver": "approver-1", + }) +} + +fn agent_token() -> String { + agent_token_for("principal-agent") +} + +/// A second booking agent, identical in scope and grant and different only in +/// subject, so what it may see is decided by ownership alone. +fn agent_token_for(subject: &str) -> String { + let mut claims = json!({ + "sub": subject, + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant_claims() + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +fn agent_token_for_service(service: &str) -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"]["permissions"] = json!([{ + "service": service, + "location": "north-counter", + "actions": ["appointment.create"], + }]); + let mut claims = json!({ + "sub": format!("principal-{service}"), + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + }); + claims + .as_object_mut() + .expect("the claims are an object") + .extend( + grant + .as_object() + .expect("the grant claims are an object") + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// A complete admission request for `offering` at `slot`. The wire shape +/// carries no `rescheduleOf`: the exclusion is the runtime's to supply from +/// inside the reschedule transaction, and a caller who sends one is refused +/// as a malformed request. +fn admission(fx: &Fixture, offering: &str, slot: DateTime) -> Value { + json!({ + "offering": offering, + "start": stamp(slot), + "party": {"recipients": 1, "attendees": 1}, + "channel": null, + "duplicateKey": null, + "policyRevision": fx.revision, + "windowRevision": null, + "capabilities": [], + "prerequisites": [], + }) +} + +fn stamp(moment: DateTime) -> String { + moment.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn moment(value: &Value, field: &str) -> DateTime { + DateTime::parse_from_rfc3339(value[field].as_str().expect("an RFC 3339 field")) + .expect("a parseable RFC 3339 field") + .with_timezone(&Utc) +} + +fn availability_uri(offering: &str, from_minutes: i64, to_minutes: i64) -> String { + let now = Utc::now(); + format!( + "/v1/availability?offering={offering}&start={}&end={}", + stamp(now + TimeDelta::minutes(from_minutes)), + stamp(now + TimeDelta::minutes(to_minutes)), + ) +} + +/// The earliest bookable slot the offering lists in `[now + from, now + to]`. +/// The test policy's grid steps every 30 minutes around the clock, so every +/// window these tests use, all at least 110 minutes wide, always carries one. +async fn first_slot( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, +) -> DateTime { + let (status, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "availability answers over the window" + ); + let start = page["items"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["kind"] == "slot") + .and_then(|entry| entry["start"].as_str()) + .unwrap_or_else(|| panic!("a bookable slot inside [{from_minutes}, {to_minutes}] minutes")); + DateTime::parse_from_rfc3339(start) + .expect("a parseable slot start") + .with_timezone(&Utc) +} + +async fn slot_offered( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, + slot: DateTime, +) -> bool { + let (_, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + page["items"].as_array().is_some_and(|items| { + items.iter().any(|entry| { + entry["kind"] == "slot" + && DateTime::parse_from_rfc3339(entry["start"].as_str().unwrap_or_default()) + .is_ok_and(|start| start.with_timezone(&Utc) == slot) + }) + }) +} + +/// The first published start of `offering` whose next start is published too. +/// Both are inside one opening, so an appointment on the first can move onto +/// the second, and for an offering serving longer than its start increment +/// the two intervals overlap. +async fn first_overlapping_pair( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, +) -> (DateTime, DateTime) { + let (status, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "availability answers over the window" + ); + let starts: Vec> = page["items"] + .as_array() + .into_iter() + .flatten() + .filter(|entry| entry["kind"] == "slot") + .filter_map(|entry| entry["start"].as_str()) + .filter_map(|start| DateTime::parse_from_rfc3339(start).ok()) + .map(|start| start.with_timezone(&Utc)) + .collect(); + starts + .iter() + .find_map(|start| { + let next = *start + TimeDelta::minutes(30); + starts.contains(&next).then_some((*start, next)) + }) + .unwrap_or_else(|| { + panic!("two consecutive bookable starts inside [{from_minutes}, {to_minutes}] minutes") + }) +} + +/// Book directly on a fresh slot of the default offering and return the +/// appointment. +async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> (String, u64) { + let slot = first_slot(fx, OFFERING, from_minutes, to_minutes).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + key, + json!({"hold": null, "admission": admission(fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "the direct create commits"); + ( + appointment["appointmentId"].as_str().unwrap().to_owned(), + appointment["revision"].as_u64().unwrap(), + ) +} + +async fn hook_fixture() -> Fixture { + hook_fixture_at("http://127.0.0.1:9/scheduling-hooks").await +} + +async fn hook_fixture_at(hook_url: &str) -> Fixture { + let policy = observer_policy(); + fixture_publishing_with_hook_url( + &policy, + &["north-counter".to_owned(), "two-counter".to_owned()], + hook_url, + ) + .await +} + +async fn captured_hook_envelopes( + fx: &Fixture, +) -> Vec<(String, String, String, i64, HookEnvelope, Vec)> { + fx.admin + .query( + "SELECT event_type, trigger, record_reference, record_revision, payload + FROM registry_outbox ORDER BY outbox_id", + &[], + ) + .await + .expect("read the captured hook outbox") + .into_iter() + .map(|row| { + let payload = row.get::<_, Vec>(4); + let envelope = HookEnvelope::from_canonical_bytes( + &payload, + &EnvelopeLimits::tightened_to(16 * 1024), + ) + .expect("the stored hook envelope is canonical"); + ( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + envelope, + payload, + ) + }) + .collect() +} + +#[tokio::test] +async fn appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projection() { + let fx = hook_fixture().await; + let first = first_slot(&fx, OFFERING, 300, 440).await; + let mut create = admission(&fx, OFFERING, first); + create["duplicateKey"] = json!("HOOK_DUPLICATE_CANARY"); + let (status, confirmed) = fx + .post( + "/v1/appointments", + &fx.agent, + "hook-confirmed", + json!({"hold": null, "admission": create}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "confirmation commits"); + let appointment_id = confirmed["appointmentId"] + .as_str() + .expect("an appointment id") + .to_owned(); + let first_revision = confirmed["revision"].as_u64().expect("a revision"); + + let second = first_slot(&fx, OFFERING, 480, 620).await; + let (status, rescheduled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "hook-rescheduled", + json!({ + "observedRevision": first_revision, + "admission": admission(&fx, OFFERING, second), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "reschedule commits"); + let second_revision = rescheduled["revision"].as_u64().expect("a revision"); + + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &fx.agent, + "hook-cancelled", + json!({ + "observedRevision": second_revision, + "reason": "HOOK_REASON_CANARY", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "cancellation commits"); + + let captured = captured_hook_envelopes(&fx).await; + assert_eq!(captured.len(), 3, "one event per matching lifecycle hook"); + let expected = [ + ( + "confirmed-observer", + "appointment.confirmed", + &confirmed, + json!({ + "appointmentId": appointment_id, + "offering": OFFERING, + "start": confirmed["start"], + "end": confirmed["end"], + "revision": confirmed["revision"], + "policyRevision": confirmed["policyRevision"], + "state": "confirmed", + }), + ), + ( + "rescheduled-observer", + "appointment.rescheduled", + &rescheduled, + json!({ + "appointmentId": appointment_id, + "offering": OFFERING, + "start": rescheduled["start"], + "end": rescheduled["end"], + "revision": rescheduled["revision"], + "policyRevision": rescheduled["policyRevision"], + "state": "confirmed", + }), + ), + ( + "cancelled-observer", + "appointment.cancelled", + &cancelled, + json!({ + "appointmentId": appointment_id, + "revision": cancelled["revision"], + "state": "cancelled", + }), + ), + ]; + + for ((event_type, trigger, reference, revision, envelope, payload), expected) in + captured.iter().zip(expected) + { + let (expected_type, expected_trigger, response, expected_data) = expected; + assert_eq!(event_type, expected_type); + assert_eq!(trigger, expected_trigger); + assert_eq!(envelope.event_type, expected_type); + assert_eq!(reference, &format!("/v1/appointments/{appointment_id}")); + assert_eq!(envelope.subject.record_reference, *reference); + assert_eq!(envelope.subject.record_revision, *revision); + assert_eq!(json!(*revision), response["revision"]); + assert_eq!(envelope.data, expected_data); + assert_eq!( + envelope.source, + format!("urn:registrystack:scheduling:{SCHEDULING_ID}") + ); + assert!(envelope + .dataschema + .starts_with("urn:registrystack:scheduling:hook-data:sha256:")); + assert_eq!(envelope.causation.root, envelope.id); + assert_eq!(envelope.causation.parent, None); + assert_eq!(envelope.causation.hop, 0); + + let spelled = std::str::from_utf8(payload).expect("the envelope is UTF-8 JSON"); + for canary in [ + "HOOK_DUPLICATE_CANARY", + "HOOK_REASON_CANARY", + "principal-agent", + "registry_grant_id", + ] { + assert!(!spelled.contains(canary), "payload leaked {canary}"); + } + for excluded in ["actor", "reason", "duplicateKey", "resource", "channel"] { + assert!( + envelope.data.get(excluded).is_none(), + "projection exposed {excluded}" + ); + } + } + + let delivery_count: i64 = fx + .admin + .query_one("SELECT count(*) FROM registry_webhook_deliveries", &[]) + .await + .expect("read captured platform delivery rows") + .get(0); + assert_eq!(delivery_count, 3, "every envelope has one delivery row"); +} + +#[tokio::test] +async fn a_hook_capture_failure_rolls_back_the_whole_appointment_transaction() { + let fx = hook_fixture().await; + fx.admin + .batch_execute( + "ALTER TABLE registry_outbox + ADD CONSTRAINT test_refuse_hook_capture + CHECK (event_type <> 'confirmed-observer')", + ) + .await + .expect("install the hook capture failure seam"); + + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "hook-rollback", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); + + let row = fx + .admin + .query_one( + "SELECT + (SELECT count(*) FROM scheduling_claims), + (SELECT count(*) FROM scheduling_history), + (SELECT count(*) FROM scheduling_attempts), + (SELECT count(*) FROM registry_outbox), + (SELECT count(*) FROM registry_webhook_deliveries)", + &[], + ) + .await + .expect("read the rolled-back commitment tables"); + let counts = (0..5) + .map(|index| row.get::<_, i64>(index)) + .collect::>(); + assert_eq!(counts, vec![0, 0, 0, 0, 0]); + assert!( + slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the failed hook capture did not consume capacity" + ); +} + +#[tokio::test] +async fn hook_delivery_sends_the_canonical_event_audits_egress_and_refuses_proposals() { + let destination = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/scheduling-hooks")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw( + r#"{"answer":"proposal","document":{"operation":"cancel"}}"#, + "application/json", + ) + .set_delay(Duration::from_secs(2)), + ) + .expect(1) + .mount(&destination) + .await; + let fx = hook_fixture_at(&format!("{}/scheduling-hooks", destination.uri())).await; + let (appointment_id, revision) = booked(&fx, 300, 440, "hook-delivery").await; + assert_eq!(revision, 1); + let captured = captured_hook_envelopes(&fx).await; + assert_eq!(captured.len(), 1); + let (_, _, _, _, envelope, payload) = &captured[0]; + + let delivery = fx.hooks.delivery_service(fx.store.clone()); + delivery + .verify_retained_bindings() + .await + .expect("the retained destination binding is exact"); + let running = tokio::spawn(async move { delivery.deliver_once().await }); + + let mut request_seen = false; + for _ in 0..100 { + if destination + .received_requests() + .await + .expect("read received hook requests") + .len() + == 1 + { + request_seen = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(request_seen, "the hook request reached the receiver"); + + // The receiver is still delaying its answer. The attempt audit therefore + // had to commit before egress, while no terminal audit can exist yet. + let pre_answer_audit = fx + .admin + .query( + "SELECT audit_record FROM scheduling_audit_outbox + WHERE audit_record->>'event' = 'scheduling.hook-delivery' + ORDER BY recorded_seq", + &[], + ) + .await + .expect("read the pre-egress hook audit"); + assert_eq!(pre_answer_audit.len(), 1); + let attempted = pre_answer_audit[0].get::<_, Value>(0); + assert_eq!(attempted["phase"], "attempt"); + assert_eq!(attempted["outcome"], "attempt_started"); + assert_eq!(attempted["disposition"], "leased"); + + let outcome = running + .await + .expect("the hook worker task completes") + .expect("the hook delivery completes"); + assert_eq!(outcome, DeliveryOutcome::Delivered); + + let requests = destination + .received_requests() + .await + .expect("read the delivered hook request"); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!( + request.body, *payload, + "the stored canonical bytes are sent" + ); + let header = |name: &str| { + request + .headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_else(|| panic!("missing valid {name} header")) + }; + assert_eq!(header("ce-specversion"), "1.0"); + assert_eq!(header("ce-id"), envelope.id); + assert_eq!(header("ce-source"), envelope.source); + assert_eq!(header("ce-type"), "confirmed-observer"); + assert_eq!(header("ce-dataschema"), envelope.dataschema); + let payload_json: Value = serde_json::from_slice(payload).expect("the canonical envelope JSON"); + assert_eq!(header("ce-time"), payload_json["time"]); + assert_eq!(header("x-registry-event-generation"), "1"); + assert_eq!(header("x-registry-delivery-attempt"), "1"); + assert!(!header("x-registry-delivery-time").is_empty()); + let idempotency_key = header("idempotency-key"); + assert!(idempotency_key.starts_with("sha256:")); + assert_eq!(idempotency_key.len(), 71); + let signature = header("x-registry-signature"); + assert!(signature.starts_with("v1=")); + assert_eq!(signature.len(), 46, "v1 plus one HMAC-SHA-256 tag"); + + let state = fx + .admin + .query_one( + "SELECT state, attempt, proposal_disposition, proposal_resulting_revision, + proposal_code, proposal_summary + FROM registry_webhook_delivery_state", + &[], + ) + .await + .expect("read the settled hook delivery"); + assert_eq!(state.get::<_, String>(0), "delivered"); + assert_eq!(state.get::<_, i16>(1), 1); + assert_eq!( + state.get::<_, Option>(2).as_deref(), + Some("refused") + ); + assert_eq!(state.get::<_, Option>(3), None); + assert_eq!( + state.get::<_, Option>(4).as_deref(), + Some("scheduling.hook.proposal_unsupported") + ); + assert_eq!( + state.get::<_, Option>(5).as_deref(), + Some("Scheduling appointment observer hooks cannot propose changes") + ); + + let audit = fx + .admin + .query( + "SELECT audit_record FROM scheduling_audit_outbox + WHERE audit_record->>'event' = 'scheduling.hook-delivery' + ORDER BY recorded_seq", + &[], + ) + .await + .expect("read the settled hook audit"); + assert_eq!(audit.len(), 2); + let terminal = audit[1].get::<_, Value>(0); + assert_eq!(terminal["phase"], "terminal"); + assert_eq!(terminal["outcome"], "delivered"); + assert_eq!(terminal["disposition"], "delivered"); + + let (status, unchanged) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged["state"], "confirmed"); + assert_eq!(unchanged["revision"], 1); + let history_count: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_history WHERE claim_id=$1", + &[&Uuid::parse_str(&appointment_id).expect("an appointment UUID")], + ) + .await + .expect("read unchanged appointment history") + .get(0); + assert_eq!( + history_count, 1, + "the proposal changed no appointment state" + ); +} + +#[tokio::test] +async fn hook_delivery_audit_failure_prevents_egress() { + let destination = MockServer::start().await; + let fx = hook_fixture_at(&format!("{}/scheduling-hooks", destination.uri())).await; + let _ = booked(&fx, 300, 440, "hook-audit-failure").await; + fx.admin + .batch_execute( + "ALTER TABLE scheduling_audit_outbox + ADD CONSTRAINT test_refuse_hook_delivery_audit + CHECK ((audit_record->>'event') IS DISTINCT FROM 'scheduling.hook-delivery')", + ) + .await + .expect("install the delivery audit failure seam"); + + let delivery = fx.hooks.delivery_service(fx.store.clone()); + assert!(delivery.deliver_once().await.is_err()); + assert!( + destination + .received_requests() + .await + .expect("read hook requests") + .is_empty(), + "a failed pre-egress audit prevents the send" + ); + let state = fx + .admin + .query_one( + "SELECT state, attempt FROM registry_webhook_delivery_state", + &[], + ) + .await + .expect("read the rolled-back claim state"); + assert_eq!(state.get::<_, String>(0), "pending"); + assert_eq!(state.get::<_, i16>(1), 0); +} + +#[tokio::test] +async fn changed_retained_hook_destination_binding_is_refused() { + let fx = hook_fixture().await; + let _ = booked(&fx, 300, 440, "hook-binding-change").await; + let policy = parse_policy_yaml(&observer_policy()).expect("the scheduling hook policy"); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") + .expect("the scheduling hook secret resolver"); + let destinations = BTreeMap::from([( + "appointment-events".to_owned(), + HookDestinationConfig { + url: "http://127.0.0.1:9/changed-scheduling-hooks".to_owned(), + hmac_sha256_key_ref: fx.hook_secret_ref.clone(), + attempt_timeout_milliseconds: 5_000, + maximum_attempts: 1, + }, + )]); + let changed = ActivatedHooks::activate( + &policy.hooks, + &destinations, + &secrets, + HookRuntimeIdentity { + scheduling_id: SCHEDULING_ID.to_owned(), + policy_revision: i64::try_from(fx.revision).expect("a bounded policy revision"), + policy_digest: policy.policy_digest(), + }, + fx.schema.clone(), + Duration::from_secs(7 * 24 * 60 * 60), + ) + .expect("activate the deliberately changed destination"); + + assert!( + changed + .delivery_service(fx.store.clone()) + .verify_retained_bindings() + .await + .is_err(), + "retained work cannot be retargeted under the same logical id" + ); +} + +#[tokio::test] +async fn a_hold_confirms_into_an_appointment_with_attributable_history() { + let fx = hook_fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-1", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert_eq!(hold["offering"], OFFERING); + assert_eq!(hold["resource"], "station-1"); + assert_eq!(hold["policyRevision"], fx.revision); + assert_eq!(moment(&hold, "start"), slot); + let expires_in = moment(&hold, "expiresAt") - Utc::now(); + assert!( + expires_in >= TimeDelta::minutes(9) && expires_in <= TimeDelta::minutes(11), + "the hold expires at the authored ten minutes: {expires_in}" + ); + + // A retried create with the same key answers as it first did. + let (status, replayed) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-1", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(replayed["holdId"], hold_id); + + // The held slot is not availability while the hold stands. + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-1", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(appointment["state"], "confirmed"); + assert_eq!(appointment["resource"], "station-1"); + assert_eq!(moment(&appointment, "start"), slot); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + + let (status, history) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + let items = history["items"].as_array().unwrap(); + // Confirming mints the appointment as its own claim: one attributable + // event, the confirmation itself. The hold's own "held" and "consumed" + // events stay on the hold's history. + assert_eq!(items.len(), 1, "a confirmed hold books one appointment"); + assert_eq!(items[0]["kind"], "confirmed"); + assert_eq!(items[0]["revision"], 1); + assert!(items[0]["eventId"].is_string()); + assert!(items[0]["occurredAt"].is_string()); + assert!(items[0]["actor"].is_string()); + + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["appointmentId"], appointment_id); + + let captured = captured_hook_envelopes(&fx).await; + assert_eq!( + captured.len(), + 1, + "hold confirmation captures one observer event" + ); + assert_eq!(captured[0].0, "confirmed-observer"); + assert_eq!(captured[0].1, "appointment.confirmed"); + assert_eq!(captured[0].4.data["appointmentId"], appointment_id); +} + +#[tokio::test] +async fn a_direct_create_books_without_a_hold_and_exhausts_capacity() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "direct-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(appointment["state"], "confirmed"); + assert_eq!(moment(&appointment, "start"), slot); + + // One member serves one booking: the same start is no longer offered and + // a second direct create is a capacity refusal. + assert!(!slot_offered(&fx, OFFERING, 300, 440, slot).await); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "direct-2", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); +} + +#[tokio::test] +async fn exact_time_commitments_include_buffers_outside_the_opening_range() { + let buffered = POLICY + .replacen("endTime: \"23:30\"", "endTime: \"23:59\"", 1) + .replacen("bufferBeforeMinutes: 0", "bufferBeforeMinutes: 30", 1) + .replacen("durationMinutes: 60", "durationMinutes: 45", 1); + let fx = fixture_publishing( + &buffered, + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let midnight = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(0, 0, 0).expect("midnight"), + Utc, + ); + let earlier = midnight - TimeDelta::hours(1); + let (status, first) = fx + .post( + "/v1/appointments", + &fx.agent, + "buffer-boundary-first", + json!({ + "hold": null, + "admission": admission(&fx, OVERLAPPING_OFFERING, earlier), + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{first}"); + + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "buffer-boundary-second", + json!({"hold": null, "admission": admission(&fx, OFFERING, midnight)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{problem}"); + assert_eq!(problem["code"], "capacity.exhausted"); +} + +#[tokio::test] +async fn duplicate_active_keys_are_scoped_to_the_offering() { + let keyed = POLICY.replacen( + " requiresCapabilities: []", + " duplicateActiveKey: subject\n requiresCapabilities: []", + 2, + ); + let fx = fixture_publishing( + &keyed, + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + let first = first_slot(&fx, OFFERING, 300, 440).await; + let second = first_slot(&fx, OVERLAPPING_OFFERING, 480, 620).await; + let mut first_admission = admission(&fx, OFFERING, first); + first_admission["duplicateKey"] = json!("subject:shared"); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-offering-first", + json!({"hold": null, "admission": first_admission}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let mut second_admission = admission(&fx, OVERLAPPING_OFFERING, second); + second_admission["duplicateKey"] = json!("subject:shared"); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-offering-second", + json!({"hold": null, "admission": second_admission}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); +} + +#[tokio::test] +async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() { + let fx = fixture().await; + let from = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "resched-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let observed = appointment["revision"].as_u64().unwrap(); + + let other = first_slot(&fx, OFFERING, 480, 620).await; + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-1", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(moved["revision"], json!(observed + 1)); + assert_eq!(moment(&moved, "start"), other); + assert!( + !slot_offered(&fx, OFFERING, 480, 620, other).await, + "the new start is committed" + ); + assert!( + slot_offered(&fx, OFFERING, 300, 440, from).await, + "the replaced start is bookable again" + ); + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-2", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "revision.mismatch"); +} + +#[tokio::test] +async fn a_foreign_actor_does_not_learn_the_current_revision() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 300, 440, "owner-revision-create").await; + let stranger = agent_token_for("principal-stranger"); + let other = first_slot(&fx, OFFERING, 480, 620).await; + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &stranger, + "foreign-reschedule-revision", + json!({ + "observedRevision": 0, + "admission": admission(&fx, OFFERING, other), + }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &stranger, + "foreign-cancel-revision", + json!({"observedRevision": 0, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); +} + +#[tokio::test] +async fn a_reschedule_refuses_an_admission_for_another_offering() { + let fx = fixture().await; + let from = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "resched-offering-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + let appointment_id = appointment["appointmentId"].as_str().unwrap(); + let observed = appointment["revision"].as_u64().unwrap(); + let other = first_slot(&fx, SECOND_OFFERING, 480, 620).await; + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-offering-mismatch", + json!({ + "observedRevision": observed, + "admission": admission(&fx, SECOND_OFFERING, other), + }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{problem}"); + assert_eq!(problem["code"], "request.unprocessable"); +} + +/// COR-1. A reschedule must never compete with the appointment it is moving. +/// The runtime supplies the appointment's own allocation as the exclusion +/// from inside the commitment transaction, so a move that overlaps the +/// interval the appointment already holds is admitted rather than answered +/// `capacity.exhausted`. +#[tokio::test] +async fn a_reschedule_moves_inside_the_interval_the_appointment_already_holds() { + let fx = fixture().await; + let (from, onto) = first_overlapping_pair(&fx, OVERLAPPING_OFFERING, 300, 900).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "self-overlap-create", + json!({"hold": null, "admission": admission(&fx, OVERLAPPING_OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let observed = appointment["revision"].as_u64().unwrap(); + + // The new interval starts inside the one the appointment already holds. + // The only claim standing in its way is its own. + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "self-overlap-move", + json!({ + "observedRevision": observed, + "admission": admission(&fx, OVERLAPPING_OFFERING, onto), + }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a reschedule excludes its own allocation: {moved}" + ); + assert_eq!(moment(&moved, "start"), onto); + + // The degenerate case: a move onto the start the appointment already + // holds, entirely inside its own occupied interval. + let (status, again) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "self-overlap-restate", + json!({ + "observedRevision": observed + 1, + "admission": admission(&fx, OVERLAPPING_OFFERING, onto), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "a move onto its own start: {again}"); + assert_eq!(moment(&again, "start"), onto); + assert_eq!(again["revision"], json!(observed + 2)); +} + +/// BL-1. The exclusion belongs to the runtime, never to the caller. A create +/// path handed a live claim's id is refused at the edge, so no caller can +/// name another party's allocation out of the capacity check. +#[tokio::test] +async fn a_create_cannot_name_a_live_claim_to_leave_out_of_the_capacity_check() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-standing", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let standing = appointment["appointmentId"].as_str().unwrap().to_owned(); + + let mut named = admission(&fx, OFFERING, slot); + named["rescheduleOf"] = json!(standing); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-create", + json!({"hold": null, "admission": named.clone()}), + ) + .await; + // The wire shape no longer declares the field, so a body carrying it is + // refused as unprocessable by the strict request parsing every create + // route already answers through. + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "the create path refuses a caller-named exclusion: {problem}" + ); + assert_eq!(problem["code"], "request.unprocessable"); + + let (status, problem) = fx.post("/v1/holds", &fx.agent, "bypass-hold", named).await; + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "the hold path refuses it too: {problem}" + ); + assert_eq!(problem["code"], "request.unprocessable"); + + // The same second caller without the field meets the standing + // appointment, which is the whole capacity of this pool. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-plain", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); + assert!( + !slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the slot the standing appointment {standing} holds is not availability" + ); +} + +#[tokio::test] +async fn cancellation_respects_the_cutoff_and_replays_its_verdict() { + let fx = fixture().await; + // Near: inside the four-hour cutoff. Far: clear of it. + let (near_id, near_revision) = booked(&fx, 90, 200, "cancel-near").await; + let (status, refused) = fx + .post( + &format!("/v1/appointments/{near_id}/cancel"), + &fx.agent, + "cancel-near-key", + json!({"observedRevision": near_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(refused["code"], "cancellation.cutoff-passed"); + // The refused verdict replays under the same key. + let (status, replayed) = fx + .post( + &format!("/v1/appointments/{near_id}/cancel"), + &fx.agent, + "cancel-near-key", + json!({"observedRevision": near_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(replayed["code"], "cancellation.cutoff-passed"); + let (status, untouched) = fx + .get(&format!("/v1/appointments/{near_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + untouched["state"], "confirmed", + "a refused cancel changes nothing" + ); + + let (far_id, far_revision) = booked(&fx, 300, 440, "cancel-far").await; + let far_slot = moment( + &fx.get(&format!("/v1/appointments/{far_id}"), &fx.agent) + .await + .1, + "start", + ); + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{far_id}/cancel"), + &fx.agent, + "cancel-far-key", + json!({"observedRevision": far_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(cancelled["state"], "cancelled"); + assert!(cancelled["cancelledAt"].is_string()); + assert!( + slot_offered(&fx, OFFERING, 300, 440, far_slot).await, + "a cancelled appointment's capacity is bookable again" + ); + // The cancelled appointment's history walks newest first: the + // cancellation over the confirmation it cancelled. + let (status, history) = fx + .get(&format!("/v1/appointments/{far_id}/history"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + let items = history["items"].as_array().unwrap(); + let kinds: Vec<&str> = items + .iter() + .filter_map(|entry| entry["kind"].as_str()) + .collect(); + assert_eq!(kinds, ["cancelled", "confirmed"]); + let revisions: Vec = items + .iter() + .map(|entry| entry["revision"].as_u64().expect("a bounded revision")) + .collect(); + assert_eq!(revisions, [2, 1], "history walks newest first"); + let (status, again) = fx + .post( + &format!("/v1/appointments/{far_id}/cancel"), + &fx.agent, + "cancel-far-key", + json!({"observedRevision": far_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(again["state"], "cancelled"); +} + +#[tokio::test] +async fn a_release_returns_capacity_answers_replay_and_blocks_confirmation() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-r", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert!(!slot_offered(&fx, OFFERING, 300, 440, slot).await); + + let (status, _) = fx.delete(&format!("/v1/holds/{hold_id}"), &fx.agent).await; + assert_eq!(status, StatusCode::NO_CONTENT); + assert!( + slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the release returns capacity" + ); + + // The release carries the hold's own id as its idempotency key: a retried + // release answers with the same empty 204. + let (status, _) = fx.delete(&format!("/v1/holds/{hold_id}"), &fx.agent).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // A released hold can no longer be confirmed. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-r", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "hold.released"); +} + +#[tokio::test] +async fn an_expired_hold_returns_capacity_and_refuses_confirmation() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-x", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + // The hold-expiry worker's pass, run past the hold's due moment. + let expired = fx + .store + .expire_due_holds(Utc::now() + TimeDelta::minutes(11), 100) + .await + .expect("the hold expiry pass"); + assert_eq!(expired, 1); + assert!( + slot_offered(&fx, OFFERING, 90, 200, slot).await, + "capacity reappears" + ); + + // Once the sweeper has closed the hold, confirmation meets a claim in no + // longer active state and answers hold.released; the distinct hold.expired + // refusal belongs to the window between lapse and sweep, which this suite + // cannot reach without sleeping past the TTL. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-x", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "hold.released"); +} + +/// SEC-01 and SEC-11 at their shared transaction boundary. A confirmation may +/// enter while its hold is live and pause before it reaches the supply anchor. +/// Once the hold expires, a later transaction may reclaim and book that +/// capacity. The delayed confirmation must observe expiry after it takes the +/// anchor; transferring under its older request time would leave two active +/// bookings on one member. +#[tokio::test] +async fn a_delayed_confirmation_cannot_transfer_rebooked_capacity_after_expiry() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "expiry-race-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!( + status, + StatusCode::CREATED, + "the setup hold commits: {hold}" + ); + let hold_id = + Uuid::parse_str(hold["holdId"].as_str().expect("a hold id")).expect("a hold UUID"); + let expires_at = moment(&hold, "expiresAt"); + let confirmation_started_at = expires_at - TimeDelta::seconds(1); + let after_expiry = expires_at + TimeDelta::seconds(1); + let hold_actor = fx + .store + .claim(hold_id) + .await + .expect("read the held claim") + .expect("the hold stands") + .actor; + + // Capture the confirmation's request clock and resolved supply while the + // hold is live, then stop it before the capacity transaction opens. This + // is the same gap as authentication and supply resolution in the service. + let delayed_store = fx.store.clone(); + let delayed_revision = fx.revision; + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + let delayed = tokio::spawn(async move { + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + let (facts, facts_revision) = delayed_store + .facts() + .await + .expect("resolve supply before the stall"); + let (members, open, closures) = exact_supply_parts(&policy, &facts, offering); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("the exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let commitment = Commitment { + now: confirmation_started_at, + policy_revision: i64::try_from(delayed_revision).expect("a bounded revision"), + facts_revision, + actor: &hold_actor, + actor_issuer: ISSUER, + actor_subject: "principal-agent", + idempotency_key: "expiry-race-confirm", + request_hash: "sha256:expiry-race-confirm", + attempt_expires_at: confirmation_started_at + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + hooks: None, + }; + started_tx + .send(()) + .expect("the test is waiting for the captured confirmation"); + resume_rx.await.expect("resume the delayed confirmation"); + delayed_store + .confirm_hold(hold_id, offering, &supply, commitment) + .await + }); + started_rx + .await + .expect("the confirmation captured its live-hold context"); + + // A later request sees the hold as expired and legitimately books the + // released capacity before the older confirmation reaches its anchor. + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + let (facts, facts_revision) = fx.store.facts().await.expect("resolve current supply"); + let (members, open, closures) = exact_supply_parts(&policy, &facts, offering); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("the exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let request: AdmissionRequest = + serde_json::from_value(admission(&fx, OFFERING, slot)).expect("the admission request"); + let competing = fx + .store + .create_appointment( + offering, + &supply, + &request, + Commitment { + now: after_expiry, + policy_revision: i64::try_from(fx.revision).expect("a bounded revision"), + facts_revision, + actor: "competing-actor", + actor_issuer: ISSUER, + actor_subject: "competing-principal", + idempotency_key: "expiry-race-booking", + request_hash: "sha256:expiry-race-booking", + attempt_expires_at: after_expiry + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + hooks: None, + }, + ) + .await + .expect("the expired hold releases capacity to the competing booking"); + assert!(matches!(competing, CommitOutcome::Booking(_))); + + // The delayed confirmation now reaches the anchor under a clock after the + // hold's expiry. It must refuse instead of transferring already-booked + // capacity under its older request observation. + fx.store.pin_clock(Arc::new(move || after_expiry)); + resume_tx + .send(()) + .expect("the delayed confirmation is still waiting"); + let confirmation = delayed.await.expect("the confirmation task answers"); + assert!( + matches!( + confirmation, + Err(CommitError::Refused(AdmissionRefusal::HoldExpired)) + ), + "the delayed confirmation is refused as expired: {confirmation:?}" + ); + + let counts = fx + .admin + .query_one( + "SELECT \ + (SELECT count(*) FROM scheduling_claims \ + WHERE kind='booking' AND state='active' AND displayed_start=$1), \ + (SELECT state FROM scheduling_claims WHERE claim_id=$2)", + &[&slot, &hold_id], + ) + .await + .expect("read the capacity decision"); + assert_eq!( + counts.get::<_, i64>(0), + 1, + "the expired hold never creates a second active booking" + ); + assert_eq!( + counts.get::<_, String>(1), + "active", + "the refused confirmation rolls back without consuming the hold row" + ); +} + +#[tokio::test] +async fn cursors_page_their_own_listing_and_refuse_foreign_contexts() { + let fx = fixture().await; + let (status, first) = fx.get("/v1/services?limit=1", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(first["items"].as_array().unwrap().len(), 1); + let cursor = first["nextCursor"].as_str().unwrap().to_owned(); + let first_id = first["items"][0]["id"].as_str().unwrap().to_owned(); + + let (status, second) = fx + .get(&format!("/v1/services?limit=1&cursor={cursor}"), &fx.reader) + .await; + assert_eq!(status, StatusCode::OK); + let items = second["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_ne!(items[0]["id"], first_id, "the page after the cursor is new"); + assert!( + second["nextCursor"].is_null(), + "two services fit in two pages" + ); + + // A cursor answers only the listing that minted it. + let (status, problem) = fx + .get( + &format!("/v1/offerings?limit=1&cursor={cursor}"), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); + + // Availability cursors bind to the exact window they paged. + let (status, window) = fx + .get( + &format!("{}&limit=1", availability_uri(OFFERING, 90, 200)), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::OK); + let availability_cursor = window["nextCursor"].as_str().unwrap().to_owned(); + let (status, problem) = fx + .get( + &format!( + "{}&limit=1&cursor={availability_cursor}", + availability_uri(OFFERING, 300, 440), + ), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); +} + +#[tokio::test] +async fn the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + let (status, problem) = fx.request("GET", "/v1/services", "", None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(problem["code"], "authentication.refused"); + + // The reader holds the reads scope but not the explain scope. + let (status, problem) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "profile.not-authorized"); + + // Readable availability is not authority to book: the reader carries no + // task grant. + let (status, problem) = fx + .post( + "/v1/holds", + &fx.reader, + "reader-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + // With the explain scope, a free start explains as admitting, and a + // committed one explains the public code with the detailed one behind it. + let (status, free) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + free["publicCode"].is_null(), + "a free start explains as admitting" + ); + + let other = first_slot(&fx, OFFERING, 480, 620).await; + let _ = booked(&fx, 480, 620, "explain-booked").await; + let (status, explained) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(other) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + // A contended start carries the same public and detailed code; the + // detailed vocabulary exists for refusals the public projection hides + // behind the same few words. + assert_eq!(explained["publicCode"], "capacity.exhausted"); + assert_eq!(explained["detailedCode"], "capacity.exhausted"); +} + +#[tokio::test] +async fn explain_assumes_the_offerings_declared_inputs_are_present() { + let authored = POLICY.replacen( + " prerequisites: []", + " prerequisites: [registry-update-permit]", + 1, + ); + let policy = parse_policy_yaml(&authored).expect("the policy with a required input"); + let mut pool_ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + let fx = fixture_publishing(&authored, &pool_ids).await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + let (status, explanation) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{explanation}"); + assert!( + explanation["publicCode"].is_null(), + "the synthetic probe satisfies declared inputs: {explanation}" + ); +} + +#[tokio::test] +async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { + // Publish the policy with only one of its two pools anchored: reads + // still answer, while a commitment that must lock the unanchored pool + // refuses rather than transacting without the anchor it serializes + // against, and the refusal leaves no stored attempt behind. + let fx = fixture_anchoring(&["north-counter".to_owned()]).await; + let slot = first_slot(&fx, SECOND_OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "unanchored-1", + json!({"hold": null, "admission": admission(&fx, SECOND_OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); + + // Nothing was decided: no attempt was recorded, so a fresh key meets the + // same refusal. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "unanchored-2", + json!({"hold": null, "admission": admission(&fx, SECOND_OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); +} + +#[tokio::test] +async fn an_arrival_offering_without_its_window_record_is_an_operator_gap() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_publishing( + &policy_with_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + + let (status, unavailable) = fx + .get(&availability_uri(WINDOW_OFFERING, 60, 300), &fx.reader) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{unavailable}"); + assert_eq!(unavailable["code"], "service.unavailable"); + + let (status, unavailable) = fx + .post( + "/v1/appointments", + &fx.agent, + "missing-window-record", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{unavailable}"); + assert_eq!(unavailable["code"], "service.unavailable"); +} + +/// Adoption binds one deployment identity: claiming from empty is the +/// fixture's own provisioning line, the same id again is a no-op, and a +/// different id is refused, so two deployments never share one database +/// silently. +#[tokio::test] +async fn adoption_binds_one_identity_and_refuses_a_second() { + let fx = fixture().await; + fx.store + .adopt(SCHEDULING_ID) + .await + .expect("re-adopting the same id is a no-op"); + assert!(matches!( + fx.store.adopt("another-deployment").await, + Err(registry_scheduling::store::StoreError::Corrupt) + )); + // The refused adopt changed nothing: the deployment still answers under + // its adopted identity. + let (status, document) = fx.get("/v1/scheduling", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(document["schedulingId"], SCHEDULING_ID); +} + +/// An idempotency key answers three ways over its life. The same request +/// replays the first answer verbatim. A different request under the same key +/// is refused as reused. A request retried after the receipt's retention +/// period is refused as expired, because the sweep tombstones the receipt +/// instead of deleting it: a deleted receipt would let the retry execute a +/// second time and book a second appointment. +#[tokio::test] +async fn an_idempotency_key_replays_refuses_a_reuse_and_expires_into_a_refusal() { + let fx = fixture().await; + let (first, second) = first_overlapping_pair(&fx, OFFERING, 90, 260).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + + // The same key with the same payload replays the stored answer. + let (status, replayed) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(replayed["appointmentId"], appointment_id); + + // The same key with a different payload is a reuse, not a replay. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, second)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // The retention sweep passes over the receipt's period. + let erased = fx + .store + .erase_expired_attempts(Utc::now() + TimeDelta::days(8)) + .await + .expect("the retention sweep runs"); + assert_eq!(erased, 1, "the sweep covers the one stored receipt"); + + // The key is spent, not forgotten: the retry is refused, and no second + // appointment was booked behind it. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(problem["code"], "idempotency.expired"); + + // A different payload under the spent key is still a reuse: the erased + // receipt keeps the request it answered, so a conflict outranks expiry. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, second)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // The original appointment stands untouched. + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["state"], "confirmed"); + assert_eq!(moment(&fetched, "start"), first); +} + +/// Two writers reaching the same idempotency key at once: one commits, and +/// the loser is told the key is already spoken for instead of being told the +/// service is unavailable. The loser's whole transaction rolls back, so the +/// slot it was reaching for stays free. +#[tokio::test] +async fn a_concurrent_writer_of_one_idempotency_key_is_refused_not_failed() { + let fx = fixture().await; + let (first, second) = first_overlapping_pair(&fx, OFFERING, 90, 260).await; + let (status, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "race-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + // Another writer under the caller's own identity claims "race-2" and has + // not committed yet, so the request below reads no stored attempt and + // only meets the key at the moment it writes its own. + fx.admin + .batch_execute( + "BEGIN; \ + INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + SELECT gen_random_uuid(), actor_issuer, actor_subject, scope, 'race-2', \ + 'a-different-request', state, status_code, receipt, expires_at \ + FROM scheduling_attempts WHERE idempotency_key = 'race-1'", + ) + .await + .expect("hold the idempotency key from another writer"); + + let contender = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("race-2".to_owned()), + Some(json!({"hold": null, "admission": admission(&fx, OFFERING, second)})), + )); + // The contender runs to the point where it writes its attempt and waits + // there on the key; releasing the other writer decides the race. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + fx.admin + .batch_execute("COMMIT") + .await + .expect("release the held idempotency key"); + let (status, problem) = contender.await.expect("the contending request answers"); + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // Losing the key decided nothing: the slot the contender reached for is + // still offered. + assert!(slot_offered(&fx, OFFERING, 90, 260, second).await); +} + +#[tokio::test] +async fn a_concurrent_identical_request_replays_the_winning_receipt() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 260).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "race-same-seed", + body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + fx.admin + .batch_execute( + "BEGIN; \ + INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + SELECT gen_random_uuid(), actor_issuer, actor_subject, scope, 'race-same', \ + request_hash, state, status_code, receipt, expires_at \ + FROM scheduling_attempts WHERE idempotency_key = 'race-same-seed'", + ) + .await + .expect("hold the winning receipt from another writer"); + + let contender = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("race-same".to_owned()), + Some(body), + )); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + fx.admin + .batch_execute("COMMIT") + .await + .expect("release the winning receipt"); + let (status, replayed) = contender.await.expect("the contending request answers"); + assert_eq!(status, StatusCode::CREATED, "{replayed}"); + assert_eq!(replayed["appointmentId"], appointment["appointmentId"]); +} + +/// The revision a commitment is admitted under is the one the deployment +/// stores, not the one this process read when it started. Operator tooling +/// and a rolling deploy both move the stored revision under a running +/// process, and a caller holding the older number is told the policy moved +/// rather than having a claim written under a revision that no longer +/// stands. +#[tokio::test] +async fn a_policy_applied_under_a_running_process_refuses_the_older_revision() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "moved-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + + // Operator tooling publishes a different policy against the same + // deployment, exactly as `schedulingctl` does beside a running runtime. + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + let replacement_digest = replacement.policy_digest(); + let moved = fx + .store + .apply_policy( + SCHEDULING_ID, + &replacement_digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect("publish a second policy revision"); + assert_eq!(moved, i64::try_from(fx.revision).unwrap() + 1); + + // The hold was admitted under the revision that has now moved, so + // confirming it is refused rather than booked under a stale policy. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "moved-confirm", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "policy.changed"); + + // So is a direct create carrying the revision availability advertised + // before the policy moved. + let other = first_slot(&fx, OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "moved-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "policy.changed"); +} + +/// A reminder intent is minted when the appointment commits, and a delivery +/// that failed waits out its back-off. Re-claiming a failing intent on every +/// tick would hammer the destination and burn the attempts ceiling in +/// seconds, so the claim predicate reads the back-off the retry wrote. +#[tokio::test] +async fn a_failing_reminder_intent_waits_out_its_back_off() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 90, 200, "reminder-1").await; + + // The commitment's own confirmation is due at once; the authored reminder + // offset is an hour before a start at least ninety minutes out, so the + // reminder is not due with it. + let now_due = fx + .store + .claim_due_intents(Utc::now(), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs"); + assert_eq!( + now_due + .iter() + .map(|intent| intent.purpose.as_str()) + .collect::>(), + vec!["confirmation"], + "a reminder ahead of its offset is not due yet" + ); + + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the commitment minted one reminder intent" + ); + assert_eq!(claimed[0].claim_id.to_string(), appointment_id); + assert_eq!(claimed[0].attempts, 1); + assert_eq!(claimed[0].payload["appointmentId"], appointment_id); + + // The send failed, so the intent goes back to pending behind a back-off. + fx.store + .retry_intent( + claimed[0].outbox_id, + due + TimeDelta::minutes(5), + 8, + claimed[0].attempts, + ) + .await + .expect("the failed delivery is scheduled to retry"); + + let early: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::seconds(2), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == claimed[0].outbox_id) + .collect(); + assert!( + early.is_empty(), + "an intent inside its back-off is not claimed again" + ); + + let later: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::minutes(6), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == claimed[0].outbox_id) + .collect(); + assert_eq!(later.len(), 1, "the back-off passed, so the intent is due"); + assert_eq!(later[0].attempts, 2, "the second attempt is counted once"); +} + +/// The environment records the fixture seeds, minus the named resources: the +/// document `schedulingctl records apply` would send to retire a station. +fn records_without(retired: &[&str]) -> SchedulingFacts { + let member = |resource_id: &str| PoolMember { + resource_id: resource_id.to_owned(), + capabilities: Vec::new(), + available: true, + }; + SchedulingFacts { + locations: vec![LocationRecord { + id: "north-counter".to_owned(), + timezone: "UTC".to_owned(), + }], + pools: vec![ + ResourcePool { + id: "north-counter".to_owned(), + members: vec![member("station-1")], + }, + ResourcePool { + id: "two-counter".to_owned(), + members: vec![member("station-2")], + }, + ] + .into_iter() + .map(|pool| ResourcePool { + members: pool + .members + .into_iter() + .filter(|member| !retired.contains(&member.resource_id.as_str())) + .collect(), + ..pool + }) + .collect(), + windows: Vec::new(), + exceptions: Vec::new(), + } +} + +fn operator_audit() -> Value { + json!({ + "actorKind": "operator", + "operation": "records.apply", + "outcome": "allowed", + "reason": "authorization.allowed", + }) +} + +#[tokio::test] +async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "records-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "the direct create commits"); + + let refusal = fx + .store + .replace_facts( + SCHEDULING_ID, + &records_without(&["station-1"]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect_err("a swap that strands a live booking is refused"); + assert!( + refusal.to_string().contains("station-1"), + "{refusal} does not name the occupied resource" + ); + + // The refusal rolled the whole swap back, so the records stand as they + // were and the booking still occupies its slot. + let (standing, _) = fx.store.facts().await.expect("the records survive"); + assert_eq!( + standing + .pool("north-counter") + .map(|pool| pool.members.len()), + Some(1), + "the occupied member is still a member" + ); + assert!( + !slot_offered(&fx, OFFERING, 90, 200, slot).await, + "the booking still consumes its slot" + ); + + // Retiring an idle station is exactly what the command is for. + fx.store + .replace_facts( + SCHEDULING_ID, + &records_without(&["station-2"]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("retiring an unoccupied resource commits"); + let (standing, _) = fx.store.facts().await.expect("the records are readable"); + assert_eq!( + standing.pool("two-counter").map(|pool| pool.members.len()), + Some(0), + "the idle member is gone" + ); +} + +#[tokio::test] +async fn a_records_swap_refuses_to_move_an_occupied_resource_between_pools() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "records-move-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let mut moved = records_without(&[]); + let station = moved.pools[0].members.remove(0); + moved.pools[1].members.push(station); + let refusal = fx + .store + .replace_facts(SCHEDULING_ID, &moved, Uuid::new_v4(), operator_audit()) + .await + .expect_err("an occupied resource cannot move to another pool"); + assert!(refusal.to_string().contains("station-1"), "{refusal}"); + + let (standing, _) = fx.store.facts().await.expect("the records survive"); + assert!(standing.pool("north-counter").is_some_and(|pool| pool + .members + .iter() + .any(|member| member.resource_id == "station-1"))); +} + +#[tokio::test] +async fn a_records_swap_waits_for_the_capacity_transaction_holding_the_pool() { + let fx = fixture().await; + let mut admin = fx.admin; + + // Stand in for a commitment mid-evaluation: it holds the pool's supply + // anchor from its snapshot until it commits. + let holder = admin + .transaction() + .await + .expect("the stand-in capacity transaction opens"); + holder + .execute( + "SELECT supply_id FROM scheduling_supply WHERE supply_id='north-counter' FOR UPDATE", + &[], + ) + .await + .expect("the stand-in holds the pool anchor"); + + let store = fx.store.clone(); + let swap = tokio::spawn(async move { + store + .replace_facts( + SCHEDULING_ID, + &records_without(&[]), + Uuid::new_v4(), + operator_audit(), + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !swap.is_finished(), + "the swap runs while a commitment holds the pool it would rewrite" + ); + + holder.commit().await.expect("the stand-in commits"); + swap.await + .expect("the swap task runs to completion") + .expect("the swap commits once the pool is free"); +} + +#[tokio::test] +async fn a_history_cursor_is_re_authorized_against_the_caller_of_the_page() { + let fx = fixture().await; + let (appointment_id, revision) = booked(&fx, 90, 200, "cursor-actor-1").await; + let next = first_slot(&fx, OFFERING, 300, 440).await; + let (status, _) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "cursor-actor-2", + json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, next), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "the reschedule commits"); + + let (status, page) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + let cursor = page["nextCursor"] + .as_str() + .expect("two history events page in two") + .to_owned(); + + // The cursor carries no actor, so the second page is safe only because + // the listing re-runs its ownership check against whoever presents it. + let stranger = agent_token_for("principal-stranger"); + let (status, problem) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1&cursor={cursor}"), + &stranger, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a stranger holding the token is still not the owner" + ); + assert_eq!(problem["code"], "operation.not-authorized"); + + // The owner continues the same listing normally. + let (status, page) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1&cursor={cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(page["items"].as_array().map(Vec::len), Some(1)); +} + +/// Write a runtime configuration whose migration database is a port nothing +/// listens on, so `scheduling migrate` fails at the connection and nowhere +/// else. +fn unreachable_deployment(root: &std::path::Path) -> std::path::PathBuf { + let package = root.join("package"); + std::fs::create_dir_all(&package).expect("a package directory"); + std::fs::write( + package.join(registry_scheduling_core::AUTHORED_POLICY_FILE), + POLICY, + ) + .expect("the authored policy"); + let secret_name = format!("SCHEDULING_CLOSED_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var( + &secret_name, + "postgres://scheduling:scheduling@127.0.0.1:1/scheduling_runtime", + ); + let document = json!({ + "apiVersion": registry_scheduling_core::SCHEDULING_RUNTIME_API_VERSION, + "kind": registry_scheduling_core::SCHEDULING_RUNTIME_KIND, + "package": {"root": package}, + "listener": {"bind": "127.0.0.1:8199", "tlsTermination": "development-loopback"}, + "secretProviders": {"environment": {}}, + "database": { + "runtimeUrlRef": format!("secret:env/{secret_name}"), + "migrationUrlRef": format!("secret:env/{secret_name}"), + "testOnlyPlaintext": true, + }, + "authentication": {"oidc": { + "issuer": ISSUER, + "audience": AUDIENCE, + "allowedClients": [CLIENT], + }}, + "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:env/UNUSED"}, + "destinations": {}, + "retention": {"attemptReceiptDays": 7}, + }); + let operator = root.join("runtime.yaml"); + std::fs::write( + &operator, + serde_norway::to_string(&document).expect("a serializable configuration"), + ) + .expect("the runtime configuration"); + operator +} + +#[tokio::test] +async fn a_database_that_refuses_at_startup_names_the_step_and_the_cause() { + let root = tempfile::tempdir().expect("a temporary deployment root"); + let operator = unreachable_deployment(root.path()); + let failure = registry_scheduling::runtime::migrate_from_path(&operator) + .await + .expect_err("an unreachable database fails the migration"); + let message = failure.to_string(); + assert!( + message.contains("schema migration"), + "{message} does not name the startup step that failed" + ); + assert!( + message.to_ascii_lowercase().contains("connect"), + "{message} does not name why the database was unusable" + ); + assert!( + !message.contains("scheduling:scheduling"), + "the startup failure repeats the database credentials" + ); +} + +/// A booking agent whose grant names another location, so its bounds cover no +/// action on the test policy's offering. +fn agent_token_outside_its_bounds() -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"] = json!({ + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "south-counter", + "actions": ["hold.create"], + }], + }); + let mut claims = json!({ + "sub": "principal-elsewhere", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// The pending journal keyed by the reason each record carries. +async fn audit_by_reason(fx: &Fixture) -> std::collections::BTreeMap { + fx.store + .pending_audit(100) + .await + .expect("the pending audit journal") + .into_iter() + .map(|(_, record)| { + let reason = record["reason"] + .as_str() + .expect("an audit record names its reason") + .to_owned(); + (reason, record) + }) + .collect() +} + +#[tokio::test] +async fn a_permission_refused_before_the_transaction_writes_its_audit_row() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + // A grant that covers another location is authority for nothing here. + let (status, problem) = fx + .post( + "/v1/holds", + &agent_token_outside_its_bounds(), + "outside-bounds", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + // Readable availability is not authority to book, and the standing + // reader carries no task grant at all. + let (status, problem) = fx + .post( + "/v1/holds", + &fx.reader, + "no-grant", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + let journal = audit_by_reason(&fx).await; + assert_eq!( + journal.keys().cloned().collect::>(), + vec![ + "authorization.no-grant".to_owned(), + "authorization.refused".to_owned() + ], + "a permission refused before the transaction leaves no journal trail" + ); + + let bounds_refusal = &journal["authorization.refused"]; + assert_eq!(bounds_refusal["outcome"], "denied"); + assert_eq!(bounds_refusal["actorKind"], "agent"); + assert_eq!(bounds_refusal["operation"], "hold.create"); + assert!( + bounds_refusal["grantPseudonym"].is_string(), + "a refusal under a present grant names the grant" + ); + let grantless_refusal = &journal["authorization.no-grant"]; + assert_eq!(grantless_refusal["outcome"], "denied"); + assert_eq!(grantless_refusal["actorKind"], "service"); + assert_eq!(grantless_refusal["operation"], "hold.create"); + assert!( + grantless_refusal["grantPseudonym"].is_null(), + "a refusal carrying no grant names none" + ); + assert!( + grantless_refusal["purpose"].is_null(), + "a refusal carrying no grant records no purpose" + ); + // The journal carries pseudonyms and the operation, never the caller's + // raw identity and never the bound that failed. + for record in journal.values() { + let rendered = record.to_string(); + for raw in ["principal-elsewhere", "principal-read", "north-counter"] { + assert!( + !rendered.contains(raw), + "the refusal audit record repeats {raw} in the clear" + ); + } + } +} + +/// The most octets the claim ledger stores for a caller-chosen duplicate key. +/// It mirrors the CHECK in migration 0001; the edge refuses the same size +/// first, so a caller who exceeds it is answered rather than told the service +/// is down. +const DUPLICATE_KEY_CEILING_BYTES: usize = 256; + +/// Write one claim carrying `duplicate_key` straight into the ledger, so the +/// column's own bound is what decides, not a caller-facing check above it. +async fn claim_keyed(fx: &Fixture, duplicate_key: &str) -> Result { + fx.admin + .execute( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, \ + duplicate_key, revision, policy_revision, actor) \ + VALUES($1,'booking','active','registry-update-30','station-1', \ + now(), now(), now(), now(), 1, $2, 1, 1, 'actor-pseudonym')", + &[&Uuid::new_v4(), &duplicate_key], + ) + .await +} + +#[tokio::test] +async fn the_claim_ledger_bounds_a_caller_chosen_duplicate_key() { + let fx = fixture().await; + claim_keyed(&fx, &"k".repeat(DUPLICATE_KEY_CEILING_BYTES)) + .await + .expect("a duplicate key at the ceiling is stored"); + let refused = claim_keyed(&fx, &"k".repeat(DUPLICATE_KEY_CEILING_BYTES + 1)).await; + assert!( + refused.is_err(), + "the ledger stores a duplicate key of any length the body ceiling allows" + ); +} + +#[tokio::test] +async fn an_unknown_offering_and_a_malformed_body_answer_their_own_codes() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let absent = "registry-update-90"; + + // An offering the deployed policy does not publish is not a precondition + // the caller can satisfy by reloading: it does not exist. + let (status, problem) = fx + .get(&availability_uri(absent, 300, 440), &fx.reader) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .get( + &format!( + "/v1/availability/explain?offering={absent}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .post( + "/v1/holds", + &fx.agent, + "absent-hold", + admission(&fx, absent, slot), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "absent-appointment", + json!({"hold": null, "admission": admission(&fx, absent, slot)}), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + // A create names exactly one of a hold and an admission. Both, neither, + // or a hold that is not an identifier are all unreadable requests. + for body in [ + json!({"hold": Uuid::new_v4().to_string(), "admission": admission(&fx, OFFERING, slot)}), + json!({"hold": null, "admission": null}), + json!({"hold": "not-an-identifier", "admission": null}), + ] { + let (status, problem) = fx + .post("/v1/appointments", &fx.agent, "malformed", body) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "request.invalid"); + } +} + +#[tokio::test] +async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 90, 200, "held-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs"); + let intent = |purpose: &str| { + claimed + .iter() + .find(|intent| intent.purpose == purpose) + .expect("the commitment minted this intent") + .outbox_id + }; + + // One deployment declares no destination, so its reminder stays recorded + // rather than pretending a delivery happened. One send exhausted its + // attempts. One was delivered, and one is still inside its back-off. + fx.store + .hold_intent_local(intent("reminder"), 1) + .await + .expect("the reminder is held locally"); + fx.store + .retry_intent(intent("confirmation"), due, 0, 1) + .await + .expect("the confirmation exhausts its attempts"); + + let held = fx + .store + .undelivered_intents(100) + .await + .expect("the operator reads the undelivered intents"); + let mut seen: Vec<(String, String)> = held + .iter() + .map(|intent| (intent.purpose.clone(), intent.delivery_state.clone())) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + ("confirmation".to_owned(), "failed".to_owned()), + ("reminder".to_owned(), "local".to_owned()), + ], + "an intent nothing will deliver is invisible to the operator" + ); + + let reminder = held + .iter() + .find(|intent| intent.purpose == "reminder") + .expect("the held reminder is listed"); + assert_eq!(reminder.claim_id.to_string(), appointment_id); + assert_eq!(reminder.attempts, 1); + assert_eq!(reminder.payload["appointmentId"], appointment_id); + + // A delivered intent needs no operator, and neither does one still + // waiting out its back-off. + let (second, _) = booked(&fx, 210, 320, "held-2").await; + let pending = fx + .store + .claim_due_intents(Utc::now(), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs"); + let delivered = pending + .iter() + .find(|intent| intent.claim_id.to_string() == second) + .expect("the second commitment confirms at once") + .outbox_id; + fx.store + .mark_intent_delivered(delivered, 1) + .await + .expect("the confirmation is delivered"); + let held = fx + .store + .undelivered_intents(100) + .await + .expect("the operator reads the undelivered intents"); + assert!( + held.iter().all(|intent| intent.outbox_id != delivered), + "a delivered intent is listed as needing an operator" + ); + assert_eq!( + held.len(), + 2, + "an intent the sweep will still retry needs no operator" + ); +} + +/// AT-01 run in parallel: two callers reach for the same last unit at the same +/// moment. Every commitment locks its supply anchor before it reads the +/// ledger, so the two transactions serialize there and the one that arrives +/// second reads a snapshot already carrying the first booking. Exactly one +/// caller is created, the other is refused a conflict over capacity rather +/// than answered an infrastructure failure for having lost, and each key +/// stays spent on the answer it got: a replay repeats the verdict instead of +/// reaching for the unit a second time. +#[tokio::test] +async fn two_callers_reaching_for_one_unit_book_it_exactly_once() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let contender = agent_token_for("principal-contender"); + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + + let first = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("last-unit-first".to_owned()), + Some(body.clone()), + )); + let second = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + contender.clone(), + Some("last-unit-second".to_owned()), + Some(body.clone()), + )); + let answers = [ + first.await.expect("the first request answers"), + second.await.expect("the second request answers"), + ]; + + let created: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CREATED) + .map(|(_, answer)| answer) + .collect(); + let refused: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CONFLICT) + .map(|(_, answer)| answer) + .collect(); + assert_eq!( + created.len(), + 1, + "exactly one caller books the last unit: {answers:?}" + ); + assert_eq!( + refused.len(), + 1, + "the caller that lost is refused a conflict: {answers:?}" + ); + assert_eq!(refused[0]["code"], "capacity.exhausted"); + + // The ledger carries one claim on the contested slot, the winning + // appointment is that claim, and availability stops offering the start. + let active: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE state='active' AND displayed_start=$1", + &[&slot], + ) + .await + .expect("count the claims on the contested slot") + .get(0); + assert_eq!(active, 1, "the request that lost wrote no claim"); + let booked = Uuid::parse_str( + created[0]["appointmentId"] + .as_str() + .expect("an appointment id"), + ) + .expect("a parseable appointment identifier"); + assert!( + fx.store + .claim(booked) + .await + .expect("read the booked claim") + .is_some(), + "the appointment the winner was answered is in the ledger" + ); + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + // Each caller's key is spent on its own verdict, so neither replay moves + // the ledger. + let (replayed, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "last-unit-first", + body.clone(), + ) + .await; + assert_eq!(replayed, answers[0].0, "the first key replays its answer"); + let (replayed, _) = fx + .post("/v1/appointments", &contender, "last-unit-second", body) + .await; + assert_eq!(replayed, answers[1].0, "the second key replays its answer"); + let active: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE state='active' AND displayed_start=$1", + &[&slot], + ) + .await + .expect("count the claims on the contested slot") + .get(0); + assert_eq!(active, 1, "replaying either key books nothing further"); +} + +/// A booking agent whose grant covers holds on both of the test policy's +/// services, so one caller can reach for two pools at once. The default agent +/// may only hold against `registry-update`, and the hold ceiling is a property +/// of the caller, not of the pool a hold happens to land on. +fn agent_token_holding_both_services() -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"]["permissions"][1]["actions"] = + json!(["hold.create", "hold.release", "appointment.create"]); + let mut claims = json!({ + "sub": "principal-two-pools", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// The count of holds one caller currently holds open. +async fn active_holds(fx: &Fixture) -> i64 { + fx.admin + .query_one( + "SELECT count(*) FROM scheduling_claims \ + WHERE kind='hold' AND state='active' AND hold_expires_at > now()", + &[], + ) + .await + .expect("count the caller's open holds") + .get(0) +} + +/// The hold ceiling bounds the caller, not the pool. A commitment locks the +/// supply anchor it is about to draw from, and two holds on two pools lock two +/// different rows, so the ceiling check alone is a count with nothing standing +/// behind it: two concurrent holds each read the same under-ceiling count and +/// both write. The caller-scoped lock is what makes the count mean something, +/// and this test is what says so. +#[tokio::test] +async fn one_caller_cannot_outrun_the_hold_ceiling_across_two_pools() { + let fx = fixture().await; + let caller = agent_token_holding_both_services(); + let north = first_slot(&fx, OFFERING, 90, 200).await; + let (status, _) = fx + .post( + "/v1/holds", + &caller, + "ceiling-first", + admission(&fx, OFFERING, north), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "one hold is under the ceiling"); + + // Two more at once, against two pools whose supply anchors are distinct + // rows. The test policy admits two holds per caller, so exactly one of + // these may commit. + let other_north = first_slot(&fx, OFFERING, 210, 400).await; + let review = first_slot(&fx, SECOND_OFFERING, 90, 200).await; + let north_hold = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/holds".to_owned(), + caller.clone(), + Some("ceiling-north".to_owned()), + Some(admission(&fx, OFFERING, other_north)), + )); + let review_hold = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/holds".to_owned(), + caller.clone(), + Some("ceiling-review".to_owned()), + Some(admission(&fx, SECOND_OFFERING, review)), + )); + let answers = [ + north_hold.await.expect("the first hold answers"), + review_hold.await.expect("the second hold answers"), + ]; + let created = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CREATED) + .count(); + let refused: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CONFLICT) + .map(|(_, answer)| answer) + .collect(); + assert_eq!( + created, 1, + "one of the two concurrent holds reaches the ceiling: {answers:?}" + ); + assert_eq!( + refused.len(), + 1, + "the hold over the ceiling is refused: {answers:?}" + ); + assert_eq!(refused[0]["code"], "capacity.exhausted"); + assert_eq!( + active_holds(&fx).await, + 2, + "the caller never holds more than the policy admits" + ); + + // The same ceiling in sequence, so the concurrent refusal above is the + // ceiling and not a coincidence of the race. + let later = first_slot(&fx, OFFERING, 500, 700).await; + let (status, problem) = fx + .post( + "/v1/holds", + &caller, + "ceiling-third", + admission(&fx, OFFERING, later), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); + assert_eq!(active_holds(&fx).await, 2); +} + +/// How many rows each table the retention sweep must not touch currently +/// holds, in one reading, so a sweep can be shown to have left every one of +/// them exactly as it found them. +async fn committed_row_counts(fx: &Fixture) -> Vec<(&'static str, i64)> { + let mut counts = Vec::new(); + for table in [ + "scheduling_claims", + "scheduling_history", + "scheduling_outbox", + "scheduling_audit_outbox", + ] { + let count: i64 = fx + .admin + .query_one(&format!("SELECT count(*) FROM {table}"), &[]) + .await + .unwrap_or_else(|_| panic!("count {table}")) + .get(0); + counts.push((table, count)); + } + counts +} + +/// What the retention sweep erases, and just as much what it does not. The +/// deployment advertises one retention period, for idempotency receipts, and +/// the sweep enforces that one plus the fifteen minutes the listing contract +/// gives a cursor. Appointments, their history, the delivery outbox and the +/// audit journal have no retention period in this milestone and the sweep +/// passes over them, by decision rather than omission; this test is what +/// stops the single configured knob from quietly growing into a promise to +/// erase committed scheduling data. +#[tokio::test] +async fn the_retention_sweep_erases_its_two_tables_and_leaves_the_rest_standing() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let booking = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "retention-1", + booking.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let (status, page) = fx.get("/v1/services?limit=1", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + let cursor = page["nextCursor"] + .as_str() + .expect("the first page mints a cursor") + .to_owned(); + let committed = committed_row_counts(&fx).await; + assert!( + committed.iter().all(|(_, count)| *count > 0), + "the booking wrote to every table the sweep must not touch: {committed:?}" + ); + + // Long past the cursor's fifteen minutes and the receipt period the + // fixture deploys. + let after = Utc::now() + TimeDelta::days(8); + assert_eq!( + fx.store + .erase_expired_cursors(after) + .await + .expect("the cursor retention pass runs"), + 1, + "the one expired cursor is erased" + ); + assert_eq!( + fx.store + .erase_expired_attempts(after) + .await + .expect("the receipt retention pass runs"), + 1, + "the one expired receipt is erased" + ); + + // A cursor is erased outright, so what the caller is told is that the + // token no longer names a listing. The row is gone, so nothing + // distinguishes a cursor swept past its expiry from one that never + // existed; a caller that waited is told to restart the listing either + // way. + let (status, problem) = fx + .get(&format!("/v1/services?limit=1&cursor={cursor}"), &fx.reader) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); + let cursors: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_cursors", &[]) + .await + .expect("count the cursors") + .get(0); + assert_eq!(cursors, 0, "an erased cursor leaves no row behind"); + + // A receipt is tombstoned, not deleted: the row keeps the key and the + // request it answered and drops only the answer, so the key stays spent. + let attempt = fx + .admin + .query_one( + "SELECT count(*), count(receipt), count(erased_at) FROM scheduling_attempts", + &[], + ) + .await + .expect("read the swept attempt row"); + assert_eq!(attempt.get::<_, i64>(0), 1, "the attempt row is kept"); + assert_eq!(attempt.get::<_, i64>(1), 0, "the answer is dropped"); + assert_eq!(attempt.get::<_, i64>(2), 1, "the row is stamped erased"); + let (status, problem) = fx + .post("/v1/appointments", &fx.agent, "retention-1", booking) + .await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(problem["code"], "idempotency.expired"); + + // Everything the deployment committed is still there, and the + // appointment still reads back. + assert_eq!( + committed_row_counts(&fx).await, + committed, + "the sweep erased no committed scheduling data" + ); + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["state"], "confirmed"); + + // Running the sweep again erases nothing: both passes are idempotent, so + // a tick that overlaps the previous one cannot double-count its work. + assert_eq!( + fx.store + .erase_expired_cursors(after) + .await + .expect("the cursor retention pass runs"), + 0 + ); + assert_eq!( + fx.store + .erase_expired_attempts(after) + .await + .expect("the receipt retention pass runs"), + 0 + ); +} + +/// The environment variable the publication tests key their audit chain from, +/// and the master secret they put in it: a test constant like the token +/// signing secret above, never a deployment value. The platform profile +/// refuses anything shorter than 32 bytes. +const AUDIT_SECRET_ENV: &str = "SCHEDULING_TEST_AUDIT_CHAIN_SECRET"; +const AUDIT_SECRET: &str = "scheduling-test-audit-chain-secret"; + +/// Every envelope the journal retains, in the order it was written. +fn retained(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).expect("a retained audit envelope")) + .collect() +} + +/// One whole deployment life over the same journal: bootstrap the retained +/// chain under the deployment key, recover the append/mark gap from its tail, +/// drain what the store holds, and put the journal down again. Returns how +/// many envelopes the journal retains afterwards. +/// +/// The sink is a single-writer sink, so dropping it at the end is what makes +/// the next call a restart rather than a second writer forking the chain. +async fn publish_one_life(fx: &Fixture, path: &std::path::Path) -> usize { + let profile = AuditProfile::production_from_env(AUDIT_SECRET_ENV).expect("the audit profile"); + let sink = Arc::new(JsonlFileSink::new_single_writer(path).expect("the audit journal sink")); + let chain = Arc::new( + profile + .bootstrap_or_start_empty(sink.as_ref()) + .await + .expect("bootstrap the retained chain under the deployment key"), + ); + let mut state = AuditPublicationState::from_verified_tail( + sink.last_envelope() + .await + .expect("read the retained tail") + .as_ref(), + ); + let publisher = AuditPublisher { + store: fx.store.clone(), + chain, + sink, + }; + publish_audit_pass(&publisher, &mut state) + .await + .expect("the publication pass runs"); + drop(publisher); + retained(path).len() +} + +/// The audit journal is published in the order it was written. The publisher +/// appends what the store hands it to a hash chain, so the order it reads in +/// is the order the chain goes on to attest to. An event id is a random UUID +/// and sorts in no order at all; these rows are written under identifiers +/// that sort against the order they were written, so a reader ordering by +/// identifier hands them back reversed. +#[tokio::test] +async fn the_audit_journal_is_read_in_the_order_it_was_written() { + let fx = fixture().await; + let written: Vec = (0..6) + .map(|position| { + Uuid::parse_str(&format!("{:08x}-0000-4000-8000-000000000000", 5 - position)) + .expect("a well-formed test event identifier") + }) + .collect(); + for (position, event_id) in written.iter().enumerate() { + fx.store + .record_refusal_audit(*event_id, json!({"marker": position})) + .await + .expect("record one refusal in the journal"); + } + + let pending = fx + .store + .pending_audit(100) + .await + .expect("read the pending journal"); + let ours: Vec = pending + .iter() + .map(|(event_id, _)| *event_id) + .filter(|event_id| written.contains(event_id)) + .collect(); + assert_eq!( + ours, written, + "the journal reads back in the order it was written" + ); +} + +/// DB-12: the keyed audit chain survives the process that built it. A +/// deployment stops and starts again over the same retained journal, and the +/// first record of the second life links to the last record of the first: the +/// chain is one unbroken sequence from genesis, with exactly one record that +/// has no predecessor. A restart that forgot the retained chain would start +/// again at genesis and leave the earlier records unattested. +#[tokio::test] +async fn the_audit_chain_continues_across_a_restart() { + std::env::set_var(AUDIT_SECRET_ENV, AUDIT_SECRET); + let fx = fixture().await; + let journal = tempfile::tempdir().expect("a temporary audit journal"); + let path = journal.path().join("audit.jsonl"); + + booked(&fx, 90, 200, "chain-first").await; + let first_life = publish_one_life(&fx, &path).await; + assert!(first_life > 0, "the commitment wrote records to publish"); + assert!( + fx.store + .pending_audit(100) + .await + .expect("read the pending journal") + .is_empty(), + "a completed pass leaves nothing pending" + ); + + // The process stops. A second life over the same journal commits more and + // publishes it. + booked(&fx, 300, 440, "chain-second").await; + let second_life = publish_one_life(&fx, &path).await; + assert!( + second_life > first_life, + "the second life appended its own records" + ); + + let envelopes = retained(&path); + assert_eq!(envelopes.len(), second_life); + assert!( + envelopes[0].prev_hash.is_none(), + "the journal reaches genesis exactly once" + ); + for pair in envelopes.windows(2) { + assert_eq!( + pair[1].prev_hash, + Some(pair[0].record_hash), + "every envelope links to the one before it" + ); + } + assert_eq!( + envelopes[first_life].prev_hash, + Some(envelopes[first_life - 1].record_hash), + "the second life continues the chain the first life left" + ); + + // Every record is retained exactly once, and carries the identity the + // store knows it by. + let identities: Vec<&str> = envelopes + .iter() + .filter_map(|envelope| envelope.record["eventId"].as_str()) + .collect(); + assert_eq!( + identities.len(), + envelopes.len(), + "every retained envelope carries its event id" + ); + assert_eq!( + identities + .iter() + .copied() + .collect::>() + .len(), + identities.len(), + "no record is retained twice" + ); + + // A crash between the append and the mark: the tail is retained but the + // store still lists it pending. The next life re-marks it from the + // retained tail rather than appending it a second time. + let tail = Uuid::parse_str( + envelopes.last().expect("a retained tail").record["eventId"] + .as_str() + .expect("the tail carries its event id"), + ) + .expect("a parseable event identifier"); + fx.admin + .execute( + "UPDATE scheduling_audit_outbox SET published_at=NULL WHERE event_id=$1", + &[&tail], + ) + .await + .expect("reopen the append and mark gap"); + let reconciled = publish_one_life(&fx, &path).await; + assert_eq!( + reconciled, second_life, + "the record retained before the crash is not appended again" + ); + assert!( + fx.store + .pending_audit(100) + .await + .expect("read the pending journal") + .is_empty(), + "it is marked published instead" + ); +} + +/// How one intent stands after a dispatch pass: the state written back, how +/// many attempts it has consumed, and whether its next attempt is held behind +/// a back-off. +async fn intent_state(fx: &Fixture, claim_id: &str) -> (String, i32, bool) { + let claim = Uuid::parse_str(claim_id).expect("a claim identifier"); + let row = fx + .admin + .query_one( + "SELECT delivery_state, attempts, next_attempt_at > now() \ + FROM scheduling_outbox WHERE claim_id=$1 AND purpose='confirmation'", + &[&claim], + ) + .await + .expect("read one intent back"); + (row.get(0), row.get(1), row.get(2)) +} + +/// A dispatch pass sends every due intent to the configured destination and +/// reads each answer back the way the classes say: a taken event is delivered +/// and never sent twice, a destination that could not answer proves nothing +/// and waits out a back-off, and an outright refusal is held for an operator +/// rather than retried against a destination that has already said no. +/// +/// The destination is a local server this test controls, because what the +/// deployment puts on the wire, and what it concludes from an answer, cannot +/// be observed without one that answers. +#[tokio::test] +async fn a_dispatch_pass_delivers_retries_and_holds_by_what_the_destination_answers() { + let destination = MockServer::start().await; + let fx = fixture().await; + let (taken, _) = booked(&fx, 90, 200, "dispatch-taken").await; + let (unanswered, _) = booked(&fx, 300, 440, "dispatch-unanswered").await; + let (refused, _) = booked(&fx, 600, 800, "dispatch-refused").await; + + // One stub per appointment, matched on the identity the event carries, so + // a single pass produces all three outcomes against one destination. + for (appointment, status) in [(&taken, 202), (&unanswered, 503), (&refused, 403)] { + Mock::given(method("POST")) + .and(path("/events")) + .and(header("content-type", "application/cloudevents+json")) + .and(body_string_contains(appointment.as_str())) + .respond_with(ResponseTemplate::new(status)) + .mount(&destination) + .await; + } + + // The destination declares no bearer reference, so nothing is resolved; + // the resolver still has to exist, and refuses to be built with no + // provider enabled at all. + let secrets = SecretResolver::new([SecretProvider::Environment], std::path::Path::new("")) + .expect("a resolver this destination asks nothing of"); + let transport = reminder_transport( + &ReminderDestinationConfig { + url: format!("{}/events", destination.uri()), + bearer_token_ref: None, + }, + &secrets, + ) + .expect("the configured destination compiles into a frozen transport"); + + dispatch_due_intents(&fx.store, SCHEDULING_ID, Some(&transport)) + .await + .expect("the dispatch pass runs"); + + assert_eq!( + intent_state(&fx, &taken).await, + ("delivered".to_owned(), 1, false), + "a destination that took the event leaves nothing to retry" + ); + let (state, attempts, backed_off) = intent_state(&fx, &unanswered).await; + assert_eq!( + (state.as_str(), attempts), + ("pending", 1), + "a destination that could not answer proves nothing" + ); + assert!(backed_off, "so the next attempt waits out a back-off"); + assert_eq!( + intent_state(&fx, &refused).await, + ("failed".to_owned(), 1, false), + "a destination that refused outright is held for an operator" + ); + + // What went on the wire is one CloudEvents 1.0 event per intent, naming + // this deployment and carrying the committed payload, and nothing else. + let sent = destination.received_requests().await.expect("the requests"); + assert_eq!(sent.len(), 3, "one request per due intent, and no more"); + let event: Value = serde_json::from_slice( + &sent + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains(taken.as_str())) + .expect("the taken appointment's event") + .body, + ) + .expect("a JSON event body"); + assert_eq!(event["specversion"], "1.0"); + assert_eq!(event["source"], SCHEDULING_ID); + assert_eq!(event["type"], "org.registrystack.scheduling.confirmation"); + assert_eq!(event["data"]["appointmentId"], taken); + + // A second pass repeats nothing: the delivered intent is done and the + // other two are held or backed off, so the destination hears no more. + dispatch_due_intents(&fx.store, SCHEDULING_ID, Some(&transport)) + .await + .expect("the second dispatch pass runs"); + assert_eq!( + destination + .received_requests() + .await + .expect("the requests") + .len(), + 3, + "nothing a pass concluded is sent to the destination again" + ); +} + +/// The arrival-window offering and the window it serves, added to the test +/// policy. Both are only in the policies that ask for them: the exact-time +/// fixtures publish no window, so no test pays for supply it never reads. +const WINDOW_OFFERING: &str = "registry-arrivals"; +const WINDOW_ID: &str = "morning-arrivals"; +/// The window's own revision, deliberately not the policy revision, so a +/// request that sends one where the other belongs is caught rather than +/// accepted by coincidence. +const WINDOW_REVISION: u64 = 3; + +/// The test policy with one arrival offering that references the window ID +/// owned by the runtime records. +fn policy_with_window() -> String { + POLICY.replace( + "holdPolicy:", + &format!( + " - id: {WINDOW_OFFERING}\n\ + \x20 service: registry-update\n\ + \x20 label: Counter arrivals\n\ + \x20 mode: arrival-window\n\ + \x20 location: north-counter\n\ + \x20 because: test\n\ + \x20 cancellationCutoffMinutes: 240\n\ + \x20 arrival:\n\ + \x20 window: {WINDOW_ID}\n\ + \x20 leadTimeMinutes: 0\n\ + \x20 horizonDays: 60\n\ + \x20 requiresCapabilities: []\n\ + \x20 prerequisites: []\n\ + holdPolicy:", + ), + ) +} + +const FOREIGN_WINDOW_OFFERING: &str = "registry-review-arrivals"; + +fn policy_with_shared_window() -> String { + policy_with_window().replace( + "holdPolicy:", + &format!( + " - id: {FOREIGN_WINDOW_OFFERING}\n\ + \x20 service: registry-review\n\ + \x20 label: Review arrivals\n\ + \x20 mode: arrival-window\n\ + \x20 location: north-counter\n\ + \x20 because: test\n\ + \x20 cancellationCutoffMinutes: 240\n\ + \x20 arrival:\n\ + \x20 window: {WINDOW_ID}\n\ + \x20 leadTimeMinutes: 60\n\ + \x20 horizonDays: 60\n\ + \x20 requiresCapabilities: []\n\ + \x20 prerequisites: []\n\ + holdPolicy:" + ), + ) +} + +/// The operator records that publish one concrete arrival window. +fn records_with_window(start: DateTime) -> SchedulingFacts { + let mut facts = records_without(&[]); + facts.windows.push(PublishedWindow { + id: WINDOW_ID.to_owned(), + revision: WINDOW_REVISION, + offering: WINDOW_OFFERING.to_owned(), + location: "north-counter".to_owned(), + start, + end: start + TimeDelta::hours(2), + units: 2, + units_policy: RequiredUnitsPolicy::Fixed { + units: 1, + because: "test".to_owned(), + }, + subquotas: vec![WindowSubquota { + id: "assisted-quota".to_owned(), + channel: Channel::Assisted, + units: 1, + because: "test".to_owned(), + }], + leftover: None, + staffing: None, + because: "test".to_owned(), + }); + facts +} + +async fn fixture_with_window(start: DateTime) -> Fixture { + let fx = fixture_publishing( + &policy_with_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + fx.store + .replace_facts( + SCHEDULING_ID, + &records_with_window(start), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("publish the window records"); + fx +} + +/// One direct-create body admitting an arrival on the window, on `channel` +/// when it names one. +fn arrival(fx: &Fixture, start: DateTime, channel: Option<&str>) -> Value { + json!({"hold": null, "admission": { + "offering": WINDOW_OFFERING, + "start": stamp(start), + "party": {"recipients": 1, "attendees": 1}, + "channel": channel, + "duplicateKey": null, + "policyRevision": fx.revision, + "windowRevision": WINDOW_REVISION, + "capabilities": [], + "prerequisites": [], + }}) +} + +/// The window entry availability lists for the window offering, if any. +async fn window_entry(fx: &Fixture, from_minutes: i64, to_minutes: i64) -> Option { + let (status, page) = fx + .get( + &availability_uri(WINDOW_OFFERING, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::OK, "availability answers over a window"); + page["items"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["kind"] == "window") + .cloned() +} + +#[tokio::test] +async fn records_replacement_refuses_to_move_a_window_with_standing_commitments() { + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let start = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(9, 0, 0).expect("a morning instant"), + Utc, + ); + let fx = fixture_with_window(start).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "window-publication-standing", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let mut moved = records_with_window(start); + moved.windows[0].start += TimeDelta::days(1); + moved.windows[0].end += TimeDelta::days(1); + let refusal = fx + .store + .replace_facts(SCHEDULING_ID, &moved, Uuid::new_v4(), operator_audit()) + .await + .expect_err("a moved window cannot strand a standing appointment"); + assert!(refusal.to_string().contains(WINDOW_ID), "{refusal}"); +} + +#[tokio::test] +async fn changed_window_records_must_advance_the_revision_even_after_removal() { + let start = (Utc::now() + TimeDelta::days(2)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; + let (initial, facts_revision) = fx.store.facts().await.expect("the initial records"); + let audit_rows = fx + .store + .pending_audit(100) + .await + .expect("the initial audit rows") + .len(); + + let mut changed = records_with_window(start); + changed.windows[0].end += TimeDelta::minutes(30); + for proposed_revision in [WINDOW_REVISION, WINDOW_REVISION - 1] { + changed.windows[0].revision = proposed_revision; + let refusal = fx + .store + .replace_facts(SCHEDULING_ID, &changed, Uuid::new_v4(), operator_audit()) + .await + .expect_err("changed terms must advance their public revision"); + assert!(matches!( + refusal, + StoreError::WindowRevision { + current: WINDOW_REVISION, + proposed, + .. + } if proposed == proposed_revision + )); + } + let (after_refusals, after_refusal_revision) = fx + .store + .facts() + .await + .expect("the refused records roll back"); + assert_eq!(after_refusals, initial); + assert_eq!(after_refusal_revision, facts_revision); + assert_eq!( + fx.store + .pending_audit(100) + .await + .expect("the audit rows after refusal") + .len(), + audit_rows, + "a refused replacement writes no allowed audit row" + ); + + changed.windows[0].revision = WINDOW_REVISION + 1; + fx.store + .replace_facts(SCHEDULING_ID, &changed, Uuid::new_v4(), operator_audit()) + .await + .expect("an advanced revision accepts changed terms"); + fx.store + .replace_facts( + SCHEDULING_ID, + &records_without(&[]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("the idle window may be removed"); + let (_, removed_revision) = fx.store.facts().await.expect("the removed records"); + let removed_audit_rows = fx + .store + .pending_audit(100) + .await + .expect("the audit rows after removal") + .len(); + + let mut republished = changed.clone(); + republished.windows[0].end += TimeDelta::minutes(30); + for proposed_revision in [WINDOW_REVISION + 1, WINDOW_REVISION] { + republished.windows[0].revision = proposed_revision; + let refusal = fx + .store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect_err("removal does not erase the revision high-water mark"); + assert!(matches!( + refusal, + StoreError::WindowRevision { + current, + proposed, + .. + } if current == WINDOW_REVISION + 1 && proposed == proposed_revision + )); + } + let (still_removed, after_republish_refusal) = fx + .store + .facts() + .await + .expect("the refused republication rolls back"); + assert!(still_removed.windows.is_empty()); + assert_eq!(after_republish_refusal, removed_revision); + assert_eq!( + fx.store + .pending_audit(100) + .await + .expect("the audit rows after republication refusal") + .len(), + removed_audit_rows + ); + + republished.windows[0].revision = WINDOW_REVISION + 2; + fx.store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("an advanced revision may republish the identifier"); + fx.store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("an exact unchanged reapply remains accepted"); +} + +#[tokio::test] +async fn a_window_record_must_belong_to_the_authorized_offering_and_location() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_publishing( + &policy_with_shared_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + fx.store + .replace_facts( + SCHEDULING_ID, + &records_with_window(start), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("seed the intentionally misbound operator record"); + let caller = agent_token_for_service("registry-review"); + let mut request = arrival(&fx, start, None); + request["admission"]["offering"] = json!(FOREIGN_WINDOW_OFFERING); + + let (status, problem) = fx + .post( + "/v1/appointments", + &caller, + "foreign-window-offering", + request.clone(), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}"); + assert_eq!(problem["code"], "service.unavailable"); + + let mut wrong_location = records_with_window(start); + wrong_location.windows[0].revision = WINDOW_REVISION + 1; + wrong_location.windows[0].offering = FOREIGN_WINDOW_OFFERING.to_owned(); + wrong_location.windows[0].location = "two-counter".to_owned(); + fx.store + .replace_facts( + SCHEDULING_ID, + &wrong_location, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("seed the intentionally wrong-location operator record"); + request["admission"]["windowRevision"] = json!(WINDOW_REVISION + 1); + let (status, problem) = fx + .post( + "/v1/appointments", + &caller, + "foreign-window-location", + request, + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}"); + assert_eq!(problem["code"], "service.unavailable"); + + let claims: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_claims", &[]) + .await + .expect("count the claims") + .get(0); + assert_eq!(claims, 0, "misbound records never reach a commitment"); +} + +#[tokio::test] +async fn a_location_closure_hides_and_refuses_an_arrival_window() { + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let start = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(9, 0, 0).expect("a morning instant"), + Utc, + ); + let fx = fixture_with_window(start).await; + let mut facts = records_with_window(start); + facts.exceptions.push(CalendarExceptionRecord { + id: "counter-closure".to_owned(), + location: "north-counter".to_owned(), + kind: ExceptionRecordKind::Closure, + date: day.to_string(), + start_time: "08:00".to_owned(), + end_time: "12:00".to_owned(), + reopens: None, + authority: None, + }); + fx.store + .replace_facts(SCHEDULING_ID, &facts, Uuid::new_v4(), operator_audit()) + .await + .expect("apply the location closure"); + + assert!( + window_entry(&fx, 60, 4_000).await.is_none(), + "a closed window is not published as availability" + ); + let (status, refused) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-closed", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["code"], "location.closed"); +} + +#[tokio::test] +async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointment() { + let fx = fixture().await; + let (appointment_id, revision) = booked(&fx, 300, 440, "retained-offering-create").await; + let current = parse_policy_yaml(POLICY).expect("the current policy"); + let mut replacement = current.clone(); + replacement.scheduling.version += 1; + replacement + .offerings + .retain(|offering| offering.id != OFFERING); + let digest = replacement.policy_digest(); + let mut pool_ids: Vec = replacement + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + + let refusal = fx + .store + .apply_policy(SCHEDULING_ID, &digest, &pool_ids, &replacement) + .await + .expect_err("a live appointment keeps its offering operable"); + assert!(refusal.to_string().contains(OFFERING), "{refusal}"); + + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &fx.agent, + "retained-offering-cancel", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); +} + +#[tokio::test] +async fn policy_publication_refuses_to_move_an_active_offering_between_pools() { + let fx = fixture().await; + booked(&fx, 300, 440, "retained-offering-pool-create").await; + let mut replacement = parse_policy_yaml(POLICY).expect("the current policy"); + replacement.scheduling.version += 1; + replacement + .offerings + .iter_mut() + .find(|offering| offering.id == OFFERING) + .and_then(|offering| offering.exact_time.as_mut()) + .expect("the exact-time offering") + .pool = "two-counter".to_owned(); + let digest = replacement.policy_digest(); + + let refusal = fx + .store + .apply_policy( + SCHEDULING_ID, + &digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect_err("an active offering cannot move to different supply"); + assert!(refusal.to_string().contains("supply"), "{refusal}"); +} + +/// An arrival window is a second admission mode with a ledger of its own: its +/// claims occupy the window rather than a member of a pool, its capacity is +/// counted in units rather than in slots, and a channel subquota is a ceiling +/// inside the window's total. Nothing in the database suite had ever booked +/// one, so none of that was covered against a real ledger. +/// +/// The window here carries two units, one of them reserved to the assisted +/// channel. The assisted caller takes that one, the next assisted caller is +/// refused on the subquota while the window still has a unit left, a public +/// caller takes the last unit, and the window then offers nothing. +#[tokio::test] +async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { + // The published interval is stamped to the second, which is the precision + // every comparison below reads it back at. + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; + + let offered = window_entry(&fx, 60, 300) + .await + .expect("the published window is offered"); + assert_eq!(offered["window"], WINDOW_ID); + assert_eq!(offered["start"], stamp(start)); + assert_eq!(offered["remaining"], 2, "the whole window is unallocated"); + + let (status, mismatch) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-wrong-start", + arrival(&fx, start + TimeDelta::minutes(15), None), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{mismatch}"); + assert_eq!(mismatch["code"], "schedule.unpublished"); + + // The assisted channel holds one of the two units. + let (status, booked) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-assisted", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked}"); + assert_eq!(booked["start"], stamp(start)); + assert_eq!(booked["end"], stamp(start + TimeDelta::hours(2))); + // An arrival joins a window, so there is no member to name. The field is + // the pool member an exact-time booking occupies, and the ledger's one + // supply column must not be reported through it as if it were one. + assert!( + booked["resource"].is_null(), + "an arrival names no resource: {booked}" + ); + + // The window still has a unit, but the assisted subquota does not, so a + // second assisted arrival is refused on the ceiling inside the total. + let (status, refused) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-assisted-again", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["code"], "capacity.exhausted"); + assert_eq!( + window_entry(&fx, 60, 300).await.expect("still offered")["remaining"], + 1, + "the window's own total is what availability reports" + ); + + // The same unit the assisted caller could not have is free to a public + // one, and it is the window's last. + let (status, last) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-public", + arrival(&fx, start, Some("public")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{last}"); + let (status, exhausted) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-too-late", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{exhausted}"); + assert_eq!(exhausted["code"], "capacity.exhausted"); + assert!( + window_entry(&fx, 60, 300).await.is_none(), + "a window with no units left is not offered" + ); + + // A window claim occupies the window, not a member of a pool. The ledger + // holds both in one `supply_id` column, which carries the resource an + // exact-time claim books and the window an arrival claim joins, so the + // column means two things and only the claim's offering says which. + let occupied: Vec<(String, Option)> = fx + .admin + .query( + "SELECT supply_id, channel FROM scheduling_claims \ + WHERE state='active' AND offering=$1 ORDER BY created_at", + &[&WINDOW_OFFERING], + ) + .await + .expect("read the window's claims") + .iter() + .map(|row| (row.get(0), row.get(1))) + .collect(); + assert_eq!( + occupied, + vec![ + (WINDOW_ID.to_owned(), Some("assisted".to_owned())), + (WINDOW_ID.to_owned(), Some("public".to_owned())), + ], + "both arrivals occupy the window itself, each under its own channel" + ); + + // The window revision is the caller's agreement about the window, and a + // stale one is refused whatever the policy revision says. + let mut stale = arrival(&fx, start, None); + stale["admission"]["windowRevision"] = json!(WINDOW_REVISION - 1); + let (status, mismatch) = fx + .post("/v1/appointments", &fx.agent, "arrival-stale", stale) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{mismatch}"); + assert_eq!(mismatch["code"], "revision.mismatch"); +} + +// --------------------------------------------------------------------------- +// Concurrency and ownership regressions +// --------------------------------------------------------------------------- + +/// COR-3. Two lifecycle writers that accepted the same observed revision +/// must not both commit. The observed revision and the active state guard +/// the UPDATE itself, so one transition lands and the losing writer's whole +/// transaction rolls back behind a revision mismatch, whatever order the +/// two arrive in. +#[tokio::test] +async fn competing_cancellations_commit_exactly_one_transition() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "race-cancel").await; + let cancel = |key: &'static str| { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/cancel"); + let body = json!({"observedRevision": revision, "reason": null}); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some(key.into()), + Some(body), + ) + .await + }) + }; + let (first, second) = tokio::join!(cancel("race-cancel-a"), cancel("race-cancel-b")); + let mut answers = [ + first.expect("the first cancel answered"), + second.expect("the second cancel answered"), + ]; + answers.sort_by_key(|(status, _)| *status); + assert_eq!( + answers[0].0, + StatusCode::OK, + "exactly one writer wins: {}", + answers[0].1 + ); + // The loser is refused whichever way it met the moved claim: a + // revision.mismatch when its fenced write lost the race, or a + // hold.released when its read landed after the winner committed. Both + // are coherent refusals of a superseded observation. + assert!( + answers[1].0 == StatusCode::PRECONDITION_FAILED || answers[1].0 == StatusCode::CONFLICT, + "exactly one writer loses: {}", + answers[1].1 + ); + assert!(answers[1].0.is_client_error()); + // The ledger carries one coherent transition: one lifecycle event, one + // cancellation intent, and the revision the winner wrote. + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let counts = fx + .admin + .query_one( + "SELECT \ + (SELECT count(*) FROM scheduling_history WHERE claim_id=$1 AND revision=2), \ + (SELECT count(*) FROM scheduling_outbox WHERE claim_id=$1 AND purpose='cancellation'), \ + (SELECT revision FROM scheduling_claims WHERE claim_id=$1)", + &[&claim_id], + ) + .await + .expect("read the committed transition"); + assert_eq!( + counts.get::<_, i64>(0), + 1, + "one history event at the next revision" + ); + assert_eq!(counts.get::<_, i64>(1), 1, "one cancellation intent"); + assert_eq!( + counts.get::<_, i64>(2), + 2, + "the revision moved exactly once" + ); +} + +/// The same guard coordinates a reschedule against a cancellation: the two +/// writers take different locks, but the claim row's own predicate decides, +/// and the response the caller keeps is the one that actually landed. +#[tokio::test] +async fn a_reschedule_and_a_cancellation_commit_exactly_one_transition() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "race-mixed").await; + let other = first_slot(&fx, OFFERING, 480, 620).await; + let reschedule = { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/reschedule"); + let body = json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, other), + }); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some("race-mixed-move".into()), + Some(body), + ) + .await + }) + }; + let cancel = { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/cancel"); + let body = json!({"observedRevision": revision, "reason": null}); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some("race-mixed-cancel".into()), + Some(body), + ) + .await + }) + }; + let (moved, cancelled) = tokio::join!(reschedule, cancel); + let moved = moved.expect("the reschedule answered"); + let cancelled = cancelled.expect("the cancel answered"); + let successes = usize::from(moved.0.is_success()) + usize::from(cancelled.0.is_success()); + assert_eq!( + successes, 1, + "exactly one transition commits: {moved:?} {cancelled:?}" + ); + // Whichever won, the appointment's stored state and its history agree, + // and no two history events share a revision. + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let duplicates = fx + .admin + .query_one( + "SELECT count(*) FROM (SELECT revision FROM scheduling_history \ + WHERE claim_id=$1 GROUP BY revision HAVING count(*) > 1) AS doubled", + &[&claim_id], + ) + .await + .expect("read the history revisions"); + assert_eq!( + duplicates.get::<_, i64>(0), + 0, + "no two history events share a revision" + ); +} + +/// SEC-03. The task grant is re-checked against a fresh observation of the +/// clock immediately before the final write: a grant that still stands at +/// the door and lapses before the commit books nothing. +#[tokio::test] +async fn a_grant_that_lapses_before_the_commit_never_books() { + let fx = fixture().await; + // The pinned clock sits past the test token's grant expiry, while the + // request's own now, observed at the door, is still inside it. + let pinned = Utc::now() + TimeDelta::minutes(20); + fx.store.pin_clock(Arc::new(move || pinned)); + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "lapsed-grant", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + let committed = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("read the claim ledger"); + assert_eq!( + committed.get::<_, i64>(0), + 0, + "a lapsed grant commits nothing" + ); +} + +/// Every mutation must roll back its tentative writes when authorization +/// expires after its admission-time check but before the final commit check. +#[tokio::test] +async fn every_mutation_rechecks_expiry_after_its_writes() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + for operation in [ + "hold", + "create", + "confirm", + "release", + "reschedule", + "cancel", + ] { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 480, 620).await; + let (method, uri, body) = match operation { + "hold" => ( + "POST", + "/v1/holds".to_owned(), + Some(admission(&fx, OFFERING, slot)), + ), + "create" => ( + "POST", + "/v1/appointments".to_owned(), + Some(json!({"hold": null, "admission": admission(&fx, OFFERING, slot)})), + ), + "confirm" | "release" => { + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "setup", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{hold}"); + let id = hold["holdId"].as_str().expect("a hold identifier"); + if operation == "confirm" { + ( + "POST", + "/v1/appointments".to_owned(), + Some(json!({"hold": id, "admission": null})), + ) + } else { + ("DELETE", format!("/v1/holds/{id}"), None) + } + } + "reschedule" | "cancel" => { + let (id, revision) = booked(&fx, 300, 440, "setup").await; + if operation == "reschedule" { + ( + "POST", + format!("/v1/appointments/{id}/reschedule"), + Some(json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, slot), + })), + ) + } else { + ( + "POST", + format!("/v1/appointments/{id}/cancel"), + Some(json!({ + "observedRevision": revision, "reason": null, + })), + ) + } + } + _ => unreachable!(), + }; + // Compare all transactional business effects, not just the status. + // A refusal may add its own refused receipt and denied audit event. + let state = "SELECT jsonb_build_array( \ + (SELECT jsonb_agg(to_jsonb(c) ORDER BY claim_id) FROM scheduling_claims c), \ + (SELECT jsonb_agg(to_jsonb(h) ORDER BY event_id) FROM scheduling_history h), \ + (SELECT jsonb_agg(to_jsonb(o) ORDER BY outbox_id) FROM scheduling_outbox o), \ + (SELECT count(*) FROM scheduling_attempts WHERE status_code BETWEEN 200 AND 299))"; + let before: Value = fx + .admin + .query_one(state, &[]) + .await + .expect("snapshot before mutation") + .get(0); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let valid = Utc::now(); + fx.store.pin_clock(Arc::new(move || { + if observed.fetch_add(1, Ordering::SeqCst) == 0 { + valid + } else { + valid + TimeDelta::minutes(20) + } + })); + let (status, problem) = fx + .request(method, &uri, &fx.agent, Some("expires-after-writes"), body) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{operation}: {problem}"); + assert_eq!(problem["code"], "operation.not-authorized", "{operation}"); + assert!( + calls.load(Ordering::SeqCst) >= 2, + "{operation} observes the final clock" + ); + let after: Value = fx + .admin + .query_one(state, &[]) + .await + .expect("snapshot after refusal") + .get(0); + assert_eq!(after, before, "{operation} rolls back all business writes"); + } +} + +/// A running process refuses a commitment even when the caller names the +/// stored revision another process just published: the rules this process +/// evaluates are not the rules that revision names, and the claim is +/// refused rather than stamped with a policy it never saw. +#[tokio::test] +async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { + let fx = fixture().await; + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + let replacement_digest = replacement.policy_digest(); + let moved = fx + .store + .apply_policy( + SCHEDULING_ID, + &replacement_digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect("publish a second policy revision"); + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let mut admission_body = admission(&fx, OFFERING, slot); + admission_body["policyRevision"] = json!(u64::try_from(moved).expect("a bounded revision")); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "stale-process-current-revision", + json!({"hold": null, "admission": admission_body}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{problem}"); + assert_eq!(problem["code"], "policy.changed"); +} + +#[tokio::test] +async fn a_stale_process_can_still_release_an_appointment() { + let fx = fixture().await; + let (id, revision) = booked(&fx, 480, 620, "before-policy-change").await; + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + replacement.offerings[0].cancellation_cutoff_minutes += 1; + let replacement_digest = replacement.policy_digest(); + fx.store + .apply_policy( + SCHEDULING_ID, + &replacement_digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect("publish the replacement policy"); + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{id}/cancel"), + &fx.agent, + "stale-policy-cancellation", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); + let row = fx + .admin + .query_one( + "SELECT state, revision FROM scheduling_claims WHERE claim_id=$1", + &[&Uuid::parse_str(&id).expect("appointment id")], + ) + .await + .expect("read cancelled appointment"); + assert_eq!(row.get::<_, String>(0), "cancelled"); + assert_eq!(row.get::<_, i64>(1), i64::try_from(revision + 1).unwrap()); +} + +/// A records replacement moves the records revision under the supply +/// anchor: a commitment whose facts were resolved before the swap is +/// refused whole, rather than naming a resource the deployment retired. +#[tokio::test] +async fn a_records_swap_refuses_a_commitment_resolving_the_old_facts() { + let fx = fixture().await; + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + // The slot resolves while the station still stands: the swap retires it. + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (facts, resolved_under) = fx.store.facts().await.expect("resolve the standing facts"); + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let timezone = facts + .location(&offering.location) + .expect("the seeded location") + .timezone + .clone(); + let members: Vec = facts + .pool( + &offering + .exact_time + .as_ref() + .expect("an exact-time offering") + .pool, + ) + .expect("the seeded pool") + .members + .clone(); + let open = location_open_intervals(&policy, &offering.location, &timezone, &exceptions) + .expect("resolve the openings"); + let closures = location_closure_intervals(&facts, &offering.location, &timezone) + .expect("resolve the closures"); + // The operator retires the station this offering books; nothing occupies + // it, so the swap commits. + let swapped = records_without(&["station-1"]); + fx.store + .replace_facts(SCHEDULING_ID, &swapped, Uuid::new_v4(), operator_audit()) + .await + .expect("the records swap commits"); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("an exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let request = AdmissionRequest { + offering: OFFERING.to_owned(), + start: slot, + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: fx.revision, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + }; + let commitment = Commitment { + now: Utc::now(), + policy_revision: i64::try_from(fx.revision).expect("a bounded revision"), + facts_revision: resolved_under, + actor: "actor-pseudonym", + actor_issuer: ISSUER, + actor_subject: "principal-agent", + idempotency_key: "stale-facts", + request_hash: "sha256:stale-facts", + attempt_expires_at: Utc::now() + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + hooks: None, + }; + let outcome = fx + .store + .create_appointment(offering, &supply, &request, commitment) + .await; + assert!( + matches!(outcome, Err(CommitError::FactsStale)), + "a commitment resolving the pre-swap facts is refused: {outcome:?}" + ); + let committed = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("read the claim ledger"); + assert_eq!( + committed.get::<_, i64>(0), + 0, + "a stale facts resolution commits nothing" + ); +} + +/// The dispatch lease: a claimed intent is not claimable again until its +/// lease passes, and an outcome write only lands for the attempt that owns +/// the intent. +#[tokio::test] +async fn a_claimed_intent_is_leased_and_its_outcome_is_fenced_by_the_attempt() { + let fx = fixture().await; + let _ = booked(&fx, 90, 200, "lease-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the commitment minted one reminder intent" + ); + let row = &claimed[0]; + assert_eq!(row.attempts, 1); + + // Inside the lease a second dispatcher claims nothing of this intent. + let leased = fx + .store + .claim_due_intents(due + TimeDelta::minutes(9), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs"); + assert!( + leased + .iter() + .all(|intent| intent.outbox_id != row.outbox_id), + "a leased intent is not claimable again" + ); + + // Past the lease the intent is recoverable, as a new attempt. + let reclaimed: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::minutes(11), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == row.outbox_id) + .collect(); + assert_eq!(reclaimed.len(), 1, "the lease passed, so the intent is due"); + assert_eq!( + reclaimed[0].attempts, 2, + "the second attempt is counted once" + ); + + // The superseded attempt cannot settle the intent; the owning one can. + assert!(!fx + .store + .mark_intent_delivered(row.outbox_id, 1) + .await + .expect("the stale outcome write runs")); + let state: String = fx + .admin + .query_one( + "SELECT delivery_state FROM scheduling_outbox WHERE outbox_id=$1", + &[&row.outbox_id], + ) + .await + .expect("read the intent") + .get(0); + assert_eq!(state, "pending", "an obsolete attempt changes nothing"); + assert!(fx + .store + .mark_intent_delivered(row.outbox_id, 2) + .await + .expect("the owning outcome write runs")); +} + +#[tokio::test] +async fn an_expired_dispatch_lease_cannot_start_a_send() { + let fx = fixture().await; + let _ = booked(&fx, 90, 200, "expired-dispatch").await; + let due = Utc::now() + TimeDelta::days(1); + let rows = fx + .store + .claim_due_intents(due, 1, TimeDelta::minutes(10)) + .await + .expect("claim one intent"); + let row = rows.first().expect("a due intent"); + for (remaining, dispatchable) in [(6, true), (5, false), (0, false), (-1, false)] { + let now = due + TimeDelta::minutes(10) - TimeDelta::seconds(remaining); + fx.store.pin_clock(Arc::new(move || now)); + assert_eq!( + fx.store + .intent_still_dispatchable(row.outbox_id, row.attempts) + .await + .expect("check dispatch eligibility"), + dispatchable, + "remaining lease seconds: {remaining}" + ); + } +} + +/// Suppression wins up to the send: a reminder claimed for dispatch whose +/// appointment is then cancelled is skipped, its row is retained as +/// accounting, and the in-flight attempt's outcome cannot land. +#[tokio::test] +async fn a_suppressed_reminder_is_skipped_and_keeps_its_accounting() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "suppress-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!(claimed.len(), 1); + let reminder = &claimed[0]; + + // The caller cancels while the claimed reminder is in flight. + let (status, _) = fx + .post( + &format!("/v1/appointments/{appointment}/cancel"), + &fx.agent, + "suppress-cancel", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // The pre-send check skips the suppressed reminder. + assert!( + !fx.store + .intent_still_dispatchable(reminder.outbox_id, reminder.attempts) + .await + .expect("the liveness read runs"), + "a suppressed reminder is not sent" + ); + // The in-flight attempt's outcome write cannot land either. + assert!(!fx + .store + .mark_intent_delivered(reminder.outbox_id, reminder.attempts) + .await + .expect("the stale outcome write runs")); + // The row survives as accounting, suppressed rather than deleted. + let state: String = fx + .admin + .query_one( + "SELECT delivery_state FROM scheduling_outbox WHERE outbox_id=$1", + &[&reminder.outbox_id], + ) + .await + .expect("read the intent") + .get(0); + assert_eq!(state, "suppressed"); + + // The cancellation the caller made is still live for its own dispatch: + // suppression takes reminders, not the audit trail of the change. + let cancellation: Uuid = fx + .admin + .query_one( + "SELECT outbox_id FROM scheduling_outbox WHERE claim_id=$1 AND purpose='cancellation'", + &[&Uuid::parse_str(&appointment).expect("a claim identifier")], + ) + .await + .expect("the cancellation intent is minted") + .get(0); + fx.store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("lease the cancellation before dispatch"); + assert!( + fx.store + .intent_still_dispatchable(cancellation, 1) + .await + .expect("the liveness read runs"), + "the cancellation intent stays dispatchable" + ); +} + +/// An arrival-window offering explains its own start: the probe carries the +/// window's current revision, so a free start explains as admitting and a +/// full one as the public capacity refusal, never as a revision mismatch. +#[tokio::test] +async fn explain_answers_a_window_offering_rather_than_a_revision_mismatch() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; + let uri = format!( + "/v1/availability/explain?offering={WINDOW_OFFERING}&start={}", + stamp(start) + ); + let (status, free) = fx.get(&uri, &fx.agent).await; + assert_eq!(status, StatusCode::OK, "{free}"); + assert!( + free["publicCode"].is_null(), + "a free window start explains as admitting: {free}" + ); + + // Fill both published units; the same probe now explains the refusal a + // booking would receive. + for (key, channel) in [ + ("explain-a", Some("assisted")), + ("explain-b", Some("public")), + ] { + let (status, booked_body) = fx + .post( + "/v1/appointments", + &fx.agent, + key, + arrival(&fx, start, channel), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked_body}"); + } + let (status, full) = fx.get(&uri, &fx.agent).await; + assert_eq!(status, StatusCode::OK, "{full}"); + assert_eq!(full["publicCode"], "capacity.exhausted", "{full}"); +} + +/// A default availability request, with neither start nor end, mints a +/// cursor its own continuation can use: the effective interval rides with +/// the cursor instead of being re-derived from the next request's now. +#[tokio::test] +async fn a_default_availability_request_continues_its_own_cursor() { + let fx = fixture().await; + let (status, first) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{first}"); + let cursor = first["nextCursor"] + .as_str() + .expect("a limited availability page carries a cursor") + .to_owned(); + let first_last = moment(&first["items"][0], "start"); + + // The continuation omits start and end exactly as the first page did. + let (status, second) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2&cursor={cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{second}"); + assert!(!second["items"] + .as_array() + .expect("a page of items") + .is_empty()); + let second_first = moment(&second["items"][0], "start"); + assert!( + second_first > first_last, + "the continuation resumes after the first page" + ); + + // A caller that narrows the range on continuation is asking a different + // listing, and the cursor refuses it. + let (status, _) = fx + .get( + &format!( + "/v1/availability?offering={OFFERING}&limit=2&start={}&cursor={cursor}", + stamp(Utc::now() + TimeDelta::minutes(90)) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Explicit bounds continue as they always did: the same interval twice + // is the same listing. + let (status, bounded) = fx + .get( + &format!("{}&limit=2", availability_uri(OFFERING, 90, 620)), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{bounded}"); + let bounded_cursor = bounded["nextCursor"] + .as_str() + .expect("a bounded window under a page limit pages"); + let (status, _) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2&cursor={bounded_cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +/// A reschedule suppresses the reminder its obsolete revision minted and +/// mints the replacement at the new revision: the obsolete row is retained +/// as accounting, the replacement stays live for dispatch, and a reminder +/// that names anything but the appointment's current revision is never +/// sent, whether a swap marked it or not. +#[tokio::test] +async fn a_reschedule_suppresses_the_obsolete_reminder_and_mints_its_replacement() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "reschedule-remind").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the booking minted one reminder at revision 1" + ); + let obsolete = &claimed[0]; + + // The caller moves the appointment clear of the old slot. + let later = first_slot(&fx, OFFERING, 480, 620).await; + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment}/reschedule"), + &fx.agent, + "reschedule-remind-move", + json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, later), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{moved}"); + assert_eq!(moved["revision"], 2, "the move wrote the next revision"); + + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let rows = fx + .admin + .query( + "SELECT outbox_id, appointment_revision, delivery_state FROM scheduling_outbox \ + WHERE claim_id=$1 AND purpose='reminder' ORDER BY created_at", + &[&claim_id], + ) + .await + .expect("read the appointment's reminders"); + assert_eq!( + rows.len(), + 2, + "the obsolete reminder is retained beside its replacement" + ); + let obsolete_state: String = rows[0].get(2); + assert_eq!( + obsolete_state, "suppressed", + "the claimed reminder is suppressed, not deleted" + ); + let replacement: Uuid = rows[1].get(0); + assert_eq!( + rows[1].get::<_, i64>(1), + 2, + "the replacement names the new revision" + ); + assert_eq!( + rows[1].get::<_, String>(2), + "pending", + "the replacement is live for dispatch" + ); + + fx.store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("lease the replacement before dispatch"); + // The obsolete reminder is skipped, and the replacement is live. + assert!(!fx + .store + .intent_still_dispatchable(obsolete.outbox_id, obsolete.attempts) + .await + .expect("the liveness read runs")); + assert!(fx + .store + .intent_still_dispatchable(replacement, 1) + .await + .expect("the liveness read runs")); + + // The revision clause decides on its own: a pending reminder that names + // an appointment revision other than the current one is not sent, even + // when nothing marked the row. + fx.admin + .execute( + "UPDATE scheduling_outbox SET appointment_revision=1 WHERE outbox_id=$1", + &[&replacement], + ) + .await + .expect("age the replacement's revision"); + assert!( + !fx.store + .intent_still_dispatchable(replacement, 1) + .await + .expect("the liveness read runs"), + "a reminder naming an obsolete revision is not sent" + ); +} diff --git a/crates/registry-schedulingctl/Cargo.toml b/crates/registry-schedulingctl/Cargo.toml new file mode 100644 index 0000000000..782556e3be --- /dev/null +++ b/crates/registry-schedulingctl/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "registry-schedulingctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Registry Scheduling adopter and local operator tooling." +repository.workspace = true +publish = false + +[[bin]] +name = "schedulingctl" +path = "src/main.rs" + +[features] +postgres-test = ["registry-scheduling/postgres-test"] + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +chrono-tz.workspace = true +clap.workspace = true +registry-platform-buildinfo.workspace = true +registry-platform-config.workspace = true +registry-scheduling.workspace = true +registry-scheduling-core.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +serde_json.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["rt"] } +uuid.workspace = true + +[dev-dependencies] +# The database suites reach the runtime's plaintext test connection through +# this crate's own `postgres-test` feature, which their `required-features` +# already demand. Asking for it here instead would turn it on for every build +# holding both crates, and the runtime's own database suite would then be +# compiled and run by shards that have no server to offer it. +tokio-postgres.workspace = true + +[[test]] +name = "records_apply_postgres" +required-features = ["postgres-test"] + +[[test]] +name = "intents_postgres" +required-features = ["postgres-test"] diff --git a/crates/registry-schedulingctl/src/intents.rs b/crates/registry-schedulingctl/src/intents.rs new file mode 100644 index 0000000000..2a6ce172f0 --- /dev/null +++ b/crates/registry-schedulingctl/src/intents.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The undelivered delivery-intents read command. +//! +//! A deployment that declares no reminders destination holds every delivery +//! intent locally, and a destination that refused every attempt leaves the +//! intent failed. Both are recorded in the outbox and then waited on by +//! nobody, so this is the read path an operator has without one: it lists +//! what the sweep has stopped carrying, oldest due first, in the store's own +//! terms. A reminder suppressed by a cancellation or a reschedule is not +//! listed: nobody was ever meant to deliver it, and the row is retained as +//! accounting rather than as work. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use serde_json::{json, Value}; + +pub fn undelivered(config_path: &Path, limit: i64) -> Result { + let config_path = + fs::canonicalize(config_path).context("resolving the Scheduling runtime configuration")?; + let config = RuntimeConfig::load(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + let resolver = crate::records::secret_resolver(&config)?; + let store = PostgresStore::connect_runtime(&config.database, &resolver) + .context("the Scheduling runtime database configuration is invalid")?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting the Scheduling operator runtime")?; + let intents = runtime + .block_on(store.undelivered_intents(limit)) + .context("reading undelivered delivery intents")? + .into_iter() + .map(|intent| { + json!({ + "outboxId": intent.outbox_id.to_string(), + "purpose": intent.purpose, + "claimId": intent.claim_id.to_string(), + "appointmentRevision": intent.appointment_revision, + "dueAt": intent.due_at.to_rfc3339(), + "deliveryState": intent.delivery_state, + "attempts": intent.attempts, + "payload": intent.payload, + }) + }) + .collect::>(); + Ok(json!({ + "ok": true, + "command": "intents", + "config": config_path, + "intents": intents, + })) +} diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs new file mode 100644 index 0000000000..7dc829d9ee --- /dev/null +++ b/crates/registry-schedulingctl/src/lib.rs @@ -0,0 +1,1060 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `schedulingctl`, the Registry Scheduling authoring and local operator +//! tooling: `init` writes a complete starter project, `check` validates the +//! authored policy offline, `test` replays every fixture offline, `explain` +//! publishes what the runtime would serve, `package` writes the deployment +//! identity the runtime verifies, `records apply` performs the one +//! attributable operator write of a deployment's live environment records, +//! and `intents` reads the delivery intents a deployment's sweep has stopped +//! carrying. + +pub mod intents; +mod project; +pub mod records; +mod templates; + +use anyhow::Result; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use registry_scheduling::config::RuntimeConfigError; +use registry_scheduling::store::StoreError; +use registry_scheduling_core::AUTHORED_POLICY_FILE; +use serde_json::{json, Value}; +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command( + name = "schedulingctl", + version = registry_platform_buildinfo::DISPLAY_VERSION, + about = "Registry Scheduling authoring and local operator tooling" +)] +struct Cli { + /// Emit the selected command's report in this format. + #[arg(long, value_enum, global = true, default_value_t)] + format: OutputFormat, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Create a complete Registry Scheduling authoring project in a new directory. + Init(InitArgs), + /// Validate the authored policy offline and print effective defaults. + Check(CheckArgs), + /// Run every fixture's replay cases offline. + Test(ProjectArgs), + /// Explain the checked policy offline: what the runtime would publish. + Explain(ProjectArgs), + /// Write the verified policy package manifest beside the authored policy. + Package(ProjectArgs), + /// Apply the live environment records of a deployment. + Records(RecordsArgs), + /// List delivery intents a deployment's sweep has stopped carrying. + Intents(IntentsArgs), +} + +#[derive(Debug, Args)] +struct InitArgs { + /// New directory for the authored scheduling project. + #[arg(value_name = "PROJECT")] + project: PathBuf, + /// Project template: standalone-exact-time or standalone-arrival-window. + #[arg(long, value_name = "NAME")] + template: String, +} + +#[derive(Debug, Args)] +struct CheckArgs { + /// Authored scheduling project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + /// Exit unsuccessfully when the authoring check reports any finding. + #[arg(long)] + deny_findings: bool, +} + +#[derive(Debug, Args)] +struct ProjectArgs { + /// Authored scheduling project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, +} + +#[derive(Debug, Args)] +struct RecordsArgs { + #[command(subcommand)] + command: RecordsCommand, +} + +#[derive(Debug, Subcommand)] +enum RecordsCommand { + /// Replace the deployment's environment records in one attributable write. + Apply(RecordsApplyArgs), +} + +#[derive(Debug, Args)] +struct RecordsApplyArgs { + /// Runtime configuration document of the deployment to write. + #[arg(value_name = "RUNTIME_CONFIG")] + config: PathBuf, + /// Environment records document: locations, pools, and exceptions. + #[arg(value_name = "RECORDS")] + records: PathBuf, +} + +#[derive(Debug, Args)] +struct IntentsArgs { + /// Runtime configuration document of the deployment to read. + #[arg(value_name = "RUNTIME_CONFIG")] + config: PathBuf, + /// Most intents to list. + #[arg(long, default_value_t = 50)] + limit: i64, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + #[default] + Human, + Json, +} + +const DOMAIN_REFUSAL_EXIT: u8 = 1; +const OPERATIONAL_FAILURE_EXIT: u8 = 3; + +pub fn main_entry() -> ExitCode { + main_entry_from( + std::env::args_os(), + &mut std::io::stdout().lock(), + &mut std::io::stderr().lock(), + ) +} + +fn main_entry_from( + args: I, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode +where + I: IntoIterator, + T: Into, +{ + let args = args.into_iter().map(Into::into).collect::>(); + let machine_mode = args + .windows(2) + .any(|pair| pair[0] == OsStr::new("--format") && pair[1] == OsStr::new("json")) + || args.iter().any(|arg| arg == OsStr::new("--format=json")); + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + let _ = write!(stdout, "{error}"); + return ExitCode::SUCCESS; + } + Err(error) => { + if machine_mode { + write_failure( + &usage_failure(error.to_string()), + OutputFormat::Json, + stdout, + stderr, + ); + } else { + let _ = write!(stderr, "{error}"); + } + return ExitCode::from(2); + } + }; + let format = cli.format; + let deny_findings = matches!(&cli.command, Command::Check(args) if args.deny_findings); + match run(cli) { + Ok(mut report) => { + // The exit code already tells the caller whether this refused; + // the JSON must agree instead of reporting "ok": true underneath + // a nonzero exit. + let refusal = report_is_refusal(&report, deny_findings); + if refusal { + report["ok"] = json!(false); + } + let outcome = write_success(&report, format, stdout, stderr); + if outcome != ExitCode::SUCCESS { + return outcome; + } + if refusal { + ExitCode::from(DOMAIN_REFUSAL_EXIT) + } else { + ExitCode::SUCCESS + } + } + Err(error) => { + let (exit, diagnostic) = classify_failure(&error); + write_failure( + &json!({"ok": false, "diagnostics": [diagnostic]}), + format, + stdout, + stderr, + ); + ExitCode::from(exit) + } + } +} + +fn run(cli: Cli) -> Result { + match cli.command { + Command::Init(args) => project::init(&args.project, &args.template), + Command::Check(args) => project::check(&args.project), + Command::Test(args) => project::test(&args.project), + Command::Explain(args) => project::explain(&args.project), + Command::Package(args) => project::package(&args.project), + Command::Records(args) => match args.command { + RecordsCommand::Apply(apply) => records::apply(&apply.config, &apply.records), + }, + Command::Intents(args) => intents::undelivered(&args.config, args.limit), + } +} + +/// A completed report may still describe a refusal: an authoring check that +/// found something when the caller passed `--deny-findings`, a value whose +/// text is outside its grammar (never gated behind `--deny-findings`, because +/// there is nothing to opt into), or a fixture run with failing cases, which +/// always fails the build. The report is the evidence; the exit code is the +/// signal. +fn report_is_refusal(report: &Value, deny_findings: bool) -> bool { + if report["command"] == "check" { + return report["status"] == "invalid" + || (deny_findings && report["status"] == "incomplete"); + } + report["authoringStatus"] == "invalid" + || report["fixtures"] + .as_array() + .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")) +} + +fn usage_failure(message: String) -> Value { + json!({ + "ok": false, + "command": "usage", + "diagnostics": [{ + "severity": "error", + "code": "schedulingctl.usage-invalid", + "artifact": "command_arguments", + "path": "arguments", + "message": message, + "suggestedAction": "Correct the command arguments and retry.", + }], + }) +} + +fn classify_failure(error: &anyhow::Error) -> (u8, Value) { + let io_failure = error.chain().any(|cause| cause.is::()); + // A runtime configuration that will not load is a defect in an authored + // document; a store that cannot be reached is a defect in the deployment + // environment. Both name their own artifact instead of hiding behind the + // generic authoring refusal. + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return ( + DOMAIN_REFUSAL_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.runtime-configuration.invalid", + "artifact": "runtime_configuration", + "path": "runtime.yaml", + "message": format!("{error:#}"), + "suggestedAction": "Correct the runtime configuration the message names, then retry.", + }), + ); + } + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return ( + OPERATIONAL_FAILURE_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.store-unavailable", + "artifact": "database", + "path": "database", + "message": format!("{error:#}"), + "suggestedAction": "Restore the Scheduling database or its credentials, then retry.", + }), + ); + } + if io_failure { + ( + OPERATIONAL_FAILURE_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.io-failure", + "artifact": "filesystem", + "path": "filesystem", + "message": format!("{error:#}"), + "suggestedAction": "Correct the path or permissions, then retry.", + }), + ) + } else { + ( + DOMAIN_REFUSAL_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.refused", + "artifact": "scheduling_project", + "path": AUTHORED_POLICY_FILE, + "message": format!("{error:#}"), + "suggestedAction": "Correct the authored input the message names, then retry.", + }), + ) + } +} + +fn write_success( + report: &Value, + format: OutputFormat, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode { + let result = match format { + OutputFormat::Json => serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)), + OutputFormat::Human => render_human(report, stdout), + }; + if result.is_ok() { + ExitCode::SUCCESS + } else { + let _ = writeln!(stderr, "schedulingctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } +} + +fn write_failure( + report: &Value, + format: OutputFormat, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) { + if format == OutputFormat::Json { + let _ = serde_json::to_writer_pretty(&mut *stdout, report); + let _ = writeln!(stdout); + return; + } + if let Some(diagnostics) = report["diagnostics"].as_array() { + for finding in diagnostics { + let _ = writeln!( + stderr, + "{}[{}] {}: {}", + finding["severity"].as_str().unwrap_or("error"), + finding["code"].as_str().unwrap_or("schedulingctl.refused"), + finding["path"].as_str().unwrap_or("command"), + finding["message"].as_str().unwrap_or("command refused"), + ); + if let Some(action) = finding["suggestedAction"].as_str() { + let _ = writeln!(stderr, " next: {action}"); + } + } + } +} + +fn human_lead(report: &Value) -> String { + let command = report["command"].as_str().unwrap_or("command"); + let failed_fixtures = report["fixtures"] + .as_array() + .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")); + match ( + command, + report["status"].as_str(), + report["authoringStatus"].as_str(), + ) { + ("check", Some("invalid"), _) => { + "Authoring check refused: a value's text is outside the grammar its field requires." + .to_owned() + } + ("check", Some("incomplete"), _) => { + "Authoring check completed with incomplete inputs.".to_owned() + } + ("check", Some("complete"), _) => "Authoring check passed with complete inputs.".to_owned(), + ("test", _, _) if failed_fixtures => { + "Offline synthetic fixtures reported failures.".to_owned() + } + ("test", _, Some("invalid")) => { + "Offline synthetic fixtures were not run: a value's text is outside the grammar its field requires." + .to_owned() + } + ("test", _, Some("incomplete")) => { + "Offline synthetic fixtures passed with incomplete authored inputs.".to_owned() + } + ("test", _, _) => "Offline synthetic fixtures passed.".to_owned(), + ("package", _, _) => { + "Policy package manifest written beside the authored policy.".to_owned() + } + ("records-apply", _, _) => "Environment records applied.".to_owned(), + ("intents", _, _) => "Undelivered delivery intents listed.".to_owned(), + _ => format!("{command} succeeded."), + } +} + +fn render_human(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> { + writeln!(stdout, "{}", human_lead(report))?; + if let Some(fields) = report.as_object() { + for (key, value) in fields { + if matches!(key.as_str(), "ok" | "command" | "findings" | "diagnostics") + || value.is_null() + { + continue; + } + let rendered = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + writeln!(stdout, "{key}: {rendered}")?; + } + } + if let Some(findings) = report["findings"].as_array() { + for finding in findings { + writeln!( + stdout, + "finding {}: {}", + finding["path"].as_str().unwrap_or(AUTHORED_POLICY_FILE), + finding["reason"].as_str().unwrap_or("review required"), + )?; + } + } + Ok(()) +} + +/// Command tree available to command-reference tooling. +#[must_use] +pub fn command() -> clap::Command { + Cli::command() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run one invocation in JSON mode and return its exit code, its report + /// (null when stdout carried no report), and its stderr. + fn run_json(arguments: &[&str]) -> (ExitCode, Value, Vec) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut all = vec![ + OsString::from("schedulingctl"), + OsString::from("--format=json"), + ]; + all.extend(arguments.iter().map(OsString::from)); + let exit = main_entry_from(all, &mut stdout, &mut stderr); + let report = serde_json::from_slice(&stdout).unwrap_or(Value::Null); + (exit, report, stderr) + } + + fn initialized(template: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + project::init(&project, template).unwrap(); + (root, project) + } + + #[test] + fn canonical_command_shapes_and_default_format_are_stable() { + let cli = Cli::try_parse_from(["schedulingctl", "check", "/tmp/project"]).unwrap(); + assert_eq!(cli.format, OutputFormat::Human); + let Command::Check(args) = cli.command else { + panic!("expected check") + }; + assert_eq!(args.project, PathBuf::from("/tmp/project")); + assert!(!args.deny_findings); + + // `--format` matches every sibling ctl's value names: `human` and + // `json`, never `text`. + let cli = Cli::try_parse_from([ + "schedulingctl", + "--format", + "human", + "check", + "/tmp/project", + ]) + .unwrap(); + assert_eq!(cli.format, OutputFormat::Human); + assert!(Cli::try_parse_from([ + "schedulingctl", + "--format", + "text", + "check", + "/tmp/project" + ]) + .is_err()); + + let cli = + Cli::try_parse_from(["schedulingctl", "check", "/tmp/project", "--deny-findings"]) + .unwrap(); + let Command::Check(args) = cli.command else { + panic!("expected check") + }; + assert!(args.deny_findings); + + let cli = + Cli::try_parse_from(["schedulingctl", "--format", "json", "test", "/tmp/project"]) + .unwrap(); + assert_eq!(cli.format, OutputFormat::Json); + + let cli = Cli::try_parse_from([ + "schedulingctl", + "init", + "/tmp/project", + "--template", + "standalone-exact-time", + ]) + .unwrap(); + let Command::Init(args) = cli.command else { + panic!("expected init") + }; + assert_eq!(args.template, "standalone-exact-time"); + + for command in ["test", "explain", "package"] { + let cli = Cli::try_parse_from(["schedulingctl", command, "/tmp/project"]).unwrap(); + match command { + "test" => assert!(matches!(cli.command, Command::Test(_))), + "explain" => assert!(matches!(cli.command, Command::Explain(_))), + _ => assert!(matches!(cli.command, Command::Package(_))), + } + } + + let cli = Cli::try_parse_from([ + "schedulingctl", + "records", + "apply", + "/runtime.yaml", + "/records.yaml", + ]) + .unwrap(); + let Command::Records(RecordsArgs { + command: RecordsCommand::Apply(args), + }) = cli.command + else { + panic!("expected records apply") + }; + assert_eq!(args.config, PathBuf::from("/runtime.yaml")); + assert_eq!(args.records, PathBuf::from("/records.yaml")); + + assert!(Cli::try_parse_from(["schedulingctl", "records"]).is_err()); + assert!( + Cli::try_parse_from(["schedulingctl", "records", "apply", "/runtime.yaml"]).is_err() + ); + + let cli = Cli::try_parse_from(["schedulingctl", "intents", "/runtime.yaml"]).unwrap(); + let Command::Intents(args) = cli.command else { + panic!("expected intents") + }; + assert_eq!(args.config, PathBuf::from("/runtime.yaml")); + assert_eq!(args.limit, 50); + + let cli = + Cli::try_parse_from(["schedulingctl", "intents", "/runtime.yaml", "--limit", "10"]) + .unwrap(); + let Command::Intents(args) = cli.command else { + panic!("expected intents") + }; + assert_eq!(args.limit, 10); + + assert!(Cli::try_parse_from(["schedulingctl", "intents"]).is_err()); + + assert!(Cli::try_parse_from([ + "schedulingctl", + "init", + "--template", + "standalone-exact-time" + ]) + .is_err()); + assert!(Cli::try_parse_from(["schedulingctl", "check"]).is_err()); + } + + #[test] + fn every_template_initializes_checks_tests_and_explains_green() { + for template in ["standalone-exact-time", "standalone-arrival-window"] { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let project_string = project.to_str().unwrap().to_owned(); + + let (exit, report, stderr) = + run_json(&["init", &project_string, &format!("--template={template}")]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert_eq!(report["command"], "init"); + + let (exit, check, stderr) = run_json(&["check", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert_eq!(check["status"], "complete", "{check}"); + + let (exit, test, stderr) = run_json(&["test", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert!( + test["fixtures"] + .as_array() + .unwrap() + .iter() + .all(|fixture| fixture["status"] == "passed"), + "{test}" + ); + + let (exit, explain, stderr) = run_json(&["explain", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert!(explain["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + } + } + + /// A check that completes reports its findings and exits zero, so a human + /// reads the report without parsing an exit code; `--deny-findings` is the + /// CI switch that makes the same finding fail the build. A complete check + /// passes either way. + #[test] + fn an_incomplete_check_exits_zero_unless_findings_are_denied() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["check", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "incomplete"); + // Findings alone, without --deny-findings, are not a refusal. + assert_eq!(report["ok"], true); + assert!(report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["path"] == "scheduling.version" && finding["reason"] == "invalid-bound" + })); + + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "incomplete"); + // The exit code already says this refused; the JSON must agree. + assert_eq!(report["ok"], false); + + let (_root, clean) = initialized("standalone-exact-time"); + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", clean.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "complete"); + assert_eq!(report["ok"], true); + } + + /// A value whose text is outside its grammar (a clock that is not + /// `HH:MM`) is not unfinished authoring: no `--deny-findings` switch + /// governs it, because there is nothing to opt into. `check` refuses it + /// unconditionally, and the `version: 0` case above must keep exiting + /// zero without `--deny-findings` so the two families stay separated. + #[test] + fn a_malformed_value_refuses_check_regardless_of_deny_findings() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "startTime: \"09:00\"", + "startTime: \"9:00\"", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + + let (exit, report, stderr) = run_json(&["check", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "invalid"); + assert_eq!(report["ok"], false); + assert!(report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["path"] == "openings[0].startTime" && finding["reason"] == "malformed-value" + })); + + // --deny-findings changes nothing here: the refusal does not depend + // on it. + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "invalid"); + assert_eq!(report["ok"], false); + } + + /// A malformed policy has no business running fixtures against it: + /// `test` refuses the same way `check` does, before it ever reads the + /// fixtures directory. + #[test] + fn a_malformed_value_refuses_test_without_running_fixtures() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "startTime: \"09:00\"", + "startTime: \"9:00\"", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["authoringStatus"], "invalid"); + assert_eq!(report["ok"], false); + assert_eq!(report["fixtures"].as_array().unwrap().len(), 0); + } + + #[test] + fn a_failing_fixture_case_is_reported_and_exits_nonzero() { + let (_root, project) = initialized("standalone-exact-time"); + let fixture_path = project.join("fixtures/counter-stations.yaml"); + let broken = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "code: booking.duplicate-active", + "code: capacity.exhausted", + 1, + ); + std::fs::write(&fixture_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + // The exit code already says this refused; the JSON must agree. + assert_eq!(report["ok"], false); + let fixtures = report["fixtures"].as_array().unwrap(); + let counter = fixtures + .iter() + .find(|fixture| fixture["name"] == "counter-stations") + .unwrap(); + assert_eq!(counter["status"], "failed"); + let case = counter["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| case["name"] == "same-key-retry-is-refused") + .unwrap(); + assert_eq!(case["status"], "fail"); + // A failing case prints what the fixture expected and what actually + // happened side by side, so a mismatch is legible without opening + // the fixture file. + let detail = case["detail"].as_str().unwrap(); + assert!(detail.contains("expected"), "{detail}"); + assert!(detail.contains("capacity.exhausted"), "{detail}"); + assert!(detail.contains("booking.duplicate-active"), "{detail}"); + } + + #[test] + fn a_fixture_that_cannot_replay_is_a_domain_refusal_with_a_diagnostic() { + let (_root, project) = initialized("standalone-exact-time"); + let fixture_path = project.join("fixtures/counter-stations.yaml"); + let broken = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "code: booking.duplicate-active", + "code: not-a-problem-code", + 1, + ); + std::fs::write(&fixture_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let diagnostic = &report["diagnostics"][0]; + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + assert_eq!(diagnostic["code"], "schedulingctl.refused"); + // The path names the authored policy file by the crate's own + // constant, not a literal that could drift from it. + assert_eq!(diagnostic["path"], AUTHORED_POLICY_FILE); + assert!(diagnostic["message"] + .as_str() + .unwrap() + .contains("not-a-problem-code")); + } + + #[test] + fn usage_json_has_the_common_diagnostic_fields_and_exit_two() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "--format", "json", "check"], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::from(2)); + assert!(stderr.is_empty()); + let report: Value = serde_json::from_slice(&stdout).unwrap(); + assert_eq!(report["command"], "usage"); + let diagnostic = &report["diagnostics"][0]; + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + + stdout.clear(); + let exit = main_entry_from(["schedulingctl", "check"], &mut stdout, &mut stderr); + assert_eq!(exit, ExitCode::from(2)); + assert!(stdout.is_empty()); + assert!(!stderr.is_empty()); + } + + #[test] + fn a_missing_project_is_an_operational_failure_on_the_selected_channel() { + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("missing").to_str().unwrap().to_owned(); + let (exit, report, stderr) = run_json(&["check", &missing]); + assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["diagnostics"][0]["code"], "schedulingctl.io-failure"); + // The failure names the path and the operating system's reason + // instead of a generic placeholder that could be any I/O failure. + let message = report["diagnostics"][0]["message"].as_str().unwrap(); + assert!(message.contains(&missing), "{message}"); + assert!( + message.to_lowercase().contains("no such file or directory"), + "{message}" + ); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", missing.as_str()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT)); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).starts_with("error[schedulingctl.io-failure]")); + } + + #[test] + fn text_reports_render_the_lead_the_proof_boundary_and_findings() { + let (_root, project) = initialized("standalone-exact-time"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "test", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Offline synthetic fixtures passed.")); + assert!(text.contains("proofBoundary: offline_synthetic")); + assert!(text.contains("productionClosure: false")); + assert!(text.contains("networkAccess: false")); + + let mut stdout = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Authoring check passed with complete inputs.")); + // Nested report objects render as compact JSON. + assert!(text.contains("\"schedulingId\":\"registry-updates\"")); + + // An incomplete check renders its findings in text mode too, and + // exits zero unless findings were denied. + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let mut stdout = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Authoring check completed with incomplete inputs.")); + assert!(text.contains("finding scheduling.version: invalid-bound")); + } + + #[test] + fn package_writes_a_verifiable_manifest_and_refuses_replacement() { + let (_root, project) = initialized("standalone-exact-time"); + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["command"], "package"); + assert!(report["packageDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert!(report["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + // The package identity and the policy's own digest are different + // documents and must never be conflated. + assert_ne!(report["packageDigest"], report["policyDigest"]); + assert_eq!(report["files"][0]["path"], "scheduling.yaml"); + assert_eq!(report["runtimeConfigurationIncluded"], false); + assert_eq!(report["secretsIncluded"], false); + + let manifest_path = project.join("scheduling.package.json"); + assert!(manifest_path.is_file()); + // The written manifest is exactly the one the runtime's own verifier + // accepts against the same policy text. + let policy_path = project.join("scheduling.yaml"); + let policy_text = std::fs::read_to_string(&policy_path).unwrap(); + let verified = + registry_scheduling::config::verify_policy_package(&policy_path, &policy_text) + .unwrap() + .expect("the written manifest verifies"); + // `verify_policy_package` returns the manifest's own byte-exact + // digest, which the on-disk manifest calls `policyDigest`; the + // report must mirror that naming, not the policy's semantic digest. + assert_eq!(verified, report["policyDigest"].as_str().unwrap()); + // Pretty-printed, newline-terminated: a text document an operator + // diffs. + let bytes = std::fs::read(&manifest_path).unwrap(); + assert_eq!(bytes.last(), Some(&b'\n')); + + // Repackaging is a deliberate act: the existing manifest is refused, + // never silently replaced. + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let message = report["diagnostics"][0]["message"].as_str().unwrap(); + assert!(message.contains("already exists"), "{message}"); + } + + #[test] + fn package_refuses_incomplete_authoring_and_writes_nothing() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert!(report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .contains("finding")); + assert!(!project.join("scheduling.package.json").exists()); + } + + #[test] + fn runtime_configuration_and_store_failures_map_to_their_own_diagnostics() { + let config_error = anyhow::Error::new(RuntimeConfigError::RelativeRuntimePath); + let (exit, diagnostic) = classify_failure(&config_error); + assert_eq!(exit, DOMAIN_REFUSAL_EXIT); + assert_eq!( + diagnostic["code"], + "schedulingctl.runtime-configuration.invalid" + ); + assert_eq!(diagnostic["artifact"], "runtime_configuration"); + + let store_error = anyhow::Error::new(StoreError::Configuration).context("connecting"); + let (exit, diagnostic) = classify_failure(&store_error); + assert_eq!(exit, OPERATIONAL_FAILURE_EXIT); + assert_eq!(diagnostic["code"], "schedulingctl.store-unavailable"); + assert_eq!(diagnostic["artifact"], "database"); + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + } + + /// A records document that does not hold together is refused in its own + /// terms before any connection is opened: the write path never reaches + /// the database with a document the store would reject mid-swap. + #[test] + fn records_apply_refuses_an_invalid_document_before_touching_a_database() { + let (root, project) = initialized("standalone-exact-time"); + let config_path = root.path().join("runtime.yaml"); + std::fs::write( + &config_path, + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://issuer.example.test\n\ + \x20 audience: scheduling-api\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULINGCTL_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULINGCTL_TEST_DATABASE\n\ + audit:\n path: {}/audit.jsonl\n\ + \x20 hashKeyRef: secret:env/SCHEDULINGCTL_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project.display(), + root.path().display() + ), + ) + .unwrap(); + let records_path = root.path().join("records.yaml"); + std::fs::write( + &records_path, + "locations:\n - id: bangkok-counter\n timezone: Asia/Nowhere\n", + ) + .unwrap(); + let (exit, report, stderr) = run_json(&[ + "records", + "apply", + config_path.to_str().unwrap(), + records_path.to_str().unwrap(), + ]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let message = report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .to_owned(); + assert!( + message.contains("unknown timezone Asia/Nowhere"), + "{message}" + ); + } +} diff --git a/crates/registry-schedulingctl/src/main.rs b/crates/registry-schedulingctl/src/main.rs new file mode 100644 index 0000000000..bfd3e73c67 --- /dev/null +++ b/crates/registry-schedulingctl/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +fn main() -> ExitCode { + registry_schedulingctl::main_entry() +} diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs new file mode 100644 index 0000000000..572952a691 --- /dev/null +++ b/crates/registry-schedulingctl/src/project.rs @@ -0,0 +1,796 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Project loading and the offline init, check, test, and explain reports. +//! +//! Everything here runs without a network, a database, or a clock: the +//! authored policy and its fixtures are read from the project directory, +//! checked and replayed through the pure core, and rendered as one JSON +//! report per command. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use registry_scheduling::config::{verify_policy_package, PolicyPackageManifest}; +use registry_scheduling_core::{ + parse_fixture_yaml, parse_policy_yaml, CaseStatus, FixtureExpectation, ReplayError, + SchedulingDiagnostic, SchedulingFacts, SchedulingFixture, SchedulingPolicy, + AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, +}; +use serde_json::{json, Value}; + +use crate::templates; + +/// The largest policy or fixture an authoring project may carry. +const MAXIMUM_INPUT_BYTES: usize = 1024 * 1024; + +/// The directory an authored project keeps its replay fixtures in. +const FIXTURES_DIRECTORY: &str = "fixtures"; + +pub(super) fn init(project: &Path, template: &str) -> Result { + let Some(files) = templates::template_files(template) else { + bail!( + "unknown template {template:?}; available templates: standalone-exact-time, standalone-arrival-window" + ); + }; + match fs::symlink_metadata(project) { + Ok(_) => bail!("destination already exists; init never overwrites a project"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("checking the project destination"), + } + let parent = project + .parent() + .context("project destination has no parent")?; + fs::create_dir_all(parent).context("creating project parent")?; + let staging = tempfile::Builder::new() + .prefix(".scheduling-init-") + .tempdir_in(parent) + .context("creating project staging directory")?; + fs::create_dir(staging.path().join(FIXTURES_DIRECTORY))?; + let mut created = Vec::new(); + for (relative, contents) in &files { + fs::write(staging.path().join(relative), contents)?; + created.push((*relative).to_owned()); + } + let staging_path = staging.keep(); + fs::rename(&staging_path, project) + .context("publishing scheduling project without replacement")?; + Ok(json!({ + "ok": true, + "command": "init", + "template": template, + "project": project, + "created": created, + "next": [ + "Run schedulingctl check PROJECT, then schedulingctl test PROJECT.", + "Copy runtime.example.yaml to runtime.yaml, set its absolute paths, run scheduling migrate, apply records.yaml, then run scheduling serve with it.", + ], + })) +} + +pub(super) fn check(project: &Path) -> Result { + let policy = load_policy(project)?; + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); + let status = authoring_status(&findings); + Ok(json!({ + "ok": true, + "command": "check", + "status": status, + "project": project, + "findings": findings_json(&findings), + "effective": effective(&policy, &facts), + "networkAccess": false, + "databaseAccess": false, + })) +} + +/// A value whose text is outside its grammar (a clock that is not `HH:MM`, a +/// date that is not `YYYY-MM-DD`) is not unfinished authoring: no addition +/// makes it valid, only a correction. `invalid` names that instead of +/// `incomplete`, so a caller can refuse it without opting into +/// `--deny-findings`. +fn authoring_status(findings: &[SchedulingDiagnostic]) -> &'static str { + if findings + .iter() + .any(|finding| finding.reason.is_malformed_value()) + { + "invalid" + } else if findings.is_empty() { + "complete" + } else { + "incomplete" + } +} + +pub(super) fn test(project: &Path) -> Result { + let policy = load_policy(project)?; + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); + let authoring_status = authoring_status(&findings); + if authoring_status == "invalid" { + // A malformed value has no business running fixtures against it: the + // authored text does not mean what the offering's evaluators would + // read it to mean, so replaying against it would answer a question + // the author never asked. + return Ok(json!({ + "ok": true, + "command": "test", + "project": project, + "authoringStatus": authoring_status, + "findings": findings_json(&findings), + "fixtures": [], + "proofBoundary": "offline_synthetic", + "productionClosure": false, + "networkAccess": false, + "databaseAccess": false, + })); + } + let fixture_dir = project.join(FIXTURES_DIRECTORY); + // An entry the directory cannot hand over is a filesystem failure, not a + // fixture that does not exist: swallowing it here would report a partial + // replay as a complete, passing one. + let mut paths = fs::read_dir(&fixture_dir) + .context("reading fixtures directory")? + .map(|entry| { + entry + .context("reading a fixtures directory entry") + .map(|entry| entry.path()) + }) + .collect::, _>>()? + .into_iter() + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml")) + .collect::>(); + paths.sort(); + if paths.is_empty() { + bail!("test requires at least one YAML fixture"); + } + let mut reports = Vec::new(); + for path in paths { + reports.push(run_fixture(project, &policy, &path)?); + } + Ok(json!({ + "ok": true, + "command": "test", + "project": project, + "authoringStatus": authoring_status, + "findings": findings_json(&findings), + "fixtures": reports, + "proofBoundary": "offline_synthetic", + "productionClosure": false, + "networkAccess": false, + "databaseAccess": false, + })) +} + +pub(super) fn explain(project: &Path) -> Result { + let policy = load_policy(project)?; + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); + if !findings.is_empty() { + bail!( + "explain requires a policy that passes its check; run schedulingctl check first. Findings: {}", + findings + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ); + } + let offerings = policy + .offerings + .iter() + .map(|offering| { + let mut report = json!({ + "id": offering.id, + "service": offering.service, + "mode": offering.mode, + "location": offering.location, + }); + if let Some(exact) = &offering.exact_time { + report["exactTime"] = serde_json::to_value(exact)?; + } + if let Some(arrival) = &offering.arrival { + report["arrival"] = serde_json::to_value(arrival)?; + } + Ok(report) + }) + .collect::>>()?; + let windows = facts + .windows + .iter() + .map(|window| { + let mut units_policy = serde_json::to_value(&window.units_policy)?; + units_policy["subquotas"] = json!(window + .subquotas + .iter() + .map(|subquota| json!({ + "channel": subquota.channel.as_str(), + "units": subquota.units, + })) + .collect::>()); + Ok(json!({ + "id": window.id, + "revision": window.revision, + "start": window.start, + "end": window.end, + "units": window.units, + "unitsPolicy": units_policy, + })) + }) + .collect::>>()?; + Ok(json!({ + "ok": true, + "command": "explain", + "scheduling": { + "id": policy.scheduling.id, + "version": policy.scheduling.version, + }, + "policyDigest": policy.policy_digest(), + "offerings": offerings, + "windows": windows, + "holdPolicy": { + "ttlMinutes": policy.hold_policy.ttl_minutes, + "maxPerCaller": policy.hold_policy.max_per_caller, + "because": policy.hold_policy.because, + }, + "networkAccess": false, + "databaseAccess": false, + })) +} + +/// Write the verified package manifest beside the authored policy. The +/// manifest is the deployment identity the runtime verifies at startup, so +/// repackaging after an edit is a deliberate act: an existing manifest is +/// never silently replaced, and the written manifest is proven to verify +/// against the exact policy text before the command reports success. +pub(super) fn package(project: &Path) -> Result { + let project = + fs::canonicalize(project).context("resolving the Scheduling authoring project")?; + let policy_text = read_authoring_input(&project.join(AUTHORED_POLICY_FILE))?; + let policy = parse_policy_yaml(&policy_text) + .map_err(|error| anyhow!("parsing {AUTHORED_POLICY_FILE} failed at {}", error.path()))?; + let findings = policy.check(); + if !findings.is_empty() { + bail!( + "the authored policy reports {} finding(s); run schedulingctl check and fix them before packaging", + findings.len() + ); + } + let manifest = PolicyPackageManifest::build(&policy_text) + .context("building the Scheduling policy package identity")?; + let manifest_path = project.join(SCHEDULING_PACKAGE_MANIFEST_FILE); + if manifest_path.exists() { + bail!( + "{} already exists; remove it deliberately before repackaging", + manifest_path.display() + ); + } + let mut bytes = + serde_json::to_vec_pretty(&manifest).context("encoding the package manifest")?; + bytes.push(b'\n'); + fs::write(&manifest_path, bytes) + .with_context(|| format!("writing {}", manifest_path.display()))?; + let verified = verify_policy_package(&project.join(AUTHORED_POLICY_FILE), &policy_text) + .with_context(|| format!("verifying the written {}", manifest_path.display()))?; + match verified { + Some(digest) if digest == manifest.policy_digest => {} + _ => bail!("the written package manifest does not verify against the authored policy"), + } + Ok(json!({ + "ok": true, + "command": "package", + "project": project, + "manifest": manifest_path, + // The manifest on disk names its own byte-exact digest `policyDigest` + // (see `PolicyPackageManifest`); mirror that naming here instead of + // reporting the policy's separate semantic digest under the same + // key. `packageDigest` is that semantic digest, the one `explain` + // also reports. + "policyDigest": manifest.policy_digest, + "packageDigest": policy.policy_digest(), + "files": manifest.files, + "runtimeConfigurationIncluded": false, + "secretsIncluded": false, + "networkAccess": false, + "databaseAccess": false, + })) +} + +fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result { + let relative: PathBuf = path.strip_prefix(project).unwrap_or(path).to_owned(); + let fixture = load_fixture(path)?; + // A calendar gap or fold is invisible to `check`: an opening carries no + // timezone, only a fixture's location record does, so authoring can + // never see this coming. Report it the way any other fixture failure is + // reported instead of refusing the whole command over a fixture whose + // policy `check` already called complete. + let outcomes = match fixture.replay(policy) { + Err(ReplayError::Calendar(error)) => { + return Ok(json!({ + "name": fixture.name, + "status": "failed", + "file": relative, + "cases": [], + "calendarRefusal": error.to_string(), + })); + } + other => other.with_context(|| format!("replaying fixture {}", relative.display()))?, + }; + let cases = outcomes + .iter() + .zip(fixture.cases.iter()) + .map(|(outcome, case)| { + // A failing case names what the fixture expected beside what + // replay actually produced, so a mismatched problem code is + // legible from the report alone, never only from opening the + // fixture file. + let detail = match (outcome.status, &outcome.detail) { + (CaseStatus::Fail, Some(actual)) => Some(format!( + "expected {}, actual {actual}", + expected_summary(&case.expect) + )), + (_, detail) => detail.clone(), + }; + json!({ + "name": outcome.name, + "status": outcome.status.as_str(), + "detail": detail, + }) + }) + .collect::>(); + let status = if outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass) + { + "passed" + } else { + "failed" + }; + Ok(json!({ + "name": fixture.name, + "status": status, + "file": relative, + "cases": cases, + })) +} + +/// Render a fixture case's expectation the same way replay renders what +/// actually happened, so the two read side by side. +fn expected_summary(expect: &FixtureExpectation) -> String { + match expect { + FixtureExpectation::Admitted { units, resource } => format!( + "admitted onto {} for {units} unit(s)", + resource.as_deref().unwrap_or("the window") + ), + FixtureExpectation::Refused { code } => format!("refused {code}"), + } +} + +fn load_policy(project: &Path) -> Result { + let bytes = read_authoring_input(&project.join(AUTHORED_POLICY_FILE))?; + parse_policy_yaml(&bytes).with_context(|| format!("parsing {AUTHORED_POLICY_FILE}")) +} + +fn load_records(project: &Path) -> Result { + let bytes = read_authoring_input(&project.join("records.yaml"))?; + crate::records::parse_records(&bytes).context("parsing records.yaml") +} + +fn load_fixture(path: &Path) -> Result { + let bytes = read_authoring_input(path)?; + parse_fixture_yaml(&bytes).with_context(|| format!("parsing fixture {}", path.display())) +} + +pub(super) fn read_authoring_input(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + if bytes.len() > MAXIMUM_INPUT_BYTES { + bail!("{} exceeds the one MiB authoring limit", path.display()); + } + String::from_utf8(bytes).with_context(|| format!("reading {}", path.display())) +} + +/// Every check finding as its path and closed reason, ready for a report. +fn findings_json(findings: &[SchedulingDiagnostic]) -> Vec { + findings + .iter() + .map(|finding| json!({"path": finding.path, "reason": finding.reason.as_str()})) + .collect() +} + +/// The effective policy a check saw: identity, digest, collection sizes and +/// identifiers, and the hold policy. +fn effective(policy: &SchedulingPolicy, facts: &SchedulingFacts) -> Value { + fn summary<'a>(ids: impl Iterator) -> Value { + let all: Vec<&str> = ids.map(String::as_str).collect(); + json!({"count": all.len(), "ids": all}) + } + json!({ + "schedulingId": policy.scheduling.id, + "schedulingVersion": policy.scheduling.version, + "policyDigest": policy.policy_digest(), + "services": summary(policy.services.iter().map(|service| &service.id)), + "offerings": summary(policy.offerings.iter().map(|offering| &offering.id)), + "openings": summary(policy.openings.iter().map(|opening| &opening.id)), + "windows": summary(facts.windows.iter().map(|window| &window.id)), + "holdPolicy": { + "ttlMinutes": policy.hold_policy.ttl_minutes, + "maxPerCaller": policy.hold_policy.max_per_caller, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_scheduling::config::RuntimeConfig; + + /// A tempdir holding one freshly initialized template project, plus the + /// paths the tempdir keeps alive. + fn initialized(template: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + init(&project, template).unwrap(); + (root, project) + } + + #[test] + fn init_writes_every_template_file_without_overwriting() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let report = init(&project, "standalone-arrival-window").unwrap(); + assert_eq!(report["command"], "init"); + assert_eq!(report["template"], "standalone-arrival-window"); + assert_eq!( + report["created"], + json!([ + "scheduling.yaml", + "runtime.example.yaml", + "records.yaml", + "fixtures/household-morning.yaml", + "fixtures/household-afternoon.yaml" + ]) + ); + assert!(project.join(AUTHORED_POLICY_FILE).is_file()); + assert!(project.join("runtime.example.yaml").is_file()); + assert!(project.join("records.yaml").is_file()); + assert!(project.join("fixtures/household-morning.yaml").is_file()); + assert!(project.join("fixtures/household-afternoon.yaml").is_file()); + let error = init(&project, "standalone-arrival-window").unwrap_err(); + assert!(error.to_string().contains("never overwrites")); + let error = init(&root.path().join("other"), "no-such-template").unwrap_err(); + assert!(error.to_string().contains("standalone-arrival-window")); + } + + /// The example's placeholder roots, replaced with real absolute paths so + /// the emitted document can be loaded and checked as it stands. + const EXAMPLE_PACKAGE_ROOT: &str = "/srv/registry-scheduling/package"; + const EXAMPLE_STATE_ROOT: &str = "/var/lib/registry-scheduling"; + + #[test] + fn init_writes_a_runtime_example_beside_the_authored_project() { + let mut examples = Vec::new(); + for template in ["standalone-exact-time", "standalone-arrival-window"] { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let report = init(&project, template).unwrap(); + assert!( + report["created"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry == "runtime.example.yaml"), + "{template}: {report}" + ); + let text = fs::read_to_string(project.join("runtime.example.yaml")).unwrap(); + // Nothing in the document names the directory it was written + // beside, so the committed example can never drift from what an + // adopter initializes. + assert!(!text.contains(root.path().to_str().unwrap())); + examples.push(text); + } + // One document serves both templates. + assert_eq!(examples[0], examples[1]); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + init(&project, "standalone-exact-time").unwrap(); + let text = fs::read_to_string(project.join("runtime.example.yaml")) + .unwrap() + .replace( + EXAMPLE_PACKAGE_ROOT, + project.to_str().expect("utf-8 project path"), + ) + .replace( + EXAMPLE_STATE_ROOT, + root.path().to_str().expect("utf-8 root path"), + ); + let runtime = root.path().join("runtime.yaml"); + fs::write(&runtime, &text).unwrap(); + let config = RuntimeConfig::load(&runtime).expect("the emitted example loads"); + assert_eq!( + config.policy_path(), + project.join(AUTHORED_POLICY_FILE), + "the example selects the project beside it" + ); + assert_eq!(config.retention.attempt_receipt_days, 7); + assert!(config.destinations.reminders.is_none()); + } + + #[test] + fn check_reports_the_effective_policy_and_no_findings_for_a_template() { + let (_root, project) = initialized("standalone-exact-time"); + let report = check(&project).unwrap(); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "check"); + assert_eq!(report["status"], "complete"); + assert_eq!(report["findings"], json!([])); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + let effective = &report["effective"]; + assert_eq!(effective["schedulingId"], "registry-updates"); + assert_eq!(effective["schedulingVersion"], 1); + assert!(effective["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!( + effective["services"], + json!({"count": 1, "ids": ["registry-update"]}) + ); + assert_eq!(effective["offerings"]["count"], 2); + assert_eq!(effective["openings"]["count"], 2); + assert_eq!(effective["windows"], json!({"count": 0, "ids": []})); + assert_eq!(effective["holdPolicy"]["ttlMinutes"], 5); + assert_eq!(effective["holdPolicy"]["maxPerCaller"], 3); + } + + #[test] + fn a_policy_because_breach_is_a_finding_pair_with_an_incomplete_status() { + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "because: The hall opens on Saturday mornings.", + "because: ", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let report = check(&project).unwrap(); + assert_eq!(report["status"], "incomplete"); + assert_eq!( + report["findings"], + json!([{"path": "openings[0].because", "reason": "invalid-because"}]) + ); + } + + #[test] + fn test_reports_per_fixture_and_per_case_outcomes_with_the_proof_boundary() { + let (_root, project) = initialized("standalone-exact-time"); + let report = test(&project).unwrap(); + assert_eq!(report["command"], "test"); + assert_eq!(report["authoringStatus"], "complete"); + assert_eq!(report["proofBoundary"], "offline_synthetic"); + assert_eq!(report["productionClosure"], false); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + let fixtures = report["fixtures"].as_array().unwrap(); + let names: Vec<&str> = fixtures + .iter() + .map(|fixture| fixture["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["counter-stations", "fold-day-rebooking"]); + let counter = &fixtures[0]; + assert_eq!(counter["status"], "passed"); + assert_eq!(counter["file"], "fixtures/counter-stations.yaml"); + let cases = counter["cases"].as_array().unwrap(); + assert_eq!(cases.len(), 4); + assert!(cases.iter().all(|case| case["status"] == "pass")); + // A refused case reports the public code it was refused with. + let refused = cases + .iter() + .find(|case| case["name"] == "same-key-retry-is-refused") + .unwrap(); + assert!(refused["detail"] + .as_str() + .unwrap() + .contains("booking.duplicate-active")); + // The fold fixture replays too, including its reschedule case. + let fold = &fixtures[1]; + assert_eq!(fold["status"], "passed"); + let fold_cases = fold["cases"].as_array().unwrap(); + let rescheduled = fold_cases + .iter() + .find(|case| case["name"] == "reschedule-into-the-later-morning") + .unwrap(); + assert_eq!(rescheduled["status"], "pass"); + } + + #[test] + fn an_incomplete_policy_still_runs_its_fixtures_and_reports_the_status() { + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + // A blank because fails the check but never changes what replay does. + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "because: The hall opens on Saturday mornings.", + "because: ", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let report = test(&project).unwrap(); + assert_eq!(report["authoringStatus"], "incomplete"); + assert_eq!( + report["findings"], + json!([{"path": "openings[0].because", "reason": "invalid-because"}]) + ); + assert!(report["fixtures"] + .as_array() + .unwrap() + .iter() + .all(|fixture| fixture["status"] == "passed")); + } + + #[test] + fn a_daylight_saving_gap_check_cannot_see_is_a_fixture_failure_not_a_command_refusal() { + // Openings carry no timezone; only a fixture's location record does. + // `check` never resolves a calendar, so it cannot see that an + // opening's local wall-clock hours fall inside a real spring-forward + // gap. Move `standalone-arrival-window`'s Saturday opening onto the + // Sunday of 2026-03-08, 02:00-03:00 America/New_York: a nonexistent + // local time, per registry-platform-calendar's own pinned case. + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let policy_text = std::fs::read_to_string(&policy_path) + .unwrap() + .replacen("weekdays: [sat]", "weekdays: [sun]", 1) + .replacen("startTime: \"08:00\"", "startTime: \"02:00\"", 1) + .replacen("endTime: \"12:00\"", "endTime: \"03:00\"", 1) + .replacen( + "effectiveFrom: \"2026-10-01\"", + "effectiveFrom: \"2026-03-08\"", + 1, + ) + .replacen( + "effectiveUntil: \"2026-12-31\"", + "effectiveUntil: \"2026-03-08\"", + 1, + ); + std::fs::write(&policy_path, policy_text).unwrap(); + let fixture_path = project.join("fixtures/household-morning.yaml"); + let fixture_text = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "timezone: Asia/Bangkok", + "timezone: America/New_York", + 1, + ); + std::fs::write(&fixture_path, fixture_text).unwrap(); + + // check() has no calendar to resolve, so it reports clean. + let checked = check(&project).unwrap(); + assert_eq!(checked["status"], "complete"); + assert_eq!(checked["findings"], json!([])); + + // test() replays the calendar and must not let one fixture's + // calendar refusal abort the whole command: it reports that + // fixture as failed and names the calendar problem, the same way + // any other fixture failure is reported. + let tested = test(&project).unwrap(); + assert_eq!(tested["authoringStatus"], "complete"); + let fixtures = tested["fixtures"].as_array().unwrap(); + let fixture = fixtures + .iter() + .find(|fixture| fixture["name"] == "household-morning") + .unwrap(); + assert_eq!(fixture["status"], "failed"); + assert_eq!(fixture["cases"], json!([])); + let detail = fixture["calendarRefusal"].as_str().unwrap(); + assert!(detail.contains("does not exist"), "{detail}"); + } + + #[test] + fn explain_publishes_offerings_windows_hold_policy_and_digest() { + let (_root, project) = initialized("standalone-arrival-window"); + let report = explain(&project).unwrap(); + assert_eq!(report["command"], "explain"); + assert_eq!( + report["scheduling"], + json!({"id": "household-days", "version": 3}) + ); + assert!(report["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + let offerings = report["offerings"].as_array().unwrap(); + assert_eq!(offerings[0]["id"], "household-morning"); + assert_eq!(offerings[0]["mode"], "arrival-window"); + assert_eq!(offerings[0]["location"], "civic-hall"); + assert_eq!( + offerings[0]["arrival"], + json!({"window": "household-morning-window", "leadTimeMinutes": 60, "horizonDays": 45}) + ); + let windows = report["windows"].as_array().unwrap(); + assert_eq!(windows[0]["id"], "household-morning-window"); + assert_eq!(windows[0]["revision"], 2); + assert_eq!(windows[0]["start"], "2026-10-10T01:00:00Z"); + assert_eq!(windows[0]["end"], "2026-10-10T03:00:00Z"); + assert_eq!(windows[0]["units"], 3); + assert_eq!(windows[0]["unitsPolicy"]["kind"], "perRecipient"); + assert_eq!( + windows[0]["unitsPolicy"]["subquotas"], + json!([{"channel": "public", "units": 2}, {"channel": "assisted", "units": 1}]) + ); + assert_eq!(report["holdPolicy"]["ttlMinutes"], 10); + assert_eq!(report["holdPolicy"]["maxPerCaller"], 2); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + + // The exact-time template explains its fold-day offering's block. + let (_root, project) = initialized("standalone-exact-time"); + let report = explain(&project).unwrap(); + let offerings = report["offerings"].as_array().unwrap(); + let fold = offerings + .iter() + .find(|offering| offering["id"] == "fold-day-update-30") + .unwrap(); + assert_eq!(fold["exactTime"]["durationMinutes"], 30); + assert_eq!(fold["exactTime"]["startIncrementMinutes"], 30); + assert!(fold["arrival"].is_null()); + } + + #[test] + fn explain_refuses_a_policy_that_fails_its_check() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let error = explain(&project).unwrap_err(); + assert!(error.to_string().contains("passes its check")); + } + + #[test] + fn a_project_without_fixtures_cannot_test() { + let (_root, project) = initialized("standalone-exact-time"); + // An empty fixtures directory is the authoring error; a missing one is + // a filesystem failure the CLI reports separately. + let fixtures = project.join(FIXTURES_DIRECTORY); + for entry in std::fs::read_dir(&fixtures).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + let error = test(&project).unwrap_err(); + assert!(error.to_string().contains("at least one YAML fixture")); + } + + #[test] + fn a_missing_or_unparsable_policy_is_a_reading_failure() { + let root = tempfile::tempdir().unwrap(); + let error = check(&root.path().join("nowhere")).unwrap_err(); + assert!(error.to_string().contains("nowhere")); + assert!(error.to_string().contains("scheduling.yaml")); + + let project = root.path().join("project"); + init(&project, "standalone-exact-time").unwrap(); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "holdPolicy:\n", + "surprise: true\nholdPolicy:\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let error = check(&project).unwrap_err(); + assert!(error.to_string().contains("parsing scheduling.yaml")); + } +} diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs new file mode 100644 index 0000000000..90b7c161e0 --- /dev/null +++ b/crates/registry-schedulingctl/src/records.rs @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The live environment-records command. +//! +//! `records apply` is the one attributable operator write that swaps a +//! deployment's locations, pools, members, published windows, and exceptions +//! wholesale. The +//! records document is parsed and validated offline first, nothing reaches +//! the database until the whole document holds together, and the swap itself +//! lands through the store's single replace transaction, which writes its own +//! audit row beside the new facts. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use registry_scheduling_core::{location_open_intervals, SchedulingFacts}; +use serde_json::{json, Value}; +use uuid::Uuid; + +pub fn apply(config_path: &Path, records_path: &Path) -> Result { + let config_path = + fs::canonicalize(config_path).context("resolving the Scheduling runtime configuration")?; + let records_path = + fs::canonicalize(records_path).context("resolving the Scheduling environment records")?; + let config = RuntimeConfig::load(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + let policy = config + .load_policy() + .with_context(|| format!("loading {}", config.policy_path().display()))?; + let text = crate::project::read_authoring_input(&records_path)?; + let facts = parse_records(&text)?; + validate(&facts, &policy)?; + let counts = counts(&facts); + let resolver = secret_resolver(&config)?; + let store = PostgresStore::connect_migration(&config.database, &resolver) + .context("the Scheduling migration database configuration is invalid")?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting the Scheduling operator runtime")?; + runtime + .block_on(store.ready()) + .context( + "checking the Scheduling schema; run `scheduling --runtime-config migrate` first", + )?; + let audit_event = Uuid::new_v4(); + let audit_record = json!({ + "actorKind": "operator", + "operation": "records.apply", + "outcome": "allowed", + "reason": "authorization.allowed", + "counts": counts, + }); + runtime + .block_on(store.replace_facts(&policy.scheduling.id, &facts, audit_event, audit_record)) + .context("replacing the environment records")?; + Ok(json!({ + "ok": true, + "command": "records-apply", + "config": config_path, + "records": records_path, + "applied": counts, + })) +} + +/// Read the environment records, refusing a document the model does not +/// carry and naming the path of the first offending field. +pub(crate) fn parse_records(text: &str) -> Result { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer).map_err(|error| { + let path = error.path().to_string(); + anyhow!( + "parsing the environment records failed at {}: {error}", + if path.is_empty() { + "/".to_owned() + } else { + path + } + ) + }) +} + +/// The checks the store cannot make: the tables it writes have primary keys +/// and typed columns, so an inconsistent document would fail mid-swap with a +/// database error naming none of the author's vocabulary. Every rule here +/// refuses before the transaction opens, in the records document's own terms. +pub(crate) fn validate( + facts: &SchedulingFacts, + policy: ®istry_scheduling_core::SchedulingPolicy, +) -> Result<()> { + let mut locations = BTreeSet::new(); + for location in &facts.locations { + if location.id.is_empty() { + bail!("a location record carries an empty id"); + } + if !locations.insert(&location.id) { + bail!("location {} is declared more than once", location.id); + } + if location.timezone.parse::().is_err() { + bail!( + "location {} names an unknown timezone {}", + location.id, + location.timezone + ); + } + } + let mut pools = BTreeSet::new(); + let mut resources = BTreeSet::new(); + for pool in &facts.pools { + if pool.id.is_empty() { + bail!("a resource pool carries an empty id"); + } + if !pools.insert(&pool.id) { + bail!("resource pool {} is declared more than once", pool.id); + } + for member in &pool.members { + if member.resource_id.is_empty() { + bail!( + "pool {} carries a member with an empty resource id", + pool.id + ); + } + if !resources.insert(&member.resource_id) { + bail!( + "resource {} is declared in more than one pool", + member.resource_id + ); + } + } + } + if let Some(finding) = policy + .check_window_records(&facts.windows) + .into_iter() + .next() + { + bail!("invalid window record at {finding}"); + } + for window in &facts.windows { + if !locations.contains(&window.location) { + bail!( + "window {} names location {} the records do not declare", + window.id, + window.location + ); + } + if pools.contains(&window.id) { + bail!( + "window {} collides with a resource-pool supply identifier", + window.id + ); + } + if let Some(staffing) = &window.staffing { + if !pools.contains(&staffing.pool) { + bail!( + "window {} names staffing pool {} the records do not declare", + window.id, + staffing.pool + ); + } + } + } + let mut exception_ids = BTreeSet::new(); + for exception in &facts.exceptions { + if exception.id.is_empty() { + bail!("an exception record carries an empty id"); + } + if !exception_ids.insert(&exception.id) { + bail!("exception {} is declared more than once", exception.id); + } + if !locations.contains(&exception.location) { + bail!( + "exception {} names location {} the records do not declare", + exception.id, + exception.location + ); + } + if !is_iso_date(&exception.date) { + bail!( + "exception {} carries a date that is not YYYY-MM-DD: {}", + exception.id, + exception.date + ); + } + for time in [&exception.start_time, &exception.end_time] { + if !is_hh_mm(time) { + bail!( + "exception {} carries a time that is not HH:MM: {}", + exception.id, + time + ); + } + } + } + for location in &facts.locations { + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == location.id) + .map(|exception| exception.borrowed()) + .collect(); + location_open_intervals(policy, &location.id, &location.timezone, &exceptions) + .with_context(|| format!("validating calendar records for location {}", location.id))?; + } + Ok(()) +} + +/// The strict shapes the database columns accept: exactly `YYYY-MM-DD` and +/// exactly `HH:MM`, so a loose value is refused here rather than rejected by +/// the column mid-swap. +fn is_iso_date(value: &str) -> bool { + value.len() == 10 + && value.as_bytes()[4] == b'-' + && value.as_bytes()[7] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) + && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() +} + +fn is_hh_mm(value: &str) -> bool { + value.len() == 5 + && value.as_bytes()[2] == b':' + && value + .bytes() + .enumerate() + .all(|(index, byte)| index == 2 || byte.is_ascii_digit()) + && chrono::NaiveTime::parse_from_str(value, "%H:%M").is_ok() +} + +fn counts(facts: &SchedulingFacts) -> Value { + json!({ + "locations": facts.locations.len(), + "pools": facts.pools.len(), + "members": facts.pools.iter().map(|pool| pool.members.len()).sum::(), + "windows": facts.windows.len(), + "exceptions": facts.exceptions.len(), + }) +} + +pub(crate) fn secret_resolver(config: &RuntimeConfig) -> Result { + let mut providers = Vec::new(); + if config.secret_providers.file.is_some() { + providers.push(SecretProvider::File); + } + if config.secret_providers.environment.is_some() { + providers.push(SecretProvider::Environment); + } + SecretResolver::new( + providers, + config + .secret_providers + .file + .as_ref() + .map_or_else(|| Path::new(""), |file| file.root.as_path()), + ) + .context("configuring the Scheduling secret providers") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy() -> registry_scheduling_core::SchedulingPolicy { + let files = crate::templates::template_files("standalone-exact-time").unwrap(); + let text = files + .iter() + .find(|(path, _)| *path == "scheduling.yaml") + .unwrap() + .1; + registry_scheduling_core::parse_policy_yaml(text).unwrap() + } + + fn facts_from(text: &str) -> SchedulingFacts { + serde_norway::from_str(text).unwrap() + } + + #[test] + fn a_consistent_document_validates() { + let facts = facts_from( + "locations:\n - id: north-counter\n timezone: Asia/Bangkok\n\ + pools:\n - id: stations\n members:\n - resourceId: station-1\n\ + \x20 capabilities: []\n available: true\n\ + exceptions:\n - id: training\n location: north-counter\n kind: closure\n\ + \x20 date: '2026-10-07'\n startTime: '09:00'\n endTime: '12:30'\n", + ); + assert!(validate(&facts, &policy()).is_ok()); + assert_eq!( + counts(&facts), + json!({"locations": 1, "pools": 1, "members": 1, "windows": 0, "exceptions": 1}) + ); + } + + #[test] + fn inconsistent_documents_are_refused_in_their_own_terms() { + let cases = [ + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\n - id: a\n timezone: Asia/Bangkok\n", + "location a is declared more than once", + ), + ( + "locations:\n - id: a\n timezone: Mars/Olympus\n", + "names an unknown timezone Mars/Olympus", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\npools:\n \ + - id: p\n members:\n - resourceId: r1\n capabilities: []\n\ + \x20 available: true\n - resourceId: r1\n capabilities: []\n\ + \x20 available: true\n", + "resource r1 is declared in more than one pool", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: elsewhere\n kind: closure\n date: '2026-10-07'\n\ + \x20 startTime: '09:00'\n endTime: '12:30'\n", + "names location elsewhere the records do not declare", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: a\n kind: closure\n date: '2026-10-7'\n\ + \x20 startTime: '09:00'\n endTime: '12:30'\n", + "not YYYY-MM-DD", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: a\n kind: closure\n date: '2026-10-07'\n\ + \x20 startTime: '9:00'\n endTime: '12:30'\n", + "not HH:MM", + ), + ]; + for (document, expected) in cases { + let facts = facts_from(document); + let refusal = validate(&facts, &policy()).expect_err("the document is refused"); + assert!( + refusal.to_string().contains(expected), + "{refusal} does not name {expected}" + ); + } + } + + #[test] + fn exception_layer_semantics_are_refused_before_apply() { + let cases = [ + ( + "duplicate id", + " - id: repeated\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '10:00'\n\ + \x20 - id: repeated\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '10:00'\n endTime: '11:00'\n", + "exception repeated is declared more than once", + ), + ( + "opening without a named closure", + " - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n", + "exceptional reopening reopening does not name an existing closure", + ), + ( + "opening without authority", + " - id: closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '11:00'\n\ + \x20 - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n reopens: closure\n", + "exceptional reopening reopening has no explicit authority", + ), + ( + "opening names an unknown closure", + " - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n reopens: absent\n authority: office-manager\n", + "exceptional reopening reopening does not name an existing closure", + ), + ( + "closure carries reopening fields", + " - id: closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '10:00'\n reopens: another\n authority: office-manager\n", + "blocking closure closure carries reopening fields", + ), + ( + "invalid interval", + " - id: backwards\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '11:00'\n endTime: '10:00'\n", + "exception times must be valid HH:MM local values with start before end", + ), + ( + "reopening outside the pattern", + " - id: early-closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '08:00'\n endTime: '10:00'\n\ + \x20 - id: early-reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '08:00'\n endTime: '08:30'\n reopens: early-closure\n authority: office-manager\n", + "reopening early-reopening is not inside the opening pattern's time", + ), + ]; + + for (name, exceptions, expected) in cases { + let facts = facts_from(&format!( + "locations:\n - id: bangkok-counter\n timezone: Asia/Bangkok\nexceptions:\n{exceptions}" + )); + let refusal = validate(&facts, &policy()).expect_err(name); + let message = format!("{refusal:#}"); + assert!( + message.contains("validating calendar records for location bangkok-counter") + || expected.contains("declared more than once"), + "{name}: {message} does not name the affected location" + ); + assert!( + message.contains(expected), + "{name}: {message} does not name {expected}" + ); + } + } + + #[test] + fn a_document_the_model_does_not_carry_is_refused_with_its_path() { + let refusal = parse_records( + "locations:\n - id: a\n timezone: Asia/Bangkok\n\ + schedule: daily\n", + ) + .expect_err("the stray field is refused"); + let message = refusal.to_string(); + assert!( + message.contains("locations") || message.contains("schedule"), + "{message} names neither the container nor the field" + ); + } +} diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs new file mode 100644 index 0000000000..dae1c1b92a --- /dev/null +++ b/crates/registry-schedulingctl/src/templates.rs @@ -0,0 +1,917 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The complete starter projects `schedulingctl init` writes. +//! +//! Each template is a working project: its policy passes the check with zero +//! findings and its fixtures replay all-Pass offline, which the tests prove +//! end to end. + +/// The `standalone-exact-time` policy: a Bangkok counter with two +/// interchangeable stations, and a New York hall whose fold-day opening +/// spans the repeated hour with unambiguous endpoints. +const EXACT_TIME_POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + reminders: + - minutesBefore: 1440 + because: One reminder the day before a counter update. + - minutesBefore: 120 + because: A second reminder two hours before, when stations are scarce. + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] + - id: fold-day-update-30 + service: registry-update + label: 30-minute fold-day update + mode: exact-time + location: new-york-hall + because: A 30-minute update at the hall, published across the autumn time change. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 1 + horizonDays: 3650 + pool: hall-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. + - id: new-york-hall-hours + location: new-york-hall + holidaySet: office-holidays + weekdays: [sun] + startTime: "00:30" + endTime: "02:30" + effectiveFrom: "2026-11-01" + effectiveUntil: "2026-11-01" + because: Fold-day hours whose endpoints sit outside the repeated local hour. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +/// The `standalone-exact-time` counter fixture: a first caller takes a +/// station, a same-key retry is refused, a second caller gets the other +/// station, and a fourth finds both busy. +const COUNTER_STATIONS_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: counter-stations +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: bangkok-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-a-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active + - name: second-caller-gets-the-other-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-2 + - name: both-stations-busy-refuses-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:three + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted +"#; + +/// The `standalone-exact-time` fold-day fixture: the pre-opening closure +/// moves the start grid to 04:45Z, a booking on the moved grid is rescheduled +/// into the three-real-hour opening, the freed slot serves another caller, +/// and a wall-clock match the moved grid does not publish is refused. +const FOLD_DAY_REBOOKING_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: fold-day-rebooking +now: 2026-11-01T04:00:00Z +# America/New_York falls back on 2026-11-01: local 01:30 occurs twice, and the +# 00:30-02:30 opening spans three real hours, [04:30Z, 07:30Z). The closure +# 00:15-00:45 has unambiguous endpoints in one offset and moves the opening +# start to 04:45Z, so the 30-minute grid anchors there: 04:45, 05:15, 05:45... +facts: + locations: + - id: new-york-hall + timezone: America/New_York + pools: + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true + exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +initial: [] +cases: + - name: morning-booking-after-the-closure + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: reschedule-into-the-later-morning + request: + offering: fold-day-update-30 + start: 2026-11-01T06:45:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + rescheduleOf: case:morning-booking-after-the-closure + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: a-wall-clock-match-off-the-moved-grid-is-not-served + request: + offering: fold-day-update-30 + start: 2026-11-01T06:30:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: schedule.unpublished +"#; + +/// Live environment records for the exact-time starter. They carry every +/// location and resource pool its policy references, plus the fold-day closure +/// the example explains and exercises. +const EXACT_TIME_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +windows: [] +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +"#; + +/// The `standalone-arrival-window` policy: a Saturday morning household block +/// with a public and an assisted subquota over a per-recipient units table, +/// and a Saturday afternoon block over a banded table that refuses a party +/// above its highest band instead of guessing what it costs. +const ARRIVAL_WINDOW_POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] + - id: household-afternoon + service: household-day + label: Afternoon household block + mode: arrival-window + location: civic-hall + because: Larger households arrive in an afternoon block sized by party, not a flat headcount. + arrival: + window: household-afternoon-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. + - id: hall-afternoon-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "13:00" + endTime: "17:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall reopens on Saturday afternoons for banded household visits. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#; + +/// The `standalone-arrival-window` fixture: the public subquota depletes +/// while the assisted channel still admits. +const HOUSEHOLD_MORNING_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. +initial: [] +cases: + - name: first-household-takes-the-public-quota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: next-public-household-finds-the-quota-spent + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 +"#; + +/// The `standalone-arrival-window` fixture: two households fit the banded +/// table's first two bands, and a household above the highest band is +/// refused instead of costed by a guess. +const HOUSEHOLD_AFTERNOON_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-afternoon +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + windows: + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. +initial: [] +cases: + - name: a-small-household-fits-the-first-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 3 + attendees: 3 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: a-household-above-the-highest-band-is-refused + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 9 + attendees: 9 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: party.capacity-inadequate + - name: a-mid-size-household-fits-the-second-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 6 + attendees: 6 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 +"#; + +/// Live environment records for the arrival-window starter. The policy names +/// each window by id; this operator document owns its concrete supply. +const ARRIVAL_WINDOW_RECORDS: &str = r#"locations: + - id: civic-hall + timezone: Asia/Bangkok +pools: [] +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. +exceptions: [] +"#; + +/// The operator runtime configuration example every template writes beside +/// the authored policy. It names no project path, so one document serves every +/// template and the committed example can never drift from what an adopter +/// initializes; the absolute paths an operator replaces are placeholders. +pub(super) const RUNTIME_EXAMPLE: &str = r#"# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. + # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 +retention: + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. + attemptReceiptDays: 7 + hookPayloadDays: 7 +"#; + +/// The files one template writes, relative to the project directory, in +/// write order. The first entry is always the authored policy. +pub(super) fn template_files(template: &str) -> Option> { + match template { + "standalone-exact-time" => Some(vec![ + ("scheduling.yaml", EXACT_TIME_POLICY), + ("runtime.example.yaml", RUNTIME_EXAMPLE), + ("records.yaml", EXACT_TIME_RECORDS), + ("fixtures/counter-stations.yaml", COUNTER_STATIONS_FIXTURE), + ( + "fixtures/fold-day-rebooking.yaml", + FOLD_DAY_REBOOKING_FIXTURE, + ), + ]), + "standalone-arrival-window" => Some(vec![ + ("scheduling.yaml", ARRIVAL_WINDOW_POLICY), + ("runtime.example.yaml", RUNTIME_EXAMPLE), + ("records.yaml", ARRIVAL_WINDOW_RECORDS), + ("fixtures/household-morning.yaml", HOUSEHOLD_MORNING_FIXTURE), + ( + "fixtures/household-afternoon.yaml", + HOUSEHOLD_AFTERNOON_FIXTURE, + ), + ]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_scheduling_core::{ + parse_fixture_yaml, parse_policy_yaml, AboveHighestBand, CaseStatus, RequiredUnitsPolicy, + SchedulingFacts, AUTHORED_POLICY_FILE, SCHEDULING_FIXTURE_API_VERSION, + SCHEDULING_FIXTURE_KIND, SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND, + }; + + const TEMPLATE_NAMES: [&str; 2] = ["standalone-exact-time", "standalone-arrival-window"]; + + #[test] + fn the_templates_are_exactly_the_two_standalone_projects() { + for template in TEMPLATE_NAMES { + assert!(template_files(template).is_some(), "{template}"); + } + assert!(template_files("standalone-decision").is_none()); + assert!(template_files("").is_none()); + } + + #[test] + fn every_template_carries_parseable_records_for_its_policy_references() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + let records = files + .iter() + .find(|(relative, _)| *relative == "records.yaml") + .expect("the template carries live environment records"); + let facts: SchedulingFacts = + serde_norway::from_str(records.1).expect("the template records parse"); + for offering in &policy.offerings { + assert!( + facts.location(&offering.location).is_some(), + "{template}: missing location {}", + offering.location + ); + if let Some(exact_time) = &offering.exact_time { + assert!( + facts.pool(&exact_time.pool).is_some(), + "{template}: missing pool {}", + exact_time.pool + ); + } + } + } + } + + #[test] + fn every_template_policy_parses_and_checks_clean_with_pinned_names() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + assert_eq!(files[0].0, AUTHORED_POLICY_FILE); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + assert_eq!(policy.api_version, SCHEDULING_POLICY_API_VERSION); + assert_eq!(policy.kind, SCHEDULING_POLICY_KIND); + let findings = policy.check(); + assert!(findings.is_empty(), "{template}: {findings:?}"); + } + } + + #[test] + fn every_template_fixture_parses_and_fits_its_policy() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + for (relative, contents) in template_fixtures(&files) { + let fixture = parse_fixture_yaml(contents).unwrap_or_else(|error| { + panic!("{relative}: the template fixture parses: {error}") + }); + assert_eq!(fixture.api_version, SCHEDULING_FIXTURE_API_VERSION); + assert_eq!(fixture.kind, SCHEDULING_FIXTURE_KIND); + fixture + .check(&policy) + .expect("the template fixture fits its policy"); + } + } + } + + /// The acceptance proof for both templates: every fixture replays + /// all-Pass against its own template policy. + #[test] + fn every_template_fixture_replays_all_pass() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + for (relative, contents) in template_fixtures(&files) { + let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); + let outcomes = fixture + .replay(&policy) + .unwrap_or_else(|error| panic!("{relative}: the fixture replays: {error}")); + assert!( + outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass), + "{template}/{relative}: {outcomes:?}" + ); + } + } + } + + /// The template's replay fixtures, in write order. The runtime example is + /// written beside the policy and is not a fixture. + fn template_fixtures<'a>( + files: &'a [(&'static str, &'static str)], + ) -> Vec<(&'static str, &'a str)> { + files + .iter() + .filter(|(relative, _)| { + relative.starts_with("fixtures/") && relative.ends_with(".yaml") + }) + .map(|(relative, contents)| (*relative, *contents)) + .collect() + } + + /// The arrival-window template's second offering exists so an adopter has + /// a working example of a banded units table that refuses a party above + /// its highest band, rather than only the fixed per-recipient table the + /// first offering demonstrates. + #[test] + fn the_arrival_window_template_demonstrates_a_banded_table_refusing_above_its_highest_band() { + let files = template_files("standalone-arrival-window").unwrap(); + let policy = parse_policy_yaml( + files + .iter() + .find(|(path, _)| *path == "scheduling.yaml") + .expect("the policy file exists") + .1, + ) + .expect("the template policy parses"); + let records: registry_scheduling_core::SchedulingFacts = serde_norway::from_str( + files + .iter() + .find(|(path, _)| *path == "records.yaml") + .expect("the records file exists") + .1, + ) + .expect("the template records parse"); + let window = records + .windows + .iter() + .find(|window| window.id == "household-afternoon-window") + .expect("the afternoon window is published"); + assert!( + matches!( + window.units_policy, + RequiredUnitsPolicy::BandedTable { + above_highest_band: AboveHighestBand::Refuse, + .. + } + ), + "{:?}", + window.units_policy + ); + + let (relative, contents) = template_fixtures(&files) + .into_iter() + .find(|(relative, _)| relative.contains("household-afternoon")) + .expect("the afternoon fixture ships with the template"); + let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); + let outcomes = fixture + .replay(&policy) + .unwrap_or_else(|error| panic!("{relative}: the fixture replays: {error}")); + assert!( + outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass), + "{outcomes:?}" + ); + let refused = outcomes + .iter() + .find(|outcome| outcome.name.contains("above-the-highest-band")) + .expect("a case exercises the above-highest-band refusal"); + assert!( + refused + .detail + .as_deref() + .is_some_and(|detail| detail.contains("party.capacity-inadequate")), + "{refused:?}" + ); + } + + #[test] + fn the_exact_time_template_carries_the_example_and_both_fixtures() { + let files = template_files("standalone-exact-time").unwrap(); + let names: Vec<&str> = files.into_iter().map(|(relative, _)| relative).collect(); + assert_eq!( + names, + vec![ + "scheduling.yaml", + "runtime.example.yaml", + "records.yaml", + "fixtures/counter-stations.yaml", + "fixtures/fold-day-rebooking.yaml", + ] + ); + } +} diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs new file mode 100644 index 0000000000..57f3b397f8 --- /dev/null +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `intents` against a real PostgreSQL, on a disposable schema. +//! +//! The test proves the read path end to end: a pending intent the sweep is +//! still carrying and a delivered one are never listed, a `local` intent (no +//! destination declared) and a `failed` one (every attempt refused) both are, +//! oldest due first, and a `--limit` narrower than the qualifying rows keeps +//! only the earliest of them. Every field the command reports is pinned +//! against what was seeded, so nothing beyond what the test asserts on is +//! carried through. + +use chrono::{DateTime, Duration, SubsecRound, Utc}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use serde_json::{json, Value}; +use std::path::Path; +use uuid::Uuid; + +/// A minimal exact-time policy that passes its checks, in the vocabulary of +/// the standalone starter project. `RuntimeConfig::check` reads and +/// validates the authored policy at `package.root`, so a deployment +/// configuration this test can load needs one, even though `intents` itself +/// never reads it. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +fn scoped_url(base: &str, schema: &str) -> String { + let separator = if base.contains('?') { '&' } else { '?' }; + format!("{base}{separator}options=-csearch_path%3D{schema}") +} + +/// `intents` builds its own single-threaded runtime, so the call runs on a +/// plain thread: it cannot nest inside this test's runtime. +fn undelivered(config: &Path, limit: i64) -> Value { + let config = config.to_path_buf(); + std::thread::spawn(move || registry_schedulingctl::intents::undelivered(&config, limit)) + .join() + .expect("intents does not panic") + .expect("intents succeeds") +} + +async fn seed_claim(client: &tokio_postgres::Client, claim_id: Uuid) { + client + .execute( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, \ + revision, policy_revision, actor) \ + VALUES($1,'booking','active','registry-update-30','station-1', \ + now(), now(), now(), now(), 1, 1, 1, 'actor-pseudonym')", + &[&claim_id], + ) + .await + .expect("a claim seeds cleanly"); +} + +#[allow(clippy::too_many_arguments)] +async fn seed_outbox( + client: &tokio_postgres::Client, + outbox_id: Uuid, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + delivery_state: &str, + attempts: i32, + payload: &Value, +) { + client + .execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, \ + appointment_revision, due_at, delivery_state, attempts, next_attempt_at, \ + payload) VALUES($1,$2,$3,$4,$5,$6,$7,$5,$8)", + &[ + &outbox_id, + &purpose, + &claim_id, + &appointment_revision, + &due_at, + &delivery_state, + &attempts, + payload, + ], + ) + .await + .expect("an outbox row seeds cleanly"); +} + +#[tokio::test] +async fn intents_lists_local_and_failed_oldest_due_first_and_respects_limit() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("intents_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write( + root.path().join("runtime.yaml"), + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8106\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_INTENTS_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = root.path().join("audit.ndjson").display(), + ), + ) + .unwrap(); + let config_path = root.path().join("runtime.yaml"); + std::env::set_var( + "SCHEDULING_INTENTS_TEST_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_INTENTS_TEST_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + + let seed = tokio_postgres::connect(&scoped_url(&base, &schema), tokio_postgres::NoTls) + .await + .expect("a seeding connection scoped to the schema connects"); + tokio::spawn(async move { seed.1.await.expect("the seeding connection stays up") }); + let seed = seed.0; + + let claim_a = Uuid::new_v4(); + let claim_b = Uuid::new_v4(); + seed_claim(&seed, claim_a).await; + seed_claim(&seed, claim_b).await; + + // PostgreSQL `timestamptz` holds microseconds, so the nanosecond tail of a + // clock reading does not survive the round trip through `due_at`. Truncating + // at the source keeps the seeded instant and the `dueAt` the command reports + // comparable as strings on any clock. + let base_time = Utc::now().trunc_subsecs(6); + let pending_id = Uuid::new_v4(); + let local_first_id = Uuid::new_v4(); + let failed_id = Uuid::new_v4(); + let delivered_id = Uuid::new_v4(); + let local_second_id = Uuid::new_v4(); + + let local_first_payload = + json!({"appointmentId": claim_a, "revision": 1, "offering": "registry-update-30"}); + let failed_payload = + json!({"appointmentId": claim_b, "revision": 2, "offering": "registry-update-30"}); + let local_second_payload = + json!({"appointmentId": claim_a, "revision": 3, "offering": "registry-update-30"}); + + // Still carried by the sweep: never listed. + seed_outbox( + &seed, + pending_id, + "reminder", + claim_a, + 1, + base_time, + "pending", + 0, + &json!({"appointmentId": claim_a, "revision": 1, "offering": "registry-update-30"}), + ) + .await; + // No destination declared: listed. + seed_outbox( + &seed, + local_first_id, + "reminder", + claim_a, + 1, + base_time + Duration::seconds(60), + "local", + 0, + &local_first_payload, + ) + .await; + // Every attempt refused: listed. + seed_outbox( + &seed, + failed_id, + "confirmation", + claim_b, + 2, + base_time + Duration::seconds(120), + "failed", + 3, + &failed_payload, + ) + .await; + // Delivered: never listed. + seed_outbox( + &seed, + delivered_id, + "reminder", + claim_b, + 2, + base_time + Duration::seconds(180), + "delivered", + 1, + &json!({"appointmentId": claim_b, "revision": 2, "offering": "registry-update-30"}), + ) + .await; + // A second, later local intent: listed last. + seed_outbox( + &seed, + local_second_id, + "reminder", + claim_a, + 3, + base_time + Duration::seconds(240), + "local", + 0, + &local_second_payload, + ) + .await; + + let report = undelivered(&config_path, 10); + assert_eq!(report["command"], "intents"); + let intents = report["intents"].as_array().expect("intents is an array"); + assert_eq!(intents.len(), 3, "{intents:?}"); + + assert_eq!(intents[0]["outboxId"], local_first_id.to_string()); + assert_eq!(intents[0]["purpose"], "reminder"); + assert_eq!(intents[0]["claimId"], claim_a.to_string()); + assert_eq!(intents[0]["appointmentRevision"], 1); + assert_eq!( + intents[0]["dueAt"], + (base_time + Duration::seconds(60)).to_rfc3339() + ); + assert_eq!(intents[0]["deliveryState"], "local"); + assert_eq!(intents[0]["attempts"], 0); + assert_eq!(intents[0]["payload"], local_first_payload); + + assert_eq!(intents[1]["outboxId"], failed_id.to_string()); + assert_eq!(intents[1]["purpose"], "confirmation"); + assert_eq!(intents[1]["claimId"], claim_b.to_string()); + assert_eq!(intents[1]["appointmentRevision"], 2); + assert_eq!( + intents[1]["dueAt"], + (base_time + Duration::seconds(120)).to_rfc3339() + ); + assert_eq!(intents[1]["deliveryState"], "failed"); + assert_eq!(intents[1]["attempts"], 3); + assert_eq!(intents[1]["payload"], failed_payload); + + assert_eq!(intents[2]["outboxId"], local_second_id.to_string()); + assert_eq!(intents[2]["deliveryState"], "local"); + assert_eq!(intents[2]["payload"], local_second_payload); + + // A pending intent the sweep still carries and a delivered one are never + // listed, regardless of the limit. + let outbox_ids: Vec<&str> = intents + .iter() + .map(|intent| intent["outboxId"].as_str().unwrap()) + .collect(); + assert!(!outbox_ids.contains(&pending_id.to_string().as_str())); + assert!(!outbox_ids.contains(&delivered_id.to_string().as_str())); + + // A limit narrower than the qualifying rows keeps only the earliest due. + let limited = undelivered(&config_path, 2); + let limited_intents = limited["intents"].as_array().expect("intents is an array"); + assert_eq!(limited_intents.len(), 2); + assert_eq!(limited_intents[0]["outboxId"], local_first_id.to_string()); + assert_eq!(limited_intents[1]["outboxId"], failed_id.to_string()); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs new file mode 100644 index 0000000000..c1c2bf1e75 --- /dev/null +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `records apply` against a real PostgreSQL, on a disposable schema. +//! +//! The test proves the one attributable operator write end to end: the schema +//! migration runs, the first document lands whole (locations, pools, members, +//! and the typed exception columns), a second document replaces the first +//! rather than appending to it, and every apply leaves its audit row in the +//! outbox carrying the operator's allowed reason. + +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use registry_schedulingctl::records; +use serde_json::json; +use std::path::Path; +use uuid::Uuid; + +/// A minimal exact-time policy that passes its checks, in the vocabulary of +/// the standalone starter project. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] + - id: registry-arrivals + service: registry-update + label: Counter arrivals + mode: arrival-window + location: bangkok-counter + because: Arrivals join an operator-published window. + arrival: + window: morning-arrivals + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +const FIRST_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +windows: + - id: morning-arrivals + revision: 1 + offering: registry-arrivals + location: bangkok-counter + start: 2026-10-08T02:00:00Z + end: 2026-10-08T04:00:00Z + units: 10 + unitsPolicy: {kind: fixed, units: 1, because: Each arrival consumes one unit.} + subquotas: [] + because: The operator published the morning arrival block. +exceptions: + - id: staff-training + location: bangkok-counter + kind: closure + date: "2026-10-07" + startTime: "09:00" + endTime: "12:30" +"#; + +const SECOND_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: chiang-mai-counter + timezone: Asia/Bangkok +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true +windows: + - id: morning-arrivals + revision: 2 + offering: registry-arrivals + location: bangkok-counter + start: 2026-10-08T03:00:00Z + end: 2026-10-08T05:00:00Z + units: 8 + unitsPolicy: {kind: fixed, units: 1, because: Each arrival consumes one unit.} + subquotas: [] + because: The operator republished the arrival block. +"#; + +fn scoped_url(base: &str, schema: &str) -> String { + let separator = if base.contains('?') { '&' } else { '?' }; + format!("{base}{separator}options=-csearch_path%3D{schema}") +} + +/// `records apply` builds its own single-threaded runtime, so the call runs +/// on a plain thread: it cannot nest inside this test's runtime. +fn apply(config: &Path, records_path: &Path) -> serde_json::Value { + let config = config.to_path_buf(); + let records_path = records_path.to_path_buf(); + std::thread::spawn(move || records::apply(&config, &records_path)) + .join() + .expect("records apply does not panic") + .expect("records apply succeeds") +} + +fn apply_error(config: &Path, records_path: &Path) -> String { + let config = config.to_path_buf(); + let records_path = records_path.to_path_buf(); + std::thread::spawn(move || { + let error = records::apply(&config, &records_path) + .expect_err("records apply refuses a different deployment identity"); + format!("{error:#}") + }) + .join() + .expect("records apply does not panic") +} + +#[tokio::test] +async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("records_apply_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + )) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write(root.path().join("first.yaml"), FIRST_RECORDS).unwrap(); + std::fs::write(root.path().join("second.yaml"), SECOND_RECORDS).unwrap(); + std::fs::write( + root.path().join("runtime.yaml"), + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = root.path().join("audit.ndjson").display(), + ), + ) + .unwrap(); + let config_path = root.path().join("runtime.yaml"); + std::env::set_var( + "SCHEDULING_RECORDS_TEST_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_RECORDS_TEST_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + store + .adopt("registry-updates") + .await + .expect("the policy identity adopts the database"); + + let report = apply(&config_path, &root.path().join("first.yaml")); + assert_eq!(report["command"], "records-apply"); + assert_eq!( + report["applied"], + json!({"locations": 1, "pools": 1, "members": 2, "windows": 1, "exceptions": 1}) + ); + + let (facts, _) = store.facts().await.unwrap(); + assert_eq!(facts.locations.len(), 1); + assert_eq!(facts.locations[0].id, "bangkok-counter"); + assert_eq!(facts.locations[0].timezone, "Asia/Bangkok"); + assert_eq!(facts.pools.len(), 1); + assert_eq!(facts.pools[0].id, "update-stations"); + assert_eq!(facts.windows.len(), 1); + assert_eq!(facts.windows[0].id, "morning-arrivals"); + assert_eq!(facts.windows[0].revision, 1); + let member_ids: Vec<&str> = facts.pools[0] + .members + .iter() + .map(|member| member.resource_id.as_str()) + .collect(); + assert_eq!(member_ids, vec!["station-1", "station-2"]); + assert_eq!(facts.exceptions.len(), 1); + assert_eq!(facts.exceptions[0].id, "staff-training"); + assert_eq!(facts.exceptions[0].date, "2026-10-07"); + + let audit = store.pending_audit(10).await.unwrap(); + let applies: Vec<_> = audit + .iter() + .filter(|(_, record)| record["operation"] == "records.apply") + .collect(); + assert_eq!(applies.len(), 1, "{audit:?}"); + assert_eq!(applies[0].1["outcome"], "allowed"); + assert_eq!(applies[0].1["reason"], "authorization.allowed"); + assert_eq!(applies[0].1["counts"]["exceptions"], 1); + assert_eq!(applies[0].1["counts"]["windows"], 1); + + // The second document replaces the first: the second location lands, the + // retired station is gone, and the closure does not survive. + let report = apply(&config_path, &root.path().join("second.yaml")); + assert_eq!( + report["applied"], + json!({"locations": 2, "pools": 1, "members": 1, "windows": 1, "exceptions": 0}) + ); + let (facts, _) = store.facts().await.unwrap(); + assert_eq!(facts.locations.len(), 2); + let retired_station = facts + .pools + .iter() + .flat_map(|pool| pool.members.iter()) + .any(|member| member.resource_id == "station-2"); + assert!(!retired_station, "a replace is not an append"); + assert!(facts.exceptions.is_empty(), "the closure did not survive"); + assert_eq!(facts.windows.len(), 1); + assert_eq!(facts.windows[0].revision, 2, "the window was replaced"); + + let audit = store.pending_audit(10).await.unwrap(); + let applies: Vec<_> = audit + .iter() + .filter(|(_, record)| record["operation"] == "records.apply") + .collect(); + assert_eq!(applies.len(), 2, "{audit:?}"); + assert!(applies + .iter() + .all(|(_, record)| record["reason"] == "authorization.allowed")); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} + +#[tokio::test] +async fn records_apply_rejects_a_different_deployment_identity_without_writing() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("records_identity_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let adopted_project = root.path().join("adopted-project"); + let other_project = root.path().join("other-project"); + std::fs::create_dir_all(&adopted_project).unwrap(); + std::fs::create_dir_all(&other_project).unwrap(); + std::fs::write(adopted_project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write( + other_project.join("scheduling.yaml"), + POLICY.replacen(" id: registry-updates\n", " id: permit-renewals\n", 1), + ) + .unwrap(); + std::fs::write(root.path().join("first.yaml"), FIRST_RECORDS).unwrap(); + std::fs::write(root.path().join("second.yaml"), SECOND_RECORDS).unwrap(); + + let runtime = |project: &Path, audit: &Path| { + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_IDENTITY_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = audit.display(), + ) + }; + let adopted_config = root.path().join("adopted-runtime.yaml"); + let other_config = root.path().join("other-runtime.yaml"); + std::fs::write( + &adopted_config, + runtime(&adopted_project, &root.path().join("adopted-audit.ndjson")), + ) + .unwrap(); + std::fs::write( + &other_config, + runtime(&other_project, &root.path().join("other-audit.ndjson")), + ) + .unwrap(); + std::env::set_var( + "SCHEDULING_RECORDS_IDENTITY_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_RECORDS_IDENTITY_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let config = + RuntimeConfig::load(&adopted_config).expect("the adopted runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + store + .adopt("registry-updates") + .await + .expect("the first policy identity adopts the database"); + apply(&adopted_config, &root.path().join("first.yaml")); + let (before, _) = store.facts().await.expect("the first records landed"); + + let error = apply_error(&other_config, &root.path().join("second.yaml")); + assert_eq!( + error, + "replacing the environment records: the Scheduling database belongs to another deployment" + ); + let (after, _) = store + .facts() + .await + .expect("the existing records remain readable"); + assert_eq!( + after, before, + "the refused apply changed the existing facts" + ); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 0488356c2c..9e2d413a3d 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -21,8 +21,9 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } -export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } | { type: 'scheduling'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { diff --git a/crates/registry-stack-client/src/lib.rs b/crates/registry-stack-client/src/lib.rs index 75c2cfa9be..3156ef247e 100644 --- a/crates/registry-stack-client/src/lib.rs +++ b/crates/registry-stack-client/src/lib.rs @@ -46,13 +46,13 @@ pub mod casework { ListWorkItemsQuery, MutationResponse, NextWorkItemQuery, OccurrenceKind, OccurrenceState, OneOfPredicate, OpaqueActorRef, OperationName, Page, PageStatus, QueueRecord, RecoverAttemptRequest, ReleaseRequest, RequesterHostedItem, RoutingActivity, - RoutingCondition, RoutingPredicate, RoutingRule, SaveDraftRequest, SourceBinding, - SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, - SubjectClockCompletion, SubjectClockPause, SubjectRef, TaskApprovalRequest, - TaskAssertionResponse, TaskGrantBounds, TaskGrantList, TaskGrantRevocation, - TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, - TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, WorkItemPage, - WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, + RoutingCondition, RoutingPredicate, RoutingRule, SaveDraftRequest, + SchedulingTaskPermission, SourceBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, + StaffingDiagnostic, SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, + SubjectRef, TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, TaskGrantList, + TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + TaskPermission, TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, + WorkItemPage, WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, }; } diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 5cedc351b3..7e1bd4285f 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -65,6 +65,7 @@ export function resolveDocsetBuildContext(docsets, env = process.env) { const isSearchExcludedBuild = isHistoricalArchiveBuild || selectedDocset.availability === 'unreleased'; const hasCasework = Boolean(selectedDocset.products?.['registry-casework']); + const hasScheduling = Boolean(selectedDocset.products?.['registry-scheduling']); const hasRender = Boolean(selectedDocset.products?.['registry-render']); const currentDocset = docsets.docsets.find((entry) => entry.id === docsets.current); if (!currentDocset) throw new Error(`current docs docset "${docsets.current}" not found`); @@ -84,6 +85,7 @@ export function resolveDocsetBuildContext(docsets, env = process.env) { isHistoricalArchiveBuild, isSearchExcludedBuild, hasCasework, + hasScheduling, hasRender, internalRedirect, currentDocsetRedirect, @@ -97,6 +99,7 @@ const { isHistoricalArchiveBuild, isSearchExcludedBuild, hasCasework, + hasScheduling, hasRender, internalRedirect, currentDocsetRedirect, @@ -132,6 +135,28 @@ const caseworkOpenApiSchema = { operations: { labels: /** @type {'path'} */ ('path'), badges: true }, }, }; +const schedulingOpenApiSchema = { + base: 'reference/apis/scheduling', + schema: './openapi/registry-scheduling.openapi.json', + sidebar: { + label: 'API operations', + collapsed: true, + operations: { labels: /** @type {'path'} */ ('path'), badges: true }, + }, +}; +const schedulingRoutes = [ + '/reference/apis/registry-scheduling/', +]; +/** + * @param {boolean} hasScheduling + * @param {(path: string) => string} currentDocsetRedirect + */ +export function schedulingRedirects(hasScheduling, currentDocsetRedirect) { + if (hasScheduling) return {}; + return Object.fromEntries(schedulingRoutes.flatMap((route) => [ + [route, currentDocsetRedirect(route)], + ])); +} const caseworkRoutes = [ '/start/casework/', '/tutorials/first-casework/', @@ -170,6 +195,7 @@ export default defineConfig({ ...buildNotaryRetirementRedirects(currentDocsetRedirect), ...buildRelayV2RetirementRedirects(currentDocsetRedirect), ...caseworkRedirects(hasCasework, currentDocsetRedirect), + ...schedulingRedirects(hasScheduling, currentDocsetRedirect), '/start/': internalRedirect('/'), '/start/see-it-live/': internalRedirect('/'), // Retired product choosers. The homepage chooses between the products, so @@ -308,6 +334,7 @@ export default defineConfig({ }, }, ...[caseworkOpenApiSchema].filter(() => hasCasework), + ...[schedulingOpenApiSchema].filter(() => hasScheduling), ]), ], defaultLocale: 'root', @@ -544,6 +571,14 @@ export default defineConfig({ { label: 'Client API reference', slug: 'reference/client-api' }, ], }] : []), + ...(hasScheduling ? [{ + label: 'Registry Scheduling', + collapsed: true, + items: [ + { label: 'API contract', slug: 'reference/apis/registry-scheduling' }, + ...openAPISidebarGroups.slice(2, 3), + ], + }] : []), ...(hasRender ? [{ label: 'Registry Render', collapsed: true, diff --git a/docs/site/redocly.yaml b/docs/site/redocly.yaml index 00d60d4366..795160c6d4 100644 --- a/docs/site/redocly.yaml +++ b/docs/site/redocly.yaml @@ -3,6 +3,8 @@ extends: apis: registry-casework: root: openapi/registry-casework.openapi.json + registry-scheduling: + root: openapi/registry-scheduling.openapi.json registry-evidence: root: openapi/registry-evidence.openapi.json rules: diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 24bc2c4964..9a694e5285 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -42,6 +42,7 @@ const cacheRoot = resolve(root, '.repo-docs-cache'); // aggregated API reference; others are skipped. const SPEC_SOURCES = { 'registry-casework': 'products/casework/generated/registry-casework.openapi.json', + 'registry-scheduling': 'products/scheduling/generated/registry-scheduling.openapi.json', 'registry-evidence': 'products/evidence/generated/registry-evidence.openapi.json', }; @@ -117,7 +118,9 @@ async function main() { let written = 0; for (const [repoId, specPath] of Object.entries(SPEC_SOURCES)) { - if (repoId === 'registry-casework' && !docset.products[repoId]) { + // Casework and Scheduling first enter the docset line after the archives + // that predate them, so a docset without the product pins no spec for it. + if ((repoId === 'registry-casework' || repoId === 'registry-scheduling') && !docset.products[repoId]) { console.log(`Skipped ${repoId} OpenAPI spec absent from docset ${docset.id}.`); continue; } diff --git a/docs/site/scripts/generate-cli-reference.mjs b/docs/site/scripts/generate-cli-reference.mjs index bfbcba8fc3..e8e797328c 100644 --- a/docs/site/scripts/generate-cli-reference.mjs +++ b/docs/site/scripts/generate-cli-reference.mjs @@ -35,6 +35,8 @@ export const expectedBinaries = [ 'registry-render', 'relay', 'relayctl', + 'scheduling', + 'schedulingctl', ]; const generatedTree = 'src/content/docs/reference/cli'; @@ -51,6 +53,7 @@ const hiddenCommands = new Set([ const groups = [ { title: 'Base Registry Engine', binaries: ['breg', 'bregctl'] }, { title: 'Registry Casework', binaries: ['casework', 'caseworkctl'] }, + { title: 'Registry Scheduling', binaries: ['scheduling', 'schedulingctl'] }, { title: 'Registry Relay', binaries: ['relay', 'relayctl'] }, { title: 'Evidence Gateway', binaries: ['evidence', 'evidencectl'] }, { title: 'Evidence credential delivery', binaries: ['evidence-oid4vci'] }, diff --git a/docs/site/scripts/generate-data.mjs b/docs/site/scripts/generate-data.mjs index a0aaaffc76..654f9ca221 100644 --- a/docs/site/scripts/generate-data.mjs +++ b/docs/site/scripts/generate-data.mjs @@ -30,6 +30,7 @@ const required = { 'breg-events': ['id', 'title', 'columns', 'rows'], 'breg-api': ['id', 'title', 'columns', 'rows'], 'casework-api': ['id', 'title', 'columns', 'rows'], + 'scheduling-api': ['id', 'title', 'columns', 'rows'], }; const generated = []; diff --git a/docs/site/scripts/generated-api-bases.test.mjs b/docs/site/scripts/generated-api-bases.test.mjs index 63f0e33fa4..1465c850e8 100644 --- a/docs/site/scripts/generated-api-bases.test.mjs +++ b/docs/site/scripts/generated-api-bases.test.mjs @@ -42,6 +42,8 @@ test('a generated base and its descendants are recognised', () => { assert.equal(isGeneratedApiPath('/reference/apis/evidence/operations/gethealth/'), true); assert.equal(isGeneratedApiDir('reference/apis/casework'), true); assert.equal(isGeneratedApiPath('/reference/apis/casework/operations/health/'), true); + assert.equal(isGeneratedApiDir('reference/apis/scheduling'), true); + assert.equal(isGeneratedApiPath('/reference/apis/scheduling/operations/gethealthz/'), true); }); test('the hand-authored narrative pages keep their Markdown twin', () => { @@ -52,6 +54,8 @@ test('the hand-authored narrative pages keep their Markdown twin', () => { assert.equal(isGeneratedApiPath('/reference/apis/registry-evidence/'), false); assert.equal(isGeneratedApiDir('reference/apis/registry-casework'), false); assert.equal(isGeneratedApiPath('/reference/apis/registry-casework/'), false); + assert.equal(isGeneratedApiDir('reference/apis/registry-scheduling'), false); + assert.equal(isGeneratedApiPath('/reference/apis/registry-scheduling/'), false); assert.equal(isGeneratedApiDir('reference/apis'), false); assert.equal(isGeneratedApiPath('/reference/apis/'), false); }); diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 36a8e38eb1..c56af41dd9 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -23,6 +23,13 @@ const resolveDocsetBuildContext = new Function( const caseworkRedirects = new Function( `${caseworkRoutesSource}; ${redirectsSource.replace(/^export /, '')}; return caseworkRedirects;`, )(); +const schedulingRedirectsSource = configSource.match(/export function schedulingRedirects[\s\S]*?^}\n/m)?.[0]; +const schedulingRoutesSource = configSource.match(/const schedulingRoutes = \[[\s\S]*?\];/)?.[0]; +assert.ok(schedulingRedirectsSource && schedulingRoutesSource, + 'could not isolate Scheduling docset routing'); +const schedulingRedirects = new Function( + `${schedulingRoutesSource}; ${schedulingRedirectsSource.replace(/^export /, '')}; return schedulingRedirects;`, +)(); const homepageSource = readFileSync(resolve(siteRoot, 'src/content/docs/index.mdx'), 'utf8'); const validationSource = readFileSync( resolve(siteRoot, 'src/content/docs/verify/index.mdx'), @@ -58,11 +65,18 @@ const caseworkOpenApiSchema = { schema: './openapi/registry-casework.openapi.json', sidebar: { label: 'API operations', collapsed: true }, }; +const schedulingOpenApiSchema = { + base: 'reference/apis/scheduling', + schema: './openapi/registry-scheduling.openapi.json', + sidebar: { label: 'API operations', collapsed: true }, +}; const apiSchemas = new Function( 'hasCasework', + 'hasScheduling', 'caseworkOpenApiSchema', + 'schedulingOpenApiSchema', `return ${apiConfigSource};`, -)(true, caseworkOpenApiSchema); +)(true, true, caseworkOpenApiSchema, schedulingOpenApiSchema); const generatedAPI = apiSchemas.map((schema) => ({ ...schema.sidebar, items: [{ label: 'Generated tag', items: [] }], @@ -74,6 +88,7 @@ const sidebarFactory = new Function( 'openAPISidebarGroups', 'flattenSidebarGroups', 'hasCasework', + 'hasScheduling', 'hasRender', `return [${sidebarSource}];`, ); @@ -87,7 +102,7 @@ const sidebarArguments = [ generatedAPI, flattenSidebarGroups, ]; -const sidebar = sidebarFactory(...sidebarArguments, true, true); +const sidebar = sidebarFactory(...sidebarArguments, true, true, true); function section(label) { const group = sidebar.find((item) => item.label === label); @@ -199,6 +214,7 @@ test('uses the product navigation in its published order', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', + 'Registry Scheduling', 'Registry Render', 'Registry Discovery', 'Operations', @@ -212,30 +228,40 @@ test('selects Casework routes, sidebar, and API from the docset product manifest current: 'latest', released: 'v0.29.0', docsets: [ - { id: 'latest', status: 'current', availability: 'unreleased', path: '/dev/', products: { 'registry-casework': { ref: 'HEAD' }, 'registry-render': { ref: 'HEAD' } } }, + { id: 'latest', status: 'current', availability: 'unreleased', path: '/dev/', products: { 'registry-casework': { ref: 'HEAD' }, 'registry-scheduling': { ref: 'HEAD' }, 'registry-render': { ref: 'HEAD' } } }, { id: 'v0.29.0', status: 'archived', availability: 'released', path: '/v/0.29.0/', products: {} }, { id: 'v0.30.0', status: 'archived', availability: 'candidate', path: '/v/0.30.0/', products: { 'registry-casework': { ref: 'v0.30.0' } } }, ], }; - for (const [id, env, hasCasework, hasRender] of [ - ['latest', {}, true, true], - ['v0.29.0', {}, false, false], - ['v0.30.0', {}, true, false], - ['v0.30.0', { DOCS_RELEASED_ARCHIVE: 'true' }, true, false], + for (const [id, env, hasCasework, hasScheduling, hasRender] of [ + ['latest', {}, true, true, true], + ['v0.29.0', {}, false, false, false], + ['v0.30.0', {}, true, false, false], + ['v0.30.0', { DOCS_RELEASED_ARCHIVE: 'true' }, true, false, false], ]) { const context = resolveDocsetBuildContext(docsets, { DOCS_DOCSET: id, ...env }); assert.equal(context.hasCasework, hasCasework, id); + assert.equal(context.hasScheduling, hasScheduling, id); assert.equal(context.hasRender, hasRender, id); - const docsetSidebar = sidebarFactory(...sidebarArguments, context.hasCasework, context.hasRender); + const docsetSidebar = sidebarFactory( + ...sidebarArguments, + context.hasCasework, + context.hasScheduling, + context.hasRender, + ); assert.equal(docsetSidebar.some((item) => item.label === 'Registry Casework'), hasCasework, id); + assert.equal(docsetSidebar.some((item) => item.label === 'Registry Scheduling'), hasScheduling, id); assert.equal(docsetSidebar.some((item) => item.label === 'Registry Render'), hasRender, id); - assert.equal(new Function('hasCasework', 'caseworkOpenApiSchema', `return ${apiConfigSource};`) - (context.hasCasework, caseworkOpenApiSchema).length, hasCasework ? 2 : 1, id); + assert.equal(new Function('hasCasework', 'hasScheduling', 'caseworkOpenApiSchema', 'schedulingOpenApiSchema', `return ${apiConfigSource};`) + (context.hasCasework, context.hasScheduling, caseworkOpenApiSchema, schedulingOpenApiSchema).length, + 1 + Number(hasCasework) + Number(hasScheduling), id); const redirects = caseworkRedirects(context.hasCasework, context.currentDocsetRedirect); assert.equal(redirects['/operate/casework/'] !== undefined, !hasCasework, id); assert.equal(redirects['/tutorials/first-casework.md'] !== undefined, !hasCasework, id); + const schedulingRedirectsResult = schedulingRedirects(context.hasScheduling, context.currentDocsetRedirect); + assert.equal(schedulingRedirectsResult['/reference/apis/registry-scheduling/'] !== undefined, !hasScheduling, id); } - assert.match(fetchOpenapiSource, /repoId === 'registry-casework' && !docset\.products\[repoId\]/); + assert.match(fetchOpenapiSource, /\(repoId === 'registry-casework' \|\| repoId === 'registry-scheduling'\) && !docset\.products\[repoId\]/); for (const route of [ '/start/casework/', '/tutorials/first-casework/', @@ -247,6 +273,8 @@ test('selects Casework routes, sidebar, and API from the docset product manifest assert.match(configSource, new RegExp(`'${route}'`)); } assert.match(configSource, /\.\.\.caseworkRedirects\(hasCasework, currentDocsetRedirect\)/); + assert.match(configSource, /'\/reference\/apis\/registry-scheduling\/'/); + assert.match(configSource, /\.\.\.schedulingRedirects\(hasScheduling, currentDocsetRedirect\)/); }); test('starts with only Start expanded and all secondary groups collapsed', () => { @@ -322,6 +350,7 @@ test('uses the formal product names for top-level sections', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', + 'Registry Scheduling', 'Registry Render', 'Registry Discovery', ]) { diff --git a/docs/site/scripts/scheduling-api-reference.test.mjs b/docs/site/scripts/scheduling-api-reference.test.mjs new file mode 100644 index 0000000000..cf77270fac --- /dev/null +++ b/docs/site/scripts/scheduling-api-reference.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { parse } from 'yaml'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); +const tables = parse(read('../src/data/scheduling-api.yaml')); +const page = read('../src/content/docs/reference/apis/registry-scheduling.mdx'); + +test('every ApiReferenceTable id used in the Scheduling reference exists in scheduling-api.yaml', () => { + const referenced = [...page.matchAll(/ match[1]); + assert.ok(referenced.includes('problem-codes'), 'reference/apis/registry-scheduling.mdx should render the problem-codes table'); + assert.equal( + [...page.matchAll(/ table.id)); + for (const id of referenced) { + assert.ok(ids.has(id), `${id} is referenced by in reference/apis/registry-scheduling.mdx but missing from scheduling-api.yaml`); + } +}); + +test('scheduling-api tables are rectangular with non-empty columns and rows', () => { + assert.equal(new Set(tables.map((table) => table.id)).size, tables.length); + for (const table of tables) { + assert.ok(table.columns.length > 0, `${table.id} has no columns`); + assert.ok(table.rows.length > 0, `${table.id} has no rows`); + for (const row of table.rows) { + assert.equal(row.length, table.columns.length, `${table.id} has a row whose cell count does not match its column count`); + } + } +}); + +test('scheduling-api.json is the generated output of scheduling-api.yaml', () => { + assert.deepEqual(JSON.parse(read('../src/data/generated/scheduling-api.json')), tables); +}); + +test('every problem code row matches the product problem catalogue', () => { + const document = JSON.parse(read('../../../products/scheduling/generated/registry-scheduling.openapi.json')); + const catalogue = new Map(); + for (const schema of Object.values(document.components.schemas)) { + for (const part of schema.allOf ?? []) { + const code = part.properties?.code?.const; + if (code) catalogue.set(code, part.properties.status.const); + } + } + assert.ok(catalogue.size >= 30, 'the generated document should carry the full problem catalogue'); + const rows = tables.find((table) => table.id === 'problem-codes').rows; + assert.equal(rows.length, catalogue.size, 'the table and the catalogue hold the same number of codes'); + for (const [code, status, when] of rows) { + const bareCode = code.replace(/`/g, ''); + assert.ok(catalogue.has(bareCode), `${bareCode} appears in scheduling-api.yaml but not in the product catalogue`); + assert.equal(String(catalogue.get(bareCode)), status, `${bareCode} lists status ${status} but the catalogue pins ${catalogue.get(bareCode)}`); + assert.ok(when.length > 0, `${bareCode} has an empty when sentence`); + } +}); diff --git a/docs/site/src/components/ApiReferenceTable.astro b/docs/site/src/components/ApiReferenceTable.astro index 1cf45fb151..ec14288807 100644 --- a/docs/site/src/components/ApiReferenceTable.astro +++ b/docs/site/src/components/ApiReferenceTable.astro @@ -1,13 +1,18 @@ --- import bregTables from '../data/generated/breg-api.json'; import caseworkTables from '../data/generated/casework-api.json'; +import schedulingTables from '../data/generated/scheduling-api.json'; interface Props { id: string; - source?: 'breg-api' | 'casework-api'; + source?: 'breg-api' | 'casework-api' | 'scheduling-api'; } -const sources = { 'breg-api': bregTables, 'casework-api': caseworkTables }; +const sources = { + 'breg-api': bregTables, + 'casework-api': caseworkTables, + 'scheduling-api': schedulingTables, +}; const source = Astro.props.source ?? 'breg-api'; const table = sources[source].find((entry) => entry.id === Astro.props.id); if (!table) throw new Error(`Unknown ${source} reference table: ${Astro.props.id}`); diff --git a/docs/site/src/content/docs/configure/casework.mdx b/docs/site/src/content/docs/configure/casework.mdx index e4cc45e5f6..5d45717c84 100644 --- a/docs/site/src/content/docs/configure/casework.mdx +++ b/docs/site/src/content/docs/configure/casework.mdx @@ -325,7 +325,7 @@ Each revision is immutable, and a running clock occurrence stays pinned to the r [Retain, erase, and settle](../../operate/casework-retention/) covers the operator side of that change. {/* Evidence: crates/registry-casework-core/src/policy.rs, HolidaySetDocument and CalendarPolicy; - crates/registry-casework-core/src/calendar.rs, evaluate_working_day_deadline(). */} + crates/registry-platform-calendar/src/working_day.rs, evaluate_working_day_deadline(). */} ### Drafts, recovery, and inbox bounds diff --git a/docs/site/src/content/docs/explanation/how-casework-works.mdx b/docs/site/src/content/docs/explanation/how-casework-works.mdx index 455047e75d..da3fd34b76 100644 --- a/docs/site/src/content/docs/explanation/how-casework-works.mdx +++ b/docs/site/src/content/docs/explanation/how-casework-works.mdx @@ -202,7 +202,7 @@ A calendar names a timezone and its working weekdays, and its holiday dates arri Republishing holidays never silently rewrites a running deadline: an Administrator previews the recompute in batches, reviews it, and applies the reviewed preview. {/* Evidence: crates/registry-casework-core/src/policy.rs, MAXIMUM_CLOCKS, MAXIMUM_CALENDARS, and MAXIMUM_CLOCK_REMINDERS; - crates/registry-casework-core/src/calendar.rs, evaluate_working_day_deadline; + crates/registry-platform-calendar/src/working_day.rs, evaluate_working_day_deadline; crates/registry-casework/src/clocks.rs, apply_clock_recompute. */} What a clock does when it fires is narrow and local. diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx new file mode 100644 index 0000000000..8425ec2403 --- /dev/null +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -0,0 +1,209 @@ +--- +title: Registry Scheduling API +description: Authority profiles, task grants, idempotency, problem codes, and route families for the generated Registry Scheduling OpenAPI operations. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-09-15" +doc_type: reference +locale: en +standards_referenced: + - openapi +--- + +import ApiReferenceTable from '../../../../components/ApiReferenceTable.astro'; + +Registry Scheduling serves one HTTP surface for the published catalogue, availability, holds, and +appointments. Every call presents a bearer access token, and one of three authority profiles decides +what that token must carry: a read scope for catalogue and owned-record reads, a separate explain scope +for the diagnostic availability read, or a complete task grant for every commitment. The contract is +unreleased and carries no frozen compatibility promise. + +[Open the Registry Scheduling API operations](../scheduling/) for every route, parameter, schema, and +reachable problem code. This page holds the authority, revision, and recovery rules those operations +assume. + +## Contract boundary + +Every request presents `Authorization: Bearer ` holding an RFC 9068 access token, `typ: +at+jwt`, whose claims name the caller's actor kind in `registry_actor_kind` as `human`, `agent`, or +`service`. Liveness and readiness are the only routes that take no token. + +{/* Evidence: crates/registry-scheduling/src/auth.rs, authenticate_mutate() and validate_compact_access_token; + crates/registry-platform-oidc/src/authorization_claims.rs, actor_kind(). */} + +The four caller-driven commitments carry an `Idempotency-Key` the caller chooses, bounded to 128 +bytes: creating a hold, creating an appointment, rescheduling one, and cancelling one. A retry under +the same key with the same request returns the stored first answer, and the same key with a different +request is refused with `idempotency.key-reused`. Releasing a hold carries no caller key: the hold's +own identifier is the idempotency key, so a retried release replays the first answer without the +caller holding anything back. + +{/* Evidence: crates/registry-scheduling/src/http.rs, idempotency_key() and router(); + crates/registry-scheduling/src/service.rs, release_hold(); + crates/registry-scheduling-core/src/naming.rs, MAXIMUM_IDEMPOTENCY_KEY_BYTES. */} + +Observed revisions travel in request bodies, not in an `If-Match` header: an admission names the +`policyRevision` it resolved the offering under, and a reschedule or cancel names the appointment +revision it acted on. A stale revision is refused with `policy.changed` or `revision.mismatch`, and +the caller reloads the catalogue and tries again. An offering the deployed policy does not publish +is a separate answer, `request.not-found`: it does not exist, so no reload produces it, and the +offering listing every caller holding the reads scope can read is where the published ones are +named. + +{/* Evidence: crates/registry-scheduling-core/src/model.rs, AdmissionRequest; + crates/registry-scheduling-core/src/admission.rs, check_duplicate(); + products/scheduling/generated/registry-scheduling.openapi.json. */} + +A refusal answers `application/problem+json` with `type`, `title`, `status`, `detail`, `code`, and +`traceId`. The `type` is the code with each dot replaced by a slash under +`https://id.registrystack.org/problems/registry-scheduling/`, and `detail` is the fixed sentence that +belongs to the code. No field repeats a submitted value. Every response, refusals included, carries +`Cache-Control: no-store` and propagates or mints a `traceparent` value. + +{/* Evidence: crates/registry-platform-httpsec/src/server.rs, security_headers() and ProblemBody; + crates/registry-scheduling-core/src/problem.rs, ProblemCode. */} + +Availability pages list a slot only while it holds a free unit: a slot whose members are all +committed, held, or closed leaves the page rather than appearing with zero free. A caller watching a +slot disappear therefore reads the same fact as one reading `free: 0`. + +{/* Evidence: crates/registry-scheduling/src/service.rs, exact_time_slots(). */} + +## Authority profiles + +The three profiles are disjoint. A token carrying a product scope does not thereby gain a commitment +path, and a task grant does not thereby gain a catalogue read. + +| Profile | Routes | The token must carry | +| --- | --- | --- | +| Catalogue read | `/v1/scheduling`, `/v1/services`, `/v1/offerings`, `/v1/resources`, `/v1/locations`, `/v1/availability`, one appointment read, its history | The configured read scope, `scheduling-read` by default, in `registry_scopes` | +| Explain | `/v1/availability/explain` | The separately configured explain scope, `scheduling-explain` by default, which is never the read scope | +| Commitment | Hold create and release, appointment create, reschedule, cancel | A complete task grant; no product scope | + +{/* Evidence: crates/registry-scheduling/src/auth.rs, authenticate_read(), authenticate_explain(), and + authenticate_mutate(); crates/registry-scheduling/src/http.rs, router(). */} + +The explain path is separately authorized because it can name the member-level cause +`resource.unavailable`. The public availability path answers the same state as `capacity.exhausted` +and names no member. + +{/* Evidence: crates/registry-scheduling-core/src/admission.rs, detailed_code(); + products/scheduling/generated/registry-scheduling.openapi.json. */} + +## Task grants + +A commitment carries no product scope. Its authority is a task grant inside the access token: the six +`registry_grant_*` members that make a grant detectable, plus `registry_purpose` and +`registry_approver`, bound to the verified client and to this deployment's audience before the caller +reaches the service. An incomplete grant set is refused as a credentials problem +(`authentication.refused`), never honored partially. + +{/* Evidence: crates/registry-platform-oidc/src/authorization_claims.rs, grant_claims(), core_grant_names(), + and verify_context(); crates/registry-scheduling/src/auth.rs, verified_caller(). */} + +The grant's bounds carry one permission per location, and a permission names an offering's service, an +offering's location, and the actions it allows: + +- `hold.create` and `hold.release` +- `appointment.create`, `appointment.reschedule`, and `appointment.cancel` + +A permission matches an offering only when its service and location both equal the offering's own and +the action is listed. The match is exact: a broader service or location never covers a narrower one, +and the grant's client must equal the client the deployment verified. + +{/* Evidence: crates/registry-scheduling/src/service.rs, require_permission(), HOLD_CREATE_ACTION, + HOLD_RELEASE_ACTION, APPOINTMENT_CREATE_ACTION, APPOINTMENT_RESCHEDULE_ACTION, and + APPOINTMENT_CANCEL_ACTION; crates/registry-platform-oidc/src/authorization_claims.rs, + SchedulingPermission. */} + +The grant's bounds are matched once, at the service, before the capacity transaction opens. A grant +that does not cover the request answers `operation.not-authorized` and nothing is written. Inside the +transaction, immediately before the claim commits, exactly one fact is read again: the grant's own +expiry, against the clock that commitment is decided under. A grant that expired between the door and +the commit therefore cannot take capacity, and the attempt answers `operation.not-authorized` like any +other authority refusal. + +The bounds themselves are not read again at that point, and this milestone has no revocation check, so +a grant narrowed or withdrawn at the issuer after its token was minted keeps the authority its token +carries until the token or the grant deadline passes. Keep grant deadlines short. Re-checking the full +bounds inside the capacity transaction is a recorded deferral, not a promise this milestone keeps. + +The audit journal records `authorization.allowed` or `authorization.refused` as an audit reason, never +as a problem code, for the commitment and for a permission the service refuses before any commitment is +reached. + +{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current() checks grant_exp_unix and + nothing else; crates/registry-scheduling/src/service.rs, require_permission() and problem_of(); + products/scheduling/contracts/security-invariant-matrix.yaml, SCHEDULING-SEC-03. */} + +## Problem codes + +Every Scheduling problem response carries one `code` from this closed catalogue, always answered with +the HTTP status listed beside it. + +Two codes are reserved: `eligibility.unavailable` and `hook.unavailable` are defined, titled, and +status-pinned for future synchronous decision paths, and no path in this milestone answers them yet. +Scheduling hooks are asynchronous observers, so their delivery failures never become caller problem +responses. The authored policy accepts only `after` URL handlers for `appointment.confirmed`, +`appointment.rescheduled`, and `appointment.cancelled`. Confirmed and rescheduled events may project +the appointment identifier, revision, offering, start, end, state, and policy revision. Cancelled +events may project only the appointment identifier, revision, and state. Conditions, principals, +local handlers, and observer proposals are refused. + +{/* Evidence: crates/registry-scheduling-core/src/{policy,problem}.rs, SchedulingPolicy::check() and ProblemCode; + crates/registry-scheduling/src/hooks.rs; + products/scheduling/generated/registry-scheduling.openapi.json, + x-registry-scheduling-reserved-problems. */} + + + +{/* Problem code table generated from docs/site/src/data/scheduling-api.yaml by + docs/site/scripts/generate-data.mjs. Run npm run generate from docs/site. */} + +{/* Evidence: products/scheduling/generated/registry-scheduling.openapi.json; + docs/site/src/data/scheduling-api.yaml. */} + +## Route families + +The served routes group into these families. A family name grants nothing on its own; authority is +checked per route. + +| Family | Base paths | Authority profile | +| --- | --- | --- | +| Service description | `/v1/scheduling` | Catalogue read | +| Catalogue | `/v1/services`, `/v1/offerings`, `/v1/resources`, `/v1/locations` | Catalogue read | +| Availability | `/v1/availability`, `/v1/availability/explain` | Catalogue read; Explain | +| Holds | `/v1/holds`, `/v1/holds/{holdId}` | Commitment | +| Appointments | `/v1/appointments`, `/v1/appointments/{appointmentId}`, its `reschedule`, `cancel`, and `history` | Commitment for the mutations; Catalogue read for the reads | +| Liveness and readiness | `/healthz`, `/readyz` | Any caller, without a token | + +{/* Evidence: crates/registry-scheduling/src/http.rs, router(); + crates/registry-scheduling-core/src/naming.rs, SCHEDULING_PATH, SERVICES_PATH, OFFERINGS_PATH, + RESOURCES_PATH, LOCATIONS_PATH, AVAILABILITY_PATH, AVAILABILITY_EXPLAIN_PATH, HOLDS_PATH, and + APPOINTMENTS_PATH. */} + +## Source of truth + +The implementation is the source of truth, and the generated document is the published form of it. +The product generator builds the OpenAPI document from the Rust-owned route inventory, authority +profiles, wire schemas, and the exported problem catalogue, and the product check regenerates the +document and refuses byte drift. A running Scheduling process serves the routes in that document, and +does not serve the document itself. + +{/* Evidence: products/scheduling/scripts/{generate_openapi.py,check-checkpoint.sh}; + products/scheduling/generated/registry-scheduling.openapi.json. */} + +The problem code table is generated from maintained data whose codes and statuses match the generated +document. The route families table and the header rules are maintained by hand against the cited +source. + +{/* Evidence: docs/site/src/data/scheduling-api.yaml; + products/scheduling/scripts/test_generate_openapi.py. */} + +## Next + +- [Registry Scheduling API operations](../scheduling/) for every route, parameter, and schema. +- [schedulingctl command reference](../../cli/schedulingctl/) for the authoring and packaging commands + that produce the policy a deployment serves. diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index a6b5d8165e..8bf28d7b7d 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-20 reviewed_source_version: "0.32.0" -reviewed_catalog_sha256: a591af7baba0b23011f27f6b5c73491e80f3b25f8317ddbf34fd385ee48274e4 -reviewed_content_sha256: ec0676b605486885e23b6bc7a562c12ac9c64f9e661f364a2111a11468df359e +reviewed_catalog_sha256: 36d2d865af3318223153781c074620b512aa0e9117faf9a82893a015f404c371 +reviewed_content_sha256: 1f78a1dcecc9b5370c58eea14ed08f541d44bf4615ab5d88a32a7901608af08c diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index d49a40c69b..56ea023b20 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -26,6 +26,9 @@ docsets: registry-casework: version: main source (unreleased) ref: HEAD + registry-scheduling: + version: main source (unreleased) + ref: HEAD registry-render: version: main source (unreleased) ref: HEAD diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index 28999854d5..72fd410ff2 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -5,6 +5,16 @@ # standards_referenced and last_reviewed so future current-page reviews cannot # rewrite historical metadata. repos: + # Scheduling is unreleased MVP work. Only its product-owned OpenAPI artifact + # is pulled; the API reference page beside it is hand-authored, and no other + # Scheduling doc lane exists yet. + registry-scheduling: + remote: https://github.com/registrystack/registry-stack + ref: HEAD + version: main source (unreleased) + local: ../.. + openapi: products/scheduling/generated/registry-scheduling.openapi.json + # Casework first enters an archived docset at v0.30.0. Its product-owned # OpenAPI artifact is rendered directly; adopter prose remains hand-authored # in the Casework product lane rather than duplicating the long product README. diff --git a/docs/site/src/data/scheduling-api.yaml b/docs/site/src/data/scheduling-api.yaml new file mode 100644 index 0000000000..74740f4dae --- /dev/null +++ b/docs/site/src/data/scheduling-api.yaml @@ -0,0 +1,36 @@ +- id: problem-codes + title: Problem codes + columns: [Code, Status, When] + rows: + - ['`authentication.refused`', '401', 'The bearer credential is missing, invalid, or expired. Sign in again.'] + - ['`booking.duplicate-active`', '409', 'An active booking already holds this party''s duplicate key. Cancel or complete it before booking again.'] + - ['`cancellation.cutoff-passed`', '409', 'The cancellation cutoff for this appointment has passed.'] + - ['`capability.unmatched`', '422', 'No backing member carries every capability the offering requires.'] + - ['`capacity.exhausted`', '409', 'The supply is fully committed for the requested interval. Choose another time.'] + - ['`cursor.expired`', '410', 'A page cursor has expired. Start again without a cursor and deduplicate entries by `id`.'] + - ['`cursor.invalid`', '400', 'A page cursor is malformed or was issued for a different request.'] + - ['`eligibility.unavailable`', '503', 'A required eligibility check could not run. Reserved: no milestone path answers it yet.'] + - ['`hold.expired`', '410', 'The hold expired before confirmation. Its capacity is bookable again; start a new request.'] + - ['`hold.released`', '409', 'The claim is not an active hold. It may already be confirmed or released.'] + - ['`hook.unavailable`', '503', 'A required lifecycle hook could not run, so the request was refused rather than half-applied. Reserved: no milestone path answers it yet.'] + - ['`horizon.outside`', '422', 'The requested start is earlier than the lead time allows or further ahead than the horizon allows.'] + - ['`idempotency.expired`', '410', 'The stored response for the idempotency key has expired. Reconcile the original operation before choosing a new key.'] + - ['`idempotency.key-reused`', '409', 'An idempotency key was presented for a different request.'] + - ['`location.closed`', '409', 'A closure covers the requested start. Choose a start outside the closure.'] + - ['`operation.not-authorized`', '403', 'The caller''s task grant does not name this operation, or the deployment''s client does not match the grant''s client.'] + - ['`party.capacity-inadequate`', '422', 'The party is larger than the offering can ever serve, or its size falls outside the published units.'] + - ['`policy.changed`', '412', 'The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.'] + - ['`precondition.failed`', '412', 'Reserved: no runtime path answers it. A stale revision answers `policy.changed` or `revision.mismatch` instead.'] + - ['`precondition.required`', '428', 'The mutation requires the revision the caller loaded, or the duplicate key the offering keys on.'] + - ['`prerequisite.missing`', '422', 'The party is missing a prerequisite the offering requires.'] + - ['`profile.not-authorized`', '403', 'The caller holds no profile that authorizes this request, for example a read scope check on a mutation route.'] + - ['`request.body-too-large`', '413', 'The request body exceeds the accepted size.'] + - ['`request.invalid`', '400', 'The request could not be read as a Scheduling request.'] + - ['`request.method-not-allowed`', '405', 'The route exists but not for this method.'] + - ['`request.not-found`', '404', 'The requested route does not exist, or the deployed policy publishes no offering by that id. The offering listing names the ones it does.'] + - ['`request.unprocessable`', '422', 'The request body could not be processed.'] + - ['`request.unsupported-media-type`', '415', 'The request body is not JSON.'] + - ['`resource.unavailable`', '409', 'Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers `capacity.exhausted`.'] + - ['`revision.mismatch`', '412', 'The window revision changed before the request committed. Reload the catalogue and try again.'] + - ['`schedule.unpublished`', '422', 'No published schedule serves that start. Choose a start on the published grid.'] + - ['`service.unavailable`', '503', 'Scheduling storage is unavailable. Retry after the service recovers.'] diff --git a/docs/site/src/lib/cli-reference-sidebar.mjs b/docs/site/src/lib/cli-reference-sidebar.mjs index 22e01c3b41..e403852328 100644 --- a/docs/site/src/lib/cli-reference-sidebar.mjs +++ b/docs/site/src/lib/cli-reference-sidebar.mjs @@ -12,6 +12,8 @@ const binaries = [ 'bregctl', 'casework', 'caseworkctl', + 'scheduling', + 'schedulingctl', 'relay', 'relayctl', 'evidence', diff --git a/docs/site/src/lib/generated-api-bases.mjs b/docs/site/src/lib/generated-api-bases.mjs index b99813ec9b..4b6934a237 100644 --- a/docs/site/src/lib/generated-api-bases.mjs +++ b/docs/site/src/lib/generated-api-bases.mjs @@ -8,13 +8,14 @@ * this list, so registering a new API reference in astro.config.mjs is the only * place that has to learn about it. * - * These are the generated bases, not the hand-authored narrative page - * reference/apis/registry-evidence and reference/apis/registry-casework, which - * keep their .md. + * These are the generated bases, not the hand-authored narrative pages + * reference/apis/registry-evidence, reference/apis/registry-casework, and + * reference/apis/registry-scheduling, which keep their .md. */ export const GENERATED_API_BASES = [ 'reference/apis/evidence', 'reference/apis/casework', + 'reference/apis/scheduling', ]; /** True when a dist-relative page directory is a generated API route. */ diff --git a/products/casework/CHANGELOG.md b/products/casework/CHANGELOG.md index 391178ac1f..53e8f1e0d0 100644 --- a/products/casework/CHANGELOG.md +++ b/products/casework/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Allow governed Casework task templates to approve exact Scheduling service, + location, and commitment-action bounds. The existing generic ThunderID + exchange carries them to Scheduling without a product crate dependency. - The inbox, the next-item result, holdings, the item view, history, and clocks stay readable while a subject's source binding has moved within its source generation but reconciliation has not applied it yet. The retained occurrence diff --git a/products/casework/DEV-SOURCES.md b/products/casework/DEV-SOURCES.md index 98e9471a23..16979acacc 100644 --- a/products/casework/DEV-SOURCES.md +++ b/products/casework/DEV-SOURCES.md @@ -158,3 +158,12 @@ or bounds. Unknown, expired or revoked approvals fail. Restart retains the issuer keys, directory, database and grant deadline. `dev stop` stops owned services; `dev stop --remove` explicitly removes that session's database and private state according to the normal dev lifecycle. + +The destination is not limited to BREG. A shared issuer that registers a +Scheduling resource and scope can exchange an approved `type: scheduling` +template through the same `taskExchange` client and `dev grant` command. Set +that client's `resource` and `scopes` in the connection file to the Scheduling +audience and registered exchange scope. Scheduling then verifies its own exact +service, location, action, client, resource, deadline, and assertion-issuer +bounds; see [`../scheduling/TASK_GRANTS.md`](../scheduling/TASK_GRANTS.md) for +the booking request. diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index 6808cfa568..0acadb51ed 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -15,8 +15,10 @@ Add `taskTemplates` to the Casework project. Each template declares: `itemKinds`, and eligible `itemStates`. - Exact `agent: {issuer, subject}`, OAuth `client`, one absolute `resource`, and `purpose`, plus explicit OAuth `scopes` for that resource. -- `bounds`, either `{type: evidence, requirement: ...}` or - `{type: breg, permissions: [{collection: ..., operations: [...]}]}`. +- `bounds`, one of `{type: evidence, requirement: ...}`, + `{type: breg, permissions: [{collection: ..., operations: [...]}]}`, or + `{type: scheduling, permissions: [{service: ..., location: ..., + actions: [...]}]}`. - `subjects`, mapping token identity keys to governed source logical fields. - `lifetimeSeconds`, no more than 900 seconds. @@ -28,6 +30,14 @@ the configured service reader for later checks. Callers cannot supply subject values. Changing a template requires a new version. Retiring a version invalidates its live grants; reactivation does not restore those grants. +Scheduling permissions use the same strict claim grammar its runtime verifies: +one to 64 unique `(service, location)` pairs, each with one to 32 unique action +names. Services and locations are exact, whitespace-free values with no +wildcard. Actions are lowercase operation names such as +`appointment.create`, `appointment.reschedule`, `appointment.cancel`, +`hold.create`, or `hold.release`. Casework copies these governed bounds into +the signed assertion; it does not import Scheduling or infer an offering. + ## Configure the authority Runtime `taskAuthority` declares assertion `issuer`, `exchangeAudience`, diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index d1ffee3e90..9cd8cb290c 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -23,4 +23,4 @@ invariants: - {id: CASEWORK-SEC-15, invariants: [11, 18], state: enforced, targetWave: mvp, threat: A stale timer or recompute fires an effect for completed work or repeats an effect after restart., enforcementPoint: pinned clock calculation and generation with transactional source revision and occurrence fences, refusal: Cancel terminal or superseded clocks and reject stale claims or recompute previews before recording reminders or local reassignment., negativeId: CASEWORK-NEG-15, negativeTest: {path: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}} - {id: CASEWORK-SEC-16, invariants: [1, 3, 17, 18], state: enforced, targetWave: mvp, threat: A requester or former team member records an outcome or a policy change alters an existing hosted item., enforcementPoint: pinned hosted kind policy with separate Requester and human deciding profiles and transactional membership checks, refusal: Refuse unauthorized decisions and isolate requester feeds while cancellation and decision commit only one terminal result., negativeId: CASEWORK-NEG-16, negativeTest: {path: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}} - {id: CASEWORK-SEC-17, invariants: [6, 9, 17, 18], state: enforced, targetWave: mvp, threat: Retained payloads or cached replay responses disclose erased source or hosted data or allow rediscovery to restore it., enforcementPoint: explicit source erasure and hosted retention with transactional tombstones and bounded cursor cleanup, refusal: Preserve live attempt recovery before erasure and refuse expired replay without returning retained payloads or rehydrating erased source items., negativeId: CASEWORK-NEG-17, negativeTest: {path: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}} - - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, a keyed pseudonymous approver claim, and standard token exchange, refusal: Refuse bootstrap and status clients at the source human profile and refuse revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} + - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, closed Evidence, BREG, or Scheduling bounds, a keyed pseudonymous approver claim, standard token exchange, and a process-boundary proof that the exact exchanged Scheduling bearer passes Scheduling's real authenticator, refusal: Refuse bootstrap and status clients at the source human profile, malformed product bounds at template activation, and revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index 0a0832865e..4999dad771 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -19,4 +19,4 @@ entries: - {id: CASEWORK-SEC-15, tests: [{file: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}]} - {id: CASEWORK-SEC-16, tests: [{file: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: cancel_and_decision_race_produces_one_stable_terminal_event}, {file: crates/registry-casework/tests/hosted_standalone.rs, name: ten_items_two_create_retries_and_one_terminal_result_without_breg}]} - {id: CASEWORK-SEC-17, tests: [{file: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: terminal_cursors_and_independent_retention_are_enforced_and_erased}]} - - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} + - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-casework/src/task_grants.rs, name: governed_scheduling_bounds_are_the_platform_verifiers_wire_contract}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index 4c2c0e0ead..dab7a3d052 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -3763,6 +3763,43 @@ ], "type": "object" }, + "SchedulingTaskPermission": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9._:-]*$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "location": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1f\\x7f*]+$", + "type": "string", + "x-maximum-utf8-bytes": 512 + }, + "service": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1f\\x7f*]+$", + "type": "string", + "x-maximum-utf8-bytes": 512 + } + }, + "required": [ + "service", + "location", + "actions" + ], + "type": "object" + }, "SourceBinding": { "additionalProperties": false, "properties": { @@ -4085,6 +4122,27 @@ "permissions" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/SchedulingTaskPermission" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "scheduling" + } + }, + "required": [ + "type", + "permissions" + ], + "type": "object" } ] }, diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index c23cb8f940..84a98b5e3a 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -95,6 +95,7 @@ "TaskTemplatePreview": "TaskTemplatePreview", "TaskTemplatePreviews": "TaskTemplatePreviews", "TaskPermission": "TaskPermission", + "SchedulingTaskPermission": "SchedulingTaskPermission", "TaskApprovalRequest": "TaskApprovalRequest", "TaskGrantView": "TaskGrantView", "TaskGrantList": "TaskGrantList", @@ -1187,6 +1188,8 @@ def task_schemas() -> dict: uuid = {"type":"string", "format":"uuid"} subjects = {"type":"object", "maxProperties":32, "additionalProperties":{"type":["string","integer","boolean"]}} permission = obj({"collection":text, "operations":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":text}}, ["collection","operations"]) + grant_identifier = {"type":"string", "minLength":1, "maxLength":512, "x-maximum-utf8-bytes":512, "pattern":r"^[^\s\x00-\x1f\x7f*]+$"} + scheduling_permission = obj({"service":grant_identifier, "location":grant_identifier, "actions":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":{"type":"string", "minLength":1, "maxLength":128, "pattern":r"^[a-z][a-z0-9._:-]*$"}}}, ["service","location","actions"]) common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "scopes":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[\x21\x23-\x29\x2b-\x5b\x5d-\x7e]+$"}}, "purpose":text, "bounds":ref("TaskGrantBounds")} evidence_context = obj({"requesterTags":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[a-z][a-z0-9._-]*$"}}, "audience":{"type":"string","format":"uri","minLength":1,"maxLength":4096}}, ["requesterTags","audience"]) preview = {"id":text, "version":text, "label":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} @@ -1200,7 +1203,8 @@ def task_schemas() -> dict: "TaskTemplatePreview":obj(preview,[name for name in preview if name != "evidenceContext"]), "TaskTemplatePreviews":obj({"itemRevision":number,"templates":array(ref("TaskTemplatePreview"))},["itemRevision","templates"]), "TaskPermission":permission, - "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"])]}, + "SchedulingTaskPermission":scheduling_permission, + "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"]), obj({"type":{"const":"scheduling"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("SchedulingTaskPermission")}},["type","permissions"])]}, "TaskApprovalRequest":obj({"templateId":text,"templateVersion":text},["templateId","templateVersion"]), "TaskGrantView":obj(view,[name for name in view if name != "evidenceContext"]), "TaskGrantList":obj({"grants":{"type":"array","maxItems":128,"items":ref("TaskGrantView")}},["grants"]), diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index 395864b385..5dc6073e1c 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -589,7 +589,7 @@ entries: summary: Pinned stock ThunderID exchanges concurrent task grants into Evidence-bound tokens; different subject bounds remain separate, equivalent bounds may answer the same subject with distinct grant audit correlation, an ordinary service token and body retargeting are refused, and an issued token remains usable after authority revocation only until its immutable grant deadline. tests: - {file: crates/registry-evidence-client/tests/against_a_real_deployment.rs, name: stock_issuer_task_grants_are_subject_bound_at_the_evidence_boundary} - - {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes} + - {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid} - {file: crates/registry-evidence/tests/selector_conformance.rs, name: task_grant_context_is_bound_before_selector_or_source_access} - {file: crates/registry-evidence/src/config.rs, name: task_grant_profiles_bind_trusted_source_and_verified_client} - {file: crates/registry-evidence/src/auth.rs, name: purpose_only_service_token_is_not_a_task_grant} diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index f651e0378e..4905755d4a 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -51,6 +51,16 @@ "exporterPath": "crates/registry-casework/examples/problem-catalog.rs", "cargoPackage": "registry-casework", "cargoExample": "problem-catalog" + }, + { + "owner": "scheduling", + "status": "active", + "compatibilityLine": "v1alpha1", + "uriPrefix": "https://id.registrystack.org/problems/registry-scheduling/", + "sourcePath": "crates/registry-scheduling-core/src/problem.rs", + "exporterPath": "crates/registry-scheduling-core/examples/problem-catalog.rs", + "cargoPackage": "registry-scheduling-core", + "cargoExample": "problem-catalog" } ], "referenceExclusions": [ @@ -158,6 +168,14 @@ "status": "active", "compatibilityLine": "v1alpha1", "description": "Registry Casework runtime configuration JSON Schema." + }, + { + "glob": "products/scheduling/generated/runtime/*.schema.json", + "sourcePath": "crates/registry-scheduling/src/schema.rs", + "owner": "scheduling", + "status": "active", + "compatibilityLine": "v1alpha1", + "description": "Registry Scheduling runtime configuration JSON Schema." } ], "records": [ diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index e95efb1718..52cbc8b16f 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -1933,6 +1933,614 @@ ] } }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/authentication/refused", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Authentication refused", + "description": "The bearer credential is missing, invalid, or expired. Sign in again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "authentication.refused", + "httpStatuses": [ + 401 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/booking/duplicate-active", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Duplicate active booking", + "description": "An active booking already holds this party's duplicate key. Cancel or complete it before booking again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "booking.duplicate-active", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cancellation/cutoff-passed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cancellation cutoff passed", + "description": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "cancellation.cutoff-passed", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/capability/unmatched", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Capability unmatched", + "description": "The party or backing supply does not carry every capability this offering requires.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "capability.unmatched", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/capacity/exhausted", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Capacity exhausted", + "description": "The supply is fully committed for the requested interval. Choose another time.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "capacity.exhausted", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cursor/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cursor expired", + "description": "This cursor has expired. Start again without a cursor and deduplicate entries by id.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "cursor.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cursor/invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cursor invalid", + "description": "The cursor is invalid for this request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "cursor.invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/eligibility/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Eligibility unavailable", + "description": "A required eligibility check could not run. Retry; do not treat this as permission.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "eligibility.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hold/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hold expired", + "description": "The hold expired before confirmation. Its capacity is bookable again; start a new request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "hold.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hold/released", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hold released", + "description": "The claim is not an active hold. It may already be confirmed or released.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "hold.released", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hook/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hook unavailable", + "description": "A required lifecycle hook could not run, so the request was refused rather than half-applied.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "hook.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/horizon/outside", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Start outside the booking horizon", + "description": "The requested start is earlier than the lead time allows or further ahead than the horizon allows.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "horizon.outside", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/idempotency/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Idempotency window expired", + "description": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "idempotency.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/idempotency/key-reused", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Idempotency key reused", + "description": "This idempotency key was used for a different request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "idempotency.key-reused", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/location/closed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Location closed", + "description": "A closure covers the requested start. Choose a start outside the closure.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "location.closed", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/operation/not-authorized", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Operation not authorized", + "description": "Your current Scheduling authority does not allow this operation.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "operation.not-authorized", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/party/capacity-inadequate", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Party capacity inadequate", + "description": "The party is larger than this offering can ever serve, or its size falls outside the published units.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "party.capacity-inadequate", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/policy/changed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Policy changed", + "description": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "policy.changed", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/precondition/failed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Precondition failed", + "description": "The appointment changed since you loaded it. Reload and try again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "precondition.failed", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/precondition/required", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Precondition required", + "description": "This mutation requires the revision you loaded, or the duplicate key this offering keys on.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "precondition.required", + "httpStatuses": [ + 428 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/prerequisite/missing", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Prerequisite missing", + "description": "The party is missing a prerequisite this offering requires.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "prerequisite.missing", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/profile/not-authorized", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Profile not authorized", + "description": "The selected Scheduling profile does not authorize this request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "profile.not-authorized", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/body-too-large", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Payload too large", + "description": "The request body exceeds the accepted size.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.body-too-large", + "httpStatuses": [ + 413 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Request invalid", + "description": "The request could not be read as a Scheduling request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/method-not-allowed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Method not allowed", + "description": "The route exists but not for this method.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.method-not-allowed", + "httpStatuses": [ + 405 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/not-found", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Route not found", + "description": "The requested route does not exist.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.not-found", + "httpStatuses": [ + 404 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/unprocessable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Request unprocessable", + "description": "The request body could not be processed.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.unprocessable", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/unsupported-media-type", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Unsupported media type", + "description": "The request body is not JSON.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "request.unsupported-media-type", + "httpStatuses": [ + 415 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/resource/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Resource unavailable", + "description": "Every capable member is unavailable for this interval.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "resource.unavailable", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/revision/mismatch", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Revision mismatch", + "description": "The window revision changed before the request committed. Reload the catalogue and try again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "revision.mismatch", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/schedule/unpublished", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Schedule unpublished", + "description": "No published schedule serves that start. Choose a start on the published grid.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "schedule.unpublished", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/service/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Scheduling service unavailable", + "description": "Scheduling storage is unavailable. Try again after the service recovers.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" + }, + "problem": { + "code": "service.unavailable", + "httpStatuses": [ + 503 + ] + } + }, { "uri": "https://id.registrystack.org/profiles/registry-record/v1", "kind": "profile", @@ -2113,6 +2721,24 @@ "mediaType": "application/schema+json" } }, + { + "uri": "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Registry Scheduling runtime configuration", + "description": "Registry Scheduling runtime configuration JSON Schema.", + "source": { + "path": "crates/registry-scheduling/src/schema.rs", + "sha256": "99d43221904847661cf4331d1d5b4fd7c936bbb60ead987509bcb6c3f83b61c5" + }, + "artifact": { + "path": "products/scheduling/generated/runtime/runtime.schema.json", + "sha256": "2158f213b60c1da4498818dc6ac456d937ac965984552a0007ad6bd5dfba4938", + "mediaType": "application/schema+json" + } + }, { "uri": "https://id.registrystack.org/vocab/codelist", "kind": "vocabulary-term", diff --git a/products/scheduling/AGENTS.md b/products/scheduling/AGENTS.md new file mode 100644 index 0000000000..771ea7b2cd --- /dev/null +++ b/products/scheduling/AGENTS.md @@ -0,0 +1,29 @@ +# Registry Scheduling product + +This directory owns the Scheduling product contract, generated API +description, checkpoint examples, and product-local verification scripts. +Read the workspace `AGENTS.md`, this file, and `README.md` before changing the +product. + +- Treat `generated/registry-scheduling.openapi.json` as generated output. + Change `scripts/generate_openapi.py`, then run it and its `--check` mode. +- Treat `generated/runtime/runtime.schema.json` the same way: it is derived + from the strict `RuntimeConfig` types, and the regeneration command is in + `RUNTIME-CONFIG.md`. +- Keep `contracts/security-invariant-matrix.yaml` and + `contracts/security-test-traceability.yaml` scoped to implemented Scheduling + behavior. A mapped test is not evidence that the test ran. +- Preserve the source-neutral boundary: no scheduling crate, directly or + through any shared dependency, reaches a Base Registry Engine, Casework, or + Evidence crate, and none of those products reach a scheduling crate. Run + `scripts/check_dependency_direction.py` after dependency changes. +- Each directory under `examples/` is exactly what `schedulingctl init` writes + for the template with the same name, and the checkpoint fails if either pair + drifts apart. When `schedulingctl init` starts emitting or changing a file, + copy that output into both applicable committed examples in the same commit. +- Keep authored examples and the checkpoint demo small. Do not add a + later-wave surface to close a documentation obligation; record the exclusion + instead. +- PostgreSQL verification requires a separate disposable database and the + `postgres-test` feature. A test binary that skips because its database URL + is absent is not database verification. diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md new file mode 100644 index 0000000000..563f21ae85 --- /dev/null +++ b/products/scheduling/CHANGELOG.md @@ -0,0 +1,49 @@ +# Registry Scheduling changelog + +## Unreleased + +- Publish the scheduling MVP: published openings, exact-time offerings over + interchangeable resource pools, published arrival windows with channel + subquotas, holds, and accountable appointments, over PostgreSQL. The + contract is unreleased and carries no frozen compatibility promise. +- Keep the capacity ledger inside the runtime's own transaction. A hold or + appointment is created, moved, or released only inside that transaction, and + no other product may write the ledger; eligibility stays with the source + system. +- Authorize every commitment with a task grant whose scheduling bounds name + the offering's service, its location, and the action, bounded to 64 + permissions of 32 actions with no wildcard. Only the grant's expiry is + re-checked inside the capacity transaction. +- Document the maintained Casework approval and stock ThunderID exchange path, + and feed its exact exchanged bearer through Scheduling's real authenticator + before the adopter uses it for an appointment. +- Emit each committed scheduling change as a CloudEvents 1.0 event through the + outbox to a configured reminder destination, and keep intents readable in + place when no destination is configured. +- Retain idempotency attempt receipts for the configured period and forget + listing cursors after fifteen minutes. Appointment, history, outbox, and + audit retention are deferred. +- Provide `schedulingctl init`, `check`, `test`, `explain`, and `package`, and + write complete `runtime.example.yaml` and `records.yaml` documents beside + every initialized project. +- Carry the product's own contract checks, security-invariant matrix, and + offline authoring journey under this folder. +- Deliver declared `after` URL observers for confirmed, rescheduled, and + cancelled appointments from a transactionally captured, bounded projection; + refuse conditions, principals, local handlers, and observer proposals. +- Re-check hold expiry after locking its supply during confirmation, so a + delayed confirmation cannot claim capacity that became bookable and was + committed elsewhere after the hold expired. +- Keep arrival offerings as policy references while operators publish their + concrete window capacity through atomic runtime records. Apply location + openings and closures to live window availability and admission, and refuse + a request whose start differs from the selected window. +- Close the beta review races and contract gaps: include exact-time buffers in + locked snapshots, scope duplicate keys to their offering, enforce requested + capabilities, reject cross-offering reschedules, keep cancellations + available during rolling policy changes, prevent occupied resources moving + between pools, replay the winning idempotency receipt, and refuse policy + publications that strand standing commitments. Keep explanation probes, + multi-pattern reopenings, long-range availability, deployment-bound records + updates, ownership checks, and replayed response fields aligned with those + same runtime contracts. diff --git a/products/scheduling/README.md b/products/scheduling/README.md new file mode 100644 index 0000000000..565ce1f9ae --- /dev/null +++ b/products/scheduling/README.md @@ -0,0 +1,269 @@ +# Registry Scheduling + +Registry Scheduling gives a registry a coordinated booking surface: published +openings, exact-time offerings over interchangeable resource pools, published +arrival windows with channel subquotas, holds, and accountable bookings, over +PostgreSQL. The runtime is `crates/registry-scheduling`; the source-neutral +model and evaluators live in `crates/registry-scheduling-core`; adopter +tooling is `crates/registry-schedulingctl` (`schedulingctl`); the bounded Rust +client is `crates/registry-scheduling-client`. + +The contract is unreleased and carries no frozen compatibility promise. + +## Boundary + +Scheduling owns its capacity ledger absolutely. A hold or an appointment is +created, moved, or released only inside the Scheduling runtime's own capacity +transaction, and no other product may write that ledger, directly or through +a shared database. Another product may publish bookable supply for work it +governs, or show an appointment Scheduling holds, without either side gaining +the other's authority: eligibility stays with the source system, and the +authority to commit capacity stays with the task grant Scheduling verifies. + +## What Scheduling is not + +The product is a capacity ledger over anchored supply, and nothing else. It is +not a source of eligibility: whether a party may hold a service is decided by +the system that owns that rule, and Scheduling has no eligibility route. It is +not a queue: nothing here coordinates source-owned work items, which is +Registry Casework's surface. It is not a calendar product: openings, windows, +and closures are published facts an operator anchors, and Scheduling holds no +personal calendar and no invitation lifecycle. It is not a reminder delivery +product: the runtime records due reminder intents and delivers them to one +operator-configured destination, while the message content, the channel, and +the notification bus stay outside Scheduling. + +## Start from an authored project + +Create a starter, inspect its effective configuration, and replay its +fixtures: + +```sh +schedulingctl init ./scheduling --template standalone-exact-time +schedulingctl check ./scheduling +schedulingctl test ./scheduling +schedulingctl explain ./scheduling +``` + +Check reports `complete` or `incomplete` with field-addressed findings. Test +repeats that authoring status and replays the project's bounded synthetic +fixtures offline; a passing report carries the `offline_synthetic` proof +boundary and `productionClosure: false`, so it does not establish source +reachability or deployment readiness. Explain publishes what the runtime would +serve: identity, policy digest, offerings, the operator-published windows with +their subquotas, and the hold policy. + +`schedulingctl init` writes `runtime.example.yaml` and `records.yaml` beside +the policy. The records document contains every location, resource pool, and +arrival window the selected starter needs. Copy the runtime example to +`runtime.yaml`, set its +absolute paths and secret references, and read +[RUNTIME-CONFIG.md](RUNTIME-CONFIG.md) for every block, field, and default. +Then apply the live environment records and start: + +```sh +scheduling --runtime-config "$PWD/runtime.yaml" migrate +schedulingctl records apply "$PWD/runtime.yaml" "$PWD/records.yaml" +scheduling --runtime-config "$PWD/runtime.yaml" serve +``` + +`migrate` applies the schema and binds the database to the policy's +scheduling id, which every later start verifies before it writes anything. +A `serve` that finds a schema older than the one its binary carries refuses +at the readiness check rather than serving against it, so an upgrade runs +`migrate` before it restarts the new runtime; the few minutes in between, +an old runtime may still be serving, and its in-flight availability +continuation cursors answer `cursor.invalid` once the new binary holds +them, so a caller mid-pagination restarts that listing from its first page. +The migration also retains the current policy document beside its digest. On +an upgrade from an earlier schema, start once with the unchanged policy to +backfill that document before publishing a policy change. +`records apply` is the one attributable operator write of a deployment's +environment records: the locations with their time zones, the resource pools +and their members, the published arrival windows, and the dated exceptions +such as closures. The policy references that supply by identifier; it does +not embed it. A records replacement locks standing supply and refuses to move, +remove, or reduce a window below its live bookings and holds; the operator +error names the affected window and deficit. Database +transport security is not configurable in production: the runtime requires +TLS on both connections, and the plaintext escape is a `postgres-test` build +switch documented in [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md). + +The served routes are the published catalogue, availability and the +separately authorized explain read, holds, and appointments with their +reschedule, cancel, and history routes. Every call presents a bearer access +token, and one of three authority profiles decides what it must carry; the +published reference is +[Registry Scheduling API](https://docs.registrystack.org/reference/apis/registry-scheduling/). + +## Local development + +There is no `schedulingctl dev` verb that stands up a runtime instance +directly; this is a tracked exclusion, not an oversight. Local development +runs the demo instead: + +```sh +products/scheduling/demo/run.sh +``` + +which provisions a disposable TLS-enabled PostgreSQL database, a throwaway +signing key and JWKS, migrates and applies example environment records, and +serves the runtime on loopback (see `products/scheduling/demo/README.md`). A +verb over that same provisioning shape does not fit as a surgical addition: +the shape depends on Docker container lifecycle, TLS certificate generation, +and JWT signing infrastructure the demo carries in +`products/scheduling/demo/support/demo.py`, and a resident process supervisor +over a runtime instance is a project-sized effort on its own, comparable in +scope to `crates/registry-evidencectl/src/dev.rs`. + +## Task grants + +Reads take a scope. Every commitment takes a task grant whose scheduling +bounds name the offering's service, its location, and the action, and the +runtime matches all three exactly before the capacity transaction opens. The +bounds, the closed action vocabulary, the value limits, and the one fact the +transaction re-reads are documented in +[TASK_GRANTS.md](TASK_GRANTS.md). Scheduling verifies grants; it does not +approve them, and the approval surface stays with the product that holds the +human relationship. The maintained Casework template and stock ThunderID +exchange path is documented there through an actual appointment request. + +## Events and observers + +The runtime has two outbound integration seams. Reminder dispatch is product +notification work: a commitment mints the reminder intents its offering +declares, each due a fixed number of minutes before the appointment, and a +worker renders every due intent as one +CloudEvents 1.0 event in canonical JSON and POSTs it to the configured +reminder destination with `content-type: application/cloudevents+json`, +exactly once per claim. A transport failure schedules an exponential retry, a +refusal the retry classes do not cover holds the intent for the operator, and +eight failed attempts hold it. + +Every event carries the same envelope: + +| Field | Value | +| --- | --- | +| `specversion` | `1.0` | +| `id` | the outbox intent's identifier | +| `source` | the deployment's `scheduling.id` from the policy, and nothing else | +| `type` | `org.registrystack.scheduling.` plus the intent's purpose | +| `time` | when the intent fell due, RFC 3339 | +| `data` | the payload the runtime committed | + +There are four purposes, so four types: `confirmation` and `change` carry the +committed appointment's identifier, revision, offering, start, end, and the +policy revision it was committed under; `cancellation` carries the +identifier, revision, and reason; `reminder` carries the identifier, +revision, offering, start, and how many minutes before the start it was +minted for. + +With no reminder destination configured, the intents stay readable in place: +they are written to the outbox and marked local, never pretended delivered. +Adopting the product does not require wiring a notification bus first. + +Appointment observers are governed hooks. A policy may declare `phase: after` +with a URL handler for exactly three triggers: + +| Trigger | Allowed projected fields | +| --- | --- | +| `appointment.confirmed` | `appointmentId`, `revision`, `offering`, `start`, `end`, `state`, `policyRevision` | +| `appointment.rescheduled` | `appointmentId`, `revision`, `offering`, `start`, `end`, `state`, `policyRevision` | +| `appointment.cancelled` | `appointmentId`, `revision`, `state` | + +The runtime refuses conditions, principals, Rhai or Wasm handlers, unknown +triggers, and fields outside that table. It captures one canonical event and +its delivery row in the same transaction as the appointment change, then an +after-commit worker POSTs it to the deployment-bound destination. Delivery is +at least once, HMAC-SHA256 signed, retried within fixed product ceilings, and +audited without payload values. A receiver response is observation only: +Scheduling deterministically refuses every proposal and never turns it into a +booking, reschedule, or cancellation. + +The hook id becomes the event `type`. The source is +`urn:registrystack:scheduling:`, the subject record reference is +`/v1/appointments/`, and `data` contains only the requested +fields. `state` is the public value `confirmed` or `cancelled`; actor identity, +task grants, resource and channel identifiers, duplicate keys, and cancellation +reasons cannot be projected. Retained payloads expire after +`retention.hookPayloadDays`. Operator replay is not exposed in this slice. + +## Phase 1 scope and exclusions + +Phase 1 is one booking surface over anchored supply. It excludes, as tracked +exclusions rather than accidents: + +- Reassignment: no route moves an appointment to another party, and no + evaluator takes a substitute recipient. +- Attendance routes: no check-in, arrival, or fulfilment surface exists, and + the runtime records nothing about whether a party attended. +- Closure routes: closures are environment records the operator applies, and + no route creates or lifts one. +- Remediation workflows: a refused or expired commitment ends at its problem + answer, and nothing reopens it. +- Reports: no occupancy, utilization, or waiting-list reporting surface. +- Guest booking: a holder other than the verified caller cannot book on + someone's behalf. Every commitment's grant names the caller, and the party + a request carries is the party the caller commits. + +`externalReferences` and the integration seams that would attach a Scheduling +appointment to another product's record are Phase 4 work. They are absent from +the wire types today, and adding them is a contract change, not a fill-in. + +## Arrival windows and channel subquotas + +A published window carries a total unit budget and, optionally, subquotas +keyed by booking channel. A subquota is a per-channel ceiling, not a reserved +floor: the evaluator checks the named channel's allocation against its +subquota, then checks the request against the window's published total, so an +unclaimed subquota stays available to the window's other channels up to that +total. A channel the policy declares but gives no subquota draws from the +total alone, and a request naming an undeclared channel is refused. + +Nothing in the current grammar reserves capacity for a channel. Reserved +floors are an open Phase 2 product question: they change what "the last unit" +means for a walk-in, and they are not a bug to fix here. + +## Product material and gates + +This folder holds the product's own contracts, examples, fixtures, and gates. +The database-free checkpoint runs the contract checks, the dependency-direction +gate, and the whole offline authoring journey: + +```bash +cargo build --locked -p registry-schedulingctl +products/scheduling/scripts/check-checkpoint.sh +``` + +`examples/standalone-exact-time/` is exactly what +`schedulingctl init --template standalone-exact-time` writes, and the +checkpoint fails if the two drift apart. The runtime configuration schema is +derived from the strict Rust types, never hand-edited; its generator command +is in [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md). + +For PostgreSQL execution, the destructive test suites require a separate +disposable database and the `postgres-test` feature: + +```bash +SCHEDULING_TEST_DATABASE_URL=postgres://user:password@host/database \ + cargo test --locked -p registry-scheduling --features postgres-test \ + --test postgres_commitments +``` + +`SCHEDULING_TEST_DATABASE_URL` names that disposable database for both +PostgreSQL suites, `registry-scheduling`'s commitment suite and +`registry-schedulingctl`'s records-apply suite. Both reset the `public` +schema, so never point either at retained operator data, and never point both +at one database. A test binary that skips because its database URL is absent +is not database verification. + +## Dependency boundary + +No scheduling crate, directly or through any shared dependency, may reach a +Base Registry Engine, Casework, or Evidence crate, and none of those products +may reach a scheduling crate. The restriction is scoped to the MVP, because +the crates that would compose across those boundaries do not exist yet; the +workspace `AGENTS.md` records what changes when one does. The source-neutral +core sits at the bottom of the product depending on no other scheduling +crate, and the client shares the core without ever linking the runtime or +adopter tooling. diff --git a/products/scheduling/RUNTIME-CONFIG.md b/products/scheduling/RUNTIME-CONFIG.md new file mode 100644 index 0000000000..fd3027b557 --- /dev/null +++ b/products/scheduling/RUNTIME-CONFIG.md @@ -0,0 +1,134 @@ +# Scheduling runtime configuration + +Scheduling reads one versioned operator document selected with +`scheduling --runtime-config ABSOLUTE_FILE serve` or `migrate`. The selected +file path and every operated resource path are absolute. Local development +tooling may resolve paths before it writes the file. + +The closed envelope is: + +```yaml +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +``` + +`package.root` selects one directory. The runtime always loads +`package.root/scheduling.yaml`; no second project selector can override it. +With `listener.tlsTermination: operator-controlled-upstream`, the directory +must also contain a matching `scheduling.package.json`. Development loopback +may select an authored project directory without that manifest. + +`listener` is required. `listener.bind` is one numeric socket address, +including bracketed IPv6 forms, and defaults to `127.0.0.1:8105` when omitted +from the listener block. `listener.tlsTermination` is required. Use +`operator-controlled-upstream` behind an operator-managed TLS edge or +`development-loopback` for direct local development, which the runtime refuses +on any non-loopback bind. `listener.networkExposure` defaults to +`private-address`; `container-private` permits an unspecified bind only for a +listener kept on a private container network. A public unicast address is +refused under every combination: Scheduling does not serve a public interface +by itself. + +`secretProviders` explicitly enables each accepted reference form. Declare +`file: {root: ABSOLUTE_DIRECTORY}` before using `secret:file/name`. Declare +`environment: {}` before using `secret:env/NAME`. The runtime does not fall +back from one provider to another. The maintained example uses mounted files. + +A secret reference is `secret:env/NAME` with an uppercase environment name, or +`secret:file/name` with a lowercase file name, each of at most 128 bytes. A +resolved value is non-empty text of at most 64 KiB without NUL bytes. A secret +file must be a regular file owned by the runtime user, with mode 0400 or 0600 +and exactly one hard link; `openssl rand -hex 32` generates the audit key. + +`database.runtimeUrlRef` supplies the least-privilege service connection and +`database.migrationUrlRef` supplies the operator-run migration connection. +Transport security on the database connection is not optional: Scheduling sets +`sslmode` to `Require` on both connections and refuses a connection it cannot +protect. `database.trustedRootCertificateRef` is optional and selects the PEM +bundle of a private CA in front of PostgreSQL. The one plaintext escape is +`database.testOnlyPlaintext: true`, and only a build carrying the +`postgres-test` feature accepts it; a production build refuses the setting at +startup instead of opening an unencrypted connection. A test build that skips +because its database URL is absent is not database verification, and the +escape never turns a deployed runtime into a plaintext client. + +`authentication.oidc` requires `issuer` and `audience`. `jwksSource` defaults +to discovery and can instead select a static `documentRef`, which does no +rotation of its own: rolling a key means replacing the referenced document and +restarting Scheduling. `jwksUri` overrides the discovery document's JWKS +address. `scopeClaim` defaults to `registry_scopes` for compatibility with +existing deployments; stock ThunderID emits `scope`, so the maintained example +and `schedulingctl init` set that explicit override. `readsScope` defaults to +`scheduling-read` and `explainScope` to `scheduling-explain`; the two must +differ, because the explain path can name member-level causes the public +availability read never discloses. `allowedClients` lists the exact client ids +the runtime admits. A deployment whose `listener.tlsTermination` is +`operator-controlled-upstream` must name them: an empty or absent list is a +startup refusal there, because a forgotten field would otherwise admit every +client the issuer verifies, including an application in the same realm that has +nothing to do with booking. Development loopback keeps the empty-means-any +convenience, since it is not a deployment an unrelated client can reach. + +`assertionIssuers` maps a client id to the assertion authorities that client may +exchange a subject token from. A deployment that performs no token exchange +leaves it empty, and an exchanged token whose authority no entry declares is +refused. Once a client is listed, a token it exchanged is accepted only for one +of that client's declared authorities, so an assertion minted by an unrelated +authority the issuer happens to federate cannot become a booking credential +here. + +Scopes authorize reads only. Every commitment takes its authority from a task +grant instead, which [TASK_GRANTS.md](TASK_GRANTS.md) documents. + +`audit.path` is the absolute JSONL journal path. `audit.hashKeyRef` supplies +its keyed-chain secret. Every commitment and every authorization refusal is +journaled there, and the publication loop is the one worker whose lag bounds +the record's durability. An expired hold writes its history entry as +`system` and no audit row. + +`destinations` is optional. `destinations.reminders` is the one place due +reminder intents are delivered, as CloudEvents 1.0 events over HTTPS POST with +`bearerTokenRef` as the optional bearer secret. The URL is one reviewed +absolute endpoint without userinfo, query, or fragment; plain `http` is +accepted only for an explicit loopback host, so a plaintext destination can +never silently point at another machine. Delivery is attempted once per claim +with exponential backoff from half a minute capped at an hour; a transport +failure schedules a retry, an authorization or routing refusal holds the +intent as failed for the operator, and eight failed attempts hold it. An +absent reminders destination is a supported deployment: the intents stay +readable in the outbox and are marked local, never pretended delivered. The +README documents the envelope and the event types. + +`destinations.hooks` binds the logical destination ids named by policy hooks to +deployment-owned URLs and HMAC-SHA256 keys. Each entry requires `url` and +`hmacSha256KeyRef`; `attemptTimeoutMilliseconds` defaults to 5000 and is bounded +from 100 through 10000, while `maximumAttempts` defaults to 8 and is bounded +from 1 through 20. URLs use the same HTTPS and explicit-loopback rules as the +reminder destination. A signing key must contain at least 32 bytes. Every +destination the current policy names must be configured. Explicit extra +bindings are allowed so retained events captured under an earlier policy can +finish with their exact original binding. Startup refuses if a retained event's +binding is no longer available. + +`retention.attemptReceiptDays` covers idempotency attempt receipts; listing +cursors keep their fixed fifteen-minute lifetime. `retention.hookPayloadDays` +sets the canonical observer payload's retry and dead-letter lifetime from 1 +through 30 days. Both configured values default to seven days, which is not a +jurisdictional recommendation. Appointment, history, reminder outbox, and +audit retention remain deferred. An idempotency receipt past its period is +erased, so an exact retry after expiry answers `idempotency.expired` with HTTP +410 instead of replaying the first answer. + +See the complete maintained +[`runtime.example.yaml`](examples/standalone-exact-time/runtime.example.yaml), +which is exactly what `schedulingctl init` writes beside an authored project, +and the generated editor schema at +[`generated/runtime/runtime.schema.json`](generated/runtime/runtime.schema.json). +Regenerate the schema from the owning Rust type with: + +```sh +cargo run --locked -p registry-scheduling --features schema \ + --example runtime-schema -- --output products/scheduling/generated/runtime +cargo test --locked -p registry-scheduling --features schema \ + schema::tests::committed_runtime_schema_matches_generated_bytes +``` diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md new file mode 100644 index 0000000000..b6f49d6f6f --- /dev/null +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -0,0 +1,282 @@ +# Registry Scheduling security review notes + +Review notes for the security-sensitive Scheduling changes, held in tracked +material because commit messages do not survive a squash. Each section names +the change by its subject, the threat it answers, the defaults it ships, and +the tests that pin it. The security-invariant matrix in `contracts/` is the +row-per-invariant baseline; this file is the narrative behind the decisions +and the deferrals the matrix records. + +## The task-grant bounds in the platform + +The change `feat(auth): add scheduling bounds to task grants` carries its +review record in the `registry-platform-oidc` crate README (threat, wire +format, issuer and verifier compatibility, test evidence). It is not +restated here. In short: `GrantBounds::Scheduling` is a closed tagged union +with `deny_unknown_fields`, cardinality and length bounds (64 permissions, +32 actions each, 512-byte service and location values, unique +service-and-location pairs), and a `Debug` implementation that redacts +every value. A scheduling grant parsed by the BReg binding is refused, and +a test in `registry-breg` pins that refusal. + +## The runtime edge: authentication, audit, listeners, and defaults + +The change `feat(scheduling): serve the runtime edge with auth, workers, +and schema` introduced the HTTP edge, the authenticator, the audit +pipeline, the worker supervision, the listener policy, and the retention +defaults. AGENTS.md requires review notes for changes of this class; these +are they. + +### Threat + +A Scheduling deployment answers over HTTP to callers holding bearer +tokens, commits capacity inside PostgreSQL transactions, and writes an +audit journal. The threats this surface answers: + +1. **A caller without a complete task grant books capacity.** The edge + authenticates the bearer token against a pinned JWKS, requires the + profile scope for every read and a separately configured explain scope + for explanations, and requires a complete scheduling grant (service, + location, actions) for every commitment. A partially formed grant set + is refused as a credentials problem, never honoured partially. +2. **A token that was valid at the door commits after its grant lapsed.** + The grant's expiry is re-checked inside the capacity transaction, + immediately before the claim commits, against a fresh observation of + the store's clock taken after every lock wait, not against the + request's own entry time: a transaction delayed by contention cannot + carry a lapsed grant to a commit. The full bounds are matched once + at the service before the transaction opens; re-reading them inside + would only defend against narrowing at the issuer, which needs a + revocation path this milestone does not have (matrix deferral + SCHEDULING-DEF-01, recorded in the published reference). +3. **A deployment is reached over a public interface by accident.** The + listener must bind a private address or an explicitly declared + container network; anything else refuses to start. +4. **A commitment leaves no accountable trace.** Every ledger-decided + commitment and refusal writes an audit row with a pseudonymized + principal, and the audit rows chain by hash. A permission mismatch + refused at the service, before the transaction opens, writes one too, + so probing grant scope against the edge is visible in the journal; the + answer itself stays the same closed refusal, naming neither the failing + bound nor whether a grant was carried. The two changes that made that + true are recorded below under "The audit journal". +5. **A refusal discloses supply or another caller's booking.** Admission + refusals project through a public code; the member-level cause stays + behind the separately authorized explain path. + +### Defaults + +- The database connection requires TLS (`SslMode::Require`); the plaintext + escape exists only behind the `postgres-test` feature for disposable + test databases. +- The listener refuses public addresses absent an explicit container + network declaration. +- Every answer carries `Cache-Control: no-store`, the security headers + without HSTS (TLS termination is the deployer's), a 1 MiB body ceiling, + and the caller's trace identifier when a trace is presented. +- Retention defaults (attempt receipt days, cursor minutes) are + placeholders: a jurisdiction must approve real retention periods before + production use. Retention sweeps idempotency attempt receipts and + cursors only; appointments, history, outbox rows, and audit rows are + never swept in this milestone (SCHEDULING-DEF-06, recorded in + `RUNTIME-CONFIG.md`). +- Reminder intents with no configured destination stay local and readable + in place; nothing is delivered by default. + +### Test evidence + +`crates/registry-scheduling/src/auth.rs` pins the partial-grant refusal, +the foreign-resource profile refusal, the unknown-key credential refusal, +and the explain scope separation. `crates/registry-scheduling/src/config.rs` +pins the listener policy, the static JWKS shape (unique named asymmetric +keys), the verified-package requirement in production, and that typed parse +paths never echo a rejected value. `crates/registry-scheduling/src/http.rs` +pins the problem rendering of the closed vocabulary, framework rejection +normalization, no-store and trace stamping, and the idempotency key bounds. +The PostgreSQL suite in `crates/registry-scheduling/tests/` pins the edge +refusing unauthenticated callers and unauthorized mutations end to end. + +## The reschedule exclusion + +The change `fix(scheduling): keep the reschedule exclusion inside the +runtime's transaction` removed the caller-settable `rescheduleOf` from the +wire admission request and made the exclusion an evaluator argument that +only the reschedule transaction supplies, from the appointment row it +locks. Before it, naming any live claim's identifier on a create path +excluded that claim from every capacity check; the review classed it a +merge blocker. The threat, the wire change, and the test evidence are +recorded in that change's own notes; the standing invariant is matrix row +SCHEDULING-SEC-17. + +## The audit journal: what it records, and in what order + +Two changes to `crates/registry-scheduling/src/service.rs` and +`crates/registry-scheduling/src/store.rs` moved the journal from partly +accountable to accountable. Both are security-sensitive in the sense +AGENTS.md names, so the record belongs here rather than only in a commit +message. + +**A permission refused before the transaction is audited.** *Threat:* the +permission check answered `operation.not-authorized` and returned without +writing anything, so a caller probing which services, locations, and +actions a grant carries could enumerate the edge and leave no trail. Every +refusal the ledger decided was recorded; every refusal decided above it +was not. *Default:* both refusal paths, the grantless case and the +scope-mismatch case, write an authorization record before they answer. The +answer is unchanged, so what changes is the record and not the disclosure. +*Test:* `a_permission_refused_before_the_transaction_writes_its_audit_row`, +`crates/registry-scheduling/tests/postgres_commitments.rs`. This retires +deferral SCHEDULING-DEF-02 and promotes matrix row SCHEDULING-SEC-14 to +`enforced`. + +**The journal is published in the order it became visible.** *Threat:* the +audit outbox carried no ordering column, and the query feeding the +publisher ordered by a random version 4 event identifier while its own +documentation said oldest first. The publisher appends what that query +returns to a hash chain, so the chain was internally consistent and +attested to a sequence the deployment never had. A chain that certifies +the wrong order is worse than no chain, because it is believed. +*Default:* the outbox carries its own recorded sequence, the pending query +reads in it, and the chain continues across a restart from its verified +tail rather than from whatever the first query after startup returns. The +sequence is allocated when a row is written, not when its transaction +commits, so two concurrent transactions can publish in the opposite order +from their sequence numbers; the chain attests to publication order, and +no claim is made about insertion-sequence order between transactions that +committed in the other order. +*Tests:* `the_audit_journal_is_read_in_the_order_it_was_written` and +`the_audit_chain_continues_across_a_restart`, +`crates/registry-scheduling/tests/postgres_commitments.rs`. + +## The runtime edge hardening + +Nine changes to the authenticator, the configuration reader, and the HTTP +edge, in the file's threat / default / test shape. + +**A. Only the RFC 9068 access-token type is admitted.** *Threat:* the +accepted `typ` set included plain `JWT`, so an identity token, or any +other JWT the same issuer signs for another audience, satisfied the +access-token profile. *Default:* `at+jwt` and its case variants only; +there is no configuration key to widen it. *Test:* +`the_access_token_profile_admits_only_the_rfc_9068_pair`, +`crates/registry-scheduling/src/config.rs`. + +**B. A production deployment names the clients it admits.** *Threat:* an +absent `allowedClients` admitted every client the issuer verifies, so any +application in the issuer's realm could reach a Scheduling deployment. +*Default:* a non-loopback deployment refuses to start without a named +client list; development loopback stays permissive because it is not a +deployment an unrelated client can reach. *Test:* +`a_production_deployment_must_name_the_clients_it_admits`, +`crates/registry-scheduling/src/config.rs`. + +**C. Exchanged credentials are bound to declared assertion authorities.** +*Threat:* a token exchanged from a third-party assertion was accepted on +the strength of the issuer the token itself named, so any authority the +identity provider would exchange from could reach the deployment. +*Default:* an `assertionIssuers` map declares each accepted authority and +the client that may exchange from it; an undeclared authority is refused, +and the map is bounded in the runtime JSON Schema. *Tests:* +`an_exchanged_token_is_refused_until_its_authority_is_declared` +(`auth.rs`), `a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it` +and `an_assertion_issuer_map_outside_its_bounds_is_refused` +(`config.rs`). + +**D. An unreadable clock refuses to judge an expiry.** *Threat:* the epoch +conversion was unwrapped, so a clock behind the epoch produced a timestamp +against which every grant compared as unexpired. *Default:* fail closed; a +clock the runtime cannot read is an authentication refusal, not a pass. +*Test:* +`an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it`, +`crates/registry-scheduling/src/auth.rs`. + +**E. A verifier that reached no verdict answers unavailable, not a +challenge.** *Threat:* a JWKS or issuer outage was rendered as 401, which +tells a caller their credential is bad when the deployment could not check +it, and invites a credential rotation that fixes nothing. *Default:* +infrastructure failure answers 503 with `Retry-After`; only a verdict of +refused answers a challenge. *Tests:* +`a_key_endpoint_outage_is_unavailable_not_a_refusal`, +`only_a_verifier_that_reached_no_verdict_is_unavailable` (`auth.rs`), +`a_verifier_that_cannot_answer_is_unavailable_not_a_challenge` with its +negative `a_refused_credential_still_answers_a_challenge` (`http.rs`). + +**F. Caller strings are bounded at the edge, at the authoring grammar's +own bounds.** *Threat:* every caller string an admission or cancellation +carries is stored verbatim in the ledger entry, the idempotency receipt, +and the audit journal. Under the 1 MiB body allowance a credentialed +caller could write a megabyte into each, and an unbounded duplicate key +could be varied at will to slip the duplicate guard. *Default:* the bounds +are the policy grammar's, not new numbers, because a value outside them +could never have matched anything the deployment published; the +cancellation reason is bounded to the column that stores it, so an +over-long reason is `request.invalid` rather than a database refusal +surfacing as `service.unavailable`. *Tests:* +`an_admission_request_is_bounded_before_it_reaches_the_store`, +`a_cancellation_reason_is_bounded_below_the_column_that_stores_it`, +`a_listing_offering_is_bounded_the_same_way_a_body_is`, all +`crates/registry-scheduling/src/http.rs`. The standing invariant is matrix +row SCHEDULING-SEC-19, which replaced deferral SCHEDULING-DEF-03. + +**G. A startup refusal says where and why without repeating the value.** +*Threat:* an operator could not find the member that failed, and the +obvious fix, putting serde's reason in the message, would echo +operator-authored text into the startup log, where a runtime configuration +names secret references, database URLs, and destinations. *Default:* the +message names the member path (a document refused whole is reported at +`/`), carries serde's reason with its line and column, and strips the +offending value out of any `invalid type:` or `invalid value:` clause, +keeping only the shape word. *Tests:* +`a_runtime_document_refused_whole_is_reported_at_the_root`, +`a_refused_value_never_survives_the_clause_that_names_it`, +`a_runtime_document_the_reader_stops_on_names_the_line_and_column`, +`a_rejected_runtime_member_names_the_cause_without_the_value`, +`a_refused_authored_policy_names_the_member_and_the_cause` (all +`config.rs`), plus the standing +`typed_parse_path_does_not_echo_the_rejected_value` +(SCHEDULING-SEC-13). + +**H. The package manifest is a different document from the policy it +identifies.** *Threat:* one `apiVersion` and `kind` pair named both +documents, so neither reader could refuse the other's document on its +declared names, and the package identity digest folded in a pair that did +not describe it. *Default:* the manifest declares its own pair, which the +identity digest binds; a document carrying the authored policy's names +never verifies as a package identity. *Tests:* +`a_manifest_wearing_the_authored_policy_names_is_refused` with +`a_package_manifest_is_a_different_document_from_the_policy_it_identifies`, +`crates/registry-scheduling/src/config.rs`. + +**I. Client tolerance is response-only.** *Threat:* relaxing client-side +parsing so a client outlives a deployment that adds a member would, if +applied to the wrong half, let a caller smuggle a field the server does +not declare. *Default:* response shapes tolerate unknown members and +problems are matched on the code alone, never on title or detail text; +request shapes stay strict. *Tests:* +`answer_documents_tolerate_a_member_a_later_deployment_added` and +`request_documents_refuse_a_member_they_do_not_declare` +(`crates/registry-scheduling-core/src/wire.rs`), +`a_reworded_title_or_detail_is_still_the_problem_the_code_names` +(`crates/registry-scheduling-client/src/error.rs`). + +## Known deferrals + +The matrix records four deferrals with their compensating controls. +SCHEDULING-DEF-01 is stated in threat 2 above; the other three are +restated here as the index the matrix's `recordedIn` points at: + +- **SCHEDULING-DEF-04, the channel is not bound to the verified caller.** + A caller entitled to book may choose which subquota it draws from. The + policy declares a closed channel set and every subquota is a ceiling + inside the published total, so a mis-chosen channel cannot oversell the + window. Binding the channel to the verified client is a product + decision for a later phase. +- **SCHEDULING-DEF-05, no database backstop under the duplicate-key + guard.** The guard runs inside the same capacity transaction as the + claim it protects, so a concurrent pair cannot straddle it; the + supporting index is partial rather than unique. +- **SCHEDULING-DEF-06, retention scope.** Recorded in + `RUNTIME-CONFIG.md`, which states plainly what the sweeps cover. + +A change that closes one of these promotes the matrix entry in the same +commit and rewrites this section with it. diff --git a/products/scheduling/TASK_GRANTS.md b/products/scheduling/TASK_GRANTS.md new file mode 100644 index 0000000000..cc2d14861f --- /dev/null +++ b/products/scheduling/TASK_GRANTS.md @@ -0,0 +1,220 @@ +# Task grants for scheduling commitments + +A task grant gives an institutional agent bounded authority to commit +Scheduling capacity under its own principal. It does not make the agent the +human who approved the task, it carries no catalogue or availability read +authority, and it never decides eligibility: whether a party may hold a +service remains with the source system that owns the rule. + +The three authority profiles are disjoint. A token carrying a product scope +does not thereby gain a commitment path, and a task grant does not by itself +grant a catalogue read. Scopes authorize reads and the separately configured +explain scope authorizes the diagnostic read; every commitment takes its +authority from the grant alone. One access token may carry both profiles, but +each operation still checks only its own authority. + +## The claim set + +A commitment requires no product scope. Its authority is the grant inside the +access token, whether or not that token also carries read scopes: the six +`registry_grant_*` members (`registry_grant_id`, +`registry_grant_source_issuer`, `registry_grant_client`, +`registry_grant_resource`, `registry_grant_exp`, `registry_grant_bounds`), +plus `registry_purpose` and `registry_approver`. A token carrying none of +them is an ordinary standing token; a token carrying some of them is refused +as a malformed credential rather than honored partially. + +Before the caller reaches the service, the runtime binds the grant to two +facts it verified itself: the grant's client must equal the client the +deployment verified (`azp` or `client_id`), and the grant's resource must +equal this deployment's configured OIDC audience. A grant minted for another +product's resource is a profile refusal here, and the closed union of bounds +makes it one in every verifier: an unknown bounds tag is a malformed claim, +so a grant minted for one product can never be interpreted by another +product's runtime. + +## The scheduling bounds + +The bounds are `{"type": "scheduling", "permissions": [...]}`. One permission +names a service, a location, and the actions it allows there: + +```json +{ + "type": "scheduling", + "permissions": [ + { + "service": "registry-update", + "location": "bangkok-counter", + "actions": ["appointment.create", "appointment.reschedule"] + } + ] +} +``` + +The action vocabulary is closed, and it is the vocabulary the runtime knows: + +- `hold.create` and `hold.release` +- `appointment.create`, `appointment.reschedule`, and `appointment.cancel` + +The bounds a signed claim may carry are bounded exactly as BREG's are: + +- at most 64 permissions, each a unique `(service, location)` pair; +- at most 32 actions per permission, each at most 128 bytes; +- service and location values of at most 512 bytes, without whitespace, + control characters, or a `*`. + +Wildcards are refused, unknown members are refused at every level rather than +ignored, and duplicate actions or duplicate pairs are refused. Nothing about a +permission is optional: an empty `actions` list is malformed, not a grant to +read. + +An issuer may mint the `scheduling` tag only once every verifier it targets +understands it. A verifier built before the tag existed rejects the whole +grant as malformed, which is the intended fail-closed direction; an operator +rolling a verifier back past that point needs to know it. The review record +in `crates/registry-platform-oidc/README.md` carries the threat model and the +release note to update when the first accepting release is cut. + +## How the runtime matches a permission + +A permission matches an offering only when its service equals the offering's +service, its location equals the offering's location, and its action list +names the operation the route performs. The match is exact: a broader service +or location never covers a narrower one, and no rule maps one location onto +another. A grant that does not cover the request answers +`operation.not-authorized` and nothing is written. + +The bounds are matched once, at the service, before the capacity transaction +opens. Inside the transaction, immediately before the claim commits, exactly +one fact is read again: the grant's own expiry, against the clock that +commitment is decided under. A grant that expired between the door and the +commit cannot take capacity, and the attempt answers +`operation.not-authorized` like any other authority refusal. + +The bounds themselves are not read again at that point, and this milestone +has no revocation check, so a grant narrowed or withdrawn at the issuer after +its token was minted keeps the authority its token carries until the token or +the grant deadline passes. Keep grant deadlines short. Re-checking the full +bounds inside the capacity transaction is a recorded deferral, not a promise +this milestone keeps. + +## What the audit journal records + +Every commitment and every refused commitment writes one authorization record +under `authorization.allowed` or `authorization.refused`, and so does a +permission the service refuses before any commitment is reached. The principal, +client, grant, and approver are keyed pseudonyms scoped to the request's +reference class, the purpose is recorded as presence only and never as a +value, and the record adds the grant's source issuer and its deadline. No +bound value, service, location, or action appears in the journal. + +## Where a grant comes from + +Scheduling verifies grants; it does not approve them. A grant is minted by the +token-exchange issuer from a template a current human holder approved, and the +approver's identity travels in the claim. Casework's maintained task authority +accepts Scheduling bounds directly, and stock ThunderID's institutional grant +exchange copies those verified bounds into the access token. Scheduling is a +relying party of that authority and inherits no Casework authorization. +The maintained native boundary fixture passes that exact stock-issued bearer, +its public JWKS, and bounded deployment identity over stdin to Scheduling's +real `SchedulingAuthenticator`; the credential never enters argv or test logs. + +The Casework project declares the exact destination in a governed template: + +```yaml +taskTemplates: + - id: schedule-registry-update + version: "1" + label: Book a registry update + eligibleTeams: [registry-review] + eligibleProfiles: [staff] + source: registry-requests + itemKinds: [request] + itemStates: [claimed] + agent: + issuer: https://identity.example.test/realms/registry + subject: scheduling-agent-service-account + client: scheduling-agent + resource: urn:example:scheduling + scopes: [scheduling-read, scheduling:commit] + purpose: schedule-registry-update + bounds: + type: scheduling + permissions: + - service: registry-update + location: bangkok-counter + actions: [appointment.create] + subjects: + subject_reference: subject-reference + lifetimeSeconds: 900 +``` + +`scheduling:commit` is an explicit scope registered at the exchange issuer. The +Scheduling runtime derives mutation authority from the grant bounds rather than +that scope. `scheduling-read` is the starter runtime's default read scope and +lets the same short-lived token select a currently published free start. + +Register `scheduling-agent` as a Casework `taskExchange` service client, and +register `urn:example:scheduling` plus those scopes at the shared issuer. In the +Scheduling runtime, use that issuer and audience, admit the client, and bind it +to the Casework task authority: + +```yaml +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + scopeClaim: scope + allowedClients: [scheduling-agent] + assertionIssuers: + scheduling-agent: [https://casework.example.test/task-authority] +``` + +After a current holder previews and approves the template, put the approved +grant UUID into the existing Casework development exchange path. Its connection +entry selects the Scheduling destination, not policy fields: + +```yaml +clients: + scheduling-agent: + assertionKeyFile: /absolute/casework/.casework/dev/credentials/scheduling-agent/assertion-key.jwk + resource: urn:example:scheduling + scopes: [scheduling-read, scheduling:commit] +``` + +```sh +caseworkctl dev grant scheduling-agent \ + --grant APPROVED_UUID \ + --connection /absolute/scheduling-connection.yaml \ + /absolute/casework +``` + +The command reports an owner-only header file and the immutable grant deadline. +Use that header to read a free start, then book it. Set `START` and +`POLICY_REVISION` from the availability response: + +```sh +curl --fail-with-body \ + -H "$(cat "$HEADER_FILE")" \ + "$SCHEDULING_URL/v1/availability?offering=registry-update-30" + +curl --fail-with-body -X POST \ + -H "$(cat "$HEADER_FILE")" \ + -H 'content-type: application/json' \ + -H 'idempotency-key: approved-registry-update-1' \ + --data @- "$SCHEDULING_URL/v1/appointments" <- + Behaviour a reader could reasonably mistake for an oversight, recorded as a + deliberate choice with the reasoning that settled it. A later change may + overturn any of these, but it overturns the entry in the same commit rather + than rediscovering the question. +decisions: + - id: SCHEDULING-DEC-01 + subject: Releasing a hold whose lifetime has passed answers 204 + decision: >- + A release names the hold's state, not its clock. The release path admits + any claim that is still a hold in the active state and closes it as + released, so a hold whose lifetime has passed but whose row the sweeper + has not yet touched releases cleanly and answers 204 No Content. + reasoning: >- + Consumption is a live predicate, never a stored counter: every capacity + and quota query counts a hold only while its state is active and its + expiry is still ahead of the transaction's clock. An expired hold has + therefore already stopped consuming capacity everywhere capacity is + computed, whether or not any process has written the expired state yet. + Releasing it can return nothing, because nothing is outstanding, so + there is no second decrement to protect against. Answering a refusal + instead would tell the caller to keep a hold it has correctly finished + with, and would make the caller's answer depend on whether a background + sweep happened to have run. + alsoTrue: >- + A release carries no caller-chosen idempotency key: the hold's own + identifier is the key, so a retried release replays the first answer. A + hold that does not exist, or one no longer in the active state, answers + hold.released rather than a not-found, which keeps the answer the same + whether the identifier was never valid or was merely finished. A hold + belonging to another caller is refused inside the transaction, where the + refusal is audited. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_expired_hold_returns_capacity_and_refuses_confirmation} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_release_returns_capacity_answers_replay_and_blocks_confirmation} + - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} + - id: SCHEDULING-DEC-02 + subject: Cancellation never answers policy.changed + decision: >- + The cancel path does not read the policy revision at all. It is guarded + by the observed appointment revision, the caller's ownership, and the + cancellation cutoff, and by nothing else. + reasoning: >- + Cancellation releases capacity; it evaluates no admission and resolves no + offering against a policy, so there is no policy-shaped decision left to + fence. An appointment stays cancellable under whatever policy currently + governs its offering. The alternative traps a caller in a booking it + wants to shed because the operator republished the policy underneath it, + which is a worse outcome for both sides than letting the seat go back. + contrast: >- + Reschedule does check, and deliberately lets the policy guard outrank the + revision guard, because a reschedule re-evaluates admission against the + policy. Hold creation, direct appointment creation, and hold confirmation + check for the same reason. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} + - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} + - id: SCHEDULING-DEC-03 + subject: The commit-error projection lives in the service, not beside the enum + decision: >- + CommitError is declared in the runtime's store module, where the + transaction raises it, and the single projection from it to a public + problem code lives in the service module, in one exhaustive match with no + wildcard arm. + reasoning: >- + The projection is a disclosure decision, and disclosure decisions belong + on the layer that answers the caller. The store layer raises what went + wrong; the service layer decides what the caller is allowed to learn from + it, which is the same layer that projects an admission refusal through + its public code and decides which refusals are audited. Moving the + projection next to the enum would put a caller-facing choice in the + module that talks to PostgreSQL. The source-neutral core stays free of + every HTTP library and owns only the numeric status pinned to each + problem code, so one binding cannot answer a different status from + another. + safeguard: >- + The match is exhaustive over a closed enum that is not marked + non-exhaustive, so a tenth variant fails the build rather than falling + through to a wrong status, and there is exactly one projection site. + evidence: + - {path: crates/registry-scheduling/src/service.rs, name: commit_errors_project_to_their_public_codes} + - {path: crates/registry-scheduling/src/service.rs, name: problem_receipts_carry_the_pinned_problem_without_a_trace} + - id: SCHEDULING-DEC-04 + subject: Subquotas are ceilings, not reserved floors + decision: >- + A channel subquota caps how much of a published window that channel may + take. It reserves nothing for that channel: an unclaimed subquota is + available to the window's other channels up to the published total. + reasoning: >- + A ceiling is the bound that prevents one channel exhausting a window; a + floor is a different product promise, with its own failure mode when the + reserved share goes unused. This milestone implements and tests the + ceiling. Reserved floors stay an open product question rather than an + implied behaviour. + evidence: + - {path: crates/registry-scheduling-core/src/admission.rs, name: subquota_channels_are_checked_before_the_total} + - {path: crates/registry-scheduling-core/src/policy.rs, name: subquota_slices_may_not_overdraw_the_window} + - id: SCHEDULING-DEC-05 + subject: One staffing pool cannot back independently counted overlapping supply + decision: >- + The authoring check refuses a resource pool that serves an exact-time + offering and a published window at the same time. It also refuses two + overlapping windows backed by the same pool, whatever reservedMembers + either window declares. Those are not supported configurations in this + milestone; disjoint windows may reuse the pool. + reasoning: >- + Exact-time commitments count pool members while arrival commitments count + window units, and two windows count separate window ids. Each shared shape + therefore needs ledger-level staffing partition enforcement to stay + honest. reservedMembers records operator intent but does not create that + enforcement. Refusing the configuration at authoring time is a promise + the product can keep now; enforcing partitions in the ledger is later + work, and until it exists a deployment must not be able to author its way + into the unsound shape. + evidence: + - {path: crates/registry-scheduling-core/src/policy.rs, name: an_unpartitioned_shared_staffing_block_is_rejected} + - {path: crates/registry-scheduling-core/src/policy.rs, name: overlapping_windows_may_not_share_a_staffing_pool} + - id: SCHEDULING-DEC-06 + subject: Listing cursors carry no actor binding field + decision: >- + A stored cursor holds its identity, its listing context, its position, + and its expiry. It is not keyed by the caller. + reasoning: >- + Both listing call sites are safe without it: catalogue availability is + public to any reader with the read scope, and appointment history sits + behind the ownership check, which is re-applied before the cursor is + honoured rather than trusted from the cursor. The invariant is therefore + held by the call sites, which is why the contract is stated where a + reader of the cursor module will see it. A third listing that is neither + public nor ownership-checked must add the binding rather than assume it. + evidence: + - {path: crates/registry-scheduling/src/cursors.rs, name: a_cursor_binds_to_its_listing_context_and_expires} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cursors_page_their_own_listing_and_refuse_foreign_contexts} + - id: SCHEDULING-DEC-07 + subject: Member selection reads under the pool lock without SKIP LOCKED + decision: >- + Selecting a member for an exact-time commitment locks the pool row the + offering anchors on, then reads the pool's members and their occupancy + with plain queries inside that lock. The member rows themselves are + never locked. + reasoning: >- + One pool row is the serialization point for every offering that sells + that pool, so every commitment against the pool runs one at a time and + the member selection cannot race another selection. SKIP LOCKED on the + member rows would add machinery without adding correctness while the + pool lock already excludes every competitor, and it would invite the + false reading that members are independently selectable. The cost is + that a hot pool serializes every commitment on one row, which is the + same cost the window anchor already carries; if a pool ever becomes + that hot, the anchor is the thing to revisit, not the member query. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} + - {path: crates/registry-scheduling-core/src/admission.rs, name: the_second_caller_against_the_last_unit_is_refused} + - id: SCHEDULING-DEC-08 + subject: An environment-records swap is refused whole when it retires occupied supply + decision: >- + `records apply` replaces the environment facts wholesale, but not + unconditionally. The swap takes every supply anchor in the anchor's own + order before it reads a claim, and refuses the whole apply when any + active booking, or any hold whose lifetime is still ahead, occupies a + pool member the incoming records remove or move, or when incoming window + records remove, move, or reduce capacity below standing commitments. + The operator keeps the supply or closes what stands on it first; nothing + partial is written. + reasoning: >- + The ledger's claims name their supply by id and the records give that id + its pool membership or published window capacity. Changing those records + under a live claim can strand promised capacity that availability no + longer counts. Taking the anchors first is what makes + the guard honest rather than advisory: a capacity transaction holds its + own anchor from its snapshot until it commits, so holding all of them + means no commitment is mid-evaluation against a member about to be + deleted, and a claim written a moment later is seen by the guard rather + than stranded behind it. A capacity transaction takes exactly one anchor + and the swap takes them in order, so the two cannot cycle. + alsoTrue: >- + Window facts and their lock anchors are replaced in the same transaction + as locations, pools, members, and exceptions. An expired hold occupies + nothing, matching the live-predicate rule the rest of the ledger uses, + so it never blocks a swap. + residual: >- + Emergency capacity reduction remains a separate incident operation and + does not pass through ordinary records replacement. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_move_an_occupied_resource_between_pools} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: records_replacement_refuses_to_move_a_window_with_standing_commitments} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_waits_for_the_capacity_transaction_holding_the_pool} + - {path: crates/registry-schedulingctl/tests/records_apply_postgres.rs, name: records_apply_replaces_facts_wholesale_and_audits_each_write} diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml new file mode 100644 index 0000000000..06b0a48311 --- /dev/null +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -0,0 +1,476 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: scheduling +scope: mvp +evidenceStatus: source-mapped-not-executed +evidenceNote: >- + Test references identify maintained proof obligations. This file does not + assert that any test ran or passed in a particular review. Rows whose named + test lives in a PostgreSQL suite are proven only when that suite runs against + a disposable database under the postgres-test feature. +reviewRule: >- + This matrix is the baseline every later Scheduling change is reviewed + against. A change that weakens a row rewrites the row in the same commit and + says why. A row may not lose its negative test, and a deferred entry may not + be quietly promoted to an enforced row without the test that earns it. +invariants: + - id: SCHEDULING-SEC-01 + state: enforced + threat: >- + Two callers competing for the last unit both commit, or a hold and a + direct booking consume the same member, so the deployment oversells + capacity it cannot serve. + enforcementPoint: >- + One PostgreSQL capacity transaction per commitment that reads the ledger + snapshot, evaluates admission against it, and writes the claim before it + commits. + refusal: >- + Commit exactly one winner and refuse every competing claim with + capacity.exhausted; never write a partial claim. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: the_second_caller_against_the_last_unit_is_refused + - id: SCHEDULING-SEC-02 + state: enforced + threat: >- + Capacity is committed over supply no operator anchored, so the runtime + books a pool or window that does not exist in this deployment. + enforcementPoint: >- + Offering anchors resolved from runtime records inside the capacity + transaction, not from the authored policy alone. + refusal: >- + Refuse the commitment loudly rather than inventing supply, and name the + unanchored reference in the operator's diagnostics, never to the caller. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: an_arrival_offering_without_its_window_record_is_an_operator_gap + - id: SCHEDULING-SEC-03 + state: partial + threat: >- + A token that passed the door commits capacity after the task grant behind + it has expired. + enforcementPoint: >- + The grant's expiry re-checked inside the capacity transaction, after + every lock wait and immediately before the claim commits, against a + fresh observation of the store's clock rather than the request's own + entry time. + refusal: >- + Refuse with operation.not-authorized and write the authorization.refused + audit row, leaving no claim behind. + partialNote: >- + Only the grant's expiry is read again inside the transaction. The bounds + themselves, the service, the location, the action, and the client, are + matched once at the service before the transaction opens, and this + milestone has no revocation check, so a grant narrowed or withdrawn at the + issuer keeps the authority its token carries until the token or the grant + deadline passes. See deferred entry SCHEDULING-DEF-01. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: a_grant_that_lapses_before_the_commit_never_books + - id: SCHEDULING-SEC-04 + state: enforced + threat: >- + A catalogue read scope, an explain scope, or a partially formed grant + reaches a commitment route and books capacity. + enforcementPoint: >- + Three disjoint authority profiles in auth.rs: a read scope, a separately + configured explain scope that is never the read scope, and a complete + task grant bound to the verified client and this deployment's audience. + refusal: >- + Refuse an incomplete grant set as a credentials problem and never honour + it partially; refuse a grant naming another resource as a profile + refusal. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: a_partial_grant_is_refused_as_invalid_claims + - id: SCHEDULING-SEC-05 + state: enforced + threat: >- + A grant for a neighbouring service, location, or action covers this + offering because the match widens. + enforcementPoint: >- + require_permission in service.rs, matching one permission whose service + and location both equal the offering's own and whose action list contains + the requested action, followed by bound_window requiring the operator + record to name that same offering and location before any window capacity + is exposed or consumed. + refusal: >- + Answer operation.not-authorized when the grant does not match, or fail + closed as service.unavailable when the operator record is misbound. A + broader service or location never covers a narrower one and no action is + inferred from another. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: a_grant_naming_another_resource_is_a_profile_refusal + - id: SCHEDULING-SEC-06 + state: enforced + threat: >- + A grant minted for one product is spent on another because the grant + union leaks across product boundaries. + enforcementPoint: >- + The closed GrantBounds tagged union in registry-platform-oidc, with + deny_unknown_fields, pinned value and cardinality bounds, and a redacting + Debug. + refusal: >- + Parse a foreign grant as a grant that carries no permission for this + product and refuse it before any authorization decision or store access. + negativeTest: + path: crates/registry-breg/src/task_grant/tests.rs + name: a_scheduling_grant_is_refused_by_the_breg_binding + features: [runtime] + - id: SCHEDULING-SEC-07 + state: enforced + threat: >- + A public availability or refusal answer names the member, the resource, + or the neighbouring booking that caused it, letting a caller enumerate + supply or other people's appointments. + enforcementPoint: >- + problem_of projecting every admission refusal through public_code, and a + separately authorized explain path for the member-level cause. + refusal: >- + Answer the public code for the same state and name no member; keep the + detailed code behind the explain scope. + negativeTest: + path: crates/registry-scheduling/src/service.rs + name: the_detailed_code_stays_behind_the_explain_path + - id: SCHEDULING-SEC-08 + state: enforced + threat: >- + One caller reads or pages another caller's appointment, history, or + listing by guessing an identifier or replaying a cursor. + enforcementPoint: >- + owned_booking on the appointment read and history paths, and cursors that + are opaque version 4 UUIDs bound to their listing context and expiring + fifteen minutes out, with ownership re-checked before a cursor is + honoured. + refusal: >- + Answer the same value-free absence for a foreign record as for a missing + one, and refuse a cursor presented against a different context. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: cursors_page_their_own_listing_and_refuse_foreign_contexts + - id: SCHEDULING-SEC-09 + state: enforced + threat: >- + A retried commitment books twice, or a replayed key under a different + body commits a second decision under the first one's answer. + enforcementPoint: >- + A caller-chosen Idempotency-Key bounded to 128 graphical bytes on the + four caller-driven commitments, hashed with the canonical request, and + the hold's own identifier as the key for a release. + refusal: >- + Replay the stored first answer for the same key and body, refuse the same + key under a different body with idempotency.key-reused, and answer the + expired code once the receipt has been tombstoned. + negativeTest: + path: crates/registry-scheduling/src/http.rs + name: the_idempotency_key_is_present_bounded_and_graphical + - id: SCHEDULING-SEC-10 + state: enforced + threat: >- + A caller commits against a policy, published window, or appointment that + changed under it, so the booking rests on supply or terms the operator + has withdrawn. + enforcementPoint: >- + The policy revision the request names, the published-window revision for + arrival commitments, and the observed appointment revision carried in + the request body. Records replacement requires changed window terms to + advance that public revision, and the request revisions are fenced inside + the capacity transaction after the supply anchor is locked and before the + claim is written. + refusal: >- + Refuse a stale policy revision with policy.changed ahead of any narrower + mismatch, and refuse a stale window or appointment revision with + revision.mismatch. + scopeNote: >- + Cancellation is deliberately outside this row: it releases capacity and + is guarded by the observed appointment revision and the cutoff, never by + the policy revision, so an appointment stays cancellable under whatever + policy currently governs its offering. See recorded-decisions.yaml, + SCHEDULING-DEC-02. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: policy_change_outranks_a_window_revision_mismatch + - id: SCHEDULING-SEC-11 + state: enforced + threat: >- + An expired hold keeps consuming capacity, or is confirmed into an + appointment after its window has passed. + enforcementPoint: >- + Hold lifetime evaluated in the ledger, never against a sweeper having + already run. Capacity snapshots use the commitment's clock; confirmation + re-checks expiry against a fresh clock observation after it locks the + supply anchor, before transferring the reservation into an appointment. + refusal: >- + Count an expired hold as consuming nothing and refuse its confirmation; + the sweeper is housekeeping, not the authority on expiry. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed + - id: SCHEDULING-SEC-12 + state: enforced + threat: >- + Credentials or refusal details cross a public plaintext edge, a browser + cache, or an unbounded framework response. + enforcementPoint: >- + A listener that must be private or an explicitly declared container + network, mandatory TLS termination, a 1 MiB body ceiling, security + headers without CORS, Cache-Control no-store on every answer, and one + problem rendering path that stamps the caller's trace. + refusal: >- + Refuse to start on a public listener, and normalize every framework + rejection into a closed problem rather than a framework error page. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: listener_requires_a_private_address_or_explicit_container_network + - id: SCHEDULING-SEC-13 + state: enforced + threat: >- + A submitted value, a secret, or a grant's contents is echoed back into a + problem document, a log line, a trace, or a configuration error. + enforcementPoint: >- + A closed problem vocabulary whose detail is fixed per code, value-free + Display implementations, a redacting Debug on the grant bounds, and a + configuration parse path that names the field without the value. + refusal: >- + Emit the closed category and nothing else; no field of a problem document + repeats a submitted value. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: typed_parse_path_does_not_echo_the_rejected_value + - id: SCHEDULING-SEC-14 + state: enforced + threat: >- + A commitment decision leaves no accountable record, so a booking or a + refusal cannot be attributed afterwards, and grant-scope enumeration + against the edge passes unseen. + enforcementPoint: >- + An authorization audit record built with a pseudonymized principal and + stamped with its identity exactly once, written for the allowed case, + for every refusal the ledger decides, and for every permission mismatch + the service refuses before the capacity transaction opens. The journal + is published in the order it was written, and the hash chain continues + across a restart from its verified tail. + refusal: >- + Record authorization.allowed or authorization.refused as an audit reason, + never as a problem code, and never carry the caller's raw identifier. + negativeTest: + path: crates/registry-scheduling/src/runtime.rs + name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once + - id: SCHEDULING-SEC-15 + state: enforced + threat: >- + An unverified, drifted, or failing policy package is served, so the + deployment answers under terms nobody reviewed. + enforcementPoint: >- + Startup package verification in production, the authoring exception + confined to a loopback listener, and the authoring checks re-run before + the policy reaches the runtime. + refusal: >- + Refuse to start rather than serve an unverified package or a policy that + carries findings. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: production_requires_a_verified_package_while_loopback_accepts_authoring + - id: SCHEDULING-SEC-16 + state: enforced + threat: >- + An operator-pinned JWKS selects unintended verification material because + two keys share a name or a symmetric key slips in. + enforcementPoint: >- + Static JWKS validation before the verifier is constructed. + refusal: >- + Refuse the document unless every key is asymmetric and uniquely named. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: static_jwks_requires_unique_named_asymmetric_keys + - id: SCHEDULING-SEC-17 + state: enforced + threat: >- + A caller supplies a field the wire contract does not declare, such as the + reschedule exclusion the store owns, and changes how the request is + evaluated. + enforcementPoint: >- + Strict request parsing across the core's ledger and request shapes, with + unknown fields refused rather than ignored. + refusal: >- + Refuse the request as malformed; the exclusion is supplied by the store + from inside the reschedule transaction and never by the caller. + negativeTest: + path: crates/registry-scheduling-core/src/model.rs + name: an_admission_request_refuses_a_caller_supplied_exclusion + - id: SCHEDULING-SEC-18 + state: enforced + threat: >- + A scheduling crate reaches a Base Registry Engine, Casework, or Evidence + crate, or one of those products reaches a scheduling crate, and the + capacity ledger acquires a second writer or a foreign protocol type. + enforcementPoint: >- + The dependency-direction gate over the Cargo metadata forward closure of + every scheduling crate, in both directions. + refusal: >- + Fail the product check on a direct or transitive forbidden dependency. + negativeTest: + path: products/scheduling/scripts/test_dependency_direction.py + name: test_product_dependency_on_scheduling_is_rejected + - id: SCHEDULING-SEC-19 + state: enforced + threat: >- + Caller strings stored verbatim in the ledger entry, the idempotency + receipt, and the audit journal are bounded only by the body ceiling, and + an unbounded duplicate key can be varied at will to slip the duplicate + guard. + enforcementPoint: >- + Edge bounds at the authoring grammar's own identifier, reference, + collection, and idempotency-key limits, plus a cancellation reason + bounded to the column that stores it. + refusal: >- + Answer request.invalid at the edge rather than passing the value to a + database CHECK that surfaces as a service problem on the caller's own + request. + negativeTest: + path: crates/registry-scheduling/src/http.rs + name: an_admission_request_is_bounded_before_it_reaches_the_store + - id: SCHEDULING-SEC-20 + state: enforced + threat: >- + The accepted token type set admits a plain JWT, so an identity token, or + any other JWT the same issuer signs, satisfies the access-token profile + and reaches a scheduling operation. + enforcementPoint: >- + The access-token profile built at configuration time, which admits the + RFC 9068 media type and its case variants only. No configuration key + widens it. + refusal: >- + Refuse the credential; a token that is not an access token never reaches + authorization. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: the_access_token_profile_admits_only_the_rfc_9068_pair + - id: SCHEDULING-SEC-21 + state: enforced + threat: >- + A deployment that names no clients admits every client the issuer + verifies, so any application in the issuer's realm reaches the + scheduling edge with a token the issuer minted for something else. + enforcementPoint: >- + Configuration validation at startup, which requires a named client list + for any listener that is not loopback. + refusal: >- + Refuse to start rather than serve a deployment that admits an unnamed + client. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: a_production_deployment_must_name_the_clients_it_admits + - id: SCHEDULING-SEC-22 + state: enforced + threat: >- + A credential exchanged from a third-party assertion is accepted on the + strength of the issuer the token itself names, so any authority the + identity provider will exchange from reaches the deployment. + enforcementPoint: >- + A declared assertion-issuer map binding each accepted authority to the + client that may exchange from it, matched when the exchanged credential + is authenticated, and bounded in the runtime schema. + refusal: >- + Refuse the credential until its authority is declared; an undeclared + authority is a credentials problem, never a silent acceptance. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: an_exchanged_token_is_refused_until_its_authority_is_declared + - id: SCHEDULING-SEC-23 + state: enforced + threat: >- + A clock the runtime cannot read yields a timestamp against which every + grant compares as unexpired, so an expired credential commits. + enforcementPoint: >- + The expiry comparison in the authenticator, which propagates a clock + failure rather than substituting a default instant. + refusal: >- + Refuse the request as an authentication failure; an unreadable clock + never judges an expiry as passed. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it + - id: SCHEDULING-SEC-24 + state: enforced + threat: >- + An appointment observer gains mutation authority, receives caller or + capacity secrets, or observes an event not durably bound to the state + change it describes. + enforcementPoint: >- + A closed policy compiler admitting only after-phase URL handlers for + three appointment lifecycle triggers; product-owned projections that + cannot name actor, credential, capacity, duplicate-key, or cancellation + reason fields; outbox capture in the appointment transaction; and a + delivery seam that refuses every proposal. + refusal: >- + Refuse unsupported declarations before startup, fail the appointment + transaction if its matching event cannot be captured, and record a + value-free proposal refusal without applying receiver output. + negativeTest: + path: crates/registry-scheduling/src/hooks.rs + name: projections_disclose_only_the_requested_closed_fields + - id: SCHEDULING-SEC-25 + state: enforced + threat: >- + An operator records replacement drops, moves, or reduces a published window + below the bookings and live holds that already consume it. + enforcementPoint: >- + Records replacement locks every existing supply anchor, loads the current + window records and standing window ledger, and assesses the proposal + before atomically advancing the durable facts revision. + refusal: >- + Refuse replacement with an operator-facing deficit and leave all records + and their revision unchanged. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: records_replacement_refuses_to_move_a_window_with_standing_commitments +deferred: + - id: SCHEDULING-DEF-01 + subject: Full task-grant bounds re-checked inside the capacity transaction + state: deferred + reason: >- + The grant's bounds travel in the presented token and are matched once at + the service. Re-reading them inside the transaction only defends against + a grant narrowed at the issuer between the door and the commit, which + needs a revocation or introspection path this milestone does not have. + The expiry, which is the fact that does change on its own, is re-checked. + compensatingControl: >- + Short grant deadlines. The published reference states the limit plainly + rather than claiming the bounds are re-read. + recordedIn: docs/site/src/content/docs/reference/apis/registry-scheduling.mdx + - id: SCHEDULING-DEF-04 + subject: Channel bound to the verified caller + state: deferred + reason: >- + The channel that selects a window subquota is asserted by the caller and + persisted verbatim. It is not derived from the verified client or the + grant, so a caller entitled to book at all may choose which subquota it + draws from. + compensatingControl: >- + The policy declares a closed channel set, an undeclared channel is + refused with a typed code, and every subquota is a ceiling inside the one + published total, so a mis-chosen channel cannot oversell the window. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-05 + subject: Database backstop under the duplicate-key guard + state: deferred + reason: >- + The duplicate guard is evaluated in the admission path against the active + claims it reads. The supporting index is partial rather than unique, so + the guard rests on the transaction rather than on a database constraint. + compensatingControl: >- + The guard runs inside the same capacity transaction as the claim it + protects, so a concurrent pair cannot straddle it. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-06 + subject: Appointment, history, outbox, and audit retention + state: deferred + reason: >- + Retention windows for committed scheduling data are a product question + this milestone does not answer. Idempotency receipts do expire and + tombstone; nothing else is swept. + compensatingControl: >- + The configuration reference says so explicitly rather than implying a + sweep that does not run. + recordedIn: products/scheduling/RUNTIME-CONFIG.md diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml new file mode 100644 index 0000000000..4717f8c2b8 --- /dev/null +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -0,0 +1,182 @@ +contract: registry.scheduling.security-test-traceability/v1 +scope: mvp +executionStatus: not-recorded +note: >- + Every entry names the tests that carry its invariant. Tests under + crates/registry-scheduling/tests run only with the postgres-test feature and + a disposable database; tests in schema.rs run only under the schema feature. + This file records the obligation, not a passing run. +entries: + - id: SCHEDULING-SEC-01 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: the_second_caller_against_the_last_unit_is_refused} + - {path: crates/registry-scheduling-core/src/admission.rs, name: a_party_that_does_not_fit_remaining_capacity_is_refused_whole} + - {path: crates/registry-scheduling-core/src/model.rs, name: a_confirmed_booking_consumes_but_an_expired_hold_does_not} + - {path: crates/registry-scheduling-core/src/model.rs, name: supply_occupancy_is_half_open_and_honors_the_excluded_claim} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: exact_time_commitments_include_buffers_outside_the_opening_range} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_move_an_occupied_resource_between_pools} + - id: SCHEDULING-SEC-02 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_against_an_unanchored_pool_refuses_loudly} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_arrival_offering_without_its_window_record_is_an_operator_gap} + - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_pool_anchors_are_collected_without_duplicates} + - id: SCHEDULING-SEC-03 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_grant_that_lapses_before_the_commit_never_books} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_foreign_actor_does_not_learn_the_current_revision} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline} + - id: SCHEDULING-SEC-04 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: a_partial_grant_is_refused_as_invalid_claims} + - {path: crates/registry-scheduling/src/auth.rs, name: a_complete_task_grant_is_extracted_and_bound_to_the_deployment} + - {path: crates/registry-scheduling/src/auth.rs, name: a_read_without_the_reads_scope_is_a_profile_refusal_but_still_mutates} + - {path: crates/registry-scheduling/src/auth.rs, name: the_explain_path_demands_its_own_scope} + - {path: crates/registry-scheduling/src/config.rs, name: explain_and_read_scopes_must_differ_and_retention_must_be_positive} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: any_partial_core_grant_set_is_rejected} + - {path: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid, features: [postgres-test]} + - id: SCHEDULING-SEC-05 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: a_grant_naming_another_resource_is_a_profile_refusal} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_window_record_must_belong_to_the_authorized_offering_and_location} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_permission_cardinality_bounds_are_enforced} + - id: SCHEDULING-SEC-06 + tests: + - {path: crates/registry-breg/src/task_grant/tests.rs, name: a_scheduling_grant_is_refused_by_the_breg_binding, features: [runtime]} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: grant_bounds_variants_are_distinct_and_strict} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_value_boundaries_and_unknown_fields_are_enforced} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: the_scheduling_tag_round_trips_through_serde} + - id: SCHEDULING-SEC-07 + tests: + - {path: crates/registry-scheduling/src/service.rs, name: the_detailed_code_stays_behind_the_explain_path} + - {path: crates/registry-scheduling/src/service.rs, name: commit_errors_project_to_their_public_codes} + - {path: crates/registry-scheduling/src/service.rs, name: a_fully_occupied_slot_is_not_availability} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: explain_assumes_the_offerings_declared_inputs_are_present} + - {path: crates/registry-scheduling-core/src/admission.rs, name: refusals_carry_their_distinguished_public_codes} + - {path: crates/registry-scheduling-core/src/problem.rs, name: exactly_one_code_is_detailed_only} + - id: SCHEDULING-SEC-08 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cursors_page_their_own_listing_and_refuse_foreign_contexts} + - {path: crates/registry-scheduling/src/cursors.rs, name: a_cursor_binds_to_its_listing_context_and_expires} + - {path: crates/registry-scheduling/src/cursors.rs, name: cursor_expiry_is_fifteen_minutes_out} + - {path: crates/registry-scheduling/src/cursors.rs, name: positions_round_trip_and_refuse_foreign_shapes} + - id: SCHEDULING-SEC-09 + tests: + - {path: crates/registry-scheduling/src/http.rs, name: the_idempotency_key_is_present_bounded_and_graphical} + - {path: crates/registry-scheduling/src/http.rs, name: a_replayed_refusal_re_renders_its_pinned_code_under_the_current_trace} + - {path: crates/registry-scheduling/src/http.rs, name: a_replayed_release_answers_an_empty_204_again} + - {path: crates/registry-scheduling/src/service.rs, name: a_success_replay_receipt_projects_through_the_same_document} + - {path: crates/registry-scheduling-core/src/model.rs, name: the_same_request_always_hashes_identically} + - {path: crates/registry-scheduling-core/src/model.rs, name: set_order_never_changes_the_request_hash} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_concurrent_identical_request_replays_the_winning_receipt} + - id: SCHEDULING-SEC-10 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} + - {path: crates/registry-scheduling-core/src/admission.rs, name: window_revisions_must_match_and_policy_change_wins} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_refuses_even_a_request_under_the_current_revision} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: changed_window_records_must_advance_the_revision_even_after_removal} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: competing_cancellations_commit_exactly_one_transition} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_and_a_cancellation_commit_exactly_one_transition} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_can_still_release_an_appointment} + - id: SCHEDULING-SEC-11 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} + - {path: crates/registry-scheduling-core/src/admission.rs, name: a_live_hold_still_consumes} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_expired_hold_returns_capacity_and_refuses_confirmation} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_delayed_confirmation_cannot_transfer_rebooked_capacity_after_expiry} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_release_returns_capacity_answers_replay_and_blocks_confirmation} + - id: SCHEDULING-SEC-12 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: listener_requires_a_private_address_or_explicit_container_network} + - {path: crates/registry-scheduling/src/http.rs, name: answers_carry_no_store_and_the_callers_trace} + - {path: crates/registry-scheduling/src/http.rs, name: framework_rejections_normalize_to_request_problems} + - {path: crates/registry-scheduling/src/http.rs, name: an_unknown_route_answers_the_pinned_problem_under_the_callers_trace} + - {path: crates/registry-scheduling/src/http.rs, name: an_unauthenticated_problem_challenges_for_a_bearer} + - {path: crates/registry-scheduling/src/http.rs, name: an_unavailable_problem_names_a_retry_interval} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: oversized_answers_stay_bounded} + - id: SCHEDULING-SEC-13 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: typed_parse_path_does_not_echo_the_rejected_value} + - {path: crates/registry-scheduling/src/http.rs, name: every_problem_of_the_closed_vocabulary_renders_its_pinned_document} + - {path: crates/registry-scheduling/src/http.rs, name: a_receipt_in_no_known_shape_is_never_shown_to_the_caller} + - {path: crates/registry-scheduling-core/src/diagnostics.rs, name: reasons_render_as_closed_slugs} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_debug_redacts_service_location_and_actions} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: grant_debug_redacts_claim_values} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: edge_answers_stay_edge_talk} + - id: SCHEDULING-SEC-14 + tests: + - {path: crates/registry-scheduling/src/runtime.rs, name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hold_confirms_into_an_appointment_with_attributable_history} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_permission_refused_before_the_transaction_writes_its_audit_row} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_journal_is_read_in_the_order_it_was_written} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_chain_continues_across_a_restart} + - id: SCHEDULING-SEC-15 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: production_requires_a_verified_package_while_loopback_accepts_authoring} + - {path: crates/registry-scheduling/src/config.rs, name: a_refused_authored_policy_names_the_member_and_the_cause} + - {path: crates/registry-scheduling-core/src/policy.rs, name: the_policy_digest_is_stable_and_moves_with_content} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: adoption_binds_one_identity_and_refuses_a_second} + - {path: crates/registry-schedulingctl/tests/records_apply_postgres.rs, name: records_apply_rejects_a_different_deployment_identity_without_writing, features: [postgres-test]} + - id: SCHEDULING-SEC-16 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: static_jwks_requires_unique_named_asymmetric_keys} + - {path: crates/registry-scheduling/src/auth.rs, name: a_credential_signed_by_an_unknown_key_is_refused} + - id: SCHEDULING-SEC-17 + tests: + - {path: crates/registry-scheduling-core/src/model.rs, name: an_admission_request_refuses_a_caller_supplied_exclusion} + - {path: crates/registry-scheduling-core/src/model.rs, name: ledger_and_request_shapes_refuse_unknown_fields} + - {path: crates/registry-scheduling-core/src/policy.rs, name: unknown_policy_fields_are_refused} + - {path: crates/registry-scheduling-core/src/policy.rs, name: collection_bounds_are_enforced} + - {path: crates/registry-scheduling-core/src/policy.rs, name: scheduling_observer_hooks_accept_only_the_closed_runtime_contract} + - {path: crates/registry-scheduling-core/src/policy.rs, name: legacy_duplicate_and_mismatched_hook_members_are_refused_by_the_shared_shape} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: invalid_client_input_never_reaches_the_wire} + - id: SCHEDULING-SEC-18 + tests: + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_product_dependency_on_scheduling_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_transitive_product_dependency_from_core_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_evidence_dependency_from_client_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_core_depending_on_the_runtime_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_client_depending_on_the_runtime_is_rejected} + - id: SCHEDULING-SEC-19 + tests: + - {path: crates/registry-scheduling/src/http.rs, name: an_admission_request_is_bounded_before_it_reaches_the_store} + - {path: crates/registry-scheduling/src/http.rs, name: a_cancellation_reason_is_bounded_below_the_column_that_stores_it} + - {path: crates/registry-scheduling/src/http.rs, name: a_listing_offering_is_bounded_the_same_way_a_body_is} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_claim_ledger_bounds_a_caller_chosen_duplicate_key} + - id: SCHEDULING-SEC-20 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: the_access_token_profile_admits_only_the_rfc_9068_pair} + - id: SCHEDULING-SEC-21 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: a_production_deployment_must_name_the_clients_it_admits} + - id: SCHEDULING-SEC-22 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: an_exchanged_token_is_refused_until_its_authority_is_declared} + - {path: crates/registry-scheduling/src/config.rs, name: a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it} + - {path: crates/registry-scheduling/src/config.rs, name: an_assertion_issuer_map_outside_its_bounds_is_refused} + - {path: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid, features: [postgres-test]} + - id: SCHEDULING-SEC-23 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it} + - id: SCHEDULING-SEC-24 + tests: + - {path: crates/registry-scheduling-core/src/policy.rs, name: scheduling_observer_hooks_accept_only_the_closed_runtime_contract} + - {path: crates/registry-scheduling/src/config.rs, name: a_hook_policy_requires_and_accepts_its_deployment_binding} + - {path: crates/registry-scheduling/src/hooks.rs, name: projections_disclose_only_the_requested_closed_fields} + - {path: crates/registry-scheduling/src/hooks.rs, name: cancellation_projection_uses_the_public_terminal_state} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projection} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hook_capture_failure_rolls_back_the_whole_appointment_transaction} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_sends_the_canonical_event_audits_egress_and_refuses_proposals} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_audit_failure_prevents_egress} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: changed_retained_hook_destination_binding_is_refused} + - id: SCHEDULING-SEC-25 + tests: + - {path: crates/registry-scheduling-core/src/policy.rs, name: a_reduction_below_standing_commitments_is_rejected_with_its_deficit} + - {path: crates/registry-scheduling-core/src/policy.rs, name: a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total} + - {path: crates/registry-scheduling-core/src/policy.rs, name: moving_a_published_window_reports_the_stranded_commitments} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: records_replacement_refuses_to_move_a_window_with_standing_commitments} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_remove_an_offering_with_a_live_appointment} diff --git a/products/scheduling/demo/README.md b/products/scheduling/demo/README.md new file mode 100644 index 0000000000..a8551d142a --- /dev/null +++ b/products/scheduling/demo/README.md @@ -0,0 +1,59 @@ +# Registry Scheduling acceptance demo + +`run.sh` proves four acceptance scenarios from the Scheduling functional +specification against the real binaries and a real PostgreSQL database, over +the public HTTP contract and under real authentication: + +| Scenario | What the demo asserts | +| --- | --- | +| AT-01 | Two callers confirm against the last station concurrently. Exactly one appointment is created; the loser receives `capacity.exhausted` (409), and the slot afterwards holds no free unit. | +| AT-05 | A hold expires. The slot becomes bookable again, and confirming the expired hold fails with `hold.expired` (410). | +| AT-06 | A confirmation commits but its response is lost. A retry under the same `idempotency-key` returns the original appointment; a changed payload under that key is refused with `idempotency.key-reused` (409). | +| AT-19 | The New York hall opening across the 2026-11-01 daylight-saving fold serves the grid the morning closure moved (anchored 04:45Z), never the wall-clock match 06:30Z, which is refused with `schedule.unpublished` (422). | + +## Running it + +```sh +products/scheduling/demo/run.sh +``` + +Docker, cargo, openssl, python3, curl, and jq must be available. The script +builds `scheduling` and `schedulingctl`, starts a disposable PostgreSQL 17 +container it removes afterwards, publishes the `standalone-exact-time` +example policy with demo environment records (one station per pool, plus the +fold-day closure), serves the runtime on loopback, and runs the scenarios. +The demo shortens the hold TTL to one minute so the expiry scenario runs +quickly; the other scenarios run while that clock does. + +`--database-url URL` runs against a disposable PostgreSQL you own instead of +a Docker container (migrations are applied and the data is replaced). +`--installed` uses the two binaries from `PATH` rather than building them. + +## How authentication works here + +There is no auth-free mode. The script generates a throwaway RSA key pair, +publishes the public half as the static JWKS document the runtime +configuration points at, and signs RFC 9068 access tokens (`typ: at+jwt`) +for three demo callers. Reads carry the `scheduling-read` scope; bookings +carry no product scope but a complete task grant whose `scheduling` +permissions name the offering's service, location, and action, which the +runtime re-checks inside the capacity transaction. Nothing in the run +directory is a credential beyond the throwaway key pair, and the key pair +never leaves the run directory. + +## What this demo does not cover + +The AT-05 row says "while the cleanup worker is stopped". The runtime's +cleanup loop is a fixed internal interval with no operator switch, and the +store counts a hold only while it is unexpired at the observed now, so a +stopped or delayed worker cannot keep capacity locked either way. The demo +asserts the observable contract the scenario pins (capacity reopens, late +confirmation fails) immediately at expiry, and the worker-independent +enforcement is covered directly by the store's own PostgreSQL tests in +`crates/registry-scheduling/tests/postgres_commitments.rs`. + +The support module carries unit tests for its pure pieces: + +```sh +python3 -m unittest products/scheduling/demo/support/test_demo.py -v +``` diff --git a/products/scheduling/demo/run.sh b/products/scheduling/demo/run.sh new file mode 100755 index 0000000000..5ac353fa85 --- /dev/null +++ b/products/scheduling/demo/run.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail + +# The Registry Scheduling local acceptance demo. This script builds the real +# `scheduling` and `schedulingctl` binaries, stands up a disposable +# PostgreSQL database (its own container unless --database-url names one), +# publishes the standalone-exact-time policy with demo environment records, +# serves the runtime on loopback, and drives four acceptance scenarios over +# the public HTTP contract: AT-01 (two-caller race for the last unit), +# AT-05 (hold expiry), AT-06 (lost-confirmation idempotent replay), and +# AT-19 (the daylight-saving fold grid). See README.md beside this script. + +demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +root=$(cd -- "$demo_dir/../../.." && pwd) +run_dir="$demo_dir/.run" +support="$demo_dir/support/demo.py" +example="$root/products/scheduling/examples/standalone-exact-time" +database_url='' +database_root_ca='' +installed=false + +usage() { + cat >&2 <<'HELP' +usage: products/scheduling/demo/run.sh [--database-url URL [--database-root-ca PATH]] [--installed] + +Run the Scheduling acceptance demo. Without --database-url the script starts +and removes its own TLS-enabled PostgreSQL 17 container, which requires +Docker; the URL form runs against a disposable TLS-enabled database you own +(migrations are applied and its data is replaced), and --database-root-ca +names the PEM file holding the authority that signed that server's chain. +--installed uses scheduling and schedulingctl from PATH instead of building +from this checkout. Docker, cargo (unless --installed), openssl, python3, +and curl are required. The run directory is kept when a scenario fails, for +inspection. +HELP + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --database-url) + [[ $# -ge 2 ]] || usage + database_url="$2" + shift 2 + ;; + --database-root-ca) + [[ $# -ge 2 ]] || usage + database_root_ca="$2" + shift 2 + ;; + --installed) installed=true; shift ;; + --help|-h) usage ;; + *) usage ;; + esac +done +if [[ -n "$database_root_ca" && -z "$database_url" ]]; then + printf '%s\n' '--database-root-ca applies to --database-url.' >&2 + usage +fi + +for command in python3 openssl curl; do + if ! command -v "$command" >/dev/null 2>&1; then + printf '%s is required.\n' "$command" >&2 + exit 2 + fi +done + +if [[ "$installed" == true ]]; then + scheduling=$(command -v scheduling) || { printf '%s\n' 'scheduling is required in --installed mode.' >&2; exit 2; } + schedulingctl=$(command -v schedulingctl) || { printf '%s\n' 'schedulingctl is required in --installed mode.' >&2; exit 2; } +else + command -v cargo >/dev/null 2>&1 || { printf '%s\n' 'cargo is required.' >&2; exit 2; } + export CARGO_INCREMENTAL=0 + cargo build --manifest-path "$root/Cargo.toml" --locked \ + -p registry-scheduling -p registry-schedulingctl --bins >/dev/null + scheduling="$root/target/debug/scheduling" + schedulingctl="$root/target/debug/schedulingctl" +fi + +if [[ -e "$run_dir" ]]; then + printf 'demo state path already exists: %s. Remove it after stopping any session that owns it.\n' "$run_dir" >&2 + exit 2 +fi +umask 077 +mkdir -m 700 "$run_dir" + +container='' + +cleanup() { + exit_code=$? + [[ -n "${serve_pid:-}" ]] && kill "$serve_pid" >/dev/null 2>&1 || true + if [[ -n "$container" ]] && command -v docker >/dev/null 2>&1; then + docker rm -f "$container" >/dev/null 2>&1 || true + fi + if [[ "$exit_code" -eq 0 ]]; then + rm -rf -- "$run_dir" + else + printf 'demo failed; run state kept at %s\n' "$run_dir" >&2 + fi + exit "$exit_code" +} +trap cleanup EXIT INT TERM + +read -r db_port http_port < <(python3 - "$support" <<'PY' +import socket +ports = [] +with socket.socket() as first, socket.socket() as second: + first.bind(("127.0.0.1", 0)) + second.bind(("127.0.0.1", 0)) + ports = [first.getsockname()[1], second.getsockname()[1]] +print(*ports) +PY +) + +if [[ -z "$database_root_ca" ]]; then + root_ca_args=() +else + root_ca_args=(--database-root-ca "$database_root_ca") +fi +python3 "$support" prepare \ + --root "$run_dir" \ + --example "$example" \ + --database-url "${database_url:-postgres://postgres:demo@127.0.0.1:${db_port}/postgres}" \ + --port "$http_port" \ + "${root_ca_args[@]}" + +if [[ -z "$database_url" ]]; then + command -v docker >/dev/null 2>&1 || { printf '%s\n' 'docker is required without --database-url.' >&2; exit 2; } + container="scheduling-demo-pg-$$" + printf 'starting disposable TLS-enabled PostgreSQL on 127.0.0.1:%s\n' "$db_port" + # The runtime requires TLS to PostgreSQL even locally, so the container + # serves the demo's own throwaway certificate: the wrapper copies it where + # the postgres user can read it, then hands off to the stock entrypoint. + docker run -d --rm --name "$container" \ + -e POSTGRES_PASSWORD=demo \ + -p "127.0.0.1:${db_port}:5432" \ + -v "$run_dir/db-tls:/tls:ro" \ + postgres:17-alpine \ + sh -c 'cp /tls/ca.crt /tls/server.crt /tls/server.key /var/lib/postgresql/ && \ + chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key && \ + chmod 600 /var/lib/postgresql/server.key && \ + exec docker-entrypoint.sh postgres \ + -c ssl=on \ + -c ssl_cert_file=/var/lib/postgresql/server.crt \ + -c ssl_key_file=/var/lib/postgresql/server.key' >/dev/null + # Wait on TCP inside the container, not the unix socket: the stock + # entrypoint's initdb phase runs a temporary socket-only server, so a + # socket probe can report ready before the real TLS listener exists. + ready=false + for _ in $(seq 1 60); do + if docker exec "$container" pg_isready -h 127.0.0.1 -p 5432 -U postgres -d postgres >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 + done + if [[ "$ready" != true ]]; then + printf 'the demo PostgreSQL did not become ready; its logs follow.\n' >&2 + docker logs "$container" >&2 || true + exit 1 + fi +fi + +project="$run_dir/project" +"$schedulingctl" check --deny-findings "$project" >/dev/null +"$schedulingctl" test "$project" >/dev/null +"$schedulingctl" package "$project" >/dev/null +"$scheduling" --runtime-config "$run_dir/runtime.yaml" migrate +"$schedulingctl" records apply "$run_dir/runtime.yaml" "$run_dir/records.yaml" >/dev/null + +printf 'serving the Scheduling runtime on 127.0.0.1:%s\n' "$http_port" +"$scheduling" --runtime-config "$run_dir/runtime.yaml" serve >"$run_dir/serve.log" 2>&1 & +serve_pid=$! + +ready=false +for _ in $(seq 1 60); do + if curl --silent --fail --max-time 2 "http://127.0.0.1:${http_port}/readyz" >/dev/null 2>&1; then + ready=true + break + fi + if ! kill -0 "$serve_pid" >/dev/null 2>&1; then + break + fi + sleep 1 +done +if [[ "$ready" != true ]]; then + printf 'the runtime did not become ready; the serve log follows.\n' >&2 + cat "$run_dir/serve.log" >&2 || true + exit 1 +fi + +python3 "$support" verify \ + --base-url "http://127.0.0.1:${http_port}" \ + --signing-key "$run_dir/demo-signing-key.pem" diff --git a/products/scheduling/demo/support/demo.py b/products/scheduling/demo/support/demo.py new file mode 100644 index 0000000000..47e7e0b0d5 --- /dev/null +++ b/products/scheduling/demo/support/demo.py @@ -0,0 +1,803 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +"""Support module for the Registry Scheduling local acceptance demo. + +The demo drives the real `scheduling` and `schedulingctl` binaries against a +disposable PostgreSQL database and proves four acceptance scenarios over the +public HTTP contract (see products/scheduling/demo/README.md): + +- AT-01 two callers confirm against the last unit concurrently; +- AT-05 a hold expires and capacity reopens while late confirmation fails; +- AT-06 a lost confirmation response replays under the same idempotency key; +- AT-19 a weekly opening across a daylight-saving fold serves the moved grid. + +Authentication is real: the script generates an RSA key pair, publishes the +public key as a static JWKS document the runtime verifies, and signs RFC 9068 +access tokens for each demo caller, including the task grants the mutating +routes demand. Nothing here is a credential beyond this throwaway run +directory, and no secret is printed. + +Python 3 standard library only, like every Registry Stack support script. +""" + +import argparse +import base64 +import json +import secrets +import shutil +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +# The fixed demo identities. The issuer and audience never leave the run +# directory; the clients are the three callers the runtime config admits. +DEMO_ISSUER = "https://issuer.scheduling-demo.invalid" +DEMO_AUDIENCE = "scheduling-demo" +DEMO_KEY_ID = "scheduling-demo-1" +BOOKER_A = "demo-booker-a" +BOOKER_B = "demo-booker-b" +READER = "demo-reader" + +# The task-grant permissions every booking caller carries: one permission per +# location, naming the offering's service and the actions the demo exercises. +GRANT_PERMISSIONS = [ + { + "service": "registry-update", + "location": "bangkok-counter", + "actions": [ + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, + { + "service": "registry-update", + "location": "new-york-hall", + "actions": [ + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, +] + +# The offering and slot facts the scenarios pin. The Bangkok counter opens +# 2026-10-01 (a Thursday) at 09:00 local (02:00Z in Asia/Bangkok); the grid +# steps in 30-minute increments. The fold-day offering serves 2026-11-01, +# where America/New_York falls back and the closure moves the grid anchor. +BANGKOK_OFFERING = "registry-update-30" +BANGKOK_RACE_START = "2026-10-01T02:00:00Z" +# The counter offering carries five-minute buffers before and after each +# booking, so back-to-back grid slots conflict on the one station. The three +# committed Bangkok bookings (race, hold, replay) therefore sit an hour apart. +BANGKOK_HOLD_START = "2026-10-01T04:00:00Z" +BANGKOK_REPLAY_START = "2026-10-01T03:00:00Z" +BANGKOK_REPLAY_CHANGED_START = "2026-10-01T03:30:00Z" +BANGKOK_DAY_START = "2026-10-01T00:00:00Z" +BANGKOK_DAY_END = "2026-10-02T00:00:00Z" + +FOLD_OFFERING = "fold-day-update-30" +FOLD_DAY_START = "2026-11-01T00:00:00Z" +FOLD_DAY_END = "2026-11-02T00:00:00Z" +# America/New_York folds on 2026-11-01: the 00:30-02:30 opening spans +# [04:30Z, 07:30Z), and the 00:15-00:45 closure moves the grid anchor to +# 04:45Z. 06:30Z is a wall-clock match off the moved grid and must not serve. +FOLD_FIRST_SLOT = "2026-11-01T04:45:00Z" +FOLD_SERVED_SLOTS = [ + "2026-11-01T04:45:00Z", + "2026-11-01T05:15:00Z", + "2026-11-01T05:45:00Z", +] +FOLD_UNSERVED_START = "2026-11-01T06:30:00Z" +FOLD_BOOKED_START = "2026-11-01T05:15:00Z" + + +def b64url(raw: bytes) -> str: + """Base64url without padding, the JWT and JWK encoding.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +# -------------------------------------------------------------------------- +# Minimal DER reading for one purpose: turn an RSA SubjectPublicKeyInfo into +# the JWK the runtime's JWKS verifier accepts. No third-party library is +# available to support scripts, so the two-byte header walk lives here. +# -------------------------------------------------------------------------- + +def _der_tlv(data: bytes, offset: int) -> tuple[int, bytes, bytes]: + """Read one DER tag-length-value at `offset`, returning the tag, the + value bytes, and the buffer that follows it.""" + tag = data[offset] + length = data[offset + 1] + if length & 0x80: + size = length & 0x7F + length = int.from_bytes(data[offset + 2 : offset + 2 + size], "big") + offset += 2 + size + else: + offset += 2 + return tag, data[offset : offset + length], data[offset + length :] + + +def rsa_public_jwk(public_der: bytes) -> dict: + """Build the RS256 JWK for an RSA SubjectPublicKeyInfo document.""" + outer_tag, spki, remainder = _der_tlv(public_der, 0) + if outer_tag != 0x30 or remainder: + raise ValueError("the public key is not one DER sequence") + _, _, after_algorithm = _der_tlv(spki, 0) + bit_tag, bit_string, _ = _der_tlv(after_algorithm, 0) + if bit_tag != 0x03: + raise ValueError("the SubjectPublicKeyInfo carries no bit string") + _, rsa_pair, _ = _der_tlv(bit_string[1:], 0) + _, modulus_raw, after_modulus = _der_tlv(rsa_pair, 0) + _, exponent_raw, _ = _der_tlv(after_modulus, 0) + modulus = modulus_raw.lstrip(b"\x00") + exponent = exponent_raw.lstrip(b"\x00") + if not modulus or not exponent: + raise ValueError("the RSA public key integers are empty") + return { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": DEMO_KEY_ID, + "n": b64url(modulus), + "e": b64url(exponent), + } + + +# -------------------------------------------------------------------------- +# Token minting. The header is an RFC 9068 access token (`typ: at+jwt`); the +# signing itself is delegated to openssl so no signing code lives here. +# -------------------------------------------------------------------------- + +def openssl_sign(signing_input: bytes, key_path: Path) -> bytes: + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", str(key_path)], + input=signing_input, + capture_output=True, + check=True, + ) + return result.stdout + + +def mint_token(signer, claims: dict) -> str: + header = {"alg": "RS256", "typ": "at+jwt", "kid": DEMO_KEY_ID} + encoded_header = b64url(json.dumps(header, separators=(",", ":")).encode()) + encoded_payload = b64url(json.dumps(claims, separators=(",", ":")).encode()) + signature = b64url(signer(f"{encoded_header}.{encoded_payload}".encode())) + return f"{encoded_header}.{encoded_payload}.{signature}" + + +def caller_claims( + client: str, + subject: str, + scopes: list[str] | None = None, + grant: bool = False, + now: int | None = None, +) -> dict: + """One caller's claims: identity, actor kind, scopes, and an optional + task grant whose scheduling permissions cover the demo's offerings.""" + issued_at = int(time.time()) if now is None else now + claims = { + "iss": DEMO_ISSUER, + "aud": DEMO_AUDIENCE, + "sub": subject, + "iat": issued_at, + "exp": issued_at + 3600, + "client_id": client, + "registry_actor_kind": "service", + } + if scopes is not None: + claims["registry_scopes"] = scopes + if grant: + claims.update( + { + "registry_grant_id": f"grant-{secrets.token_hex(8)}", + "registry_grant_source_issuer": DEMO_ISSUER, + "registry_grant_client": client, + "registry_grant_resource": DEMO_AUDIENCE, + "registry_grant_exp": issued_at + 3600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": GRANT_PERMISSIONS, + }, + # A complete grant names its purpose and its approver beside + # the six registry_grant_* members; an incomplete set is + # refused as a credentials problem, not honored partially. + "registry_purpose": "registry-update", + "registry_approver": "demo-approver", + } + ) + return claims + + +def admission_body(offering: str, start: str, policy_revision: int, subject: str) -> dict: + """One admission request body. The example policy keys duplicate-active + bookings by subject, so each caller names itself as the duplicate key the + same way the offering's policy prescribes.""" + return { + "offering": offering, + "start": start, + "party": {"recipients": 1, "attendees": 1}, + "duplicateKey": subject, + "policyRevision": policy_revision, + "capabilities": [], + "prerequisites": [], + } + + +# -------------------------------------------------------------------------- +# HTTP over the public contract. +# -------------------------------------------------------------------------- + +def request( + method: str, + base_url: str, + path: str, + token: str | None = None, + idempotency_key: str | None = None, + body: dict | None = None, + query: dict | None = None, +) -> tuple[int, dict | None]: + url = f"{base_url}{path}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + payload = None + headers = {"accept": "application/json"} + if body is not None: + payload = json.dumps(body).encode() + headers["content-type"] = "application/json" + if token is not None: + headers["authorization"] = f"Bearer {token}" + if idempotency_key is not None: + headers["idempotency-key"] = idempotency_key + call = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(call, timeout=15) as response: + raw = response.read() + document = json.loads(raw) if raw else None + return response.status, document + except urllib.error.HTTPError as error: + raw = error.read() + return error.code, json.loads(raw) if raw else None + + +def parse_instant(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def wait_until(deadline: datetime) -> None: + remaining = (deadline - datetime.now(timezone.utc)).total_seconds() + if remaining > 0: + time.sleep(remaining + 0.5) + + +# -------------------------------------------------------------------------- +# The authoring pieces `run.sh` needs written before the runtime starts. +# -------------------------------------------------------------------------- + +def shorten_hold_ttl(policy_text: str) -> str: + """Set the hold TTL to one minute so the expiry scenario runs quickly.""" + marker = "ttlMinutes: 5" + if policy_text.count(marker) != 1: + raise ValueError("the template policy does not carry exactly one five-minute hold TTL") + return policy_text.replace(marker, "ttlMinutes: 1") + + +DEMO_RECORDS = """\ +# Live environment records for the scheduling demo: one interchangeable +# station at the Bangkok counter (so the race scenario competes for the last +# unit), one at the New York hall, and the fold-day prep closure that moves +# the 2026-11-01 grid anchor past 04:45Z. +locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +""" + + +# The OpenSSL config fragments the throwaway database TLS materials need. +# The runtime's released binary requires TLS to PostgreSQL, so the demo +# generates a two-day certificate authority, a server certificate for +# loopback, and trusts exactly that authority in its runtime configuration. +CA_REQUEST_CONFIG = """\ +[req] +distinguished_name = name +x509_extensions = ca +prompt = no +[name] +CN = Scheduling Demo Database CA +[ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign +""" + +SERVER_SIGN_CONFIG = """\ +[server] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature,keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = IP:127.0.0.1,DNS:localhost +""" + + +def generate_database_tls(root: Path) -> None: + """Generate the demo's throwaway database PKI: a certificate authority + under the secrets root (the trust the runtime configuration names) and a + server key pair the demo's PostgreSQL container serves.""" + tls = root / "db-tls" + tls.mkdir() + (tls / "ca.cnf").write_text(CA_REQUEST_CONFIG) + (tls / "server.ext").write_text(SERVER_SIGN_CONFIG) + run = lambda arguments: subprocess.run( # noqa: E731 + ["openssl", *arguments], check=True, capture_output=True, cwd=tls + ) + run([ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-keyout", "ca.key", "-out", "ca.crt", "-config", "ca.cnf", + ]) + run([ + "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", "server.key", "-out", "server.csr", + "-subj", "/CN=localhost", + ]) + run([ + "x509", "-req", "-in", "server.csr", + "-CA", "ca.crt", "-CAkey", "ca.key", "-CAcreateserial", + "-out", "server.crt", "-days", "2", + "-extfile", "server.ext", "-extensions", "server", + ]) + # Refuse a certificate the extensions never reached: an extension-free + # server certificate is rejected only at connection time, far from its + # cause, and the silent form of this failure is exactly that. + names = subprocess.run( + ["openssl", "x509", "-in", "server.crt", "-noout", "-ext", "subjectAltName"], + check=True, capture_output=True, text=True, cwd=tls, + ).stdout + if "IP Address:127.0.0.1" not in names and "DNS:localhost" not in names: + raise SystemExit("the demo server certificate carries no subjectAltName") + + +def runtime_config( + project: Path, + secrets_root: Path, + audit_path: Path, + port: int, + trusted_root_ca: Path | None, +) -> str: + trust = ( + f" trustedRootCertificateRef: secret:file/db-root-ca\n" + if trusted_root_ca is not None + else "" + ) + return f"""\ +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + root: {project} +listener: + bind: 127.0.0.1:{port} + tlsTermination: development-loopback +secretProviders: + file: + root: {secrets_root} +database: + runtimeUrlRef: secret:file/db-url + migrationUrlRef: secret:file/db-url +{trust}authentication: + oidc: + issuer: {DEMO_ISSUER} + audience: {DEMO_AUDIENCE} + allowedClients: [{BOOKER_A}, {BOOKER_B}, {READER}] + jwksSource: + kind: static + documentRef: secret:file/jwks +audit: + path: {audit_path} + hashKeyRef: secret:file/audit-key +retention: {{}} +destinations: + reminders: null + hooks: {{}} +""" + + +def prepare( + root: Path, + example: Path, + database_url: str, + port: int, + database_root_ca: Path | None, +) -> None: + """Lay out the run directory: project copy, key material, records, and + the runtime configuration, all owner-only.""" + project = root / "project" + shutil.copytree(example, project) + policy_path = project / "scheduling.yaml" + policy_path.write_text(shorten_hold_ttl(policy_path.read_text())) + + signing_key = root / "demo-signing-key.pem" + subprocess.run( + [ + "openssl", + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-out", + str(signing_key), + ], + check=True, + capture_output=True, + ) + public_der = subprocess.run( + ["openssl", "rsa", "-in", str(signing_key), "-pubout", "-outform", "DER"], + check=True, + capture_output=True, + ).stdout + jwks = {"keys": [rsa_public_jwk(public_der)]} + + secrets_root = root / "secrets" + secrets_root.mkdir() + audit_dir = root / "audit" + audit_dir.mkdir() + # The audit path is the journal file itself; its advisory lock file is + # created beside it. + audit_path = audit_dir / "audit.jsonl" + # The demo always generates its own database PKI: the demo container + # serves it, and the runtime trusts exactly its authority. An external + # --database-url server presents some other chain, so the operator names + # the authority to trust instead and the demo materials stay unused. + generate_database_tls(root) + trust_root: Path | None = None + if database_root_ca is None: + (secrets_root / "db-root-ca").write_bytes((root / "db-tls" / "ca.crt").read_bytes()) + trust_root = secrets_root / "db-root-ca" + elif database_root_ca.is_file(): + (secrets_root / "db-root-ca").write_bytes(database_root_ca.read_bytes()) + trust_root = secrets_root / "db-root-ca" + else: + raise SystemExit(f"the database root CA does not exist: {database_root_ca}") + # Secret files carry no trailing newline: every byte is the secret. + (secrets_root / "db-url").write_text(database_url) + (secrets_root / "jwks").write_text(json.dumps(jwks, separators=(",", ":"))) + (secrets_root / "audit-key").write_text(secrets.token_hex(32)) + (root / "records.yaml").write_text(DEMO_RECORDS) + (root / "runtime.yaml").write_text( + runtime_config( + project.resolve(), secrets_root.resolve(), audit_path.resolve(), port, trust_root + ) + ) + + +# -------------------------------------------------------------------------- +# The four acceptance scenarios. +# -------------------------------------------------------------------------- + +class Verifier: + def __init__(self, base_url: str, signing_key: Path) -> None: + self.base_url = base_url + self.signer = lambda data: openssl_sign(data, signing_key) + self.failures: list[str] = [] + self.checks = 0 + status, document = request("GET", base_url, "/v1/scheduling", token=self.reader_token()) + self.check("the deployment answers its service document", status == 200, + f"status={status} body={document}") + if document is None or "policyRevision" not in document: + raise SystemExit("the running deployment did not answer /v1/scheduling") + self.policy_revision = document["policyRevision"] + + def check(self, claim: str, holds: bool, evidence: str = "") -> None: + self.checks += 1 + if holds: + print(f" pass: {claim}") + else: + print(f" FAIL: {claim}{': ' + evidence if evidence else ''}") + self.failures.append(claim) + + def reader_token(self) -> str: + return mint_token( + self.signer, + caller_claims(READER, "demo-catalogue-reader", scopes=["scheduling-read"]), + ) + + def booker_token(self, name: str, subject: str) -> str: + return mint_token(self.signer, caller_claims(name, subject, grant=True)) + + def admission(self, offering: str, start: str, subject: str) -> dict: + return admission_body(offering, start, self.policy_revision, subject) + + def availability(self, token: str, offering: str, start: str, end: str) -> list[dict]: + _, document = request( + "GET", + self.base_url, + "/v1/availability", + token=token, + query={"offering": offering, "start": start, "end": end}, + ) + return document["items"] if document else [] + + def race_for_the_last_unit(self) -> None: + """AT-01: two callers confirm against the last unit concurrently; at + most one claim succeeds and the loser receives a recoverable + capacity conflict.""" + print("AT-01: two callers race for the last station") + reader = self.reader_token() + slots = self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + race_slot = next( + (item for item in slots if item["kind"] == "slot" + and parse_instant(item["start"]) == parse_instant(BANGKOK_RACE_START)), + None, + ) + self.check("the raced slot is published with one free unit", + race_slot is not None and race_slot["free"] == 1, + json.dumps(race_slot)) + + bodies: list[tuple[int, dict | None]] = [] + barrier = threading.Barrier(2) + + def fire(client: str, subject: str, key: str) -> None: + barrier.wait() + bodies.append(request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(client, subject), + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_RACE_START, subject)}, + )) + + threads = [ + threading.Thread(target=fire, args=(BOOKER_A, "demo-race-a", "demo-race-a")), + threading.Thread(target=fire, args=(BOOKER_B, "demo-race-b", "demo-race-b")), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + statuses = sorted(status for status, _ in bodies) + codes = [document.get("code") if document else None for _, document in bodies] + self.check("exactly one confirmation succeeds and one is refused", + statuses == [201, 409], f"statuses={statuses} codes={codes}") + self.check("the loser receives the recoverable capacity conflict", + "capacity.exhausted" in codes, f"codes={codes}") + + after = self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + raced = next( + (item for item in after if item["kind"] == "slot" + and parse_instant(item["start"]) == parse_instant(BANGKOK_RACE_START)), + None, + ) + # A fully committed slot is not offered at all: the page lists only + # slots that hold a free unit. + self.check("the raced slot holds no free unit afterwards", + raced is None or raced["free"] == 0, + json.dumps(raced)) + + def hold_create(self) -> dict | None: + """AT-05, first half: mint a hold and confirm its slot shows no free + unit while the hold lives.""" + print("AT-05: a hold is minted, then expires") + reader = self.reader_token() + status, hold = request( + "POST", + self.base_url, + "/v1/holds", + token=self.booker_token(BOOKER_A, "demo-hold"), + idempotency_key="demo-hold-create", + body=self.admission(BANGKOK_OFFERING, BANGKOK_HOLD_START, "demo-hold"), + ) + self.check("the hold is minted", status == 201 and hold is not None, + f"status={status} body={hold}") + if status != 201 or hold is None: + return None + held_slot = next( + (item for item in self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + if item["kind"] == "slot" and parse_instant(item["start"]) == parse_instant(BANGKOK_HOLD_START)), + None, + ) + # Like the raced slot, a held slot leaves the page entirely while no + # free unit remains. + self.check("the held slot shows no free unit while the hold lives", + held_slot is None or held_slot["free"] == 0, + json.dumps(held_slot)) + print(f" the hold expires at {hold['expiresAt']}; the other scenarios run while its clock does") + return hold + + def hold_expired(self, hold: dict) -> None: + """AT-05, second half: after the TTL passes, capacity is bookable + again and the late confirmation of the expired hold is refused. The + store counts a hold only while it is unexpired at the observed now, + so these outcomes do not depend on the cleanup worker having run.""" + reader = self.reader_token() + booker = self.booker_token(BOOKER_A, "demo-hold") + wait_until(parse_instant(hold["expiresAt"])) + expired_slot = next( + (item for item in self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + if item["kind"] == "slot" and parse_instant(item["start"]) == parse_instant(BANGKOK_HOLD_START)), + None, + ) + self.check("the expired hold's capacity is bookable again", + expired_slot is not None and expired_slot["free"] == 1, + json.dumps(expired_slot)) + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key="demo-hold-confirm-late", + body={"hold": hold["holdId"]}, + ) + self.check("late confirmation of the expired hold fails with hold.expired", + status == 410 and problem is not None and problem.get("code") == "hold.expired", + f"status={status} body={problem}") + + def lost_confirmation_replay(self) -> None: + """AT-06: a confirmation commits but its response is lost; the + same-key retry returns the original appointment and a changed + payload under that key is refused.""" + print("AT-06: a lost confirmation replays under the same key") + booker = self.booker_token(BOOKER_A, "demo-replay") + key = "demo-replay-1" + status, first = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_START, "demo-replay")}, + ) + self.check("the original confirmation commits", status == 201 and first is not None, + f"status={status}") + if first is None: + return + # The response never reached the caller; the retry repeats the exact + # request under the exact key. + status, replay = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_START, "demo-replay")}, + ) + self.check("the same-key retry returns the original appointment", + status == 201 and replay is not None + and replay.get("appointmentId") == first["appointmentId"], + f"status={status}") + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_CHANGED_START, "demo-replay")}, + ) + self.check("a changed payload under that key is refused", + status == 409 and problem is not None + and problem.get("code") == "idempotency.key-reused", + f"status={status} body={problem}") + + def fold_day_grid(self) -> None: + """AT-19: the Sunday opening across the 2026-11-01 New York fold + serves the grid the closure moved (04:45Z anchor), never a + wall-clock match off it, with no shifted commitment.""" + print("AT-19: the fold-day opening serves the moved grid") + reader = self.reader_token() + items = self.availability(reader, FOLD_OFFERING, FOLD_DAY_START, FOLD_DAY_END) + slots = [item for item in items if item["kind"] == "slot"] + starts = [parse_instant(item["start"]) for item in slots] + self.check("the fold-day grid anchors at the moved 04:45Z start", + bool(starts) and min(starts) == parse_instant(FOLD_FIRST_SLOT), + json.dumps([item["start"] for item in slots])) + for expected in FOLD_SERVED_SLOTS: + self.check(f"the moved grid serves {expected}", + parse_instant(expected) in starts) + self.check("the fold does not duplicate the pre-closure 04:30Z start", + parse_instant("2026-11-01T04:30:00Z") not in starts) + self.check("the wall-clock match 06:30Z is not served", + parse_instant(FOLD_UNSERVED_START) not in starts) + status, booked = request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(BOOKER_A, "demo-fold-a"), + idempotency_key="demo-fold-early", + body={"admission": self.admission(FOLD_OFFERING, FOLD_BOOKED_START, "demo-fold-a")}, + ) + self.check("the moved-grid start books exactly as published", + status == 201 and booked is not None + and parse_instant(booked["start"]) == parse_instant(FOLD_BOOKED_START), + f"status={status}") + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(BOOKER_B, "demo-fold-b"), + idempotency_key="demo-fold-wall-clock", + body={"admission": self.admission(FOLD_OFFERING, FOLD_UNSERVED_START, "demo-fold-b")}, + ) + self.check("a wall-clock match off the moved grid is refused", + status == 422 and problem is not None + and problem.get("code") == "schedule.unpublished", + f"status={status} body={problem}") + + def run_all(self) -> int: + self.race_for_the_last_unit() + hold = self.hold_create() + self.lost_confirmation_replay() + self.fold_day_grid() + if hold is not None: + self.hold_expired(hold) + print(f"{self.checks - len(self.failures)}/{self.checks} checks passed") + if self.failures: + for failure in self.failures: + print(f"failed: {failure}") + return 1 + return 0 + + +def verify(base_url: str, signing_key: Path) -> int: + # The four scenarios share no state except the deployment itself: each + # books under its own subject, its own slot, and its own idempotency key. + verifier = Verifier(base_url, signing_key) + return verifier.run_all() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--root", type=Path, required=True) + prepare_parser.add_argument("--example", type=Path, required=True) + prepare_parser.add_argument("--database-url", required=True) + prepare_parser.add_argument("--port", type=int, required=True) + prepare_parser.add_argument("--database-root-ca", type=Path) + + verify_parser = commands.add_parser("verify") + verify_parser.add_argument("--base-url", required=True) + verify_parser.add_argument("--signing-key", type=Path, required=True) + + arguments = parser.parse_args() + if arguments.command == "prepare": + prepare( + arguments.root, + arguments.example, + arguments.database_url, + arguments.port, + arguments.database_root_ca, + ) + return 0 + return verify(arguments.base_url, arguments.signing_key) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/demo/support/test_demo.py b/products/scheduling/demo/support/test_demo.py new file mode 100644 index 0000000000..82683102c5 --- /dev/null +++ b/products/scheduling/demo/support/test_demo.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the pure pieces of the Scheduling demo support module. + +Run from the repository root: + + python3 -m unittest products/scheduling/demo/support/test_demo.py -v +""" + +import base64 +import json +import subprocess +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import demo # noqa: E402 + + +class B64UrlTest(unittest.TestCase): + def test_encoding_drops_padding_and_keeps_url_safety(self): + self.assertEqual(demo.b64url(b"\xff"), "_w") + self.assertEqual(demo.b64url(b"ab"), "YWI") + self.assertEqual(demo.b64url(b"abc"), "YWJj") + + +class RsaJwkTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.key = demo_key_path() + subprocess.run( + ["openssl", "genpkey", "-algorithm", "RSA", + "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(cls.key)], + check=True, capture_output=True, + ) + cls.public_der = subprocess.run( + ["openssl", "rsa", "-in", str(cls.key), "-pubout", "-outform", "DER"], + check=True, capture_output=True, + ).stdout + + @classmethod + def tearDownClass(cls): + cls.key.unlink(missing_ok=True) + + def test_the_public_key_becomes_an_rs256_jwk(self): + jwk = demo.rsa_public_jwk(self.public_der) + self.assertEqual(jwk["kty"], "RSA") + self.assertEqual(jwk["alg"], "RS256") + self.assertEqual(jwk["use"], "sig") + # A 2048-bit modulus is 256 bytes before encoding; e is the usual + # 65537, which base64url-encodes to the well-known AQAB. + modulus = base64.urlsafe_b64decode(jwk["n"] + "===") + self.assertEqual(len(modulus), 256) + self.assertEqual(jwk["e"], "AQAB") + + def test_a_non_sequence_document_is_refused(self): + with self.assertRaises(ValueError): + demo.rsa_public_jwk(b"\x02\x01\x00") + + +def demo_key_path() -> Path: + import tempfile + return Path(tempfile.gettempdir()) / "scheduling-demo-test-key.pem" + + +class MintTokenTest(unittest.TestCase): + def test_the_header_is_an_rfc9068_access_token(self): + def signer(data): + self.assertEqual(data.count(b"."), 1) + return b"signature" + + token = demo.mint_token(signer, {"iss": "https://issuer.test", "sub": "s"}) + header, payload, signature = token.split(".") + decoded_header = json.loads(base64.urlsafe_b64decode(header + "===")) + decoded_payload = json.loads(base64.urlsafe_b64decode(payload + "===")) + self.assertEqual(decoded_header, {"alg": "RS256", "typ": "at+jwt", "kid": demo.DEMO_KEY_ID}) + self.assertEqual(signature, demo.b64url(b"signature")) + self.assertEqual(decoded_payload["iss"], "https://issuer.test") + self.assertEqual(decoded_payload["sub"], "s") + + +class CallerClaimsTest(unittest.TestCase): + def test_a_reader_carries_scopes_and_no_grant(self): + claims = demo.caller_claims("demo-reader", "reader-subject", scopes=["scheduling-read"]) + self.assertEqual(claims["registry_scopes"], ["scheduling-read"]) + self.assertEqual(claims["registry_actor_kind"], "service") + self.assertNotIn("registry_grant_bounds", claims) + + def test_a_booker_carries_a_complete_scheduling_grant(self): + claims = demo.caller_claims("demo-booker-a", "booker-subject", grant=True, now=1_000_000) + self.assertEqual(claims["registry_grant_client"], "demo-booker-a") + self.assertEqual(claims["registry_grant_resource"], demo.DEMO_AUDIENCE) + self.assertEqual(claims["registry_grant_exp"], 1_000_000 + 3600) + self.assertEqual(claims["registry_purpose"], "registry-update") + self.assertEqual(claims["registry_approver"], "demo-approver") + bounds = claims["registry_grant_bounds"] + self.assertEqual(bounds["type"], "scheduling") + locations = {permission["location"] for permission in bounds["permissions"]} + self.assertEqual(locations, {"bangkok-counter", "new-york-hall"}) + + +class AdmissionBodyTest(unittest.TestCase): + def test_the_body_names_its_caller_as_the_duplicate_key(self): + body = demo.admission_body(demo.BANGKOK_OFFERING, demo.BANGKOK_HOLD_START, 7, "demo-hold") + self.assertEqual(body["offering"], demo.BANGKOK_OFFERING) + self.assertEqual(body["start"], demo.BANGKOK_HOLD_START) + self.assertEqual(body["duplicateKey"], "demo-hold") + self.assertEqual(body["policyRevision"], 7) + self.assertEqual(body["party"], {"recipients": 1, "attendees": 1}) + self.assertEqual(body["capabilities"], []) + self.assertEqual(body["prerequisites"], []) + + +class HoldTtlTest(unittest.TestCase): + def test_the_demo_policy_ttl_is_shortened_exactly_once(self): + policy = "holdPolicy:\n ttlMinutes: 5\n maxPerCaller: 3\n" + self.assertEqual(demo.shorten_hold_ttl(policy), + "holdPolicy:\n ttlMinutes: 1\n maxPerCaller: 3\n") + + def test_an_unexpected_template_is_refused_rather_than_silently_kept(self): + with self.assertRaises(ValueError): + demo.shorten_hold_ttl("holdPolicy:\n ttlMinutes: 9\n") + + +class DemoRecordsTest(unittest.TestCase): + def test_the_records_document_carries_one_station_per_pool(self): + import re + members = re.findall(r"resourceId: (\S+)", demo.DEMO_RECORDS) + self.assertEqual(members, ["station-1", "hall-station-1"]) + # The fold-day closure the AT-19 grid assertions depend on. + self.assertIn('date: "2026-11-01"', demo.DEMO_RECORDS) + self.assertIn("kind: closure", demo.DEMO_RECORDS) + + +class RuntimeConfigTest(unittest.TestCase): + def test_the_runtime_config_pins_every_required_block(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit"), 8105, None + ) + for required in ( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1", + "kind: SchedulingRuntimeConfig", + "root: /run/project", + "bind: 127.0.0.1:8105", + "tlsTermination: development-loopback", + "root: /run/secrets", + "runtimeUrlRef: secret:file/db-url", + "migrationUrlRef: secret:file/db-url", + f"issuer: {demo.DEMO_ISSUER}", + f"audience: {demo.DEMO_AUDIENCE}", + "kind: static", + "documentRef: secret:file/jwks", + "path: /run/audit", + "hashKeyRef: secret:file/audit-key", + ): + self.assertIn(required, config) + self.assertNotIn("trustedRootCertificateRef", config) + + def test_the_database_trust_root_is_named_when_present(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit"), 8105, + Path("/run/secrets/db-root-ca"), + ) + self.assertIn( + "trustedRootCertificateRef: secret:file/db-root-ca", + config, + ) + + def test_the_audit_path_names_the_journal_file(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit/audit.jsonl"), 8105, None + ) + self.assertIn("path: /run/audit/audit.jsonl", config) + + +class BangkokSpacingTest(unittest.TestCase): + def test_the_committed_bangkok_bookings_clear_each_others_buffers(self): + starts = [ + demo.parse_instant(demo.BANGKOK_RACE_START), + demo.parse_instant(demo.BANGKOK_HOLD_START), + demo.parse_instant(demo.BANGKOK_REPLAY_START), + ] + for earlier, later in zip(sorted(starts), sorted(starts)[1:]): + # The offering buffers five minutes each side of a 30-minute + # booking, so two committed bookings need more than a slot's + # width between their starts to leave the station free. + self.assertGreaterEqual((later - earlier).total_seconds(), 3600) + for value in starts: + self.assertEqual(value.minute % 30, 0) + + +class FoldDayExpectationsTest(unittest.TestCase): + def test_the_pinned_grid_is_thirty_minutes_from_the_moved_anchor(self): + starts = [demo.parse_instant(value) for value in demo.FOLD_SERVED_SLOTS] + self.assertEqual(starts[0], demo.parse_instant(demo.FOLD_FIRST_SLOT)) + for earlier, later in zip(starts, starts[1:]): + self.assertEqual((later - earlier).total_seconds(), 1800) + # The wall-clock match the scenario refuses is not on the grid. + refused = demo.parse_instant(demo.FOLD_UNSERVED_START) + self.assertNotIn(refused, starts) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml new file mode 100644 index 0000000000..9b6747de18 --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml @@ -0,0 +1,75 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-afternoon +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + windows: + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. +initial: [] +cases: + - name: a-small-household-fits-the-first-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 3 + attendees: 3 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: a-household-above-the-highest-band-is-refused + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 9 + attendees: 9 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: party.capacity-inadequate + - name: a-mid-size-household-fits-the-second-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 6 + attendees: 6 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml new file mode 100644 index 0000000000..dc1d65e92d --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml @@ -0,0 +1,77 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. +initial: [] +cases: + - name: first-household-takes-the-public-quota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: next-public-household-finds-the-quota-spent + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 diff --git a/products/scheduling/examples/standalone-arrival-window/records.yaml b/products/scheduling/examples/standalone-arrival-window/records.yaml new file mode 100644 index 0000000000..686d61354f --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/records.yaml @@ -0,0 +1,49 @@ +locations: + - id: civic-hall + timezone: Asia/Bangkok +pools: [] +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. +exceptions: [] diff --git a/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml new file mode 100644 index 0000000000..7e15587001 --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml @@ -0,0 +1,107 @@ +# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. + # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 +retention: + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. + attemptReceiptDays: 7 + hookPayloadDays: 7 diff --git a/products/scheduling/examples/standalone-arrival-window/scheduling.yaml b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml new file mode 100644 index 0000000000..a39be7e198 --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml @@ -0,0 +1,63 @@ +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] + - id: household-afternoon + service: household-day + label: Afternoon household block + mode: arrival-window + location: civic-hall + because: Larger households arrive in an afternoon block sized by party, not a flat headcount. + arrival: + window: household-afternoon-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. + - id: hall-afternoon-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "13:00" + endTime: "17:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall reopens on Saturday afternoons for banded household visits. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. diff --git a/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml b/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml new file mode 100644 index 0000000000..8b58c7a9cf --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml @@ -0,0 +1,77 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: counter-stations +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: bangkok-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-a-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active + - name: second-caller-gets-the-other-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-2 + - name: both-stations-busy-refuses-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:three + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted diff --git a/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml b/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml new file mode 100644 index 0000000000..e87dcb03a1 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml @@ -0,0 +1,83 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: fold-day-rebooking +now: 2026-11-01T04:00:00Z +# America/New_York falls back on 2026-11-01: local 01:30 occurs twice, and the +# 00:30-02:30 opening spans three real hours, [04:30Z, 07:30Z). The closure +# 00:15-00:45 has unambiguous endpoints in one offset and moves the opening +# start to 04:45Z, so the 30-minute grid anchors there: 04:45, 05:15, 05:45... +facts: + locations: + - id: new-york-hall + timezone: America/New_York + pools: + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true + exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +initial: [] +cases: + - name: morning-booking-after-the-closure + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: reschedule-into-the-later-morning + request: + offering: fold-day-update-30 + start: 2026-11-01T06:45:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + rescheduleOf: case:morning-booking-after-the-closure + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: a-wall-clock-match-off-the-moved-grid-is-not-served + request: + offering: fold-day-update-30 + start: 2026-11-01T06:30:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: schedule.unpublished diff --git a/products/scheduling/examples/standalone-exact-time/records.yaml b/products/scheduling/examples/standalone-exact-time/records.yaml new file mode 100644 index 0000000000..7b9199f212 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/records.yaml @@ -0,0 +1,27 @@ +locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +windows: [] +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" diff --git a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml new file mode 100644 index 0000000000..7e15587001 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml @@ -0,0 +1,107 @@ +# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. + # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 +retention: + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. + attemptReceiptDays: 7 + hookPayloadDays: 7 diff --git a/products/scheduling/examples/standalone-exact-time/scheduling.yaml b/products/scheduling/examples/standalone-exact-time/scheduling.yaml new file mode 100644 index 0000000000..1418e7ccc9 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/scheduling.yaml @@ -0,0 +1,79 @@ +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + reminders: + - minutesBefore: 1440 + because: One reminder the day before a counter update. + - minutesBefore: 120 + because: A second reminder two hours before, when stations are scarce. + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] + - id: fold-day-update-30 + service: registry-update + label: 30-minute fold-day update + mode: exact-time + location: new-york-hall + because: A 30-minute update at the hall, published across the autumn time change. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 1 + horizonDays: 3650 + pool: hall-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. + - id: new-york-hall-hours + location: new-york-hall + holidaySet: office-holidays + weekdays: [sun] + startTime: "00:30" + endTime: "02:30" + effectiveFrom: "2026-11-01" + effectiveUntil: "2026-11-01" + because: Fold-day hours whose endpoints sit outside the repeated local hour. +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json new file mode 100644 index 0000000000..b97b4b1d97 --- /dev/null +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -0,0 +1,5491 @@ +{ + "components": { + "headers": { + "CacheControlHeader": { + "description": "Every answer is marked no-store, so a booking view is never served from a cache.", + "schema": { + "const": "no-store" + } + }, + "TraceparentHeader": { + "description": "W3C trace context for the request and response.", + "schema": { + "type": "string" + } + } + }, + "schemas": { + "AdmissionRequest": { + "additionalProperties": false, + "properties": { + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duplicateKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "offering": { + "type": "string" + }, + "party": { + "$ref": "#/components/schemas/PartyCounts" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "prerequisites": { + "items": { + "type": "string" + }, + "type": "array" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "windowRevision": { + "anyOf": [ + { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "offering", + "start", + "party", + "policyRevision", + "capabilities", + "prerequisites" + ], + "type": "object" + }, + "AppointmentDocument": { + "additionalProperties": true, + "properties": { + "appointmentId": { + "type": "string" + }, + "cancelledAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + }, + "offering": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/AppointmentState" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "appointmentId", + "offering", + "start", + "end", + "units", + "revision", + "state", + "policyRevision", + "createdAt" + ], + "type": "object" + }, + "AppointmentHistoryEntryDocument": { + "additionalProperties": true, + "properties": { + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": {}, + "eventId": { + "type": "string" + }, + "kind": { + "description": "The lifecycle step the store recorded: held, confirmed, consumed, released, rescheduled, or cancelled.", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "type": "string" + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "eventId", + "kind", + "revision", + "occurredAt", + "detail" + ], + "type": "object" + }, + "AppointmentHistoryPage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AppointmentHistoryEntryDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "AppointmentState": { + "enum": [ + "confirmed", + "cancelled" + ], + "type": "string" + }, + "AvailabilityEntry": { + "discriminator": { + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/AvailabilitySlot" + }, + { + "$ref": "#/components/schemas/AvailabilityWindow" + } + ] + }, + "AvailabilityPage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AvailabilityEntry" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "AvailabilitySlot": { + "additionalProperties": true, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "free": { + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "slot" + }, + "start": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "kind", + "start", + "end", + "free" + ], + "type": "object" + }, + "AvailabilityWindow": { + "additionalProperties": true, + "properties": { + "channelRemaining": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "end": { + "format": "date-time", + "type": "string" + }, + "kind": { + "const": "window" + }, + "remaining": { + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "window": { + "type": "string" + } + }, + "required": [ + "kind", + "window", + "start", + "end", + "remaining" + ], + "type": "object" + }, + "CancelAppointmentRequest": { + "additionalProperties": false, + "properties": { + "observedRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "observedRevision" + ], + "type": "object" + }, + "CreateAppointmentRequest": { + "additionalProperties": false, + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is request.invalid.", + "minProperties": 1, + "properties": { + "admission": { + "anyOf": [ + { + "$ref": "#/components/schemas/AdmissionRequest" + }, + { + "type": "null" + } + ] + }, + "hold": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ExplainDocument": { + "additionalProperties": true, + "properties": { + "detailedCode": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "offering": { + "type": "string" + }, + "publicCode": { + "type": "string" + }, + "start": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "offering", + "start" + ], + "type": "object" + }, + "HoldDocument": { + "additionalProperties": true, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "holdId": { + "type": "string" + }, + "offering": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "start": { + "format": "date-time", + "type": "string" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "holdId", + "offering", + "start", + "end", + "units", + "expiresAt", + "policyRevision" + ], + "type": "object" + }, + "LocationDocument": { + "additionalProperties": true, + "properties": { + "locationId": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "locationId", + "timezone" + ], + "type": "object" + }, + "LocationPage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/LocationDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "OfferingDocument": { + "additionalProperties": true, + "properties": { + "bufferAfterMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "bufferBeforeMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "cancellationCutoffMinutes": { + "minimum": 0, + "type": "integer" + }, + "durationMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "horizonDays": { + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "leadTimeMinutes": { + "minimum": 0, + "type": "integer" + }, + "location": { + "type": "string" + }, + "maxRecipients": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "mode": { + "enum": [ + "exact-time", + "arrival-window" + ], + "type": "string" + }, + "prerequisites": { + "items": { + "type": "string" + }, + "type": "array" + }, + "reminders": { + "items": { + "$ref": "#/components/schemas/ReminderDocument" + }, + "type": "array" + }, + "requiresCapabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "service": { + "type": "string" + }, + "startIncrementMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "window": { + "anyOf": [ + { + "$ref": "#/components/schemas/WindowDocument" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "service", + "label", + "mode", + "location", + "leadTimeMinutes", + "horizonDays", + "cancellationCutoffMinutes", + "reminders", + "requiresCapabilities", + "prerequisites" + ], + "type": "object" + }, + "OfferingPage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OfferingDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "PartyCounts": { + "additionalProperties": false, + "properties": { + "attendees": { + "minimum": 0, + "type": "integer" + }, + "recipients": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "recipients", + "attendees" + ], + "type": "object" + }, + "Problem": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "authentication.refused", + "booking.duplicate-active", + "cancellation.cutoff-passed", + "capability.unmatched", + "capacity.exhausted", + "cursor.expired", + "cursor.invalid", + "eligibility.unavailable", + "hold.expired", + "hold.released", + "hook.unavailable", + "horizon.outside", + "idempotency.expired", + "idempotency.key-reused", + "location.closed", + "operation.not-authorized", + "party.capacity-inadequate", + "policy.changed", + "precondition.failed", + "precondition.required", + "prerequisite.missing", + "profile.not-authorized", + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", + "resource.unavailable", + "revision.mismatch", + "schedule.unpublished", + "service.unavailable" + ], + "type": "string" + }, + "detail": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "type": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "code", + "traceId" + ], + "type": "object" + }, + "ProblemAuthenticationRefused": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "authentication.refused" + }, + "detail": { + "const": "The bearer credential is missing, invalid, or expired. Sign in again." + }, + "status": { + "const": 401 + }, + "title": { + "const": "Authentication refused" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/authentication/refused" + } + }, + "type": "object" + } + ] + }, + "ProblemBookingDuplicateActive": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "booking.duplicate-active" + }, + "detail": { + "const": "An active booking already holds this party's duplicate key. Cancel or complete it before booking again." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Duplicate active booking" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/booking/duplicate-active" + } + }, + "type": "object" + } + ] + }, + "ProblemCancellationCutoffPassed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cancellation.cutoff-passed" + }, + "detail": { + "const": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Cancellation cutoff passed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cancellation/cutoff-passed" + } + }, + "type": "object" + } + ] + }, + "ProblemCapabilityUnmatched": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "capability.unmatched" + }, + "detail": { + "const": "The party or backing supply does not carry every capability this offering requires." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Capability unmatched" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/capability/unmatched" + } + }, + "type": "object" + } + ] + }, + "ProblemCapacityExhausted": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "capacity.exhausted" + }, + "detail": { + "const": "The supply is fully committed for the requested interval. Choose another time." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Capacity exhausted" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/capacity/exhausted" + } + }, + "type": "object" + } + ] + }, + "ProblemCursorExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cursor.expired" + }, + "detail": { + "const": "This cursor has expired. Start again without a cursor and deduplicate entries by id." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Cursor expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cursor/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemCursorInvalid": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cursor.invalid" + }, + "detail": { + "const": "The cursor is invalid for this request." + }, + "status": { + "const": 400 + }, + "title": { + "const": "Cursor invalid" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cursor/invalid" + } + }, + "type": "object" + } + ] + }, + "ProblemEligibilityUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "eligibility.unavailable" + }, + "detail": { + "const": "A required eligibility check could not run. Retry; do not treat this as permission." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Eligibility unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/eligibility/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemHoldExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hold.expired" + }, + "detail": { + "const": "The hold expired before confirmation. Its capacity is bookable again; start a new request." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Hold expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hold/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemHoldReleased": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hold.released" + }, + "detail": { + "const": "The claim is not an active hold. It may already be confirmed or released." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Hold released" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hold/released" + } + }, + "type": "object" + } + ] + }, + "ProblemHookUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hook.unavailable" + }, + "detail": { + "const": "A required lifecycle hook could not run, so the request was refused rather than half-applied." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Hook unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hook/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemHorizonOutside": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "horizon.outside" + }, + "detail": { + "const": "The requested start is earlier than the lead time allows or further ahead than the horizon allows." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Start outside the booking horizon" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/horizon/outside" + } + }, + "type": "object" + } + ] + }, + "ProblemIdempotencyExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "idempotency.expired" + }, + "detail": { + "const": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Idempotency window expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/idempotency/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemIdempotencyKeyReused": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "idempotency.key-reused" + }, + "detail": { + "const": "This idempotency key was used for a different request." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Idempotency key reused" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/idempotency/key-reused" + } + }, + "type": "object" + } + ] + }, + "ProblemLocationClosed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "location.closed" + }, + "detail": { + "const": "A closure covers the requested start. Choose a start outside the closure." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Location closed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/location/closed" + } + }, + "type": "object" + } + ] + }, + "ProblemOperationNotAuthorized": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "operation.not-authorized" + }, + "detail": { + "const": "Your current Scheduling authority does not allow this operation." + }, + "status": { + "const": 403 + }, + "title": { + "const": "Operation not authorized" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/operation/not-authorized" + } + }, + "type": "object" + } + ] + }, + "ProblemPartyCapacityInadequate": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "party.capacity-inadequate" + }, + "detail": { + "const": "The party is larger than this offering can ever serve, or its size falls outside the published units." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Party capacity inadequate" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/party/capacity-inadequate" + } + }, + "type": "object" + } + ] + }, + "ProblemPolicyChanged": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "policy.changed" + }, + "detail": { + "const": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Policy changed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/policy/changed" + } + }, + "type": "object" + } + ] + }, + "ProblemPreconditionFailed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "precondition.failed" + }, + "detail": { + "const": "The appointment changed since you loaded it. Reload and try again." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Precondition failed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/precondition/failed" + } + }, + "type": "object" + } + ] + }, + "ProblemPreconditionRequired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "precondition.required" + }, + "detail": { + "const": "This mutation requires the revision you loaded, or the duplicate key this offering keys on." + }, + "status": { + "const": 428 + }, + "title": { + "const": "Precondition required" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/precondition/required" + } + }, + "type": "object" + } + ] + }, + "ProblemPrerequisiteMissing": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "prerequisite.missing" + }, + "detail": { + "const": "The party is missing a prerequisite this offering requires." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Prerequisite missing" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/prerequisite/missing" + } + }, + "type": "object" + } + ] + }, + "ProblemProfileNotAuthorized": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "profile.not-authorized" + }, + "detail": { + "const": "The selected Scheduling profile does not authorize this request." + }, + "status": { + "const": 403 + }, + "title": { + "const": "Profile not authorized" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/profile/not-authorized" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestBodyTooLarge": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.body-too-large" + }, + "detail": { + "const": "The request body exceeds the accepted size." + }, + "status": { + "const": 413 + }, + "title": { + "const": "Payload too large" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/body-too-large" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestInvalid": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.invalid" + }, + "detail": { + "const": "The request could not be read as a Scheduling request." + }, + "status": { + "const": 400 + }, + "title": { + "const": "Request invalid" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/invalid" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestMethodNotAllowed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.method-not-allowed" + }, + "detail": { + "const": "The route exists but not for this method." + }, + "status": { + "const": 405 + }, + "title": { + "const": "Method not allowed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/method-not-allowed" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestNotFound": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.not-found" + }, + "detail": { + "const": "The requested route does not exist." + }, + "status": { + "const": 404 + }, + "title": { + "const": "Route not found" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/not-found" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestUnprocessable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.unprocessable" + }, + "detail": { + "const": "The request body could not be processed." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Request unprocessable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/unprocessable" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestUnsupportedMediaType": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.unsupported-media-type" + }, + "detail": { + "const": "The request body is not JSON." + }, + "status": { + "const": 415 + }, + "title": { + "const": "Unsupported media type" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/unsupported-media-type" + } + }, + "type": "object" + } + ] + }, + "ProblemResourceUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "resource.unavailable" + }, + "detail": { + "const": "Every capable member is unavailable for this interval." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Resource unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/resource/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemRevisionMismatch": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "revision.mismatch" + }, + "detail": { + "const": "The window revision changed before the request committed. Reload the catalogue and try again." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Revision mismatch" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/revision/mismatch" + } + }, + "type": "object" + } + ] + }, + "ProblemScheduleUnpublished": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "schedule.unpublished" + }, + "detail": { + "const": "No published schedule serves that start. Choose a start on the published grid." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Schedule unpublished" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/schedule/unpublished" + } + }, + "type": "object" + } + ] + }, + "ProblemServiceUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "service.unavailable" + }, + "detail": { + "const": "Scheduling storage is unavailable. Try again after the service recovers." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Scheduling service unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/service/unavailable" + } + }, + "type": "object" + } + ] + }, + "ReminderDocument": { + "additionalProperties": true, + "properties": { + "minutesBefore": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "minutesBefore" + ], + "type": "object" + }, + "RescheduleAppointmentRequest": { + "additionalProperties": false, + "properties": { + "admission": { + "$ref": "#/components/schemas/AdmissionRequest" + }, + "observedRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "observedRevision", + "admission" + ], + "type": "object" + }, + "ResourceDocument": { + "additionalProperties": true, + "properties": { + "available": { + "type": "boolean" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pool": { + "type": "string" + }, + "resourceId": { + "type": "string" + } + }, + "required": [ + "resourceId", + "pool", + "capabilities", + "available" + ], + "type": "object" + }, + "ResourcePage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ResourceDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "SchedulingServiceDocument": { + "additionalProperties": true, + "properties": { + "policyDigest": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "schedulingId": { + "type": "string" + } + }, + "required": [ + "schedulingId", + "policyRevision", + "policyDigest" + ], + "type": "object" + }, + "ServiceDocument": { + "additionalProperties": true, + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "type": "object" + }, + "ServicePage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ServiceDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "WindowDocument": { + "additionalProperties": true, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "revision", + "start", + "end", + "units" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "A fresh access token from the configured issuer, for this deployment's audience, signed by a key the issuer published. Catalogue, availability, and owned-record reads demand the configured reads scope (scheduling-read by default); the explain path demands the configured explain scope (scheduling-explain by default), which is never the reads scope. A mutating route demands no scope at all: its authority is the complete task grant the token carries, whose scheduling permissions the service checks against the offering's service and location and the action the route performs (hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel). A token with partial grant claims is refused rather than half-trusted.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Implemented Scheduling HTTP contract: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings. Mutating authority is a complete task grant; every refusal is one problem from the closed vocabulary.", + "license": { + "identifier": "Apache-2.0", + "name": "Apache-2.0" + }, + "title": "Registry Scheduling API", + "version": "v1alpha1" + }, + "openapi": "3.1.0", + "paths": { + "/healthz": { + "get": { + "description": "Process is live. No authentication, no storage, no policy.", + "operationId": "getLiveness", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Liveness", + "x-scheduling-authority": "unauthenticated" + } + }, + "/readyz": { + "get": { + "description": "Asks the Scheduling store to answer. A store that cannot answer is service.unavailable, with Retry-After.", + "operationId": "getReadiness", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Readiness", + "x-scheduling-authority": "unauthenticated" + } + }, + "/v1/appointments": { + "post": { + "description": "Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", + "operationId": "createAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + }, + { + "$ref": "#/components/schemas/ProblemHoldExpired" + } + ] + } + } + }, + "description": "Problem response: idempotency.expired, hold.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Confirm a hold or book directly", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/appointments/{appointment_id}": { + "get": { + "description": "Answers only to the caller that owns the booking. Another caller's appointment, a hold, and an unknown identifier are all operation.not-authorized, so existence is never disclosed.", + "operationId": "getAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read one owned appointment", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/appointments/{appointment_id}/cancel": { + "post": { + "description": "Releases the appointment's capacity and records the reason, under the task grant that names appointment.cancel. It is guarded by observedRevision and the offering's cancellation cutoff, never by the policy revision, so an appointment is always cancellable under the policy that currently governs its offering.", + "operationId": "cancelAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemCancellationCutoffPassed" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, cancellation.cutoff-passed, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + } + }, + "description": "Problem response: revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Cancel an owned appointment", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/appointments/{appointment_id}/history": { + "get": { + "description": "Newest first, one bounded page at a time. Each step carries the revision it produced, the pseudonymized actor reference when it had one, and the detail the store recorded. Existence is never disclosed: another caller's appointment answers operation.not-authorized.", + "operationId": "listAppointmentHistory", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentHistoryPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read an owned appointment's history", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/appointments/{appointment_id}/reschedule": { + "post": { + "description": "Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + "operationId": "rescheduleAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RescheduleAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Move an owned appointment", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/availability": { + "get": { + "description": "Bounded availability. Exact-time offerings answer in grid slots and arrival-window offerings answer in published windows; only entries with capacity left are listed, so a slot nobody can serve is not availability. A search spans at most 62 days from start, and a wider ask continues by cursor.", + "operationId": "searchAvailability", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", + "in": "query", + "name": "offering", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Earliest answerable start, defaulting to now.", + "in": "query", + "name": "start", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Latest answerable start. An end earlier than one minute after start is raised to it, and one further ahead than the 62-day span allows is clipped to it.", + "in": "query", + "name": "end", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this offering, its range, and the instant the last page ended at. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired. Deduplicate entries by their start.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Search published availability", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/availability/explain": { + "get": { + "description": "The separately authorized explanation of one start: the problem code every caller may see, the detailed code only this path discloses, and the refusal in words. A start that admits as things stand answers with no codes at all. The probe is a minimal party, so the answer explains the calendar and the capacity, never another caller's booking. resource.unavailable is disclosed here and never on the public path, whose answer is capacity.exhausted.", + "operationId": "explainAvailability", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", + "in": "query", + "name": "offering", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "The start to explain.", + "in": "query", + "name": "start", + "required": true, + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Explain one start's refusal", + "x-scheduling-authority": "explain-scope" + } + }, + "/v1/holds": { + "post": { + "description": "Reserves supply instead of committing it and answers the hold with its expiry. The caller must carry a complete task grant whose scheduling permission names the offering's service, its location, and hold.create; readable availability is not authority to book. The hold expires on its own, so its capacity returns without a release.", + "operationId": "createHold", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HoldDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reserve an admission", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/holds/{hold_id}": { + "delete": { + "description": "Gives a hold's capacity back before it expires, under the task grant that names hold.release. The hold's own identifier is the idempotency key, so a retried release answers as the first one did; a receipt retained past its window is idempotency.expired. A claim that is not an active hold is hold.released, whether unknown, already confirmed, or already released, and a grant naming another holder is operation.not-authorized.", + "operationId": "releaseHold", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The hold's identifier.", + "in": "path", + "name": "hold_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + } + }, + "description": "Problem response: hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Release a hold", + "x-scheduling-authority": "task-grant" + } + }, + "/v1/locations": { + "get": { + "description": "Each location carries the IANA timezone its published openings expand in.", + "operationId": "listLocations", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the locations", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/offerings": { + "get": { + "description": "The public view of the authored policy, sorted by identifier. Exact-time offerings carry durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, startIncrementMinutes, and maxRecipients; arrival-window offerings carry window. A field the offering's mode does not use is null, never zero.", + "operationId": "listOfferings", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferingPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the published offering catalogue", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/resources": { + "get": { + "description": "A pool is its concrete members, never an independent counter. available carries no reason: why a member is unavailable is private staff information, and the public answer to a caller is capacity.exhausted.", + "operationId": "listResources", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the backing resource pool members", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/scheduling": { + "get": { + "description": "Answers the deployment identifier, the current authored policy revision, and its digest, so a caller can detect that the policy it read has since moved. Reading it grants no catalogue, availability, or booking authority.", + "operationId": "describeScheduling", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulingServiceDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Describe this Scheduling deployment", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/services": { + "get": { + "description": "Sorted by identifier. A page is a 15-minute view of a live catalogue; follow nextCursor until it is absent.", + "operationId": "listServices", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServicePage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the service catalogue", + "x-scheduling-authority": "reads-scope" + } + } + }, + "servers": [ + { + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + "url": "https://scheduling.example.test" + } + ], + "x-registry-scheduling-edge-problems": [ + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type" + ], + "x-registry-scheduling-reserved-problems": [ + "eligibility.unavailable", + "hook.unavailable", + "precondition.failed", + "resource.unavailable" + ] +} diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json new file mode 100644 index 0000000000..59ec7a9323 --- /dev/null +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -0,0 +1,523 @@ +{ + "$defs": { + "AuditConfig": { + "additionalProperties": false, + "properties": { + "hashKeyRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "path": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "path", + "hashKeyRef" + ], + "type": "object" + }, + "AuthenticationConfig": { + "additionalProperties": false, + "properties": { + "oidc": { + "$ref": "#/$defs/OidcConfig" + } + }, + "required": [ + "oidc" + ], + "type": "object" + }, + "DatabaseConfig": { + "additionalProperties": false, + "properties": { + "migrationUrlRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "runtimeUrlRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "testOnlyPlaintext": { + "default": false, + "type": "boolean" + }, + "trustedRootCertificateRef": { + "default": null, + "pattern": "^secret:(?:env|file)/", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "runtimeUrlRef", + "migrationUrlRef" + ], + "type": "object" + }, + "DestinationsConfig": { + "additionalProperties": false, + "description": "Where due reminder and lifecycle-hook intents are dispatched. An absent\nreminders destination and an empty hook map are supported: intents stay\nlocal, so an operator can adopt the product before wiring delivery buses.", + "properties": { + "hooks": { + "additionalProperties": { + "$ref": "#/$defs/HookDestinationConfig" + }, + "description": "Logical URL hook destinations. The governed policy names only these\nids; URL, signing secret, and retry ceilings stay deployment-owned.", + "type": "object" + }, + "reminders": { + "anyOf": [ + { + "$ref": "#/$defs/ReminderDestinationConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "EnvironmentSecretProviderConfig": { + "additionalProperties": false, + "type": "object" + }, + "FileSecretProviderConfig": { + "additionalProperties": false, + "properties": { + "root": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "HookDestinationConfig": { + "additionalProperties": false, + "properties": { + "attemptTimeoutMilliseconds": { + "default": 5000, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "hmacSha256KeyRef": { + "type": "string" + }, + "maximumAttempts": { + "default": 8, + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "required": [ + "url", + "hmacSha256KeyRef" + ], + "type": "object" + }, + "ListenerConfig": { + "additionalProperties": false, + "properties": { + "bind": { + "default": "127.0.0.1:8105", + "type": "string" + }, + "networkExposure": { + "$ref": "#/$defs/ListenerNetworkExposure" + }, + "tlsTermination": { + "$ref": "#/$defs/TlsTermination" + } + }, + "required": [ + "tlsTermination" + ], + "type": "object" + }, + "ListenerNetworkExposure": { + "description": "The operator-declared private network placement of the HTTP listener.", + "enum": [ + "private-address", + "container-private" + ], + "type": "string" + }, + "OidcConfig": { + "additionalProperties": false, + "properties": { + "allowedClients": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "assertionIssuers": { + "additionalProperties": { + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "default": {}, + "description": "The assertion authorities each client may exchange a subject token\nfrom, keyed by client identifier.\n\nA deployment that performs no token exchange leaves this empty. Once a\nclient is listed, a token it exchanged is accepted only for one of that\nclient's declared authorities, so an assertion minted by an unrelated\nauthority the issuer happens to federate cannot become a booking\ncredential here.", + "maxProperties": 64, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "type": "object" + }, + "audience": { + "type": "string" + }, + "explainScope": { + "default": "scheduling-explain", + "description": "The scope the separately authorized explain path requires.", + "type": "string" + }, + "issuer": { + "type": "string" + }, + "jwksSource": { + "$ref": "#/$defs/OidcJwksSource" + }, + "jwksUri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "readsScope": { + "default": "scheduling-read", + "description": "The scope every listing and availability read requires.", + "type": "string" + }, + "scopeClaim": { + "default": "registry_scopes", + "type": "string" + } + }, + "required": [ + "issuer", + "audience" + ], + "type": "object" + }, + "OidcJwksSource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "discovery", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "documentRef": { + "pattern": "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + }, + "kind": { + "const": "static", + "type": "string" + } + }, + "required": [ + "kind", + "documentRef" + ], + "type": "object" + } + ] + }, + "ReminderDestinationConfig": { + "additionalProperties": false, + "properties": { + "bearerTokenRef": { + "default": null, + "pattern": "^secret:(?:env|file)/", + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RetentionConfig": { + "additionalProperties": false, + "description": "Retention periods the retention sweep enforces. Every value is deployment\nconfiguration, never policy: a jurisdiction's retention schedule approves\nthe deployed numbers, and changing one is an operational event.", + "properties": { + "attemptReceiptDays": { + "default": 7, + "description": "How long a stored idempotency receipt stays replayable.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "hookPayloadDays": { + "default": 7, + "description": "Lifetime of canonical hook envelopes retained for retry and\ndead-letter inspection.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RuntimePackageConfig": { + "additionalProperties": false, + "description": "The root of an authored scheduling project, holding `scheduling.yaml` and\nits optional package manifest.", + "properties": { + "root": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "SecretProvidersConfig": { + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "file": { + "$ref": "#/$defs/FileSecretProviderConfig" + } + }, + "required": [ + "file" + ] + }, + { + "properties": { + "environment": { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + } + }, + "required": [ + "environment" + ] + } + ], + "properties": { + "environment": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + }, + { + "type": "null" + } + ] + }, + "file": { + "anyOf": [ + { + "$ref": "#/$defs/FileSecretProviderConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "TlsTermination": { + "description": "Declares the trusted transport boundary for the runtime's plaintext HTTP\nlistener. Production listeners require operator-controlled upstream TLS\ntermination; direct plaintext is limited to the explicit loopback-only\ndevelopment mode.", + "enum": [ + "operator-controlled-upstream", + "development-loopback" + ], + "type": "string" + } + }, + "$id": "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": { + "pattern": "^secret:env/" + } + }, + "required": [ + "documentRef" + ] + } + }, + "required": [ + "jwksSource" + ] + } + }, + "required": [ + "oidc" + ] + } + }, + "required": [ + "authentication" + ] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + "environment": { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + } + }, + "required": [ + "environment" + ] + } + } + } + }, + { + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": { + "pattern": "^secret:file/" + } + }, + "required": [ + "documentRef" + ] + } + }, + "required": [ + "jwksSource" + ] + } + }, + "required": [ + "oidc" + ] + } + }, + "required": [ + "authentication" + ] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + "file": { + "$ref": "#/$defs/FileSecretProviderConfig" + } + }, + "required": [ + "file" + ] + } + } + } + } + ], + "description": "The operator runtime configuration document.", + "properties": { + "apiVersion": { + "const": "registry.registrystack.org/scheduling-runtime/v1alpha1", + "type": "string" + }, + "audit": { + "$ref": "#/$defs/AuditConfig" + }, + "authentication": { + "$ref": "#/$defs/AuthenticationConfig" + }, + "database": { + "$ref": "#/$defs/DatabaseConfig" + }, + "destinations": { + "$ref": "#/$defs/DestinationsConfig" + }, + "kind": { + "const": "SchedulingRuntimeConfig", + "type": "string" + }, + "listener": { + "$ref": "#/$defs/ListenerConfig" + }, + "package": { + "$ref": "#/$defs/RuntimePackageConfig" + }, + "retention": { + "$ref": "#/$defs/RetentionConfig" + }, + "secretProviders": { + "$ref": "#/$defs/SecretProvidersConfig" + } + }, + "required": [ + "apiVersion", + "kind", + "package", + "listener", + "secretProviders", + "database", + "authentication", + "audit", + "retention" + ], + "title": "Registry Scheduling runtime configuration", + "type": "object" +} diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh new file mode 100755 index 0000000000..dde869dbe0 --- /dev/null +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eu + +# shellcheck disable=SC1007 # the space after CDPATH= is the POSIX empty-assignment idiom the sibling products use +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +schedulingctl_bin=${SCHEDULINGCTL_BIN:-"$repo_root/target/debug/schedulingctl"} + +cd "$repo_root" +python3 products/scheduling/scripts/generate_openapi.py --check +python3 products/scheduling/scripts/check_dependency_direction.py +python3 products/scheduling/scripts/check_database_test_isolation.py +python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py' +python3 products/scheduling/scripts/validate_contracts.py + +# The committed runtime configuration schema is reproduced by its generator, +# never hand-edited; the drift test runs only under the schema feature, so +# the checkpoint is the gate that runs it. +CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked --quiet -p registry-scheduling --features schema + +if [ ! -x "$schedulingctl_bin" ]; then + echo "build schedulingctl first or set SCHEDULINGCTL_BIN" >&2 + exit 2 +fi + +work=$(mktemp -d "${TMPDIR:-/tmp}/scheduling-checkpoint.XXXXXX") +cleanup() { + case "$work" in + "${TMPDIR:-/tmp}"/scheduling-checkpoint.*) rm -rf -- "$work" ;; + *) exit 1 ;; + esac +} +trap cleanup EXIT HUP INT TERM + +for template in standalone-exact-time standalone-arrival-window; do + "$schedulingctl_bin" init "$work/$template" --template "$template" >/dev/null + "$schedulingctl_bin" check "$work/$template" >/dev/null + "$schedulingctl_bin" test "$work/$template" >/dev/null + "$schedulingctl_bin" explain "$work/$template" >/dev/null +done + +# The journeys must answer what they claim to answer, not merely exit 0. +check_output=$("$schedulingctl_bin" check "$work/standalone-exact-time") +echo "$check_output" | grep -q '^status: complete' +test_output=$("$schedulingctl_bin" test "$work/standalone-exact-time") +echo "$test_output" | grep -q '^Offline synthetic fixtures passed.$' +echo "$test_output" | grep -q '^proofBoundary: offline_synthetic$' + +# A project that carries findings reports them: check exits 0 with findings +# by default, refuses under --deny-findings, and a broken fixture proof is +# never green. sed writes beside the file and mv replaces it, because sed -i +# spells differ between the development hosts and CI. +broken="$work/broken" +"$schedulingctl_bin" init "$broken" --template standalone-exact-time >/dev/null +sed 's/ttlMinutes: [0-9][0-9]*/ttlMinutes: 0/' \ + "$broken/scheduling.yaml" >"$broken/scheduling.yaml.next" +mv "$broken/scheduling.yaml.next" "$broken/scheduling.yaml" +findings=$("$schedulingctl_bin" check "$broken") +echo "$findings" | grep -q '^status: incomplete$' +echo "$findings" | grep -q '^finding ' +if "$schedulingctl_bin" check "$broken" --deny-findings >/dev/null 2>&1; then + echo 'check --deny-findings accepted a finding-carrying project' >&2 + exit 1 +fi +sed 's/offering: registry-update-30/offering: no-such-offering/' \ + "$broken/fixtures/counter-stations.yaml" >"$broken/fixtures/counter-stations.yaml.next" +mv "$broken/fixtures/counter-stations.yaml.next" "$broken/fixtures/counter-stations.yaml" +if "$schedulingctl_bin" test "$broken" >/dev/null 2>&1; then + echo 'test accepted a fixture naming an offering the policy does not publish' >&2 + exit 1 +fi + +# The banded units table is reachable from the authoring journey, not only +# from the core's unit tests: swapping the arrival template's per-recipient +# record for a banded table must still check clean, and must change explain's +# window projection, which proves the records swap was read. +banded="$work/banded" +"$schedulingctl_bin" init "$banded" --template standalone-arrival-window >/dev/null +plain_explain=$("$schedulingctl_bin" explain "$banded") +python3 - "$banded/records.yaml" <<'PY' +import sys +from pathlib import Path + +import yaml + +path = Path(sys.argv[1]) +document = yaml.safe_load(path.read_text(encoding="utf-8")) +for window in document["windows"]: + window["unitsPolicy"] = { + "kind": "bandedTable", + "input": "serviceRecipientCount", + "bands": [ + {"upTo": 2, "units": 1, "because": "One unit for one or two recipients."}, + {"upTo": 3, "units": 2, "because": "Two units for three recipients."}, + ], + "aboveHighestBand": {"policy": "refuse"}, + "because": "Household size decides the serving slots.", + } +path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") +PY +"$schedulingctl_bin" check "$banded" >/dev/null +banded_explain=$("$schedulingctl_bin" explain "$banded") +if [ "$plain_explain" = "$banded_explain" ]; then + echo 'the banded units table did not change the window records explain read' >&2 + exit 1 +fi + +# The package journey on a fresh project: the manifest the runtime verifies. +"$schedulingctl_bin" init "$work/package" --template standalone-exact-time >/dev/null +"$schedulingctl_bin" package "$work/package" >/dev/null + +# The committed examples are the starter templates' output, so neither can +# drift from what an adopter initializes, including its live records document. +for template in standalone-exact-time standalone-arrival-window; do + example="$repo_root/products/scheduling/examples/$template" + "$schedulingctl_bin" init "$work/example-$template" --template "$template" >/dev/null + diff -r "$work/example-$template" "$example" >/dev/null + "$schedulingctl_bin" check "$example" >/dev/null + "$schedulingctl_bin" test "$example" >/dev/null +done + +echo "Scheduling product contracts and offline authoring journey passed." diff --git a/products/scheduling/scripts/check-contracts.sh b/products/scheduling/scripts/check-contracts.sh new file mode 100755 index 0000000000..d6ad6f6283 --- /dev/null +++ b/products/scheduling/scripts/check-contracts.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python3 "$script_dir/validate_contracts.py" +python3 -m unittest \ + "$script_dir/test_validate_contracts.py" + +echo "Scheduling product contracts passed" diff --git a/products/scheduling/scripts/check_database_test_isolation.py b/products/scheduling/scripts/check_database_test_isolation.py new file mode 100755 index 0000000000..3cb733e445 --- /dev/null +++ b/products/scheduling/scripts/check_database_test_isolation.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Keep Scheduling's maintained PostgreSQL suites out of default test runs.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +DATABASE_SUITES = { + "registry-scheduling": {"postgres_commitments"}, + "registry-schedulingctl": {"records_apply_postgres", "intents_postgres"}, +} + + +def violations(metadata: dict) -> list[str]: + """Use Cargo's default feature resolution, including aliases and forwarding. + + Only maintained database targets need a gate. Ordinary integration tests + remain database-free and may run without required-features. + """ + resolved = { + node["id"]: set(node["features"]) + for node in metadata["resolve"]["nodes"] + } + failures: list[str] = [] + for package in metadata["packages"]: + expected = DATABASE_SUITES.get(package["name"], set()) + for target in package.get("targets", []): + if "test" not in target.get("kind", []) or target["name"] not in expected: + continue + label = f"{package['name']}/{target['name']}" + required = set(target.get("required-features") or []) + if "postgres-test" not in required: + failures.append(f"{label} must require the postgres-test feature") + elif package["id"] not in resolved: + failures.append(f"{label} has no resolved feature inventory") + elif required <= resolved[package["id"]]: + failures.append(f"{label} is enabled by default resolved features") + return failures + + +def load_metadata(repository_root: Path, fixture: Path | None) -> dict: + if fixture: + return json.loads(fixture.read_text(encoding="utf-8")) + completed = subprocess.run( + ["cargo", "metadata", "--locked", "--format-version", "1"], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", type=Path, help="read a Cargo metadata fixture") + args = parser.parse_args() + repository_root = Path(__file__).resolve().parents[3] + try: + failures = violations(load_metadata(repository_root, args.metadata)) + except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: + print(f"database-test isolation check could not inspect Cargo metadata: {error}", file=sys.stderr) + return 2 + if failures: + for failure in failures: + print(f"database-test isolation violation: {failure}", file=sys.stderr) + return 1 + print("Scheduling database suites remain opt-in under default Cargo resolution.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/check_dependency_direction.py b/products/scheduling/scripts/check_dependency_direction.py new file mode 100644 index 0000000000..d8c41f0a82 --- /dev/null +++ b/products/scheduling/scripts/check_dependency_direction.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Check Scheduling dependency direction from Cargo's resolved graph.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# The runtime products whose protocol surfaces must never reach a scheduling +# crate, directly or through any shared dependency. +PRODUCT_PREFIXES = ("registry-breg", "registry-casework", "registry-evidence") + +# The source-neutral scheduling crates: the model and evaluators, and the +# caller-facing client. Their dependency closures must stay product-free and, +# for the core, free of every other scheduling crate. +NEUTRAL_CRATES = ("registry-scheduling-core", "registry-scheduling-client") + + +def package_graph(metadata: dict) -> tuple[dict[str, str], dict[str, set[str]]]: + names = {package["id"]: package["name"] for package in metadata["packages"]} + resolve = metadata.get("resolve") + if not resolve: + raise ValueError("cargo metadata did not include a resolved dependency graph") + edges = { + node["id"]: { + dependency["pkg"] if isinstance(dependency, dict) else dependency + for dependency in node.get("deps", node.get("dependencies", [])) + } + for node in resolve["nodes"] + } + return names, edges + + +def closure(start: str, edges: dict[str, set[str]]) -> set[str]: + seen: set[str] = set() + pending = list(edges.get(start, set())) + while pending: + package = pending.pop() + if package in seen: + continue + seen.add(package) + pending.extend(edges.get(package, set()) - seen) + return seen + + +def product_violations(name: str, package_id: str, names: dict[str, str], edges: dict[str, set[str]]) -> list[str]: + forbidden = sorted( + { + names[item] + for item in closure(package_id, edges) + if names[item].startswith(PRODUCT_PREFIXES) + } + ) + if forbidden: + return [ + f"{name} transitively depends on other-product package(s): {', '.join(forbidden)}" + ] + return [] + + +def internal_violations(name: str, package_id: str, names: dict[str, str], edges: dict[str, set[str]]) -> list[str]: + failures: list[str] = [] + reached = closure(package_id, edges) + if name == "registry-scheduling-core": + # The core is the bottom of the product: it may depend on no other + # scheduling crate, or the runtime and every replay would follow it. + internal = sorted( + {names[item] for item in reached if names[item].startswith("registry-scheduling")} + ) + if internal: + failures.append( + f"registry-scheduling-core transitively depends on scheduling crate(s): {', '.join(internal)}" + ) + if name == "registry-scheduling-client": + # The client may share the core, never the runtime or adopter tooling. + reaching_runtime = sorted( + { + names[item] + for item in reached + if names[item] in ("registry-scheduling", "registry-schedulingctl") + } + ) + if reaching_runtime: + failures.append( + f"registry-scheduling-client transitively depends on runtime crate(s): {', '.join(reaching_runtime)}" + ) + return failures + + +def violations(metadata: dict) -> list[str]: + names, edges = package_graph(metadata) + ids_by_name: dict[str, list[str]] = {} + for package_id, name in names.items(): + ids_by_name.setdefault(name, []).append(package_id) + + failures: list[str] = [] + for scheduling_name, package_ids in sorted(ids_by_name.items()): + if not scheduling_name.startswith("registry-scheduling"): + continue + for package_id in package_ids: + failures.extend(product_violations(scheduling_name, package_id, names, edges)) + failures.extend(internal_violations(scheduling_name, package_id, names, edges)) + + # The boundary runs both ways: no other runtime product may reach into + # scheduling through a dependency either. + for package_id, package_name in sorted(names.items(), key=lambda item: item[1]): + if not package_name.startswith(PRODUCT_PREFIXES): + continue + forbidden = sorted( + { + names[item] + for item in closure(package_id, edges) + if names[item].startswith("registry-scheduling") + } + ) + if forbidden: + failures.append( + f"{package_name} transitively depends on Scheduling package(s): {', '.join(forbidden)}" + ) + return failures + + +def load_metadata(repository_root: Path, fixture: Path | None) -> dict: + if fixture: + return json.loads(fixture.read_text(encoding="utf-8")) + # All-features resolution, so an optional dependency no supported build + # enables by default still cannot escape the forward-closure check. + completed = subprocess.run( + ["cargo", "metadata", "--locked", "--all-features", "--format-version", "1"], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", type=Path, help="read a Cargo metadata fixture") + args = parser.parse_args() + repository_root = Path(__file__).resolve().parents[3] + try: + failures = violations(load_metadata(repository_root, args.metadata)) + except (OSError, ValueError, KeyError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"dependency-direction check could not inspect Cargo metadata: {error}", file=sys.stderr) + return 2 + if failures: + for failure in failures: + print(f"dependency-direction violation: {failure}", file=sys.stderr) + return 1 + print("Scheduling dependency direction is source-neutral.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py new file mode 100644 index 0000000000..ec5bdc371b --- /dev/null +++ b/products/scheduling/scripts/generate_openapi.py @@ -0,0 +1,1542 @@ +#!/usr/bin/env python3 +"""Generate the deterministic Registry Scheduling OpenAPI contract. + +The document is hand-shaped here and then verified against the Rust truth +beside it, so neither side can drift alone. The verification follows the +Casework generator's discipline: + +* the route inventory, and each operation's handler, authority profile, JSON + body, idempotency key, path and query parameters, and success status, are + read back out of `crates/registry-scheduling/src/http.rs`; +* the problem vocabulary, and each code's pinned type URI, title, detail, and + HTTP status, are read from the `problem-catalog` example's output, never + copied here; +* every wire document is verified field by field against the Rust struct it + projects, snake_case to camelCase. + +One contract is not Rust-owned yet: the per-operation problem mappings. The +`problem-catalog` example emits `entries` only, with no Casework-style +`operations` table, so the mappings are hand-shaped from the handler-by-handler +reading of the service and store flows and are verified in every way the +available truth allows (`verify_operation_problems`): every code must exist in +the exported catalog, its status in the document must be the code's pinned one, +each documented code must be produced somewhere in the runtime, and every code +an operation can answer with by rule must be present. Extending the exporter +with an operations contract is the follow-up that closes the remaining gap. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +HTTP_SOURCE = "crates/registry-scheduling/src/http.rs" +SERVICE_SOURCE = "crates/registry-scheduling/src/service.rs" +STORE_SOURCE = "crates/registry-scheduling/src/store.rs" +CURSORS_SOURCE = "crates/registry-scheduling/src/cursors.rs" +AUTH_SOURCE = "crates/registry-scheduling/src/auth.rs" +CONFIG_SOURCE = "crates/registry-scheduling/src/config.rs" +NAMING_SOURCE = "crates/registry-scheduling-core/src/naming.rs" +WIRE_SOURCE = "crates/registry-scheduling-core/src/wire.rs" +MODEL_SOURCE = "crates/registry-scheduling-core/src/model.rs" +ADMISSION_SOURCE = "crates/registry-scheduling-core/src/admission.rs" +PROBLEM_SOURCE = "crates/registry-scheduling-core/src/problem.rs" +HTTPSEC_SOURCE = "crates/registry-platform-httpsec/src/server.rs" + +# The committed document `--write` produces and `--check` verifies. +OUTPUT = "products/scheduling/generated/registry-scheduling.openapi.json" + +# The routes the router answers, as literal paths. `verify_source` re-derives +# this set from the `axum` router, resolving the constants the router names +# through `crates/registry-scheduling-core/src/naming.rs`. +ROUTES = { + "/healthz", + "/readyz", + "/v1/scheduling", + "/v1/services", + "/v1/offerings", + "/v1/resources", + "/v1/locations", + "/v1/availability", + "/v1/availability/explain", + "/v1/holds", + "/v1/holds/{hold_id}", + "/v1/appointments", + "/v1/appointments/{appointment_id}", + "/v1/appointments/{appointment_id}/reschedule", + "/v1/appointments/{appointment_id}/cancel", + "/v1/appointments/{appointment_id}/history", +} + +# The header name a mutating command carries, as `naming.rs` pins it. +HEADERS = { + "IDEMPOTENCY_KEY_HEADER": "idempotency-key", +} + +# The `request.*` family: the request-edge rejections the edge itself answers, +# on any route, under this product's own prefix. No documented operation +# answers `request.not-found`; it is the fallback for a route no operation +# claims. +EDGE_PROBLEMS = [ + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", +] + +# Vocabulary entries the runtime never reaches: there is no hook engine yet, +# no eligibility source yet, and the four refusals `precondition.failed` once +# carried are each answered by the code that names them. +UNPRODUCED_PROBLEMS = [ + "eligibility.unavailable", + "hook.unavailable", + "precondition.failed", +] +# `resource.unavailable` is produced, but only as the detailed code inside the +# explain document, so it is never a public problem either. +DETAILED_ONLY_PROBLEMS = ["resource.unavailable"] +# Vocabulary entries no documented operation answers. +RESERVED_PROBLEMS = UNPRODUCED_PROBLEMS + DETAILED_ONLY_PROBLEMS + +# The wire documents, verified field by field against the Rust structs. +SCHEMA_STRUCTS = { + WIRE_SOURCE: { + "SchedulingServiceDocument": "SchedulingServiceDocument", + "ServiceDocument": "ServiceDocument", + "OfferingDocument": "OfferingDocument", + "ReminderDocument": "ReminderDocument", + "WindowDocument": "WindowDocument", + "ResourceDocument": "ResourceDocument", + "LocationDocument": "LocationDocument", + "HoldDocument": "HoldDocument", + "AppointmentDocument": "AppointmentDocument", + "CreateAppointmentRequest": "CreateAppointmentRequest", + "RescheduleAppointmentRequest": "RescheduleAppointmentRequest", + "CancelAppointmentRequest": "CancelAppointmentRequest", + "AppointmentHistoryEntryDocument": "AppointmentHistoryEntryDocument", + "ExplainDocument": "ExplainDocument", + }, + MODEL_SOURCE: { + "AdmissionRequest": "AdmissionRequest", + "PartyCounts": "PartyCounts", + }, +} + +# The axum handler behind each operation. `verify_handler_wiring` reads the +# handler back out of http.rs and checks the document against what it does. +ROUTE_HANDLERS = { + ("GET", "/healthz"): "healthz", + ("GET", "/readyz"): "readyz", + ("GET", "/v1/scheduling"): "scheduling", + ("GET", "/v1/services"): "list_services", + ("GET", "/v1/offerings"): "list_offerings", + ("GET", "/v1/resources"): "list_resources", + ("GET", "/v1/locations"): "list_locations", + ("GET", "/v1/availability"): "availability", + ("GET", "/v1/availability/explain"): "explain", + ("POST", "/v1/holds"): "create_hold", + ("DELETE", "/v1/holds/{hold_id}"): "release_hold", + ("POST", "/v1/appointments"): "create_appointment", + ("GET", "/v1/appointments/{appointment_id}"): "get_appointment", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "reschedule_appointment", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancel_appointment", + ("GET", "/v1/appointments/{appointment_id}/history"): "appointment_history", +} + +OPERATION_IDS = { + ("GET", "/healthz"): "getLiveness", + ("GET", "/readyz"): "getReadiness", + ("GET", "/v1/scheduling"): "describeScheduling", + ("GET", "/v1/services"): "listServices", + ("GET", "/v1/offerings"): "listOfferings", + ("GET", "/v1/resources"): "listResources", + ("GET", "/v1/locations"): "listLocations", + ("GET", "/v1/availability"): "searchAvailability", + ("GET", "/v1/availability/explain"): "explainAvailability", + ("POST", "/v1/holds"): "createHold", + ("DELETE", "/v1/holds/{hold_id}"): "releaseHold", + ("POST", "/v1/appointments"): "createAppointment", + ("GET", "/v1/appointments/{appointment_id}"): "getAppointment", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "rescheduleAppointment", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancelAppointment", + ("GET", "/v1/appointments/{appointment_id}/history"): "listAppointmentHistory", +} + +# The per-operation problem mappings, hand-shaped from the handler-by-handler +# reading of the flows the handlers delegate to. Status is a property of the +# code, so each list below is grouped onto its pinned statuses by the catalog, +# never by hand. +# +# Every operation: the edge answers 405 for a foreign method and 413 for a body +# over the router's limit. Every authenticated operation: a missing, invalid, +# or expired bearer is authentication.refused, and a credential that does not +# carry the route's authority is profile.not-authorized. Every authenticated +# operation can also answer service.unavailable, whether or not its service +# method opens a store round trip: verifying the bearer needs the issuer's key +# material, and an issuer that cannot be reached is an outage on this side, +# answered with Retry-After rather than a challenge the caller cannot satisfy. +EDGE = ["request.body-too-large", "request.method-not-allowed"] +AUTHENTICATION = ["authentication.refused", "profile.not-authorized"] +JSON_BODY = [ + "request.invalid", + "request.unprocessable", + "request.unsupported-media-type", +] +QUERY = ["request.invalid"] +# A path segment naming a hold or an appointment is read into an identifier +# before the handler runs, so a segment that is not one is refused at the edge. +PATH = ["request.invalid"] +CURSOR = ["cursor.expired", "cursor.invalid"] +STORAGE = ["service.unavailable"] +AUTHORITY = ["operation.not-authorized"] +IDEMPOTENCY = ["idempotency.expired", "idempotency.key-reused"] +# An operation that resolves a caller-named offering against the deployed +# policy answers for one the policy does not publish. The offering listing is +# where a caller learns what exists, so naming the absence discloses nothing +# that listing would not. +OFFERING = ["request.not-found"] +# The refusal vocabulary the admission evaluator answers a booking ask with. +ADMISSION = [ + "booking.duplicate-active", + "capacity.exhausted", + "capability.unmatched", + "horizon.outside", + "location.closed", + "party.capacity-inadequate", + "policy.changed", + "precondition.required", + "prerequisite.missing", + "revision.mismatch", + "schedule.unpublished", +] + +OPERATION_PROBLEMS = { + ("GET", "/healthz"): EDGE, + ("GET", "/readyz"): EDGE + STORAGE, + ("GET", "/v1/scheduling"): EDGE + AUTHENTICATION + STORAGE, + ("GET", "/v1/services"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/offerings"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/resources"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/locations"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/availability"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE + OFFERING, + ("GET", "/v1/availability/explain"): EDGE + AUTHENTICATION + QUERY + STORAGE + OFFERING, + ("POST", "/v1/holds"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + + OFFERING + ["service.unavailable"], + # A release carries no caller-chosen key, so `idempotency.key-reused` is + # not reachable: the key and the request hash are both the hold's own id. + ("DELETE", "/v1/holds/{hold_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + + ["hold.released", "idempotency.expired", "service.unavailable"], + ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + + OFFERING + ["hold.expired", "hold.released", "service.unavailable"], + ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + STORAGE, + # A reschedule resolves its offering from the appointment rather than from + # the caller, so an unknown offering is operator state, not a refusal the + # caller can read. + ("POST", "/v1/appointments/{appointment_id}/reschedule"): EDGE + AUTHENTICATION + PATH + JSON_BODY + + ADMISSION + IDEMPOTENCY + AUTHORITY + ["hold.released", "service.unavailable"], + # Cancellation is guarded by the observed revision and the cutoff, never by + # the policy revision, so it answers no admission refusal at all. + ("POST", "/v1/appointments/{appointment_id}/cancel"): EDGE + AUTHENTICATION + PATH + JSON_BODY + + IDEMPOTENCY + AUTHORITY + + ["cancellation.cutoff-passed", "hold.released", "revision.mismatch", "service.unavailable"], + ("GET", "/v1/appointments/{appointment_id}/history"): EDGE + AUTHENTICATION + PATH + AUTHORITY + QUERY + + CURSOR + STORAGE, +} + + +def ref(name: str) -> dict: + return {"$ref": f"#/components/schemas/{name}"} + + +def header_ref(name: str) -> dict: + return {"$ref": f"#/components/headers/{name}"} + + +def obj(properties: dict, required: list[str] | None = None) -> dict: + """A document the caller sends: closed, as the Rust request type reads it. + + A member the contract does not declare is either a caller mistake or a + reach for a field the store owns, so the runtime answers it rather than + passing it over. + """ + result = {"type": "object", "additionalProperties": False, "properties": properties} + if required: + result["required"] = required + return result + + +def answer(properties: dict, required: list[str] | None = None) -> dict: + """A document the runtime sends: open, as the Rust answer type reads it. + + A client generated from this contract may be older than the deployment + answering it. Closing an answer would make every additive change on the + server a failed exchange for every client built before that change. + """ + result = obj(properties, required) + result["additionalProperties"] = True + return result + + +def array(items: dict) -> dict: + return {"type": "array", "items": items} + + +def nullable(schema: dict) -> dict: + return {"anyOf": [schema, {"type": "null"}]} + + +IDEMPOTENCY_SCHEMA = { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!-~]+$", +} +CURSOR_SCHEMA = {"type": "string", "maxLength": 256} +LIMIT_SCHEMA = {"type": "integer", "minimum": 1, "maximum": 200, "default": 50} +INSTANT_SCHEMA = {"type": "string", "format": "date-time"} +UUID_SCHEMA = {"type": "string", "format": "uuid"} +# The router's request body limit, as `registry-platform-httpsec` pins it. +MAXIMUM_BODY_BYTES = 1_048_576 + + +def parameter(name: str, where: str, description: str, schema: dict | None = None, required: bool = True) -> dict: + return {"name": name, "in": where, "required": required, "description": description, "schema": schema or {"type": "string"}} + + +TRACEPARENT = parameter( + "traceparent", + "header", + "Optional W3C trace context continued in the response.", + required=False, +) +IDEMPOTENCY = parameter( + "Idempotency-Key", + "header", + "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + IDEMPOTENCY_SCHEMA, +) +HOLD_ID = parameter("hold_id", "path", "The hold's identifier.", UUID_SCHEMA) +APPOINTMENT_ID = parameter("appointment_id", "path", "The appointment's identifier.", UUID_SCHEMA) +CURSOR_QUERY = parameter( + "cursor", + "query", + "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + CURSOR_SCHEMA, + required=False, +) +LIMIT_QUERY = parameter( + "limit", + "query", + "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + LIMIT_SCHEMA, + required=False, +) +OFFERING_QUERY = parameter( + "offering", + "query", + "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", + {"type": "string", "minLength": 1}, +) + + +def response(schema_name: str | None = None, description: str = "Success") -> dict: + result = { + "description": description, + "headers": { + "cache-control": header_ref("CacheControlHeader"), + "traceparent": header_ref("TraceparentHeader"), + }, + } + if schema_name: + result["content"] = {"application/json": {"schema": ref(schema_name)}} + return result + + +def problem_component_name(code: str) -> str: + return "Problem" + "".join(part.title() for part in re.split(r"[.-]", code)) + + +def problem_variant_schema(entry: dict) -> dict: + status = entry["httpStatuses"][0] + return { + "allOf": [ + ref("Problem"), + { + "type": "object", + "properties": { + "type": {"const": entry["uri"]}, + "title": {"const": entry["title"]}, + "status": {"const": status}, + "detail": {"const": entry["description"]}, + "code": {"const": entry["code"]}, + }, + }, + ] + } + + +def problem_variant(entry: dict) -> dict: + return ref(problem_component_name(entry["code"])) + + +def problem_responses(codes: list[str], catalog: dict[str, dict]) -> dict: + by_status: dict[int, list[dict]] = {} + # An operation can reach one refusal down more than one path, and the + # groups above name every path it has. A code the operation answers is + # documented once however many groups carry it. + for code in dict.fromkeys(codes): + entry = catalog[code] + by_status.setdefault(entry["httpStatuses"][0], []).append(entry) + result = {} + for status, entries in sorted(by_status.items()): + variants = [problem_variant(entry) for entry in entries] + schema = variants[0] if len(variants) == 1 else {"oneOf": variants} + value = { + "description": "Problem response: " + + ", ".join(entry["code"] for entry in entries), + "headers": { + "cache-control": header_ref("CacheControlHeader"), + "traceparent": header_ref("TraceparentHeader"), + }, + "content": {"application/problem+json": {"schema": schema}}, + } + if status == 401: + value["headers"]["WWW-Authenticate"] = { + "description": "Bearer authentication challenge.", + "schema": {"const": "Bearer"}, + } + if status == 503: + value["headers"]["Retry-After"] = { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": {"type": "integer", "minimum": 0}, + } + result[str(status)] = value + return result + + +def operation( + summary: str, + schema_name: str | None = None, + *, + authority: str = "reads-scope", + body: str | None = None, + parameters: list[dict] | None = None, + status: str = "200", + description: str | None = None, + idempotency: bool = False, +) -> dict: + """One authenticated operation, in the shape the edge actually answers.""" + params = [TRACEPARENT] + if idempotency: + params.append(IDEMPOTENCY) + params.extend(parameters or []) + result = { + "summary": summary, + "security": [{"bearerAuth": []}], + "x-scheduling-authority": authority, + "parameters": params, + "responses": {status: response(schema_name)}, + } + if description: + result["description"] = description + if body: + result["requestBody"] = { + "required": True, + "content": {"application/json": {"schema": ref(body)}}, + } + result["x-maximum-body-bytes"] = MAXIMUM_BODY_BYTES + return result + + +def unauthenticated(summary: str, description: str, schema_name: str | None = None) -> dict: + return { + "summary": summary, + "description": description, + "security": [], + "x-scheduling-authority": "unauthenticated", + "parameters": [TRACEPARENT], + "responses": {"200": response(schema_name)}, + } + + +def page(item_schema_name: str) -> dict: + """`PageDocument` with its item type named; the wire type is generic.""" + return answer( + {"items": array(ref(item_schema_name)), "nextCursor": nullable({"type": "string"})}, + ["items", "nextCursor"], + ) + + +def schemas(problem_entries: list[dict]) -> dict: + text = {"type": "string"} + # u32 quantities and u64 revision counters, as the wire types pin them. + count = {"type": "integer", "minimum": 0} + revision = {"type": "integer", "format": "int64", "minimum": 0} + instant = INSTANT_SCHEMA + mode = {"type": "string", "enum": ["exact-time", "arrival-window"]} + appointment_state = {"type": "string", "enum": ["confirmed", "cancelled"]} + party = obj({"recipients": count, "attendees": count}, ["recipients", "attendees"]) + admission = obj( + { + "offering": text, + "start": instant, + "party": ref("PartyCounts"), + "channel": nullable(text), + "duplicateKey": nullable(text), + "policyRevision": revision, + "windowRevision": nullable(revision), + "capabilities": array(text), + "prerequisites": array(text), + }, + ["offering", "start", "party", "policyRevision", "capabilities", "prerequisites"], + ) + result = { + "SchedulingServiceDocument": answer( + {"schedulingId": text, "policyRevision": revision, "policyDigest": text}, + ["schedulingId", "policyRevision", "policyDigest"], + ), + "ServiceDocument": answer({"id": text, "label": text}, ["id", "label"]), + "ServicePage": page("ServiceDocument"), + "ReminderDocument": answer({"minutesBefore": count}, ["minutesBefore"]), + "WindowDocument": answer( + {"id": text, "revision": revision, "start": instant, "end": instant, "units": count}, + ["id", "revision", "start", "end", "units"], + ), + "OfferingDocument": answer( + { + "id": text, + "service": text, + "label": text, + "mode": mode, + "location": text, + "leadTimeMinutes": count, + "horizonDays": count, + "cancellationCutoffMinutes": count, + "durationMinutes": nullable(count), + "bufferBeforeMinutes": nullable(count), + "bufferAfterMinutes": nullable(count), + "startIncrementMinutes": nullable(count), + "maxRecipients": nullable(count), + "window": nullable(ref("WindowDocument")), + "reminders": array(ref("ReminderDocument")), + "requiresCapabilities": array(text), + "prerequisites": array(text), + }, + [ + "id", + "service", + "label", + "mode", + "location", + "leadTimeMinutes", + "horizonDays", + "cancellationCutoffMinutes", + "reminders", + "requiresCapabilities", + "prerequisites", + ], + ), + "OfferingPage": page("OfferingDocument"), + "ResourceDocument": answer( + { + "resourceId": text, + "pool": text, + "capabilities": array(text), + "available": {"type": "boolean"}, + }, + ["resourceId", "pool", "capabilities", "available"], + ), + "ResourcePage": page("ResourceDocument"), + "LocationDocument": answer({"locationId": text, "timezone": text}, ["locationId", "timezone"]), + "LocationPage": page("LocationDocument"), + "AvailabilitySlot": answer( + {"kind": {"const": "slot"}, "start": instant, "end": instant, "free": count}, + ["kind", "start", "end", "free"], + ), + "AvailabilityWindow": answer( + { + "kind": {"const": "window"}, + "window": text, + "start": instant, + "end": instant, + "remaining": count, + "channelRemaining": nullable(count), + }, + ["kind", "window", "start", "end", "remaining"], + ), + "AvailabilityEntry": { + "oneOf": [ref("AvailabilitySlot"), ref("AvailabilityWindow")], + "discriminator": {"propertyName": "kind"}, + }, + "AvailabilityPage": page("AvailabilityEntry"), + "HoldDocument": answer( + { + "holdId": text, + "offering": text, + "start": instant, + "end": instant, + "resource": nullable(text), + "units": count, + "expiresAt": instant, + "policyRevision": revision, + }, + ["holdId", "offering", "start", "end", "units", "expiresAt", "policyRevision"], + ), + "PartyCounts": party, + "AdmissionRequest": admission, + "CreateAppointmentRequest": { + "type": "object", + "additionalProperties": False, + "minProperties": 1, + "properties": {"hold": nullable(text), "admission": nullable(ref("AdmissionRequest"))}, + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is request.invalid.", + }, + "RescheduleAppointmentRequest": obj( + {"observedRevision": revision, "admission": ref("AdmissionRequest")}, + ["observedRevision", "admission"], + ), + "CancelAppointmentRequest": obj( + {"observedRevision": revision, "reason": nullable(text)}, + ["observedRevision"], + ), + "AppointmentState": appointment_state, + "AppointmentDocument": answer( + { + "appointmentId": text, + "offering": text, + "start": instant, + "end": instant, + "resource": nullable(text), + "units": count, + "channel": nullable(text), + "revision": revision, + "state": ref("AppointmentState"), + "policyRevision": revision, + "createdAt": instant, + "cancelledAt": nullable(instant), + }, + [ + "appointmentId", + "offering", + "start", + "end", + "units", + "revision", + "state", + "policyRevision", + "createdAt", + ], + ), + "AppointmentHistoryEntryDocument": answer( + { + "eventId": text, + "kind": { + "type": "string", + "description": "The lifecycle step the store recorded: held, confirmed, consumed, released, rescheduled, or cancelled.", + }, + "revision": revision, + "occurredAt": instant, + "actor": nullable(text), + "detail": {}, + }, + ["eventId", "kind", "revision", "occurredAt", "detail"], + ), + "AppointmentHistoryPage": page("AppointmentHistoryEntryDocument"), + "ExplainDocument": answer( + { + "offering": text, + "start": instant, + "publicCode": text, + "detailedCode": text, + "explanation": text, + }, + ["offering", "start"], + ), + "Problem": obj( + { + "type": {"type": "string", "format": "uri"}, + "title": text, + "status": {"type": "integer"}, + "detail": text, + "code": {"type": "string", "enum": [entry["code"] for entry in problem_entries]}, + "traceId": text, + }, + ["type", "title", "status", "detail", "code", "traceId"], + ), + } + result.update( + { + problem_component_name(entry["code"]): problem_variant_schema(entry) + for entry in problem_entries + } + ) + return result + + +def document(contract: dict) -> dict: + entries = contract["entries"] + catalog = catalog_of(contract) + paths = { + "/healthz": {"get": unauthenticated( + "Liveness", + "Process is live. No authentication, no storage, no policy.", + )}, + "/readyz": {"get": unauthenticated( + "Readiness", + "Asks the Scheduling store to answer. A store that cannot answer is service.unavailable, with Retry-After.", + )}, + "/v1/scheduling": {"get": operation( + "Describe this Scheduling deployment", + "SchedulingServiceDocument", + description="Answers the deployment identifier, the current authored policy revision, and its digest, so a caller can detect that the policy it read has since moved. Reading it grants no catalogue, availability, or booking authority.", + )}, + "/v1/services": {"get": operation( + "List the service catalogue", + "ServicePage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="Sorted by identifier. A page is a 15-minute view of a live catalogue; follow nextCursor until it is absent.", + )}, + "/v1/offerings": {"get": operation( + "List the published offering catalogue", + "OfferingPage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="The public view of the authored policy, sorted by identifier. Exact-time offerings carry durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, startIncrementMinutes, and maxRecipients; arrival-window offerings carry window. A field the offering's mode does not use is null, never zero.", + )}, + "/v1/resources": {"get": operation( + "List the backing resource pool members", + "ResourcePage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="A pool is its concrete members, never an independent counter. available carries no reason: why a member is unavailable is private staff information, and the public answer to a caller is capacity.exhausted.", + )}, + "/v1/locations": {"get": operation( + "List the locations", + "LocationPage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="Each location carries the IANA timezone its published openings expand in.", + )}, + "/v1/availability": {"get": operation( + "Search published availability", + "AvailabilityPage", + parameters=[ + OFFERING_QUERY, + parameter( + "start", + "query", + "Earliest answerable start, defaulting to now.", + INSTANT_SCHEMA, + required=False, + ), + parameter( + "end", + "query", + "Latest answerable start. An end earlier than one minute after start is raised to it, and one further ahead than the 62-day span allows is clipped to it.", + INSTANT_SCHEMA, + required=False, + ), + parameter( + "cursor", + "query", + "Opaque 15-minute cursor bound to this offering, its range, and the instant the last page ended at. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired. Deduplicate entries by their start.", + CURSOR_SCHEMA, + required=False, + ), + LIMIT_QUERY, + ], + description="Bounded availability. Exact-time offerings answer in grid slots and arrival-window offerings answer in published windows; only entries with capacity left are listed, so a slot nobody can serve is not availability. A search spans at most 62 days from start, and a wider ask continues by cursor.", + )}, + "/v1/availability/explain": {"get": operation( + "Explain one start's refusal", + "ExplainDocument", + authority="explain-scope", + parameters=[ + OFFERING_QUERY, + parameter("start", "query", "The start to explain.", INSTANT_SCHEMA), + ], + description="The separately authorized explanation of one start: the problem code every caller may see, the detailed code only this path discloses, and the refusal in words. A start that admits as things stand answers with no codes at all. The probe is a minimal party, so the answer explains the calendar and the capacity, never another caller's booking. resource.unavailable is disclosed here and never on the public path, whose answer is capacity.exhausted.", + )}, + "/v1/holds": {"post": operation( + "Reserve an admission", + "HoldDocument", + authority="task-grant", + status="201", + body="AdmissionRequest", + idempotency=True, + description="Reserves supply instead of committing it and answers the hold with its expiry. The caller must carry a complete task grant whose scheduling permission names the offering's service, its location, and hold.create; readable availability is not authority to book. The hold expires on its own, so its capacity returns without a release.", + )}, + "/v1/holds/{hold_id}": {"delete": operation( + "Release a hold", + authority="task-grant", + status="204", + parameters=[HOLD_ID], + description="Gives a hold's capacity back before it expires, under the task grant that names hold.release. The hold's own identifier is the idempotency key, so a retried release answers as the first one did; a receipt retained past its window is idempotency.expired. A claim that is not an active hold is hold.released, whether unknown, already confirmed, or already released, and a grant naming another holder is operation.not-authorized.", + )}, + "/v1/appointments": {"post": operation( + "Confirm a hold or book directly", + "AppointmentDocument", + authority="task-grant", + status="201", + body="CreateAppointmentRequest", + idempotency=True, + description="Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", + )}, + "/v1/appointments/{appointment_id}": {"get": operation( + "Read one owned appointment", + "AppointmentDocument", + parameters=[APPOINTMENT_ID], + description="Answers only to the caller that owns the booking. Another caller's appointment, a hold, and an unknown identifier are all operation.not-authorized, so existence is never disclosed.", + )}, + "/v1/appointments/{appointment_id}/reschedule": {"post": operation( + "Move an owned appointment", + "AppointmentDocument", + authority="task-grant", + body="RescheduleAppointmentRequest", + idempotency=True, + parameters=[APPOINTMENT_ID], + description="Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + )}, + "/v1/appointments/{appointment_id}/cancel": {"post": operation( + "Cancel an owned appointment", + "AppointmentDocument", + authority="task-grant", + body="CancelAppointmentRequest", + idempotency=True, + parameters=[APPOINTMENT_ID], + description="Releases the appointment's capacity and records the reason, under the task grant that names appointment.cancel. It is guarded by observedRevision and the offering's cancellation cutoff, never by the policy revision, so an appointment is always cancellable under the policy that currently governs its offering.", + )}, + "/v1/appointments/{appointment_id}/history": {"get": operation( + "Read an owned appointment's history", + "AppointmentHistoryPage", + parameters=[APPOINTMENT_ID, CURSOR_QUERY, LIMIT_QUERY], + description="Newest first, one bounded page at a time. Each step carries the revision it produced, the pseudonymized actor reference when it had one, and the detail the store recorded. Existence is never disclosed: another caller's appointment answers operation.not-authorized.", + )}, + } + for key, codes in OPERATION_PROBLEMS.items(): + method, path = key + paths[path][method.lower()]["responses"].update(problem_responses(codes, catalog)) + paths[path][method.lower()]["operationId"] = OPERATION_IDS[key] + result = { + "openapi": "3.1.0", + "info": { + "title": "Registry Scheduling API", + "version": "v1alpha1", + "description": "Implemented Scheduling HTTP contract: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings. Mutating authority is a complete task grant; every refusal is one problem from the closed vocabulary.", + "license": {"name": "Apache-2.0", "identifier": "Apache-2.0"}, + }, + "servers": [{ + "url": "https://scheduling.example.test", + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + }], + "paths": paths, + "components": { + "headers": { + "CacheControlHeader": { + "description": "Every answer is marked no-store, so a booking view is never served from a cache.", + "schema": {"const": "no-store"}, + }, + "TraceparentHeader": { + "description": "W3C trace context for the request and response.", + "schema": {"type": "string"}, + }, + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "A fresh access token from the configured issuer, for this deployment's audience, signed by a key the issuer published. Catalogue, availability, and owned-record reads demand the configured reads scope (scheduling-read by default); the explain path demands the configured explain scope (scheduling-explain by default), which is never the reads scope. A mutating route demands no scope at all: its authority is the complete task grant the token carries, whose scheduling permissions the service checks against the offering's service and location and the action the route performs (hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel). A token with partial grant claims is refused rather than half-trusted.", + }, + }, + "schemas": schemas(entries), + }, + "x-registry-scheduling-edge-problems": EDGE_PROBLEMS, + "x-registry-scheduling-reserved-problems": RESERVED_PROBLEMS, + } + return result + + +# --- Rust truth ------------------------------------------------------------- +# +# Every verification below reads the Rust sources, or the problem catalog the +# `problem-catalog` example emits, and compares it with the hand-shaped +# document. Nothing here trusts the document's own words. + + +def rust_function(source: str, name: str) -> tuple[str, str]: + """One Rust function or method, split into its signature and its body.""" + match = re.search( + rf"(?:pub )?(?:async )?fn {re.escape(name)}\((?P[^)]*)\)(?P.*?)(?=\n(?:pub )?(?:async )?fn |\n#\[|\Z)", + source, + re.S, + ) + if not match: + raise ValueError(f"Rust function is missing: {name}") + text = match.group(0) + signature, body = text.split("{", 1) + return signature, body + + +def route_inventory(http_source: str, naming_source: str) -> dict[tuple[str, str], str]: + """The (method, path) -> handler table, read back out of the axum router.""" + signature, body = rust_function(http_source, "router") + constants: dict[str, str] = {} + for source in (naming_source, http_source): + constants.update( + { + name: value + for name, value in re.findall( + r'(?:pub )?const ([A-Z_][A-Z0-9_]*): &str = "([^"]*)";', source + ) + } + ) + inventory: dict[tuple[str, str], str] = {} + for expression, method, handler in re.findall( + r'\.route\(\s*("[^"]+"|[A-Z_][A-Z0-9_]*),\s*(get|post|put|delete)\(([a-z_][a-z0-9_]*)\)\)', + body, + ): + path = expression[1:-1] if expression.startswith('"') else constants[expression] + inventory[(method.upper(), path)] = handler + return inventory + + +def query_struct(http_source: str, name: str) -> dict[str, tuple[bool, str]]: + """One axum `Query` struct's fields as (required, type family) triples.""" + match = re.search(rf"struct {re.escape(name)}\s*\{{(?P.*?)^\}}", http_source, re.S | re.M) + if not match: + raise ValueError(f"Rust query struct is missing: {name}") + fields: dict[str, tuple[bool, str]] = {} + for field_name, field_type in re.findall( + r"(?:#\[serde\([^\)]*\)\]\s*)?\n\s*([a-z_]+):\s*([^,\n]+),", match.group("body") + ): + optional = re.match(r"^Option<(.+)>$", field_type) + base = optional.group(1) if optional else field_type + family = "date-time" if base.startswith("DateTime<") else ( + "integer" if base in ("usize", "u32", "u64") else "string" + ) + fields[camel_case(field_name)] = (not bool(optional), family) + return fields + + +def handler_wiring( + http_source: str, + service_source: str, + inventory: dict[tuple[str, str], str], + variants: dict[str, str], +) -> dict[tuple[str, str], dict]: + """What each handler does, as the router and its own body state it.""" + wiring = {} + for key, handler in inventory.items(): + signature, body = rust_function(http_source, handler) + # The extractors sit between the signature's first paren and its last, + # so a multi-line parameter list is read whole. + parameters = signature[signature.index("(") + 1 : signature.rindex(")")] + json_body = re.search(r"Json\(\w+\):\s*Json<([A-Za-z0-9_]+)>", parameters) + query = re.search(r"Query\(\w+\):\s*Query<([A-Za-z0-9_]+)>", parameters) + path_parameters = re.findall(r"Path\((\w+)\):\s*Path<([A-Za-z0-9_:]+)>", parameters) + authority = "unauthenticated" + for function, value in ( + ("authenticate_read", "reads-scope"), + ("authenticate_explain", "explain-scope"), + ("authenticate_mutate", "task-grant"), + ): + if f"{function}(&state" in body: + authority = value + service = re.search(r"state\.service\.([a-z_]+)\(", body) + method_name = service.group(1) if service else None + # A handler with no service call behind it (the infrastructure routes) + # answers nothing the service could refuse. + infallible = ( + "-> Result<" not in rust_function(service_source, method_name)[0] + if method_name + else None + ) + wiring[key] = { + "handler": handler, + "authority": authority, + "body": json_body.group(1) if json_body else None, + "query": query.group(1) if query else None, + "path_parameters": path_parameters, + "idempotency_key": "idempotency_key(&headers)?" in body, + "service_method": method_name, + "service_infallible": infallible, + "success_status": ( + 204 + if "StatusCode::NO_CONTENT" in body + else 201 + if "StatusCode::CREATED" in body + else 200 + ), + "problem_codes": [problem_code(variants, name, key) for name in re.findall(r"ProblemCode::([A-Z][A-Za-z0-9]*)", body)], + } + return wiring + + +def problem_code(variants: dict[str, str], name: str, key: tuple[str, str]) -> str: + """One handler-named variant, or a visible failure if it is not a code.""" + if name not in variants: + raise ValueError(f"{key} names {name}, which is no problem in the vocabulary") + return variants[name] + + +def verify_handler_wiring( + repository_root: Path, paths: dict, variants: dict[str, str] +) -> None: + """Check every operation against what its handler actually does.""" + http_source = production_source(repository_root, HTTP_SOURCE) + service_source = production_source(repository_root, SERVICE_SOURCE) + naming_source = production_source(repository_root, NAMING_SOURCE) + inventory = route_inventory(http_source, naming_source) + if set(inventory) != set(ROUTE_HANDLERS): + raise ValueError( + "OpenAPI route inventory drifted from the router; " + f"undocumented={sorted(set(inventory) - set(ROUTE_HANDLERS))}, " + f"unserved={sorted(set(ROUTE_HANDLERS) - set(inventory))}" + ) + for (method, path), handler in inventory.items(): + if ROUTE_HANDLERS[(method, path)] != handler: + raise ValueError(f"OpenAPI handler drifted for {(method, path)}: {handler}") + if set(ROUTE_HANDLERS) != set(OPERATION_IDS): + raise ValueError("every operation must carry an operationId") + wiring = handler_wiring(http_source, service_source, inventory, variants) + for key, derived in wiring.items(): + method, path = key + operation = paths[path][method.lower()] + if operation.get("operationId") != OPERATION_IDS[key]: + raise ValueError(f"OpenAPI operationId drifted for {key}") + expected_security = [] if derived["authority"] == "unauthenticated" else [{"bearerAuth": []}] + if operation["security"] != expected_security: + raise ValueError(f"OpenAPI security drifted from the handler for {key}") + if operation["x-scheduling-authority"] != derived["authority"]: + raise ValueError(f"OpenAPI authority drifted from the handler for {key}") + has_body = "requestBody" in operation + if has_body != bool(derived["body"]): + raise ValueError(f"OpenAPI request body drifted from the handler for {key}") + if has_body: + if derived["body"] not in operation["requestBody"]["content"]["application/json"]["schema"]["$ref"]: + raise ValueError(f"OpenAPI request body schema drifted from the handler for {key}") + if operation["x-maximum-body-bytes"] != MAXIMUM_BODY_BYTES: + raise ValueError(f"OpenAPI request body bound drifted from the router for {key}") + elif "x-maximum-body-bytes" in operation: + raise ValueError(f"OpenAPI names a body bound on a bodyless operation for {key}") + names = [parameter["name"] for parameter in operation["parameters"]] + if ("Idempotency-Key" in names) != derived["idempotency_key"]: + raise ValueError(f"OpenAPI idempotency key drifted from the handler for {key}") + if derived["idempotency_key"]: + idempotency = next( + parameter for parameter in operation["parameters"] if parameter["name"] == "Idempotency-Key" + ) + if not idempotency["required"] or idempotency["schema"] != IDEMPOTENCY_SCHEMA: + raise ValueError(f"OpenAPI idempotency key bound drifted from the handler for {key}") + template_names = re.findall(r"\{([a-z_]+)\}", path) + rust_path_names = [name for name, _ in derived["path_parameters"]] + if template_names != rust_path_names: + raise ValueError(f"OpenAPI path parameters drifted from the handler for {key}") + for name in template_names: + parameter = next(item for item in operation["parameters"] if item["name"] == name) + if not parameter["required"] or parameter["schema"] != UUID_SCHEMA: + raise ValueError(f"OpenAPI path parameter schema drifted for {key}: {name}") + query_fields = query_struct(http_source, derived["query"]) if derived["query"] else {} + documented_query = { + parameter["name"]: parameter + for parameter in operation["parameters"] + if parameter["in"] == "query" + } + if set(documented_query) != set(query_fields): + raise ValueError(f"OpenAPI query parameters drifted from the handler for {key}") + for name, (required, family) in query_fields.items(): + schema = documented_query[name]["schema"] + actual = "date-time" if schema.get("format") == "date-time" else schema["type"] + if documented_query[name]["required"] != required or actual != family: + raise ValueError(f"OpenAPI query parameter {name} drifted from the handler for {key}") + successes = [status for status in operation["responses"] if int(status) < 400] + if successes != [str(derived["success_status"])]: + raise ValueError( + f"OpenAPI success status drifted from the handler for {key}; " + f"documented={successes}, handler={derived['success_status']}" + ) + documented_problems = [ + status for status in operation["responses"] if int(status) >= 400 + ] + unavailable = "503" in documented_problems + unauthenticated = derived["authority"] == "unauthenticated" + # A fallible service method opens a store round trip, so its operation + # must document service.unavailable. An authenticated operation must + # document it too even when its service method cannot fail, because + # verifying the bearer reaches the issuer's key material and an issuer + # outage is answered with Retry-After, never with a challenge the + # caller cannot satisfy. Only an unauthenticated operation over an + # infallible service method answers no 503 at all. + if derived["service_infallible"] is True and unavailable and unauthenticated: + raise ValueError(f"an unauthenticated infallible operation documents 503 for {key}") + if derived["service_infallible"] is False and not unavailable: + raise ValueError(f"a fallible service method documents no 503 for {key}") + if not unauthenticated and not unavailable: + raise ValueError(f"an authenticated operation documents no 503 for {key}") + if not documented_problems and derived["authority"] != "unauthenticated": + raise ValueError(f"an authenticated operation answers no refusal for {key}") + for code in derived["problem_codes"]: + if code not in OPERATION_PROBLEMS[key]: + raise ValueError(f"handler names {code} but the document does not answer it for {key}") + + +def schema_problem_codes(openapi: dict, schema: dict) -> set[str]: + variants = schema.get("oneOf", [schema]) + codes = set() + for variant in variants: + reference = variant.get("$ref") + if not reference or not reference.startswith("#/components/schemas/Problem"): + raise ValueError(f"problem response does not use a closed problem component: {schema}") + name = reference.rsplit("/", 1)[1] + component = openapi["components"]["schemas"][name] + codes.add(component["allOf"][1]["properties"]["code"]["const"]) + return codes + + +def verify_operation_problems( + openapi: dict, contract: dict, catalog: dict[str, dict], variants: dict[str, str] +) -> None: + """Check the hand-shaped per-operation mappings against the catalog. + + Status is a property of the code in Scheduling, so the document's status + grouping is compared with the status the catalog pins for each code, and + every documented code must be produced somewhere in the runtime. + """ + documented = { + (method.upper(), path) + for path, path_item in openapi["paths"].items() + for method in path_item + } + if documented != set(OPERATION_PROBLEMS): + raise ValueError( + "OpenAPI problem inventory drifted; " + f"documented_only={sorted(documented - set(OPERATION_PROBLEMS))}, " + f"unmapped={sorted(set(OPERATION_PROBLEMS) - documented)}" + ) + for code in EDGE_PROBLEMS + RESERVED_PROBLEMS: + if code not in catalog: + raise ValueError(f"declared problem is outside the Rust vocabulary: {code}") + produced = produced_problem_codes(_root(), variants) + reserved = set(RESERVED_PROBLEMS) + if set(UNPRODUCED_PROBLEMS) & produced: + raise ValueError("a problem documented as unproduced is produced by the runtime") + for key, codes in OPERATION_PROBLEMS.items(): + method, path = key + operation = openapi["paths"][path][method.lower()] + for code in codes: + if code not in catalog: + raise ValueError(f"problem code is outside the Rust vocabulary: {code}") + if code in reserved: + raise ValueError(f"reserved problem documented as an operation answer: {code}") + if code not in produced: + raise ValueError(f"no runtime path produces {code}, documented for {key}") + expected_by_status: dict[int, set[str]] = {} + for code in codes: + expected_by_status.setdefault(catalog[code]["httpStatuses"][0], set()).add(code) + actual_by_status = { + int(status): schema_problem_codes( + openapi, response["content"]["application/problem+json"]["schema"] + ) + for status, response in operation["responses"].items() + if int(status) >= 400 + } + if actual_by_status != expected_by_status: + raise ValueError( + f"per-operation problem mapping drifted for {key}; " + f"documented={actual_by_status}, expected={expected_by_status}" + ) + required = {"request.body-too-large", "request.method-not-allowed"} + if operation["x-scheduling-authority"] != "unauthenticated": + # Authentication itself reaches the issuer's key material, so an + # authenticated operation answers service.unavailable even when it + # never opens the store. + required |= { + "authentication.refused", + "profile.not-authorized", + "service.unavailable", + } + if operation.get("requestBody"): + required |= {"request.invalid", "request.unprocessable", "request.unsupported-media-type"} + if any(parameter["in"] == "query" for parameter in operation["parameters"]): + required |= {"request.invalid"} + # A path segment is read into its identifier before the handler runs, + # so a segment that is not one is refused at the edge, ahead of + # authentication and ahead of the store. + if any(parameter["in"] == "path" for parameter in operation["parameters"]): + required |= {"request.invalid"} + if any(parameter["name"] == "Idempotency-Key" for parameter in operation["parameters"]): + required |= {"request.invalid"} + if not required <= set(codes): + raise ValueError( + f"per-operation problem mapping lacks the codes its wiring requires for {key}: " + f"{sorted(required - set(codes))}" + ) + answered = {code for codes in OPERATION_PROBLEMS.values() for code in codes} + if answered != set(catalog) - reserved: + raise ValueError( + "documented problem coverage drifted from the closed vocabulary; " + f"unanswered={sorted(set(catalog) - reserved - answered)}, " + f"unknown={sorted(answered - set(catalog))}" + ) + + +def production_source(repository_root: Path, relative: str) -> str: + """A source file up to its test module: production sites only.""" + source = (repository_root / relative).read_text(encoding="utf-8") + return source.split("#[cfg(test)]", 1)[0] + + +def produced_problem_codes(repository_root: Path, variants: dict[str, str]) -> set[str]: + """The codes the runtime and the core name outside the vocabulary itself. + + A code with no production site cannot reach a caller, so it may not be + documented as an operation's answer. + """ + produced = set() + for relative in (HTTP_SOURCE, SERVICE_SOURCE, STORE_SOURCE, CURSORS_SOURCE, ADMISSION_SOURCE): + source = production_source(repository_root, relative) + produced |= { + variants[name] for name in re.findall(r"ProblemCode::([A-Z][A-Za-z0-9]*)", source) + } + return produced + + +def load_rust_contract(repository_root: Path) -> dict: + with tempfile.TemporaryDirectory(prefix="registry-scheduling-openapi-") as directory: + output = Path(directory) / "problem-catalog.json" + subprocess.run( + [ + "cargo", + "run", + "--locked", + "--quiet", + "-p", + "registry-scheduling-core", + "--example", + "problem-catalog", + "--", + "--output", + str(output), + ], + cwd=repository_root, + check=True, + ) + contract = json.loads(output.read_text(encoding="utf-8")) + if set(contract) != {"entries"}: + raise ValueError(f"Rust problem catalog fields drifted: {sorted(contract)}") + codes = [entry["code"] for entry in contract["entries"]] + if codes != sorted(set(codes)): + raise ValueError("Rust problem catalog codes are not unique and sorted") + if any(len(entry["httpStatuses"]) != 1 for entry in contract["entries"]): + raise ValueError("each Scheduling problem must have exactly one HTTP status") + return contract + + +def camel_case(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.title() for part in tail) + + +def code_variant(code: str) -> str: + """The Rust variant name a problem code spells: `hold.expired` -> `HoldExpired`.""" + return "".join(part.title() for part in re.split(r"[.-]", code)) + + +def rust_struct_fields(source: str, name: str) -> set[str]: + match = re.search(rf"pub struct {re.escape(name)}(?:<[^>]+>)?\s*\{{(?P.*?)^\}}", source, re.S | re.M) + if not match: + raise ValueError(f"Rust DTO is missing: {name}") + return set(re.findall(r"^\s*pub\s+([a-z_]+)\s*:", match.group("body"), re.M)) + + +def rust_serde_attributes(source: str, name: str, keyword: str = "struct") -> str: + """The serde attribute list immediately above one Rust DTO declaration.""" + declaration = re.search( + rf"^pub {keyword} {re.escape(name)}(?:<[^>]+>)?\s*[{{(]", source, re.M + ) + if not declaration: + raise ValueError(f"Rust DTO is missing: {name}") + attribute = re.search(r"#\[serde\(([^()]*)\)\]\s*\Z", source[: declaration.start()], re.S) + if not attribute: + raise ValueError(f"Rust DTO carries no serde attributes: {name}") + return attribute.group(1) + + +def rust_refuses_unknown_members(source: str, name: str, keyword: str = "struct") -> bool: + return "deny_unknown_fields" in rust_serde_attributes(source, name, keyword) + + +def schema_is_closed(schema: dict) -> bool: + return schema.get("additionalProperties") is False + + +def rust_kebab_case_unit_enum_values(source: str, name: str) -> list[str]: + match = re.search( + rf'#\[serde\(rename_all = "kebab-case"\)\]\s*pub enum {re.escape(name)}\s*\{{(?P.*?)^\}}', + source, + re.S | re.M, + ) + if not match: + raise ValueError(f"Rust kebab-case enum is missing: {name}") + variants = re.findall(r"^\s*([A-Z][A-Za-z0-9]*)\s*,?\s*$", match.group("body"), re.M) + return [re.sub(r"(? None: + openapi_schemas = openapi["components"]["schemas"] + for relative, structs in SCHEMA_STRUCTS.items(): + source = (repository_root / relative).read_text(encoding="utf-8") + for rust_name, schema_name in structs.items(): + rust_fields = {camel_case(field) for field in rust_struct_fields(source, rust_name)} + schema_fields = set(openapi_schemas[schema_name]["properties"]) + if rust_fields != schema_fields: + raise ValueError( + f"OpenAPI DTO shape drifted from {rust_name}; " + f"documented_only={sorted(schema_fields - rust_fields)}, " + f"rust_only={sorted(rust_fields - schema_fields)}" + ) + # Openness follows the wire type, and the wire type follows the + # direction: a request the runtime reads is closed, an answer a + # client reads is open. + refuses = rust_refuses_unknown_members(source, rust_name) + if refuses != schema_is_closed(openapi_schemas[schema_name]): + raise ValueError( + f"OpenAPI openness drifted from {rust_name}; " + f"rust_refuses_unknown_members={refuses}, " + f"schema_is_closed={schema_is_closed(openapi_schemas[schema_name])}" + ) + wire = production_source(repository_root, WIRE_SOURCE) + page_fields = {camel_case(field) for field in rust_struct_fields(wire, "PageDocument")} + for schema_name in ( + "ServicePage", + "OfferingPage", + "ResourcePage", + "LocationPage", + "AvailabilityPage", + "AppointmentHistoryPage", + ): + if set(openapi_schemas[schema_name]["properties"]) != page_fields: + raise ValueError(f"OpenAPI page shape drifted for {schema_name}") + if set(openapi_schemas[schema_name]["required"]) != page_fields: + raise ValueError(f"OpenAPI page required fields drifted for {schema_name}") + if rust_refuses_unknown_members(wire, "PageDocument") != schema_is_closed( + openapi_schemas[schema_name] + ): + raise ValueError(f"OpenAPI page openness drifted from PageDocument for {schema_name}") + if set(openapi_schemas["OfferingDocument"]["properties"]["mode"]["enum"]) != set( + rust_kebab_case_unit_enum_values(wire, "SchedulingModeDocument") + ): + raise ValueError("OpenAPI offering mode values drifted from Rust") + if set(openapi_schemas["AppointmentState"]["enum"]) != set( + rust_kebab_case_unit_enum_values(wire, "AppointmentStateDocument") + ): + raise ValueError("OpenAPI appointment state values drifted from Rust") + entry_match = re.search( + r"#\[serde\((?P[^\)]*)\)\]\s*pub enum AvailabilityEntry\s*\{(?P.*?)^\}", + wire, + re.S | re.M, + ) + if not entry_match: + raise ValueError("Rust availability entry is missing") + attributes = entry_match.group("attributes") + body = entry_match.group("body") + for text in ('tag = "kind"', 'rename_all_fields = "camelCase"'): + if text not in attributes: + raise ValueError(f"OpenAPI availability entries drifted from Rust: {text}") + # An availability entry is an answer, so both variants stay open. + if "deny_unknown_fields" in attributes: + raise ValueError("Rust availability entries refuse a member a later deployment added") + variants = re.findall(r"^\s*([A-Z][A-Za-z0-9]*)\s*\{", body, re.M) + expected = {re.sub(r"(?", "window: String"): + if marker not in body: + raise ValueError(f"OpenAPI availability entry field drifted from Rust: {marker}") + if { + variant["$ref"].rsplit("/", 1)[1] + for variant in openapi_schemas["AvailabilityEntry"]["oneOf"] + } != {"AvailabilitySlot", "AvailabilityWindow"}: + raise ValueError("OpenAPI availability entry variants drifted from Rust") + for variant, kind in ( + (openapi_schemas["AvailabilitySlot"], "slot"), + (openapi_schemas["AvailabilityWindow"], "window"), + ): + if variant["properties"]["kind"]["const"] != kind: + raise ValueError("OpenAPI availability discriminator drifted from Rust") + if schema_is_closed(variant): + raise ValueError(f"OpenAPI availability variant is closed against Rust: {kind}") + explain = openapi_schemas["ExplainDocument"] + for field in ("publicCode", "detailedCode", "explanation"): + if field in explain["required"] or "anyOf" in explain["properties"][field]: + raise ValueError(f"OpenAPI explain projection drifted for {field}") + if wire.count('skip_serializing_if = "Option::is_none"') != 3: + raise ValueError("OpenAPI explain optionality drifted from Rust") + create = openapi_schemas["CreateAppointmentRequest"] + if set(create["properties"]) != {"hold", "admission"} or create["minProperties"] != 1: + raise ValueError("OpenAPI exclusive create shape drifted from Rust") + + +def marker(source: str, text: str, origin: str) -> None: + if text not in source: + raise ValueError(f"OpenAPI bound drifted from Rust in {origin}: {text}") + + +def verify_source(repository_root: Path) -> None: + """Check the bounds and edge behaviour the document states, in Rust.""" + http_source = production_source(repository_root, HTTP_SOURCE) + naming_source = production_source(repository_root, NAMING_SOURCE) + service_source = production_source(repository_root, SERVICE_SOURCE) + store_source = production_source(repository_root, STORE_SOURCE) + cursors_source = production_source(repository_root, CURSORS_SOURCE) + auth_source = production_source(repository_root, AUTH_SOURCE) + config_source = production_source(repository_root, CONFIG_SOURCE) + admission_source = production_source(repository_root, ADMISSION_SOURCE) + problem_source = production_source(repository_root, PROBLEM_SOURCE) + httpsec_source = production_source(repository_root, HTTPSEC_SOURCE) + + inventory = route_inventory(http_source, naming_source) + paths = {path for _, path in inventory} + if paths != ROUTES: + raise ValueError( + "maintained OpenAPI route inventory drifted; " + f"undocumented={sorted(paths - ROUTES)}, " + f"unserved={sorted(ROUTES - paths)}" + ) + for constant, value in HEADERS.items(): + marker(naming_source, f'pub const {constant}: &str = "{value}";', "naming") + marker(naming_source, "pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128;", "naming") + marker(naming_source, 'pub const CURSOR_QUERY_PARAMETER: &str = "cursor";', "naming") + marker(naming_source, 'pub const LIMIT_QUERY_PARAMETER: &str = "limit";', "naming") + marker(cursors_source, "pub const CURSOR_LIFETIME_MINUTES: i64 = 15;", "cursors") + marker(cursors_source, "const MAXIMUM_CURSOR_BYTES: usize = 256;", "cursors") + marker(service_source, "pub const DEFAULT_PAGE_LIMIT: usize = 50;", "service") + marker(service_source, "pub const MAXIMUM_PAGE_LIMIT: usize = 200;", "service") + marker(service_source, "const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62;", "service") + for action in ( + "HOLD_CREATE_ACTION", + "HOLD_RELEASE_ACTION", + "APPOINTMENT_CREATE_ACTION", + "APPOINTMENT_RESCHEDULE_ACTION", + "APPOINTMENT_CANCEL_ACTION", + ): + marker(service_source, f"pub const {action}: &str = ", "service") + marker(config_source, 'fn default_reads_scope() -> String {\n "scheduling-read".to_owned()', "config") + marker(config_source, 'fn default_explain_scope() -> String {\n "scheduling-explain".to_owned()', "config") + marker(httpsec_source, "pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;", "httpsec") + problem_body = re.search( + r"#\[serde\(rename_all = \"camelCase\"\)\]\s*pub struct ProblemBody\s*\{(?P.*?)^\}", + httpsec_source, + re.S | re.M, + ) + if not problem_body: + raise ValueError("the problem envelope is missing from the platform") + fields = set(re.findall(r"^\s*pub\s+([a-z_]+)\s*:", problem_body.group("body"), re.M)) + if fields != {"type_uri", "title", "status", "detail", "code", "trace_id"}: + raise ValueError(f"problem envelope fields drifted; actual={sorted(fields)}") + for text in ( + "async fn route_not_found()", + "async fn method_not_allowed()", + "fn normalize_framework_rejection", + "StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid)", + "StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge)", + "StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType)", + "StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable)", + "problem_response(ProblemCode::RequestNotFound)", + "problem_response(ProblemCode::RequestMethodNotAllowed)", + "byte.is_ascii_graphic()", + "value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES", + "parse_bearer_token(authorization)", + 'status == StatusCode::UNAUTHORIZED', + '"Bearer"', + "status == StatusCode::SERVICE_UNAVAILABLE", + 'RETRY_AFTER,\n "5"', + 'CACHE_CONTROL,\n "no-store"', + ): + marker(http_source, text, "http") + for text in ( + "pub async fn authenticate_read", + "pub async fn authenticate_explain", + "pub async fn authenticate_mutate", + "grant_claims(&verified.claims", + ".ok_or(AuthenticationError::Profile)", + ): + marker(auth_source, text, "auth") + for text in ( + "pub enum CommitError", + "WHERE actor_issuer=$1 AND actor_subject=$2 AND scope=$3 AND idempotency_key=$4", + ): + marker(store_source, text, "store") + # The commitment verdict, not the store, names the caller's problem. + for text in ( + "CommitError::KeyReused => ProblemCode::IdempotencyKeyReused", + "CommitError::KeyExpired => ProblemCode::IdempotencyExpired", + "CommitError::HoldCeiling => ProblemCode::CapacityExhausted", + "CommitError::Unauthorized => ProblemCode::OperationNotAuthorized", + "CommitError::RevisionMismatch => ProblemCode::RevisionMismatch", + "CommitError::CutoffPassed => ProblemCode::CancellationCutoffPassed", + ): + marker(service_source, text, "service") + for text in ( + "pub enum AdmissionRefusal", + "pub const fn public_code(&self) -> ProblemCode", + "Self::ResourceUnavailable { .. } => ProblemCode::CapacityExhausted", + "Self::DuplicateKeyRequired { .. } => ProblemCode::PreconditionRequired", + "pub const fn detailed_code(&self) -> ProblemCode", + "Self::ResourceUnavailable { .. } => ProblemCode::ResourceUnavailable", + ): + marker(admission_source, text, "admission") + for text in ( + "pub const fn is_detailed_only(self) -> bool", + "matches!(self, Self::ResourceUnavailable)", + "pub fn type_uri(code: &str) -> String", + "SCHEDULING_PROBLEM_TYPE_BASE", + ): + marker(problem_source, text, "problem") + + +def _root() -> Path: + return Path(__file__).resolve().parents[3] + + +def catalog_of(contract: dict) -> dict[str, dict]: + return {entry["code"]: entry for entry in contract["entries"]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", help="verify the committed document instead of writing it") + args = parser.parse_args() + repository_root = _root() + output = repository_root / OUTPUT + try: + contract = load_rust_contract(repository_root) + variants = {code_variant(entry["code"]): entry["code"] for entry in contract["entries"]} + verify_source(repository_root) + openapi = document(contract) + verify_handler_wiring(repository_root, openapi["paths"], variants) + verify_dto_schemas(repository_root, openapi) + verify_operation_problems(openapi, contract, catalog_of(contract), variants) + rendered = json.dumps(openapi, indent=2, sort_keys=True) + "\n" + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"OpenAPI source check failed: {error}", file=sys.stderr) + return 1 + if args.check: + if not output.exists() or output.read_text(encoding="utf-8") != rendered: + print(f"{output.relative_to(repository_root)} is stale; regenerate it", file=sys.stderr) + return 1 + print("Registry Scheduling OpenAPI is current.") + return 0 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(output.relative_to(repository_root)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/test_database_test_isolation.py b/products/scheduling/scripts/test_database_test_isolation.py new file mode 100644 index 0000000000..ba7dd98559 --- /dev/null +++ b/products/scheduling/scripts/test_database_test_isolation.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPT = Path(__file__).with_name("check_database_test_isolation.py") +SPEC = importlib.util.spec_from_file_location("scheduling_database_test_isolation", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def metadata(features=(), required=("postgres-test",), target="postgres_commitments"): + return { + "packages": [{"id": "runtime", "name": "registry-scheduling", "targets": [ + {"name": target, "kind": ["test"], "required-features": list(required)}, + ]}], + "resolve": {"nodes": [{"id": "runtime", "features": list(features)}]}, + } + + +class DatabaseTestIsolationTests(unittest.TestCase): + def test_explicit_opt_in_is_not_enabled_by_default(self): + self.assertEqual(MODULE.violations(metadata()), []) + + def test_resolved_default_features_cannot_enable_a_database_suite(self): + self.assertTrue(MODULE.violations(metadata(features=["default", "postgres-test"]))) + + def test_a_database_suite_cannot_lose_its_gate(self): + self.assertTrue(MODULE.violations(metadata(required=[]))) + + def test_an_unrelated_feature_cannot_replace_the_database_gate(self): + self.assertTrue(MODULE.violations(metadata(required=["schema"]))) + + def test_an_ordinary_integration_test_does_not_need_a_gate(self): + self.assertEqual(MODULE.violations(metadata(required=[], target="http_boundary")), []) + + def test_all_required_features_must_be_enabled(self): + self.assertEqual(MODULE.violations(metadata( + features=["postgres-test"], required=["postgres-test", "tooling"], + )), []) + + def test_missing_resolution_fails_visibly(self): + graph = metadata() + graph["resolve"]["nodes"] = [] + self.assertIn("no resolved feature inventory", MODULE.violations(graph)[0]) + + def test_metadata_loader_keeps_the_default_resolved_graph(self): + with patch.object(MODULE.subprocess, "run") as run: + run.return_value.stdout = json.dumps(metadata()) + MODULE.load_metadata(Path("."), None) + command = run.call_args.args[0] + self.assertNotIn("--no-deps", command) + self.assertNotIn("--all-features", command) + self.assertNotIn("--no-default-features", command) + + def test_cargo_resolves_defaults_and_transitive_forwarding(self): + with tempfile.TemporaryDirectory(prefix="scheduling-feature-test.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "tests").mkdir() + (root / "src/lib.rs").write_text("", encoding="utf-8") + (root / "tests/postgres_commitments.rs").write_text("", encoding="utf-8") + for default, expected in [('[]', False), ('["full"]', True)]: + (root / "Cargo.toml").write_text( + '[package]\nname="registry-scheduling"\nversion="0.1.0"\nedition="2021"\n' + '[workspace]\n[features]\n' + f'default={default}\nfull=["alias"]\nalias=["postgres-test"]\npostgres-test=[]\n' + '[[test]]\nname="postgres_commitments"\nrequired-features=["postgres-test"]\n', + encoding="utf-8", + ) + subprocess.run(["cargo", "generate-lockfile", "--offline"], cwd=root, check=True, + capture_output=True, text=True) + self.assertEqual(bool(MODULE.violations(MODULE.load_metadata(root, None))), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/scripts/test_dependency_direction.py b/products/scheduling/scripts/test_dependency_direction.py new file mode 100644 index 0000000000..92f7df99ba --- /dev/null +++ b/products/scheduling/scripts/test_dependency_direction.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check_dependency_direction.py") +SPEC = importlib.util.spec_from_file_location("scheduling_dependency_direction", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def metadata(edges: dict[str, list[str]]) -> dict: + names = { + "core": "registry-scheduling-core", + "client": "registry-scheduling-client", + "runtime": "registry-scheduling", + "ctl": "registry-schedulingctl", + "breg-client": "registry-breg-client", + "casework-core": "registry-casework-core", + "evidence-client": "registry-evidence-client", + "platform": "registry-platform-calendar", + "serde": "serde", + } + return { + "packages": [{"id": package_id, "name": name} for package_id, name in names.items()], + "resolve": { + "nodes": [ + { + "id": package_id, + "deps": [{"pkg": dependency, "name": names[dependency]} for dependency in dependencies], + } + for package_id, dependencies in edges.items() + ] + }, + } + + +class DependencyDirectionTests(unittest.TestCase): + def test_intended_direction_is_accepted(self): + graph = metadata( + { + "core": ["platform", "serde"], + "client": ["core", "serde"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": ["serde"], + "serde": [], + } + ) + self.assertEqual(MODULE.violations(graph), []) + + def test_transitive_product_dependency_from_core_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": ["breg-client"], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn("registry-scheduling-core transitively depends on other-product", failures) + self.assertIn("registry-scheduling-client transitively depends on other-product", failures) + self.assertIn("registry-scheduling transitively depends on other-product", failures) + self.assertIn("registry-schedulingctl transitively depends on other-product", failures) + + def test_evidence_dependency_from_client_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["evidence-client"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-client transitively depends on other-product package(s): registry-evidence-client", + failures, + ) + + def test_core_depending_on_the_runtime_is_rejected(self): + graph = metadata( + { + "core": ["runtime"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-core transitively depends on scheduling crate(s): registry-scheduling", + failures, + ) + + def test_client_depending_on_the_runtime_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["runtime"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-client transitively depends on runtime crate(s): registry-scheduling", + failures, + ) + + def test_product_dependency_on_scheduling_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": ["client"], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-breg-client transitively depends on Scheduling package(s): registry-scheduling-client", + failures, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/scripts/test_generate_openapi.py b/products/scheduling/scripts/test_generate_openapi.py new file mode 100644 index 0000000000..7f0ae8b411 --- /dev/null +++ b/products/scheduling/scripts/test_generate_openapi.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = Path(__file__).with_name("generate_openapi.py") +SPEC = importlib.util.spec_from_file_location("scheduling_generate_openapi", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +GENERATOR = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = GENERATOR +SPEC.loader.exec_module(GENERATOR) + + +def problem_codes(openapi: dict, response: dict) -> set[str]: + schema = response["content"]["application/problem+json"]["schema"] + variants = schema.get("oneOf", [schema]) + result = set() + for variant in variants: + name = variant["$ref"].rsplit("/", 1)[1] + component = openapi["components"]["schemas"][name] + result.add(component["allOf"][1]["properties"]["code"]["const"]) + return result + + +def parameter(openapi: dict, method: str, path: str, name: str) -> dict: + parameters = openapi["paths"][path][method]["parameters"] + return next(item for item in parameters if item["name"] == name) + + +def operation(openapi: dict, key: tuple[str, str]) -> dict: + method, path = key + return openapi["paths"][path][method.lower()] + + +def codes_of(openapi: dict, key: tuple[str, str]) -> dict[int, set[str]]: + return { + int(status): problem_codes(openapi, response) + for status, response in operation(openapi, key)["responses"].items() + if int(status) >= 400 + } + + +def documented_codes(openapi: dict, key: tuple[str, str]) -> set[str]: + by_status = codes_of(openapi, key) + return set().union(*by_status.values()) if by_status else set() + + +class GeneratedOpenApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = GENERATOR.load_rust_contract(ROOT) + cls.catalog = GENERATOR.catalog_of(cls.contract) + cls.variants = { + GENERATOR.code_variant(entry["code"]): entry["code"] + for entry in cls.contract["entries"] + } + cls.openapi = GENERATOR.document(cls.contract) + cls.committed = (ROOT / GENERATOR.OUTPUT).read_text(encoding="utf-8") + + def test_every_documented_problem_matches_its_pinned_rust_catalog_entry(self) -> None: + for entry in self.contract["entries"]: + component = self.openapi["components"]["schemas"][ + GENERATOR.problem_component_name(entry["code"]) + ] + properties = component["allOf"][1]["properties"] + self.assertEqual(entry["uri"], properties["type"]["const"]) + self.assertEqual(entry["title"], properties["title"]["const"]) + self.assertEqual(entry["description"], properties["detail"]["const"]) + self.assertEqual(entry["httpStatuses"][0], properties["status"]["const"]) + self.assertEqual(32, len(self.contract["entries"])) + self.assertEqual(59, len(self.openapi["components"]["schemas"])) + + def test_every_operation_answers_exactly_the_problems_it_was_mapped(self) -> None: + for key, expected in GENERATOR.OPERATION_PROBLEMS.items(): + by_status = codes_of(self.openapi, key) + # An operation can reach one refusal down more than one path, and + # the declaration names every path it has, so the document answers + # a code once however many groups carry it. + self.assertEqual(sorted(set(expected)), sorted(set().union(*by_status.values())), key) + for status, codes in by_status.items(): + for code in codes: + self.assertEqual(status, self.catalog[code]["httpStatuses"][0], code) + + def test_problem_status_is_never_documented_by_hand(self) -> None: + """A mutated catalog status must fail the check, not pass silently.""" + bad_contract = copy.deepcopy(self.contract) + entry = next( + entry for entry in bad_contract["entries"] if entry["code"] == "capacity.exhausted" + ) + entry["httpStatuses"] = [500] + with self.assertRaisesRegex(ValueError, "per-operation problem mapping drifted"): + GENERATOR.verify_operation_problems( + copy.deepcopy(self.openapi), + bad_contract, + GENERATOR.catalog_of(bad_contract), + self.variants, + ) + + def test_a_reserved_code_can_never_be_documented_as_an_answer(self) -> None: + key = ("GET", "/v1/services") + saved = list(GENERATOR.OPERATION_PROBLEMS[key]) + GENERATOR.OPERATION_PROBLEMS[key] = saved + ["eligibility.unavailable"] + try: + with self.assertRaisesRegex( + ValueError, "reserved problem documented as an operation answer" + ): + GENERATOR.verify_operation_problems( + copy.deepcopy(self.openapi), self.contract, self.catalog, self.variants + ) + finally: + GENERATOR.OPERATION_PROBLEMS[key] = saved + + def test_a_reserved_code_stays_in_the_vocabulary_it_is_reserved_from(self) -> None: + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + for code in GENERATOR.RESERVED_PROBLEMS: + self.assertNotIn(code, documented) + self.assertIn(code, self.catalog) + self.assertIn(code, self.openapi["components"]["schemas"]["Problem"]["properties"]["code"]["enum"]) + + def test_documented_coverage_is_the_closed_vocabulary_minus_the_unreachable(self) -> None: + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertEqual(set(self.catalog) - set(GENERATOR.RESERVED_PROBLEMS), documented) + self.assertEqual( + { + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", + }, + set(self.openapi["x-registry-scheduling-edge-problems"]), + ) + self.assertEqual( + { + "eligibility.unavailable", + "hook.unavailable", + "precondition.failed", + "resource.unavailable", + }, + set(self.openapi["x-registry-scheduling-reserved-problems"]), + ) + + def test_only_a_reachable_code_is_documented_as_an_answer(self) -> None: + produced = GENERATOR.produced_problem_codes(ROOT, self.variants) + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertEqual(documented, documented & produced) + self.assertEqual(set(), set(GENERATOR.UNPRODUCED_PROBLEMS) & produced) + self.assertIn("resource.unavailable", produced) + + def test_every_route_is_described_by_exactly_one_operation(self) -> None: + documented = { + (method.upper(), path) + for path, path_item in self.openapi["paths"].items() + for method in path_item + } + self.assertEqual(set(GENERATOR.ROUTE_HANDLERS), documented) + self.assertEqual(GENERATOR.ROUTES, {path for _, path in documented}) + self.assertEqual(set(GENERATOR.OPERATION_IDS), documented) + for key, operation_id in GENERATOR.OPERATION_IDS.items(): + self.assertEqual(operation_id, operation(self.openapi, key)["operationId"]) + + def test_a_drifted_route_table_fails_the_generator(self) -> None: + saved = set(GENERATOR.ROUTES) + try: + GENERATOR.ROUTES.discard("/v1/locations") + with self.assertRaisesRegex(ValueError, "route inventory drifted"): + GENERATOR.verify_source(ROOT) + finally: + GENERATOR.ROUTES.clear() + GENERATOR.ROUTES.update(saved) + + def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: + expected = { + ("GET", "/healthz"): "unauthenticated", + ("GET", "/readyz"): "unauthenticated", + ("GET", "/v1/scheduling"): "reads-scope", + ("GET", "/v1/services"): "reads-scope", + ("GET", "/v1/offerings"): "reads-scope", + ("GET", "/v1/resources"): "reads-scope", + ("GET", "/v1/locations"): "reads-scope", + ("GET", "/v1/availability"): "reads-scope", + ("GET", "/v1/availability/explain"): "explain-scope", + ("POST", "/v1/holds"): "task-grant", + ("DELETE", "/v1/holds/{hold_id}"): "task-grant", + ("POST", "/v1/appointments"): "task-grant", + ("GET", "/v1/appointments/{appointment_id}"): "reads-scope", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "task-grant", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "task-grant", + ("GET", "/v1/appointments/{appointment_id}/history"): "reads-scope", + } + for key, authority in expected.items(): + documented = operation(self.openapi, key) + self.assertEqual(authority, documented["x-scheduling-authority"], key) + if authority == "unauthenticated": + self.assertEqual([], documented["security"], key) + else: + self.assertEqual([{"bearerAuth": []}], documented["security"], key) + authorities = [ + item["x-scheduling-authority"] + for path in self.openapi["paths"].values() + for item in path.values() + ] + self.assertEqual(1, authorities.count("explain-scope")) + self.assertEqual(2, authorities.count("unauthenticated")) + self.assertEqual(5, authorities.count("task-grant")) + self.assertEqual(8, authorities.count("reads-scope")) + + def test_an_idempotency_key_is_demanded_exactly_where_the_handler_reads_it(self) -> None: + expected = { + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + ("POST", "/v1/appointments/{appointment_id}/reschedule"), + ("POST", "/v1/appointments/{appointment_id}/cancel"), + } + schema = {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[!-~]+$"} + for key in GENERATOR.ROUTE_HANDLERS: + names = {item["name"] for item in operation(self.openapi, key)["parameters"]} + self.assertEqual(key in expected, "Idempotency-Key" in names, key) + if key in expected: + demanded = parameter(self.openapi, key[0].lower(), key[1], "Idempotency-Key") + self.assertTrue(demanded["required"]) + self.assertEqual(schema, demanded["schema"]) + self.assertIn("idempotency.expired", demanded["description"]) + self.assertIn("idempotency.key-reused", demanded["description"]) + + def test_a_release_uses_the_hold_itself_as_its_idempotency_key(self) -> None: + """No caller key means a reused key is unreachable on this route.""" + key = ("DELETE", "/v1/holds/{hold_id}") + codes = documented_codes(self.openapi, key) + self.assertIn("idempotency.expired", codes) + self.assertNotIn("idempotency.key-reused", codes) + self.assertIn("own identifier is the idempotency key", + operation(self.openapi, key)["description"]) + self.assertNotIn("Idempotency-Key", + {item["name"] for item in operation(self.openapi, key)["parameters"]}) + + def test_a_request_body_is_demanded_exactly_where_the_handler_extracts_one(self) -> None: + expected = { + ("POST", "/v1/holds"): "AdmissionRequest", + ("POST", "/v1/appointments"): "CreateAppointmentRequest", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "RescheduleAppointmentRequest", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "CancelAppointmentRequest", + } + for key in GENERATOR.ROUTE_HANDLERS: + documented = operation(self.openapi, key) + self.assertEqual(key in expected, "requestBody" in documented, key) + if key in expected: + reference = documented["requestBody"]["content"]["application/json"]["schema"]["$ref"] + self.assertEqual(f"#/components/schemas/{expected[key]}", reference) + codes = documented_codes(self.openapi, key) + self.assertIn("request.invalid", codes, key) + self.assertIn("request.unprocessable", codes, key) + self.assertIn("request.unsupported-media-type", codes, key) + + def test_success_statuses_come_from_the_handlers(self) -> None: + expected = { + ("POST", "/v1/holds"): "201", + ("POST", "/v1/appointments"): "201", + ("DELETE", "/v1/holds/{hold_id}"): "204", + } + for key in GENERATOR.ROUTE_HANDLERS: + statuses = { + status + for status in operation(self.openapi, key)["responses"] + if int(status) < 400 + } + self.assertEqual({expected.get(key, "200")}, statuses, key) + + def test_a_fallible_service_method_always_documents_an_unavailable_dependency(self) -> None: + for key in GENERATOR.ROUTE_HANDLERS: + codes = documented_codes(self.openapi, key) + # Liveness is the one route that reaches neither the store nor the + # issuer's key material. Every other route reaches at least one of + # them, and an outage in either is answered with a retry. + if key == ("GET", "/healthz"): + self.assertNotIn("service.unavailable", codes, key) + else: + self.assertIn("service.unavailable", codes, key) + self.assertIn("Retry-After", operation(self.openapi, key)["responses"]["503"]["headers"]) + + def test_infrastructure_routes_are_open_and_distinguish_live_from_ready(self) -> None: + health = operation(self.openapi, ("GET", "/healthz")) + ready = operation(self.openapi, ("GET", "/readyz")) + self.assertEqual([], health["security"]) + self.assertEqual([], ready["security"]) + self.assertNotIn("requestBody", health) + self.assertNotIn("requestBody", ready) + self.assertNotIn("503", health["responses"]) + self.assertIn("503", ready["responses"]) + self.assertNotIn("401", health["responses"]) + self.assertNotIn("401", ready["responses"]) + self.assertIn("No authentication", health["description"]) + self.assertIn("service.unavailable", ready["description"]) + + def test_only_an_authenticated_operation_answers_a_bearer_challenge(self) -> None: + for key in GENERATOR.ROUTE_HANDLERS: + item = operation(self.openapi, key) + codes = documented_codes(self.openapi, key) + challenge = item["responses"].get("401", {}).get("headers", {}).get("WWW-Authenticate") + if item["x-scheduling-authority"] == "unauthenticated": + self.assertNotIn("authentication.refused", codes, key) + self.assertNotIn("profile.not-authorized", codes, key) + self.assertIsNone(challenge, key) + else: + self.assertIn("authentication.refused", codes, key) + self.assertIn("profile.not-authorized", codes, key) + self.assertEqual("Bearer", challenge["schema"]["const"], key) + + def test_every_answer_carries_the_trace_and_the_no_store_policy(self) -> None: + for path in self.openapi["paths"].values(): + for item in path.values(): + for response in item["responses"].values(): + self.assertEqual("#/components/headers/TraceparentHeader", + response["headers"]["traceparent"]["$ref"]) + self.assertEqual("#/components/headers/CacheControlHeader", + response["headers"]["cache-control"]["$ref"]) + self.assertEqual({"const": "no-store"}, + self.openapi["components"]["headers"]["CacheControlHeader"]["schema"]) + + def test_owned_appointment_reads_never_disclose_existence(self) -> None: + for key in ( + ("GET", "/v1/appointments/{appointment_id}"), + ("GET", "/v1/appointments/{appointment_id}/history"), + ): + codes = documented_codes(self.openapi, key) + self.assertIn("operation.not-authorized", codes, key) + self.assertNotIn("request.not-found", codes, key) + self.assertIn("never disclosed", operation(self.openapi, key)["description"]) + + def test_the_cancellation_guard_never_names_the_policy_revision(self) -> None: + key = ("POST", "/v1/appointments/{appointment_id}/cancel") + codes = documented_codes(self.openapi, key) + self.assertIn("cancellation.cutoff-passed", codes) + self.assertIn("revision.mismatch", codes) + self.assertNotIn("policy.changed", codes) + self.assertIn("never by the policy revision", operation(self.openapi, key)["description"]) + + def test_a_reschedule_answers_a_moved_policy_but_never_an_absent_offering(self) -> None: + # A reschedule resolves its offering from the appointment rather than + # from the caller, so there is no caller-named offering to be absent. + key = ("POST", "/v1/appointments/{appointment_id}/reschedule") + codes = documented_codes(self.openapi, key) + self.assertIn("policy.changed", codes) + self.assertIn("revision.mismatch", codes) + self.assertNotIn("request.not-found", codes) + + def test_only_a_confirming_create_answers_an_expired_hold(self) -> None: + expected = {("POST", "/v1/appointments")} + for key in GENERATOR.ROUTE_HANDLERS: + self.assertEqual( + key in expected, + "hold.expired" in documented_codes(self.openapi, key), + key, + ) + + def test_only_an_offering_the_caller_names_can_be_answered_as_absent(self) -> None: + # The four operations that resolve a caller-named offering against the + # deployed policy. Everywhere else request.not-found would conceal an + # owned record rather than name an absence the offering listing + # already discloses. + expected = { + ("GET", "/v1/availability"), + ("GET", "/v1/availability/explain"), + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + } + for key in GENERATOR.ROUTE_HANDLERS: + self.assertEqual( + key in expected, + "request.not-found" in documented_codes(self.openapi, key), + key, + ) + + def test_admission_refusals_travel_only_with_the_commitment_routes(self) -> None: + admission = { + "booking.duplicate-active", + "capacity.exhausted", + "capability.unmatched", + "horizon.outside", + "location.closed", + "party.capacity-inadequate", + "policy.changed", + "precondition.required", + "prerequisite.missing", + "schedule.unpublished", + } + commitments = { + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + ("POST", "/v1/appointments/{appointment_id}/reschedule"), + } + for key in GENERATOR.ROUTE_HANDLERS: + codes = documented_codes(self.openapi, key) + self.assertEqual(key in commitments, bool(admission & codes), key) + + def test_resource_unavailability_is_disclosed_by_the_explain_path_only(self) -> None: + explain = operation(self.openapi, ("GET", "/v1/availability/explain")) + self.assertIn("resource.unavailable is disclosed here", explain["description"]) + self.assertIn("capacity.exhausted", explain["description"]) + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertNotIn("resource.unavailable", documented) + self.assertIn("resource.unavailable", self.openapi["x-registry-scheduling-reserved-problems"]) + + def test_the_catalogue_and_availability_reads_are_cursor_bound(self) -> None: + for key in ( + ("GET", "/v1/services"), + ("GET", "/v1/offerings"), + ("GET", "/v1/resources"), + ("GET", "/v1/locations"), + ("GET", "/v1/availability"), + ("GET", "/v1/appointments/{appointment_id}/history"), + ): + names = {item["name"]: item for item in operation(self.openapi, key)["parameters"]} + self.assertIn("cursor", names, key) + self.assertIn("limit", names, key) + self.assertFalse(names["cursor"]["required"]) + self.assertFalse(names["limit"]["required"]) + self.assertEqual(256, names["cursor"]["schema"]["maxLength"]) + self.assertIn("cursor.expired", names["cursor"]["description"]) + codes = documented_codes(self.openapi, key) + self.assertIn("cursor.expired", codes, key) + self.assertIn("cursor.invalid", codes, key) + self.assertEqual({"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, + names["limit"]["schema"]) + + def test_the_security_scheme_tells_the_three_profiles_apart(self) -> None: + scheme = self.openapi["components"]["securitySchemes"]["bearerAuth"] + self.assertEqual({"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + {key: scheme[key] for key in ("type", "scheme", "bearerFormat")}) + self.assertIn("scheduling-read", scheme["description"]) + self.assertIn("scheduling-explain", scheme["description"]) + self.assertIn("no scope", scheme["description"]) + self.assertIn("complete task grant", scheme["description"]) + self.assertIn("partial grant claims", scheme["description"]) + + def test_mutating_routes_name_the_five_task_grant_actions(self) -> None: + described = " ".join( + operation(self.openapi, key)["description"] + for key in GENERATOR.ROUTE_HANDLERS + if operation(self.openapi, key)["x-scheduling-authority"] == "task-grant" + ) + for action in ( + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ): + self.assertIn(action, described) + self.assertIn("hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel", + self.openapi["components"]["securitySchemes"]["bearerAuth"]["description"]) + + def test_the_document_carries_no_tag_list_and_no_timestamp(self) -> None: + self.assertNotIn("tags", self.openapi) + self.assertEqual("3.1.0", self.openapi["openapi"]) + self.assertEqual("Registry Scheduling API", self.openapi["info"]["title"]) + self.assertEqual("v1alpha1", self.openapi["info"]["version"]) + self.assertEqual( + [{ + "url": "https://scheduling.example.test", + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + }], + self.openapi["servers"], + ) + self.assertEqual({"name": "Apache-2.0", "identifier": "Apache-2.0"}, + self.openapi["info"]["license"]) + self.assertNotRegex(self.committed, r"20\d\d-\d\d-\d\d") + + def test_wire_documents_match_the_rust_structs_field_for_field(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + for rust_name, schema_name in GENERATOR.SCHEMA_STRUCTS[GENERATOR.WIRE_SOURCE].items(): + self.assertEqual( + {GENERATOR.camel_case(field) for field in GENERATOR.rust_struct_fields(wire, rust_name)}, + set(schemas[schema_name]["properties"]), + rust_name, + ) + + def test_dto_drift_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + del bad_openapi["components"]["schemas"]["OfferingDocument"]["properties"]["window"] + with self.assertRaisesRegex(ValueError, "DTO shape drifted from OfferingDocument"): + GENERATOR.verify_dto_schemas(ROOT, bad_openapi) + + def test_query_parameter_drift_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + parameters = bad_openapi["paths"]["/v1/services"]["get"]["parameters"] + bad_openapi["paths"]["/v1/services"]["get"]["parameters"] = [ + item for item in parameters if item["name"] != "limit" + ] + with self.assertRaisesRegex(ValueError, "OpenAPI query parameters drifted"): + GENERATOR.verify_handler_wiring(ROOT, bad_openapi["paths"], self.variants) + + def test_a_drifted_problem_response_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + bad_openapi["paths"]["/v1/services"]["get"]["responses"]["409"] = copy.deepcopy( + self.openapi["paths"]["/v1/holds"]["post"]["responses"]["409"] + ) + with self.assertRaisesRegex(ValueError, "per-operation problem mapping drifted"): + GENERATOR.verify_operation_problems( + bad_openapi, self.contract, self.catalog, self.variants + ) + + def test_a_page_is_its_items_and_the_cursor_that_continues_it(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + page_fields = { + GENERATOR.camel_case(field) + for field in GENERATOR.rust_struct_fields(wire, "PageDocument") + } + self.assertEqual({"items", "nextCursor"}, page_fields) + for name in ( + "ServicePage", + "OfferingPage", + "ResourcePage", + "LocationPage", + "AvailabilityPage", + "AppointmentHistoryPage", + ): + self.assertEqual(page_fields, set(schemas[name]["properties"]), name) + self.assertEqual(["items", "nextCursor"], sorted(schemas[name]["required"]), name) + self.assertEqual({"anyOf": [{"type": "string"}, {"type": "null"}]}, + schemas[name]["properties"]["nextCursor"]) + self.assertEqual("#/components/schemas/AvailabilityEntry", + schemas["AvailabilityPage"]["properties"]["items"]["items"]["$ref"]) + + def test_the_offering_projection_keeps_its_mode_vocabulary_and_its_nulls(self) -> None: + schemas = self.openapi["components"]["schemas"] + offering = schemas["OfferingDocument"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + self.assertEqual({"exact-time", "arrival-window"}, set(offering["properties"]["mode"]["enum"])) + self.assertEqual( + set(GENERATOR.rust_kebab_case_unit_enum_values(wire, "SchedulingModeDocument")), + set(offering["properties"]["mode"]["enum"]), + ) + for field in ("durationMinutes", "bufferBeforeMinutes", "bufferAfterMinutes", + "startIncrementMinutes", "maxRecipients", "window"): + self.assertNotIn(field, offering["required"], field) + self.assertEqual({"type": "null"}, offering["properties"][field]["anyOf"][1], field) + self.assertIn("null, never zero", self.openapi["paths"]["/v1/offerings"]["get"]["description"]) + + def test_an_appointment_state_is_the_closed_vocabulary_the_wire_names(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + self.assertEqual({"confirmed", "cancelled"}, set(schemas["AppointmentState"]["enum"])) + self.assertEqual( + set(GENERATOR.rust_kebab_case_unit_enum_values(wire, "AppointmentStateDocument")), + set(schemas["AppointmentState"]["enum"]), + ) + + def test_availability_entries_discriminate_on_the_wire_kind_tag(self) -> None: + schemas = self.openapi["components"]["schemas"] + entry = schemas["AvailabilityEntry"] + self.assertEqual(["AvailabilitySlot", "AvailabilityWindow"], + [variant["$ref"].rsplit("/", 1)[1] for variant in entry["oneOf"]]) + self.assertEqual("kind", entry["discriminator"]["propertyName"]) + self.assertEqual("slot", schemas["AvailabilitySlot"]["properties"]["kind"]["const"]) + self.assertEqual("window", schemas["AvailabilityWindow"]["properties"]["kind"]["const"]) + self.assertEqual({"kind", "start", "end", "free"}, set(schemas["AvailabilitySlot"]["required"])) + self.assertEqual({"kind", "window", "start", "end", "remaining"}, + set(schemas["AvailabilityWindow"]["required"])) + self.assertIn("channelRemaining", schemas["AvailabilityWindow"]["properties"]) + + def test_the_explain_projection_leaves_a_clean_answer_undefended(self) -> None: + explain = self.openapi["components"]["schemas"]["ExplainDocument"] + self.assertEqual(["offering", "start"], sorted(explain["required"])) + for field in ("publicCode", "detailedCode", "explanation"): + self.assertNotIn(field, explain["required"], field) + self.assertNotIn("anyOf", explain["properties"][field], field) + self.assertIn("answers with no codes at all", + self.openapi["paths"]["/v1/availability/explain"]["get"]["description"]) + + def test_a_create_carries_exactly_one_of_hold_or_admission(self) -> None: + create = self.openapi["components"]["schemas"]["CreateAppointmentRequest"] + self.assertEqual({"hold", "admission"}, set(create["properties"])) + self.assertEqual(1, create["minProperties"]) + self.assertIn("Exactly one of hold or admission", create["description"]) + self.assertIn("Both, or neither, is request.invalid", create["description"]) + + def test_the_documented_bounds_are_the_rust_ones(self) -> None: + naming = GENERATOR.production_source(ROOT, GENERATOR.NAMING_SOURCE) + cursors = GENERATOR.production_source(ROOT, GENERATOR.CURSORS_SOURCE) + service = GENERATOR.production_source(ROOT, GENERATOR.SERVICE_SOURCE) + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + httpsec = GENERATOR.production_source(ROOT, GENERATOR.HTTPSEC_SOURCE) + self.assertIn('pub const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";', naming) + self.assertIn("pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128;", naming) + self.assertIn("byte.is_ascii_graphic()", http) + self.assertIn("value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES", http) + self.assertIn("pub const CURSOR_LIFETIME_MINUTES: i64 = 15;", cursors) + self.assertIn("const MAXIMUM_CURSOR_BYTES: usize = 256;", cursors) + self.assertIn("pub const DEFAULT_PAGE_LIMIT: usize = 50;", service) + self.assertIn("pub const MAXIMUM_PAGE_LIMIT: usize = 200;", service) + self.assertIn("const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62;", service) + self.assertIn("pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;", httpsec) + for action in ("hold.create", "hold.release", "appointment.create", + "appointment.reschedule", "appointment.cancel"): + self.assertIn(f'"{action}"', service) + self.assertIn("15-minute", parameter(self.openapi, "get", "/v1/services", "cursor")["description"]) + self.assertIn("62 days", self.openapi["paths"]["/v1/availability"]["get"]["description"]) + + def test_the_request_edge_rejection_map_is_the_rust_one(self) -> None: + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + for text in ( + "StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid)", + "StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge)", + "StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType)", + "StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable)", + "problem_response(ProblemCode::RequestNotFound)", + "problem_response(ProblemCode::RequestMethodNotAllowed)", + ): + self.assertIn(text, http) + for key in GENERATOR.ROUTE_HANDLERS: + self.assertIn("request.body-too-large", documented_codes(self.openapi, key), key) + self.assertIn("request.method-not-allowed", documented_codes(self.openapi, key), key) + + def test_the_problem_envelope_is_the_platform_one(self) -> None: + problem = self.openapi["components"]["schemas"]["Problem"] + self.assertEqual({"type", "title", "status", "detail", "code", "traceId"}, + set(problem["properties"])) + self.assertEqual(["code", "detail", "status", "title", "traceId", "type"], + sorted(problem["required"])) + self.assertEqual(32, len(problem["properties"]["code"]["enum"])) + httpsec = GENERATOR.production_source(ROOT, GENERATOR.HTTPSEC_SOURCE) + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + body = GENERATOR.rust_struct_fields(httpsec, "ProblemBody") + self.assertEqual({"type_uri", "title", "status", "detail", "code", "trace_id"}, body) + self.assertIn('CACHE_CONTROL,\n "no-store"', http) + self.assertIn('RETRY_AFTER,\n "5"', http) + self.assertIn('"Bearer"', http) + + def test_the_committed_document_is_the_one_this_generator_writes(self) -> None: + self.assertEqual(self.committed, json.dumps(self.openapi, indent=2, sort_keys=True) + "\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/scripts/test_validate_contracts.py b/products/scheduling/scripts/test_validate_contracts.py new file mode 100644 index 0000000000..b59602f600 --- /dev/null +++ b/products/scheduling/scripts/test_validate_contracts.py @@ -0,0 +1,366 @@ +"""Unit tests for the Scheduling contracts validator. + +The validator is exercised against synthetic trees so a broken contract +shape is caught without touching the real files, the same way the sibling +products test their contract tooling. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +import tempfile + +import yaml + +sys.dont_write_bytecode = True +_SCRIPT_PATH = Path(__file__).with_name("validate_contracts.py") +_SPEC = importlib.util.spec_from_file_location("scheduling_validate_contracts", _SCRIPT_PATH) +assert _SPEC is not None and _SPEC.loader is not None +validator = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(validator) + +RUST_TEST_FILE = """\ +#[test] +fn the_cited_test_exists() {} +""" + +PYTHON_TEST_FILE = """\ +import unittest + + +class Tests(unittest.TestCase): + def the_cited_test_exists(self) -> None: + pass +""" + +RECORDED_IN_FILE = "# Notes\n" + + +def matrix_row( + row_id: str = "SCHEDULING-SEC-01", + state: str = "enforced", + partial_note: str | None = None, + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + row = { + "id": row_id, + "state": state, + "threat": "A threat.", + "enforcementPoint": "An enforcement point.", + "refusal": "A refusal.", + "negativeTest": {"path": test_path, "name": test_name}, + } + if partial_note is not None: + row["partialNote"] = partial_note + return row + + +def deferred_row( + row_id: str = "SCHEDULING-DEF-01", + recorded_in: str = "products/scheduling/NOTES.md", +) -> dict: + return { + "id": row_id, + "subject": "A subject", + "reason": "A reason.", + "compensatingControl": "A control.", + "recordedIn": recorded_in, + } + + +def traceability_entry( + entry_id: str = "SCHEDULING-SEC-01", + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + return { + "id": entry_id, + "tests": [{"path": test_path, "name": test_name}], + } + + +def decision_row( + row_id: str = "SCHEDULING-DEC-01", + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + return { + "id": row_id, + "subject": "A subject", + "decision": "A decision.", + "reasoning": "Reasoning.", + "evidence": [{"path": test_path, "name": test_name}], + } + + +def happy_tree() -> dict[str, dict]: + return { + "security-invariant-matrix.yaml": { + "apiVersion": "registry.registrystack.org/product-contract/v1", + "product": "scheduling", + "invariants": [matrix_row()], + "deferred": [deferred_row()], + }, + "security-test-traceability.yaml": { + "contract": "registry.scheduling.security-test-traceability/v1", + "entries": [traceability_entry()], + }, + "recorded-decisions.yaml": { + "apiVersion": "registry.registrystack.org/product-contract/v1", + "purpose": "A purpose.", + "decisions": [decision_row()], + }, + } + + +class Resolver: + """A synthetic repository tree backed by files on disk.""" + + def __init__(self, files: dict[str, str]) -> None: + self.root = Path(tempfile.mkdtemp(prefix="scheduling-contracts-test.")) + for relative, text in files.items(): + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def __call__(self, path: str) -> str | None: + candidate = self.root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + +def validate_tree(tree: dict[str, dict], files: dict[str, str]) -> list[str]: + return validator.validate( + tree["security-invariant-matrix.yaml"], + tree["security-test-traceability.yaml"], + tree["recorded-decisions.yaml"], + Resolver(files), + ) + + +DEFAULT_FILES = { + "crates/x/src/a.rs": RUST_TEST_FILE, + "products/scheduling/NOTES.md": RECORDED_IN_FILE, +} + + +class RunnerValidation(unittest.TestCase): + def test_python_discovery_rejects_an_ordinary_method(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-python-inventory.") as directory: + root = Path(directory) + source = root / "test_fixture.py" + source.write_text( + 'import unittest\nclass Tests(unittest.TestCase):\n' + ' def test_selectable(self): raise AssertionError("must not execute")\n' + ' def ordinary_method(self): pass\n', encoding="utf-8", + ) + self.assertEqual(validator.runner_violations(root, [ + {"path": source.name, "name": "test_selectable"}, + ]), []) + failures = validator.runner_violations(root, [ + {"path": source.name, "name": "ordinary_method"}, + ]) + self.assertEqual(len(failures), 1, failures) + self.assertIn("not selectable", failures[0]) + + def test_a_feature_gated_library_citation_names_its_feature(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-feature-inventory.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "Cargo.toml").write_text( + '[package]\nname = "citation-fixture"\nversion = "0.1.0"\nedition = "2021"\n' + '[workspace]\n[features]\nruntime = []\n', encoding="utf-8", + ) + (root / "Cargo.lock").write_text( + 'version = 3\n[[package]]\nname = "citation-fixture"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (root / "src/lib.rs").write_text( + '#[cfg(feature = "runtime")]\n#[test]\n' + 'fn selectable() { panic!("must not execute"); }\n', encoding="utf-8", + ) + citation = {"path": "src/lib.rs", "name": "selectable"} + self.assertTrue(validator.runner_violations(root, [citation])) + citation["features"] = ["runtime"] + self.assertEqual(validator.runner_violations(root, [citation]), []) + + def test_rust_citation_must_be_listed_by_its_runner(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-runner-test.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "Cargo.toml").write_text( + '[package]\nname = "citation-fixture"\nversion = "0.1.0"\nedition = "2021"\n' + '[workspace]\n', encoding="utf-8", + ) + (root / "Cargo.lock").write_text( + 'version = 3\n[[package]]\nname = "citation-fixture"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (root / "src/lib.rs").write_text( + '#[test]\nfn selectable() { panic!("inventory must not execute tests"); }\n' + 'pub fn ordinary_function() {}\n', encoding="utf-8", + ) + self.assertEqual(validator.runner_violations(root, [ + {"path": "src/lib.rs", "name": "selectable"}, + ]), []) + failures = validator.runner_violations(root, [ + {"path": "src/lib.rs", "name": "ordinary_function"}, + ]) + self.assertEqual(len(failures), 1, failures) + self.assertIn("not selectable", failures[0]) + + +class MatrixValidation(unittest.TestCase): + def test_a_consistent_tree_has_no_violations(self) -> None: + self.assertEqual(validate_tree(happy_tree(), DEFAULT_FILES), []) + + def test_a_partial_invariant_without_its_note_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["state"] = "partial" + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue(any("partialNote" in violation or "partial invariant" in violation + for violation in violations), violations) + + def test_a_cited_test_the_file_does_not_define_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["negativeTest"]["name"] = ( + "a_test_that_does_not_exist" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("defines no test function" in violation for violation in violations), + violations, + ) + + def test_a_cited_file_that_does_not_exist_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["negativeTest"]["path"] = ( + "crates/x/src/missing.rs" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("does not exist" in violation for violation in violations), violations + ) + + def test_a_deferral_pointing_at_no_file_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["deferred"][0]["recordedIn"] = ( + "products/scheduling/ABSENT.md" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("recordedIn file" in violation for violation in violations), violations + ) + + def test_a_duplicate_invariant_id_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"].append(matrix_row()) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("listed more than once" in violation for violation in violations), + violations, + ) + + +class TraceabilityValidation(unittest.TestCase): + def test_an_invariant_without_an_entry_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"].append( + matrix_row(row_id="SCHEDULING-SEC-02", test_path="crates/x/src/a.rs") + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("SCHEDULING-SEC-02 has no entry" in violation for violation in violations), + violations, + ) + + def test_an_entry_matching_no_invariant_is_refused(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"].append( + traceability_entry(entry_id="SCHEDULING-SEC-09") + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("matches no matrix invariant" in violation for violation in violations), + violations, + ) + + def test_a_python_citation_resolves_through_def(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"][0]["tests"] = [ + {"path": "products/x/test_y.py", "name": "the_cited_test_exists"} + ] + files = dict(DEFAULT_FILES) + files["products/x/test_y.py"] = PYTHON_TEST_FILE + self.assertEqual(validate_tree(tree, files), []) + + def test_a_citation_in_a_file_of_no_known_kind_is_refused(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"][0]["tests"] = [ + {"path": "products/x/notes.txt", "name": "the_cited_test_exists"} + ] + files = dict(DEFAULT_FILES) + files["products/x/notes.txt"] = "fn the_cited_test_exists(\n" + violations = validate_tree(tree, files) + self.assertTrue( + any("neither Rust nor Python" in violation for violation in violations), + violations, + ) + + +class DecisionValidation(unittest.TestCase): + def test_a_decision_without_evidence_is_refused(self) -> None: + tree = happy_tree() + tree["recorded-decisions.yaml"]["decisions"][0]["evidence"] = [] + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("evidence test" in violation for violation in violations), violations + ) + + def test_a_decision_with_dangling_evidence_is_refused(self) -> None: + tree = happy_tree() + tree["recorded-decisions.yaml"]["decisions"][0]["evidence"][0]["name"] = "absent" + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("defines no test function" in violation for violation in violations), + violations, + ) + + +class CommittedContracts(unittest.TestCase): + """The committed contract files themselves pass the validator.""" + + def test_committed_files_validate(self) -> None: + contracts_dir = Path(__file__).resolve().parent.parent / "contracts" + root = contracts_dir.parents[2] + + def resolve(path: str) -> str | None: + candidate = root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + loaded = {} + for name in ( + "security-invariant-matrix.yaml", + "security-test-traceability.yaml", + "recorded-decisions.yaml", + ): + with (contracts_dir / name).open(encoding="utf-8") as handle: + loaded[name] = yaml.safe_load(handle) + violations = validator.validate( + loaded["security-invariant-matrix.yaml"], + loaded["security-test-traceability.yaml"], + loaded["recorded-decisions.yaml"], + resolve, + ) + self.assertEqual(violations, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/scripts/validate_contracts.py b/products/scheduling/scripts/validate_contracts.py new file mode 100644 index 0000000000..b049184008 --- /dev/null +++ b/products/scheduling/scripts/validate_contracts.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Validate the Scheduling product contract files against the code they cite. + +The three documents under products/scheduling/contracts/ are the review +baseline for every later Scheduling change. This validator holds them to +the shape the product committed to: + +- every security invariant names a threat, an enforcement point, a + refusal, and a negative test; +- every cited test exists in the cited file and is selectable by its runner. + Rust inventories compile the selected target with its required features; + citations may add features for gated library modules. Python inventories + use unittest discovery. Neither inventory executes test bodies. +- the traceability document covers exactly the matrix's invariants; +- every recorded decision cites evidence that exists; +- every deferral names the tracked file that records it, and that file + exists. + +The check is source-mapped, not executed: rows whose tests live in a +PostgreSQL suite are proven when that suite runs, and the matrix says so +in its own evidence note. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Callable + +import yaml + +CONTRACT_API_VERSION = "registry.registrystack.org/product-contract/v1" +TRACEABILITY_CONTRACT = "registry.scheduling.security-test-traceability/v1" +SECURITY_ID = re.compile(r"^SCHEDULING-SEC-\d{2}$") +DEFERRED_ID = re.compile(r"^SCHEDULING-DEF-\d{2}$") +DECISION_ID = re.compile(r"^SCHEDULING-DEC-\d{2}$") + +TextResolver = Callable[[str], str | None] + + +def _non_empty(value: object) -> bool: + return isinstance(value, str) and value.strip() != "" + + +def _cited_test_violations( + label: str, path: str | None, name: str | None, resolve: TextResolver +) -> list[str]: + """One cited test: the file must exist and must define the name.""" + if not _non_empty(path) or not _non_empty(name): + return [f"{label}: a cited test needs both a path and a name"] + if path.startswith("/") or ".." in Path(path).parts: + return [f"{label}: cited path {path!r} must be a repository-relative path"] + text = resolve(path) + if text is None: + return [f"{label}: cited file {path} does not exist"] + if path.endswith(".rs"): + if f"fn {name}(" not in text: + return [f"{label}: {path} defines no test function {name}"] + elif path.endswith(".py"): + if f"def {name}(" not in text: + return [f"{label}: {path} defines no test method {name}"] + else: + return [f"{label}: cited file {path} is neither Rust nor Python"] + return [] + + +def matrix_violations(matrix: dict, resolve: TextResolver) -> list[str]: + violations: list[str] = [] + if matrix.get("apiVersion") != CONTRACT_API_VERSION: + violations.append(f"matrix: apiVersion must be {CONTRACT_API_VERSION}") + if matrix.get("product") != "scheduling": + violations.append("matrix: product must be scheduling") + + invariants = matrix.get("invariants") + if not isinstance(invariants, list) or not invariants: + return violations + ["matrix: no invariants listed"] + + ids: set[str] = set() + for row in invariants: + row_id = row.get("id") + label = f"matrix {row_id}" + if not isinstance(row_id, str) or not SECURITY_ID.match(row_id): + violations.append(f"matrix: invariant id {row_id!r} is not SCHEDULING-SEC-NN") + continue + if row_id in ids: + violations.append(f"{label}: listed more than once") + ids.add(row_id) + for field in ("threat", "enforcementPoint", "refusal"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + state = row.get("state") + if state not in ("enforced", "partial"): + violations.append(f"{label}: state must be enforced or partial, not {state!r}") + if state == "partial" and not _non_empty(row.get("partialNote")): + violations.append(f"{label}: a partial invariant must say what is missing") + test = row.get("negativeTest") + if not isinstance(test, dict): + violations.append(f"{label}: negativeTest with path and name is required") + else: + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + + deferred = matrix.get("deferred", []) + deferred_ids: set[str] = set() + if not isinstance(deferred, list): + violations.append("matrix: deferred must be a list") + deferred = [] + for row in deferred: + row_id = row.get("id") + label = f"matrix {row_id}" + if not isinstance(row_id, str) or not DEFERRED_ID.match(row_id): + violations.append(f"matrix: deferred id {row_id!r} is not SCHEDULING-DEF-NN") + continue + if row_id in deferred_ids: + violations.append(f"{label}: listed more than once") + deferred_ids.add(row_id) + for field in ("subject", "reason", "compensatingControl", "recordedIn"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + recorded_in = row.get("recordedIn") + if _non_empty(recorded_in): + if resolve(recorded_in) is None: + violations.append(f"{label}: recordedIn file {recorded_in} does not exist") + return violations + + +def traceability_violations( + traceability: dict, matrix: dict, resolve: TextResolver +) -> list[str]: + violations: list[str] = [] + if traceability.get("contract") != TRACEABILITY_CONTRACT: + violations.append(f"traceability: contract must be {TRACEABILITY_CONTRACT}") + entries = traceability.get("entries") + if not isinstance(entries, list) or not entries: + return violations + ["traceability: no entries listed"] + + cited: dict[str, list[str]] = {} + for entry in entries: + entry_id = entry.get("id") + label = f"traceability {entry_id}" + if not isinstance(entry_id, str) or not SECURITY_ID.match(entry_id): + violations.append(f"traceability: entry id {entry_id!r} is not SCHEDULING-SEC-NN") + continue + tests = entry.get("tests") + if not isinstance(tests, list) or not tests: + violations.append(f"{label}: at least one test must be cited") + continue + cited[entry_id] = [] + for test in tests: + if not isinstance(test, dict): + violations.append(f"{label}: every cited test is a path and name pair") + continue + cited[entry_id].append(str(test.get("name"))) + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + + matrix_ids = { + row.get("id") for row in matrix.get("invariants", []) if isinstance(row, dict) + } + for missing in sorted(matrix_ids - set(cited)): + violations.append(f"traceability: invariant {missing} has no entry") + for extra in sorted(set(cited) - matrix_ids): + violations.append(f"traceability: entry {extra} matches no matrix invariant") + return violations + + +def decisions_violations(decisions: dict, resolve: TextResolver) -> list[str]: + violations: list[str] = [] + if decisions.get("apiVersion") != CONTRACT_API_VERSION: + violations.append(f"decisions: apiVersion must be {CONTRACT_API_VERSION}") + if not _non_empty(decisions.get("purpose")): + violations.append("decisions: purpose is required and empty") + entries = decisions.get("decisions") + if not isinstance(entries, list) or not entries: + return violations + ["decisions: no decisions listed"] + + seen: set[str] = set() + for row in entries: + row_id = row.get("id") + label = f"decision {row_id}" + if not isinstance(row_id, str) or not DECISION_ID.match(row_id): + violations.append(f"decisions: id {row_id!r} is not SCHEDULING-DEC-NN") + continue + if row_id in seen: + violations.append(f"{label}: listed more than once") + seen.add(row_id) + for field in ("subject", "decision", "reasoning"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + evidence = row.get("evidence") + if not isinstance(evidence, list) or not evidence: + violations.append(f"{label}: at least one evidence test must be cited") + continue + for test in evidence: + if not isinstance(test, dict): + violations.append(f"{label}: every evidence test is a path and name pair") + continue + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + return violations + + +def validate( + matrix: dict, traceability: dict, decisions: dict, resolve: TextResolver +) -> list[str]: + return [ + *matrix_violations(matrix, resolve), + *traceability_violations(traceability, matrix, resolve), + *decisions_violations(decisions, resolve), + ] + + +def runner_violations(root: Path, citations: list[dict]) -> list[str]: + """List tests, never execute them, caching each runner within this check.""" + inventories: dict[tuple[str, ...], set[str]] = {} + failures: list[str] = [] + for path, name, features in sorted({ + (test["path"], test["name"], tuple(test.get("features", []))) for test in citations + }): + source = (root / path).resolve() + if not source.is_relative_to(root.resolve()) or not source.is_file(): + failures.append(f"{path}: cited test source is missing or outside the repository") + continue + try: + if source.suffix == ".rs": + manifest = next( + (parent / "Cargo.toml" for parent in source.parents + if parent.is_relative_to(root.resolve()) + and (parent / "Cargo.toml").is_file()), + None, + ) + if manifest is None: + raise ValueError("no Cargo manifest for cited test") + package = tomllib.loads(manifest.read_text(encoding="utf-8")) + command = ["cargo", "test", "--locked", "--manifest-path", str(manifest)] + relative = source.relative_to(manifest.parent) + targets = package.get("test", []) + target = next((target for target in targets if + target.get("path", f"tests/{target['name']}.rs") == relative.as_posix()), None) + if target is not None or relative.parts[0] == "tests": + if target is None and len(relative.parts) != 2: + raise ValueError("cite the integration test target source") + target = target or {"name": source.stem} + command.extend(["--test", target["name"]]) + if target.get("required-features"): + command.extend(["--features", ",".join(target["required-features"])]) + else: + command.append("--lib") + if features: + command.extend(["--features", ",".join(features)]) + command.extend(["--", "--list", "--format", "terse"]) + key = tuple(command) + if key not in inventories: + result = subprocess.run(command, cwd=root, check=True, + capture_output=True, text=True) + inventories[key] = { + line.removesuffix(": test") for line in result.stdout.splitlines() + if line.endswith(": test") + } + matches = [test for test in inventories[key] + if test == name or test.endswith(f"::{name}")] + elif source.suffix == ".py": + # A separate interpreter keeps discovery imports and load_tests + # hooks out of the validator's own module namespace. + command = [sys.executable, "-c", PYTHON_TEST_INVENTORY, str(source)] + key = tuple(command) + if key not in inventories: + result = subprocess.run(command, cwd=root, check=True, + capture_output=True, text=True) + inventories[key] = set(json.loads(result.stdout)) + matches = [test for test in inventories[key] + if test == name or test.endswith(f".{name}")] + else: + raise ValueError("cited file is neither Rust nor Python") + if len(matches) != 1: + failures.append(f"{path}: {name} is not selectable as one test by its runner") + except (OSError, ValueError, subprocess.CalledProcessError) as error: + # Do not turn a failed build/import into an empty passing inventory. + failures.append(f"{path}: test inventory failed ({type(error).__name__})") + return failures + + +PYTHON_TEST_INVENTORY = """\ +import inspect +import json +from pathlib import Path +import sys +import unittest + +source = Path(sys.argv[1]).resolve() +loader = unittest.TestLoader() +suite = loader.discover(str(source.parent), pattern=source.name) +if loader.errors: + raise RuntimeError("unittest discovery failed") + +def names(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from names(test) + else: + method = getattr(test, test._testMethodName) + if Path(inspect.getfile(method)).resolve() == source: + yield test.id() + +print(json.dumps(list(names(suite)))) +""" + + +def cited_tests(value: object) -> list[dict]: + if isinstance(value, dict): + if "path" in value and "name" in value: + return [value] + return [test for child in value.values() for test in cited_tests(child)] + if isinstance(value, list): + return [test for child in value for test in cited_tests(child)] + return [] + + +def main() -> int: + root = Path(__file__).resolve().parents[3] + contracts = root / "products" / "scheduling" / "contracts" + + def resolve(path: str) -> str | None: + candidate = root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + failures: list[str] = [] + loaded: dict[str, dict] = {} + for name in ( + "security-invariant-matrix.yaml", + "security-test-traceability.yaml", + "recorded-decisions.yaml", + ): + try: + with (contracts / name).open(encoding="utf-8") as handle: + loaded[name] = yaml.safe_load(handle) + except FileNotFoundError: + failures.append(f"{name}: missing from {contracts}") + except yaml.YAMLError as error: + failures.append(f"{name}: not valid YAML: {error}") + + if not failures: + failures = validate( + loaded["security-invariant-matrix.yaml"], + loaded["security-test-traceability.yaml"], + loaded["recorded-decisions.yaml"], + resolve, + ) + + if not failures: + failures = runner_violations(root, cited_tests(loaded)) + + if failures: + for failure in failures: + print(f"contracts: {failure}", file=sys.stderr) + return 1 + print("Scheduling product contracts are internally consistent and source-mapped.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 6d7ae0b596..3eb9b1d3b8 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -53,9 +53,9 @@ Complete new-image onboarding outside the release clock, in this order: token on the command line: ```sh -package="${PACKAGE:?set PACKAGE to relay, evidence, discovery, breg, or casework}" +package="${PACKAGE:?set PACKAGE to relay, evidence, discovery, breg, casework, or scheduling}" case "${package}" in - relay|evidence|discovery|breg|casework) ;; + relay|evidence|discovery|breg|casework|scheduling) ;; *) echo "unsupported release image package: ${package}" >&2; exit 1 ;; esac @@ -112,7 +112,8 @@ printf '%s' "${GHCR_BOOTSTRAP_TOKEN:?set a classic PAT with write:packages}" \ Starting with `v0.21.0`, the release requires public `relay`, `evidence`, and `mint` packages, joined by `discovery` from `v0.24.0`, `breg` from `v0.26.0`, and `casework` from `v0.30.0`. Mint is retired from `v0.31.0`; -the published `v0.30.0` and older release inventories remain unchanged. After selecting +`scheduling` joins from `v0.33.0`. The published `v0.32.0` and older release +inventories remain unchanged. After selecting the candidate version, derive its exact image roster and verify each final destination: @@ -160,6 +161,13 @@ provisioned. Complete the onboarding steps above, add `casework-candidate` to the cleanup allowlist only after its private package exists, and merge its reviewed advisory baseline before requesting that candidate. +Selecting `v0.33.0` or later also includes Scheduling in both checks. The +release source deliberately deny-lists the public `scheduling` package while +leaving `scheduling-candidate` out of scheduled cleanup until its private +package identity exists. Provision both identities, add `scheduling-candidate` +to the cleanup allowlist with its matching test, and merge a reviewed +Scheduling advisory baseline before requesting a `v0.33.0` or later candidate. + The daily cleanup tolerates one delete failure: GitHub's 400 stating that publicly visible package versions with more than 5000 downloads cannot be deleted, a limit no caller can clear. The run records that version in its @@ -671,7 +679,7 @@ evidence with the scanner versions pinned in the candidate workflow: ```sh run_id= run_attempt= -name=relay # or evidence, discovery, or breg +name=relay # or evidence, discovery, breg, casework, or scheduling candidate_tag="ghcr.io/registrystack/${name}-candidate:candidate-${run_id}-${run_attempt}" digest="$(crane digest "${candidate_tag}")" candidate_ref="ghcr.io/registrystack/${name}-candidate@${digest}" diff --git a/release/VERIFY.md b/release/VERIFY.md index 9742a80b8e..403ee5a71b 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -106,7 +106,9 @@ manifest="registry-stack-${tag}-release-manifest.json" jq -e --arg tag "${tag}" ' ($tag | capture("^v(?[0-9]+)\\.(?[0-9]+)\\.(?[0-9]+)$") | {major: (.major | tonumber), minor: (.minor | tonumber)}) as $version | - (if ($version.major > 0 or $version.minor >= 31) + (if ($version.major > 0 or $version.minor >= 33) + then ["breg", "casework", "discovery", "evidence", "relay", "scheduling"] + elif $version.minor >= 31 then ["breg", "casework", "discovery", "evidence", "relay"] elif $version.minor >= 30 then ["breg", "casework", "discovery", "evidence", "mint", "relay"] elif $version.minor >= 26 then ["breg", "discovery", "evidence", "mint", "relay"] @@ -132,15 +134,16 @@ jq -e --arg tag "${tag}" ' Starting with `v0.21.0`, the exact image set is Evidence Gateway, Registry Mint, and Registry Relay. Registry Discovery joins at `v0.24.0`, and Base -Registry Engine joins at `v0.26.0`, and Registry Casework joins at `v0.30.0`. -Mint is retired from `v0.31.0`; historical releases retain their original roster. +Registry Engine joins at `v0.26.0`, Registry Casework joins at `v0.30.0`, and +Registry Scheduling joins at `v0.33.0`. Mint is retired from `v0.31.0`; +historical releases retain their original roster. The final release tags recorded in the manifest must resolve to the same digests as their candidate bindings: ```sh while IFS=$'\t' read -r name digest final_ref; do case "${name}" in - breg|casework|discovery|evidence|mint|relay) ;; + breg|casework|discovery|evidence|mint|relay|scheduling) ;; *) echo "unexpected release image: ${name}" >&2; exit 1 ;; esac resolved_digest="$(crane digest "${final_ref}")" @@ -176,7 +179,8 @@ tar -tzf "${evidence}" Starting with `v0.21.0`, the archive contains image-specific SPDX and Syft reports and Grype reports for `evidence`, `mint`, and `relay`, joined by `discovery` from `v0.24.0`, `breg` from `v0.26.0`, and `casework` from -`v0.30.0`. Mint reports are excluded from `v0.31.0` onward; `v0.19.x` and +`v0.30.0`, with `scheduling` from `v0.33.0`. Mint reports are excluded from +`v0.31.0` onward; `v0.19.x` and `v0.20.x` archives contain those reports for `relay` only. The archive also contains the advisory verdict used for candidate acceptance. Each report names diff --git a/release/docker/Dockerfile.scheduling b/release/docker/Dockerfile.scheduling new file mode 100644 index 0000000000..18ccb56973 --- /dev/null +++ b/release/docker/Dockerfile.scheduling @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH=0 + +FROM debian:trixie-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258 AS runtime-root +ARG SOURCE_DATE_EPOCH + +ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb +ADD --checksum=sha256:8784eda966b189c777a384dac5ce009e8fc9b52d006926c5a013e7fa8aa688cc https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_arm64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_arm64.deb + +# The policy package, the runtime configuration, and secrets are deployment +# inputs. The image carries only the runtime binary and license. Scheduling +# stores its capacity ledger in PostgreSQL; only its local append-only audit +# chain needs a writable persistent directory. +RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ + --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ + --mount=type=bind,source=THIRD_PARTY_NOTICES,target=/workspace/THIRD_PARTY_NOTICES,readonly \ + --mount=type=bind,source=release/scripts/install-runtime-libc6.sh,target=/workspace/install-runtime-libc6.sh,readonly \ + /workspace/install-runtime-libc6.sh /workspace/runtime-root /workspace/runtime-packages \ + && mkdir -p \ + /workspace/runtime-root/etc/registry-scheduling \ + /workspace/runtime-root/licenses/scheduling \ + /workspace/runtime-root/usr/local/bin \ + /workspace/runtime-root/var/lib/registry-scheduling/audit \ + && install -m 0755 /workspace/image-bin/scheduling /workspace/runtime-root/usr/local/bin/scheduling \ + && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/scheduling/LICENSE \ + && install -m 0644 /workspace/THIRD_PARTY_NOTICES /workspace/runtime-root/licenses/scheduling/THIRD_PARTY_NOTICES \ + && chown -R 65532:65532 /workspace/runtime-root/var/lib/registry-scheduling \ + && chmod 0700 /workspace/runtime-root/var/lib/registry-scheduling/audit \ + && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:c31ff9abcb1910f3ab25c7957bdaf0bfe12a01eb546e8df2282f1c8f682b606c AS runtime + +LABEL org.registrystack.runtime.uid="65532" \ + org.registrystack.runtime.gid="65532" + +COPY --from=runtime-root /workspace/runtime-root/ / + +WORKDIR /var/lib/registry-scheduling + +EXPOSE 8105 + +# Scheduling serves GET /healthz and GET /readyz. Distroless has no shell or +# HTTP client, so the deployment platform owns the probe. +ENTRYPOINT ["/usr/local/bin/scheduling"] +CMD ["--runtime-config", "/etc/registry-scheduling/runtime.yaml", "serve"] diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index c25469b082..8f8d4066a1 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -16,13 +16,13 @@ done if [[ "$#" -eq 1 ]]; then version="$1" else - printf 'usage: %s [--include-casework] [--group core|breg|casework] \n' "$0" >&2 + printf 'usage: %s [--include-casework] [--group core|breg|casework|scheduling] \n' "$0" >&2 exit 2 fi if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || ("${group}" != all && "${group}" != core && "${group}" != breg && - "${group}" != casework) ]]; then - printf 'usage: %s [--include-casework] [--group core|breg|casework] \n' "$0" >&2 + "${group}" != casework && "${group}" != scheduling) ]]; then + printf 'usage: %s [--include-casework] [--group core|breg|casework|scheduling] \n' "$0" >&2 exit 2 fi tag="v${version}" @@ -43,6 +43,10 @@ if ((version_major > 0 || version_minor >= 30)) || [[ "${include_casework_override}" -eq 1 ]]; then include_casework=1 fi +include_scheduling=0 +if ((version_major > 0 || version_minor >= 33)); then + include_scheduling=1 +fi # Compile and link every product binary through Zig against the glibc stubs of # the release floor. The builder carries a much newer glibc, and without this @@ -188,6 +192,16 @@ build_payload() { cp target/release/casework dist/image-bin/casework fi + if [[ ("${group}" == all || "${group}" == scheduling) && "${include_scheduling}" -eq 1 ]]; then + cargo build --release --locked \ + -p registry-scheduling --bin scheduling + cp target/release/scheduling dist/image-bin/scheduling + if [[ "${group}" == scheduling ]]; then + cp target/release/scheduling \ + "dist/bin/scheduling-${RELEASE_TAG}-linux-amd64" + fi + fi + # Nothing but the staged payload is in these directories yet: the checksum # files and the builder image record are written by the outer invocation # after this container exits. Every staged binary is checked, so a build that @@ -281,6 +295,7 @@ docker run --rm \ --env RELEASE_INCLUDE_DISCOVERY="${include_discovery}" \ --env RELEASE_INCLUDE_BREG="${include_breg}" \ --env RELEASE_INCLUDE_CASEWORK="${include_casework}" \ + --env RELEASE_INCLUDE_SCHEDULING="${include_scheduling}" \ --env RELEASE_TAG="${tag}" \ --env REGISTRY_RELEASE_TAG="${tag}" \ --env RELEASE_RUSTFLAGS="${release_rustflags}" \ @@ -325,6 +340,12 @@ if [[ ("${group}" == all || "${group}" == casework) && "${include_casework}" -eq ) image_bin_binaries+=(casework) fi +if [[ "${group}" == scheduling && "${include_scheduling}" -eq 1 ]]; then + bin_assets+=("scheduling-${tag}-linux-amd64") +fi +if [[ "${group}" == all && "${include_scheduling}" -eq 1 ]]; then + image_bin_binaries+=(scheduling) +fi if [[ "${group}" == all || "${group}" == core ]]; then bin_assets+=( "evidence-${tag}-linux-amd64" diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index a7b8966996..8f6c1dd42d 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -24,7 +24,7 @@ release_image_context="${RELEASE_IMAGE_CONTEXT:-${repo_root}}" created_builder=false case "${name}" in - discovery|evidence|breg|casework|relay) + discovery|evidence|breg|casework|scheduling|relay) dockerfile="${repo_root}/release/docker/Dockerfile.${name}" ;; *) diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index ef6cf8000e..85b90849fb 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -84,6 +84,7 @@ Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), + Path("release/docker/Dockerfile.scheduling"), ) # Adopter and development images. They build from source like the per-product @@ -175,6 +176,11 @@ "entrypoint": 'ENTRYPOINT ["/usr/local/bin/casework"]', "command": 'CMD ["--runtime-config", "/etc/registry-casework/runtime.yaml", "serve"]', }, + Path("release/docker/Dockerfile.scheduling"): { + "binary": "scheduling", + "entrypoint": 'ENTRYPOINT ["/usr/local/bin/scheduling"]', + "command": 'CMD ["--runtime-config", "/etc/registry-scheduling/runtime.yaml", "serve"]', + }, } FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index d95ab55d6b..d2b1ee27c3 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -16,6 +16,9 @@ CASEWORK_CHECKPOINT_RUNNER = ( ROOT / "products" / "casework" / "scripts" / "check-checkpoint.sh" ) +SCHEDULING_CHECKPOINT_RUNNER = ( + ROOT / "products" / "scheduling" / "scripts" / "check-checkpoint.sh" +) REQUIRED_GATES: tuple[tuple[str, str], ...] = ( ( @@ -210,8 +213,13 @@ "casework-postgres:\n name: Casework PostgreSQL transactions", ), ( + # Casework and Scheduling run the same pinned PostgreSQL image, so each + # pin names the job it belongs to. A bare digest would be satisfied by + # the other product's copy and would stop reporting its own removal. "Casework PostgreSQL 17 image pin", - "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: casework", ), ( "Casework product checkpoint wrapper", @@ -269,6 +277,68 @@ "Casework Python client binding coverage", "registry-breg-client-py registry-casework-client-py", ), + ( + "Scheduling contract path filter", + "scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }}", + ), + ( + "Scheduling contract gate", + "scheduling-contracts:\n name: Scheduling product contracts", + ), + ( + "Scheduling contract reproduction", + "run: products/scheduling/scripts/check-contracts.sh", + ), + ( + "Scheduling PostgreSQL path filter", + "scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }}", + ), + ( + "Scheduling PostgreSQL gate", + "scheduling-postgres:\n name: Scheduling PostgreSQL transactions", + ), + ( + "Scheduling PostgreSQL 17 image pin", + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: scheduling", + ), + ( + "Scheduling product checkpoint wrapper", + "run: products/scheduling/scripts/check-checkpoint.sh", + ), + ( + "Scheduling HTTP contract drift check", + "python3 products/scheduling/scripts/generate_openapi.py --check", + ), + ( + "Scheduling dependency-direction guard", + "python3 products/scheduling/scripts/check_dependency_direction.py", + ), + ( + "Scheduling database test isolation guard", + "python3 products/scheduling/scripts/check_database_test_isolation.py", + ), + ( + "Scheduling product script tests", + "python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py'", + ), + ( + "Scheduling offline authoring journeys", + '"$schedulingctl_bin" test "$work/$template"', + ), + ( + "Scheduling commitment ledger suite", + "cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments", + ), + ( + "Scheduling authoring record application suite", + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres", + ), + ( + "Scheduling delivery intent suite", + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres", + ), ( "Release Linux Node client path filter", "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", @@ -798,7 +868,7 @@ ( "build-canonical-binaries:\n name: Build canonical Linux ${{ matrix.group }} binary shard", "fail-fast: false", - "group: [core, breg, casework]", + "group: [core, breg, casework, scheduling]", "build-canonical:\n name: Build Linux payload and private images once", "name: Restore reusable Cargo cache", "restore-keys:", @@ -874,6 +944,7 @@ ' "mint",\n', ' "breg",\n', ' "casework",\n', + ' "scheduling",\n', ' "relay",\n', "if package in PUBLIC_PACKAGES:", "if package not in CANDIDATE_PACKAGES:", @@ -1104,6 +1175,7 @@ def missing_gates( classifier_text: str | None = None, platform_fuzz_runner_text: str | None = None, casework_checkpoint_runner_text: str | None = None, + scheduling_checkpoint_runner_text: str | None = None, ) -> list[str]: if classifier_text is None: classifier_text = CI_CLASSIFIER.read_text(encoding="utf-8") @@ -1119,9 +1191,15 @@ def missing_gates( if CASEWORK_CHECKPOINT_RUNNER.is_file() else "" ) + if scheduling_checkpoint_runner_text is None: + scheduling_checkpoint_runner_text = ( + SCHEDULING_CHECKPOINT_RUNNER.read_text(encoding="utf-8") + if SCHEDULING_CHECKPOINT_RUNNER.is_file() + else "" + ) inventory_text = ( f"{workflow_text}\n{classifier_text}\n{platform_fuzz_runner_text}\n" - f"{casework_checkpoint_runner_text}" + f"{casework_checkpoint_runner_text}\n{scheduling_checkpoint_runner_text}" ) return [name for name, snippet in REQUIRED_GATES if snippet not in inventory_text] @@ -1354,7 +1432,7 @@ def candidate_build_isolation_violations(workflow: str | None) -> list[str]: if ( "needs: validate" not in shards or "actions/cache@" not in shards - or "group: [core, breg, casework]" not in shards + or "group: [core, breg, casework, scheduling]" not in shards or "fail-fast: false" not in shards or shards.count("name: Build canonical Linux binary shard") != 1 or shards.count("actions/upload-artifact@") != 1 @@ -1363,7 +1441,7 @@ def candidate_build_isolation_violations(workflow: str | None) -> list[str]: or " - build-canonical-binaries" not in build_a or "actions/cache@" in build_a or "release/scripts/build-release-binaries.sh" in build_a - or build_a.count("actions/download-artifact@") != 3 + or build_a.count("actions/download-artifact@") != 4 or build_a.count("name: Merge and smoke the canonical Linux payload") != 1 or build_a.count("name: Build private candidate image layouts once") != 1 ): diff --git a/release/scripts/cleanup-release-candidates.py b/release/scripts/cleanup-release-candidates.py index e6e61cadcc..19f3e0e2e9 100755 --- a/release/scripts/cleanup-release-candidates.py +++ b/release/scripts/cleanup-release-candidates.py @@ -37,6 +37,7 @@ "mint", "breg", "casework", + "scheduling", "relay", ) diff --git a/release/scripts/collect-rehearsal-advisory-evidence.py b/release/scripts/collect-rehearsal-advisory-evidence.py index 2d82b419bc..7e94d31464 100755 --- a/release/scripts/collect-rehearsal-advisory-evidence.py +++ b/release/scripts/collect-rehearsal-advisory-evidence.py @@ -26,7 +26,9 @@ ROOT = Path(__file__).resolve().parents[2] -IMAGE_NAMES = frozenset({"breg", "casework", "discovery", "evidence", "relay"}) +IMAGE_NAMES = frozenset( + {"breg", "casework", "discovery", "evidence", "relay", "scheduling"} +) SEMVER_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}") REVISION_RE = re.compile(r"[0-9a-f]{40}") diff --git a/release/scripts/merge-release-binary-shards.py b/release/scripts/merge-release-binary-shards.py index 7017d7e644..6daf30cce9 100755 --- a/release/scripts/merge-release-binary-shards.py +++ b/release/scripts/merge-release-binary-shards.py @@ -45,6 +45,10 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: f"caseworkctl-{tag}-linux-amd64", ] image_bins.append(("casework", casework[0])) + scheduling: list[str] = [] + if parsed >= (0, 33, 0): + scheduling = [f"scheduling-{tag}-linux-amd64"] + image_bins.append(("scheduling", scheduling[0])) common = [ f"evidence-{tag}-linux-amd64", f"evidencectl-{tag}-linux-amd64", @@ -60,7 +64,12 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: for image_name in ("evidence", "mint", "relay"): if image_name != "mint" or parsed < MINT_RETIREMENT_VERSION: image_bins.append((image_name, f"{image_name}-{tag}-linux-amd64")) - return {"core": core, "breg": breg, "casework": casework}, image_bins + return { + "core": core, + "breg": breg, + "casework": casework, + "scheduling": scheduling, + }, image_bins def sha256(path: Path) -> str: @@ -167,6 +176,7 @@ def merge( core: Path, breg: Path, casework: Path | None, + scheduling: Path | None, output: Path, builder_image: str, ) -> None: @@ -199,7 +209,25 @@ def merge( version, source_sha, ) - sources = inputs["core"] | inputs["breg"] | inputs["casework"] + if scheduling is None: + if shard_rosters["scheduling"]: + raise ShardError("scheduling shard is required from version 0.33.0") + inputs["scheduling"] = {} + else: + inputs["scheduling"] = validate_shard( + "scheduling", + scheduling, + shard_rosters["scheduling"], + builder_image, + version, + source_sha, + ) + sources = ( + inputs["core"] + | inputs["breg"] + | inputs["casework"] + | inputs["scheduling"] + ) final_bin_roster: list[str] = [] if shard_rosters["core"] and shard_rosters["core"][0].startswith("discovery-"): final_bin_roster.append(shard_rosters["core"][0]) @@ -244,6 +272,7 @@ def main() -> int: parser.add_argument("--core", required=True, type=Path) parser.add_argument("--breg", required=True, type=Path) parser.add_argument("--casework", type=Path) + parser.add_argument("--scheduling", type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--builder-image", required=True) args = parser.parse_args() @@ -254,6 +283,7 @@ def main() -> int: core=args.core, breg=args.breg, casework=args.casework, + scheduling=args.scheduling, output=args.output, builder_image=args.builder_image, ) diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 8b7cbd9e70..5603de328c 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -57,6 +57,7 @@ RELAY_V2_ARTIFACT_INVENTORY = { DISCOVERY_RUNTIME_MINIMUM_VERSION = (0, 24, 0) BREG_RELEASE_MINIMUM_VERSION = (0, 26, 0) CASEWORK_RELEASE_MINIMUM_VERSION = (0, 30, 0) +SCHEDULING_RELEASE_MINIMUM_VERSION = (0, 33, 0) UNIFIED_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 26, 1) RELEASE_PLAN_SCHEMA = "registry-release.plan.v1" CANDIDATE_VERIFICATION_PLAN_SCHEMA = "registry-release.candidate-verification.v3" @@ -223,6 +224,8 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s "casework-installer", } ) + if parsed_version >= SCHEDULING_RELEASE_MINIMUM_VERSION: + expected_inventory.add("scheduling") missing = sorted(expected_inventory - artifact_names) unexpected = sorted(artifact_names - expected_inventory) if missing or unexpected: diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 30ae7d5467..76cdc34198 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -50,6 +50,7 @@ DISCOVERY_RUNTIME_IMAGE_NAMES = OFFICIAL_RUNTIME_IMAGE_NAMES | {"discovery"} BREG_RELEASE_MINIMUM_VERSION = (0, 26, 0) CASEWORK_RELEASE_MINIMUM_VERSION = (0, 30, 0) +SCHEDULING_RELEASE_MINIMUM_VERSION = (0, 33, 0) UNIFIED_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 26, 1) RELEASE_PROVENANCE_ASSET_MINIMUM_VERSION = (0, 27, 1) BREG_RUNTIME_IMAGE_NAMES = DISCOVERY_RUNTIME_IMAGE_NAMES | { @@ -59,6 +60,7 @@ # Every v0.30.x release retains Mint. v0.31.0 and later omit it. CASEWORK_RUNTIME_IMAGE_NAMES = (BREG_RUNTIME_IMAGE_NAMES - {"mint"}) | {"casework"} THIRD_PARTY_NOTICES_MINIMUM_VERSION = (0, 33, 0) +SCHEDULING_RUNTIME_IMAGE_NAMES = CASEWORK_RUNTIME_IMAGE_NAMES | {"scheduling"} V2_TOP_LEVEL_FIELDS = { "schema_version", "repository", @@ -98,7 +100,7 @@ } SECURITY_EVIDENCE_REQUIRED_FILES = SECURITY_EVIDENCE_COMMON_REQUIRED_FILES | { f"{directory}/{image}.{suffix}.json" - for image in CASEWORK_RUNTIME_IMAGE_NAMES + for image in SCHEDULING_RUNTIME_IMAGE_NAMES for directory, suffix in ( ("image-sbom", "spdx"), ("syft", "syft"), @@ -128,7 +130,9 @@ def _candidate_image_names(version: str) -> set[str]: return BREG_RUNTIME_IMAGE_NAMES if parsed < MINT_RETIREMENT_VERSION: return CASEWORK_RUNTIME_IMAGE_NAMES | {"mint"} - return CASEWORK_RUNTIME_IMAGE_NAMES + if parsed < SCHEDULING_RELEASE_MINIMUM_VERSION: + return CASEWORK_RUNTIME_IMAGE_NAMES + return SCHEDULING_RUNTIME_IMAGE_NAMES def _literal_string_roster(path: Path, name: str) -> set[str]: diff --git a/release/scripts/render-installer-libc-preflight.py b/release/scripts/render-installer-libc-preflight.py index 490fbe6414..45f022c600 100755 --- a/release/scripts/render-installer-libc-preflight.py +++ b/release/scripts/render-installer-libc-preflight.py @@ -38,6 +38,7 @@ def __init__(self, path: str, product: str) -> None: INSTALLERS = ( Installer("crates/registry-breg/install.sh", "the Base Registry Engine"), Installer("crates/registry-casework/install.sh", "Registry Casework"), + Installer("crates/registry-scheduling/install.sh", "Registry Scheduling"), Installer("crates/registry-evidencectl/install.sh", "the Evidence toolset"), Installer("crates/registry-relay-v2/install.sh", "Registry Relay"), ) diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 8732f97b14..7fc1f00bc9 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -6,7 +6,7 @@ repo_root="$(cd -- "${script_dir}/../.." && pwd)" checker="${script_dir}/check-release-image-oci-labels.py" image_builder="${script_dir}/build-release-image.sh" layout_comparator="${script_dir}/compare-release-image-layouts.py" -images=(relay evidence discovery breg casework) +images=(relay evidence discovery breg casework scheduling) relay_dockerfile="${repo_root}/release/docker/Dockerfile.relay" source_label="https://github.com/registrystack/registry-stack" diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py index 0bcff6ad95..7258d5ff88 100644 --- a/release/scripts/test_check_debian13_images.py +++ b/release/scripts/test_check_debian13_images.py @@ -59,6 +59,7 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), + Path("release/docker/Dockerfile.scheduling"), }, set(POLICY.DOCKERFILES), ) @@ -68,6 +69,7 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), + Path("release/docker/Dockerfile.scheduling"), }, set(POLICY.HTTP_PROBE_DOCKERFILES), ) diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 17276fed02..43c62255de 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -53,6 +53,9 @@ def setUp(self) -> None: self.casework_checkpoint_runner = ( ROOT / "products" / "casework" / "scripts" / "check-checkpoint.sh" ).read_text(encoding="utf-8") + self.scheduling_checkpoint_runner = ( + ROOT / "products" / "scheduling" / "scripts" / "check-checkpoint.sh" + ).read_text(encoding="utf-8") self.nightly_security = ( ROOT / ".github" / "workflows" / "nightly-security.yml" ).read_text(encoding="utf-8") @@ -918,6 +921,105 @@ def test_missing_casework_checkpoint_wrapper_steps_are_reported(self) -> None: ), ) + def test_missing_scheduling_workflow_gates_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }}", + "scheduling_contracts: 'false'", + "Scheduling contract path filter", + ), + ( + "scheduling-contracts:\n name: Scheduling product contracts", + "scheduling-contracts:\n name: Scheduling disabled", + "Scheduling contract gate", + ), + ( + "run: products/scheduling/scripts/check-contracts.sh", + "run: true # Scheduling contract reproduction disabled", + "Scheduling contract reproduction", + ), + ( + "scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }}", + "scheduling_postgres: 'false'", + "Scheduling PostgreSQL path filter", + ), + ( + "scheduling-postgres:\n name: Scheduling PostgreSQL transactions", + "scheduling-postgres:\n name: Scheduling disabled", + "Scheduling PostgreSQL gate", + ), + ( + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: scheduling", + "image: postgres:17.11\n env:\n POSTGRES_DB: scheduling", + "Scheduling PostgreSQL 17 image pin", + ), + ( + "run: products/scheduling/scripts/check-checkpoint.sh", + "run: true # Scheduling checkpoint wrapper disabled", + "Scheduling product checkpoint wrapper", + ), + ( + "cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments", + "true # Scheduling commitment ledger disabled", + "Scheduling commitment ledger suite", + ), + ( + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres", + "true # Scheduling record application disabled", + "Scheduling authoring record application suite", + ), + ( + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres", + "true # Scheduling delivery intent reads disabled", + "Scheduling delivery intent suite", + ), + ): + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement, 1) + self.assertIn(gate, self.module.missing_gates(text)) + + def test_missing_scheduling_checkpoint_wrapper_steps_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "python3 products/scheduling/scripts/generate_openapi.py --check", + "true # Scheduling OpenAPI drift check disabled", + "Scheduling HTTP contract drift check", + ), + ( + "python3 products/scheduling/scripts/check_dependency_direction.py", + "true # Scheduling dependency guard disabled", + "Scheduling dependency-direction guard", + ), + ( + "python3 products/scheduling/scripts/check_database_test_isolation.py", + "true # Scheduling database test isolation guard disabled", + "Scheduling database test isolation guard", + ), + ( + "python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py'", + "true # Scheduling product script tests disabled", + "Scheduling product script tests", + ), + ( + '"$schedulingctl_bin" test "$work/$template"', + "true # Scheduling authoring journey disabled", + "Scheduling offline authoring journeys", + ), + ): + with self.subTest(gate=gate): + runner = self.scheduling_checkpoint_runner.replace( + snippet, replacement, 1 + ) + self.assertIn( + gate, + self.module.missing_gates( + self.workflow, + scheduling_checkpoint_runner_text=runner, + ), + ) + def test_linux_node_release_proof_is_two_runner_read_only_and_aggregated( self, ) -> None: diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 662ff3a10a..a914424e30 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -640,7 +640,7 @@ def read_calls(path: Path) -> list[list[str]]: build_calls = [ call for call in read_calls(docker_log) if call[:2] == ["buildx", "build"] ] - self.assertEqual(12, len(build_calls)) + self.assertEqual(14, len(build_calls)) dockerfiles = [] for call in build_calls: self.assertEqual(["buildx", "build"], call[:2]) @@ -669,6 +669,7 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.evidence"), str(ROOT / "release/docker/Dockerfile.breg"), str(ROOT / "release/docker/Dockerfile.casework"), + str(ROOT / "release/docker/Dockerfile.scheduling"), str(ROOT / "release/docker/Dockerfile.relay"), }, set(dockerfiles), @@ -679,7 +680,13 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.relay") ), ) - for name in ("discovery", "evidence", "breg", "casework"): + for name in ( + "discovery", + "evidence", + "breg", + "casework", + "scheduling", + ): self.assertEqual( 2, dockerfiles.count( @@ -700,6 +707,7 @@ def read_calls(path: Path) -> list[list[str]]: "correct-evidence-first", "correct-breg-first", "correct-casework-first", + "correct-scheduling-first", "correct-relay-first", }, { @@ -723,7 +731,7 @@ def read_calls(path: Path) -> list[list[str]]: for call in python_calls if call and call[0].endswith("compare-release-image-layouts.py") ] - self.assertEqual(6, len(comparisons)) + self.assertEqual(7, len(comparisons)) self.assertEqual(1, sum("--rootfs-only" in call for call in comparisons)) diff --git a/release/scripts/test_cleanup_release_candidates.py b/release/scripts/test_cleanup_release_candidates.py index 624fb950c4..4f1bb4a959 100644 --- a/release/scripts/test_cleanup_release_candidates.py +++ b/release/scripts/test_cleanup_release_candidates.py @@ -295,6 +295,7 @@ def test_public_and_unknown_packages_are_rejected_before_listing(self) -> None: "mint", "breg", "casework", + "scheduling", "relay", "other-candidate", ): @@ -319,6 +320,17 @@ def test_casework_candidate_is_allowlisted_after_package_bootstrap(self) -> None self.assertIn("casework-candidate", self.module.CANDIDATE_PACKAGES) self.module.assert_candidate_package("casework-candidate") + def test_scheduling_remains_publicly_denylisted_before_candidate_onboarding( + self, + ) -> None: + self.assertIn("scheduling", self.module.PUBLIC_PACKAGES) + self.assertNotIn("scheduling-candidate", self.module.CANDIDATE_PACKAGES) + with self.assertRaisesRegex( + self.module.CleanupError, + "package is not in the exact candidate allowlist", + ): + self.module.assert_candidate_package("scheduling-candidate") + def test_malformed_or_future_timestamp_fails_without_deleting(self) -> None: for timestamp in ("not-a-date", "2026-07-25T12:00:01Z"): with self.subTest(timestamp=timestamp): diff --git a/release/scripts/test_collect_rehearsal_advisory_evidence.py b/release/scripts/test_collect_rehearsal_advisory_evidence.py index 1702ae49a7..520224897e 100644 --- a/release/scripts/test_collect_rehearsal_advisory_evidence.py +++ b/release/scripts/test_collect_rehearsal_advisory_evidence.py @@ -263,6 +263,24 @@ def test_post_mint_roster_is_owned_and_complete(self) -> None: ("breg", "casework", "discovery", "evidence", "relay"), ) + def test_v0_33_roster_includes_scheduling(self) -> None: + result = subprocess.run( + [ + "python3", + str(ROOT / "release/scripts/release_candidate.py"), + "image-names", + "--version", + "0.33.0", + ], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual( + MODULE.parse_roster(result.stdout), + ("breg", "casework", "discovery", "evidence", "relay", "scheduling"), + ) + def test_collects_every_owned_image_with_exact_daemon_context(self) -> None: fake = FakeCommands() with tempfile.TemporaryDirectory() as temporary: diff --git a/release/scripts/test_merge_release_binary_shards.py b/release/scripts/test_merge_release_binary_shards.py index 1fb682a1d0..9c7824ecde 100644 --- a/release/scripts/test_merge_release_binary_shards.py +++ b/release/scripts/test_merge_release_binary_shards.py @@ -16,7 +16,7 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) -VERSION = "0.31.0" +VERSION = "0.33.0" TAG = f"v{VERSION}" BUILDER = "rust:fixture@sha256:" + "a" * 64 SOURCE_SHA = "1" * 40 @@ -34,11 +34,13 @@ f"casework-{TAG}-linux-amd64", f"caseworkctl-{TAG}-linux-amd64", ] +SCHEDULING = [f"scheduling-{TAG}-linux-amd64"] FINAL = [CORE[0], *BREG, *CASEWORK, *CORE[1:]] IMAGE_SOURCES = { "discovery": CORE[0], "breg": BREG[0], "casework": CASEWORK[0], + "scheduling": SCHEDULING[0], "evidence": CORE[1], "relay": CORE[5], } @@ -56,6 +58,7 @@ def setUp(self) -> None: self.core = self.write_shard("core", CORE) self.breg = self.write_shard("breg", BREG) self.casework = self.write_shard("casework", CASEWORK) + self.scheduling = self.write_shard("scheduling", SCHEDULING) self.output = self.root / "dist" def write_shard(self, name: str, assets: list[str]) -> Path: @@ -85,6 +88,7 @@ def merge(self) -> None: core=self.core, breg=self.breg, casework=self.casework, + scheduling=self.scheduling, output=self.output, builder_image=BUILDER, ) @@ -107,6 +111,8 @@ def test_reconstructs_the_exact_full_layout_from_download_binary_bytes(self) -> if asset in BREG else self.casework if asset in CASEWORK + else self.scheduling + if asset in SCHEDULING else self.core ) self.assertEqual((source_root / "bin" / asset).read_bytes(), (bin_dir / asset).read_bytes()) @@ -117,6 +123,8 @@ def test_reconstructs_the_exact_full_layout_from_download_binary_bytes(self) -> if source_name in BREG else self.casework if source_name in CASEWORK + else self.scheduling + if source_name in SCHEDULING else self.core ) self.assertEqual( @@ -158,6 +166,14 @@ def test_version_gates_select_the_historical_exact_rosters(self) -> None: ["discovery", "breg", "casework", "evidence", "mint", "relay"], [name for name, _ in images_030], ) + rosters_032, images_032 = MODULE.rosters("0.32.0") + self.assertEqual([], rosters_032["scheduling"]) + self.assertNotIn("scheduling", dict(images_032)) + rosters_033, images_033 = MODULE.rosters("0.33.0") + self.assertEqual( + ["scheduling-v0.33.0-linux-amd64"], rosters_033["scheduling"] + ) + self.assertIn("scheduling", dict(images_033)) def test_mint_is_retired_only_from_v0_31_0(self) -> None: for version in ("0.30.0", "0.30.1"): @@ -196,6 +212,9 @@ def test_rejects_incomplete_or_ambiguous_shards_before_staging_output(self) -> N self.core = self.write_shard("core", CORE) self.breg = self.write_shard("breg", BREG) self.casework = self.write_shard("casework", CASEWORK) + self.scheduling = self.write_shard( + "scheduling", SCHEDULING + ) self.output = fixture / "dist" mutate() with self.assertRaises(MODULE.ShardError): diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index c01721b5d4..ae09fb85a3 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -1717,6 +1717,8 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "relay-client-contracts", "relay-v2-contracts", "casework-postgres", + "scheduling-contracts", + "scheduling-postgres", }, set(rust_result["needs"]), ) @@ -2263,13 +2265,14 @@ def test_release_canary_exercises_the_current_image_contract(self) -> None: for current in ( "_relay_v2_payload_inventory", "payloads: $payloads[0]", - "image_names=(relay evidence discovery breg casework)", + "image_names=(relay evidence discovery breg casework scheduling)", "images: $images[0]", "scans: $scans[0]", '"discovery-image"', '"evidence-image"', '"breg-image"', '"casework-image"', + '"scheduling-image"', '"relay-image"', ): self.assertIn(current, workflow) @@ -2342,6 +2345,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: "evidence", "breg", "casework", + "scheduling", "relay", ) } @@ -2366,6 +2370,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: "evidence", "breg", "casework", + "scheduling", "relay", ): self.assertIn( @@ -2377,7 +2382,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: f"/workspace/runtime-root/usr/local/bin/{name}", release_dockerfiles[name], ) - for name in ("evidence", "breg", "casework", "relay"): + for name in ("evidence", "breg", "casework", "scheduling", "relay"): self.assertIn( "--mount=type=bind,source=THIRD_PARTY_NOTICES," "target=/workspace/THIRD_PARTY_NOTICES,readonly", @@ -2389,7 +2394,9 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: release_dockerfiles[name], ) self.assertNotIn("THIRD_PARTY_NOTICES", release_dockerfiles["discovery"]) - self.assertIn("discovery|evidence|breg|casework|relay)", image_recipe) + self.assertIn( + "discovery|evidence|breg|casework|scheduling|relay)", image_recipe + ) self.assertNotIn("registry-relay)", image_recipe) def test_breg_release_image_keeps_deployment_inputs_external(self) -> None: @@ -2642,6 +2649,55 @@ def test_casework_release_surface_begins_after_published_v0_29(self) -> None: self.assertIn('"caseworkctl-${tag}-linux-amd64"', recipe) self.assertIn("image_bin_binaries+=(casework)", recipe) + def test_scheduling_image_release_surface_begins_after_published_v0_32( + self, + ) -> None: + module = load_registry_release() + published = { + name: "0.32.0" + for name in ( + module.RELAY_V2_ARTIFACT_INVENTORY + | { + "relay-installer", + "registry-docs", + "discovery", + "breg", + "bregctl", + "breg-installer", + "casework", + "caseworkctl", + "casework-installer", + "registry-client-node", + "registry-client-python", + } + ) + if name != "mint" + and name + not in { + "evidence-client-node", + "evidence-client-python", + } + } + self.assertEqual([], module.artifact_inventory_errors("0.32.0", published)) + future = {name: "0.33.0" for name in published} + future["scheduling"] = "0.33.0" + self.assertEqual([], module.artifact_inventory_errors("0.33.0", future)) + self.assertNotEqual( + [], + module.artifact_inventory_errors( + "0.32.0", published | {"scheduling": "0.32.0"} + ), + ) + del future["scheduling"] + self.assertNotEqual([], module.artifact_inventory_errors("0.33.0", future)) + + recipe = (ROOT / "release/scripts/build-release-binaries.sh").read_text( + encoding="utf-8" + ) + self.assertIn("-p registry-scheduling --bin scheduling", recipe) + self.assertIn('"scheduling-${tag}-linux-amd64"', recipe) + self.assertIn("image_bin_binaries+=(scheduling)", recipe) + def test_unified_client_manifest_surface_replaces_individual_clients(self) -> None: module = load_registry_release() base = { @@ -3316,6 +3372,14 @@ def test_validate_current_selects_highest_semver_manifest(self) -> None: data["stack"]["release"] = release_id data["stack"].pop("source_ref") data["stack"].pop("status") + catalog_bytes = ( + ROOT / "products/identifiers/generated/catalog.v1.json" + ).read_bytes() + data["identifier_catalog"] = { + "path": "products/identifiers/generated/catalog.v1.json", + "sha256": hashlib.sha256(catalog_bytes).hexdigest(), + "entry_count": len(json.loads(catalog_bytes)["entries"]), + } manifest.write_text( yaml.safe_dump(data, sort_keys=False), encoding="utf-8" ) @@ -3965,6 +4029,8 @@ def write_manifest( artifacts["casework-installer"] = version if version_tuple >= (0, 31, 0): artifacts.pop("mint") + if version_tuple >= (0, 33, 0): + artifacts["scheduling"] = version manifest = { "stack": { "release": "beta-6", diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index bb284745c6..c2810db771 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -40,6 +40,9 @@ def _load_registry_release(): CASEWORK_RELEASE_MINIMUM_VERSION = ( REGISTRY_RELEASE.CASEWORK_RELEASE_MINIMUM_VERSION ) +SCHEDULING_RELEASE_MINIMUM_VERSION = ( + REGISTRY_RELEASE.SCHEDULING_RELEASE_MINIMUM_VERSION +) MINT_RETIREMENT_VERSION = REGISTRY_RELEASE.release_candidate.MINT_RETIREMENT_VERSION FIXTURE_IDENTIFIER_CATALOG = { "version": 1, @@ -149,6 +152,8 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic "caseworkctl", "casework-installer", ) + if version_tuple >= SCHEDULING_RELEASE_MINIMUM_VERSION: + inventory += ("scheduling",) data = { "stack": { "release": release_id, diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index b9733a295e..82d0c00547 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -49,6 +49,7 @@ def security_evidence_members( "casework", "breg", "relay", + "scheduling", ), ) -> dict[str, bytes]: refs = { @@ -381,6 +382,7 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: "breg", "casework", "relay", + "scheduling", ) evidence_members = security_evidence_members(image_names) evidence_name = "registry-stack-v1.2.3-security-evidence.tar.gz" @@ -858,6 +860,24 @@ def test_third_party_notices_join_only_the_v0_33_roster(self) -> None: self.assertNotIn("THIRD_PARTY_NOTICES", historical) self.assertEqual("notice", current["THIRD_PARTY_NOTICES"]) + def test_scheduling_image_joins_only_the_v0_33_roster(self) -> None: + self.assertNotIn("scheduling", self.module._candidate_image_names("0.32.0")) + self.assertEqual( + { + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + }, + self.module._candidate_image_names("0.33.0"), + ) + self.assertNotIn( + "scheduling-v0.33.0-linux-amd64", + self.module._relay_v2_payload_inventory("0.33.0"), + ) + def test_retirement_preserves_every_v0_30_release(self) -> None: for version, expected in ( ("0.30.0", True), @@ -879,6 +899,8 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: ("0.30.0", "breg casework discovery evidence mint relay\n"), ("0.30.1", "breg casework discovery evidence mint relay\n"), ("0.31.0", "breg casework discovery evidence relay\n"), + ("0.32.0", "breg casework discovery evidence relay\n"), + ("0.33.0", "breg casework discovery evidence relay scheduling\n"), ) for version, expected in cases: with self.subTest(version=version): @@ -920,6 +942,25 @@ def test_image_onboarding_after_0_30_0_requires_reviewed_casework_baseline(self) self.module.check_image_onboarding(root, "0.31.0"), ) + def test_v0_33_scheduling_onboarding_stays_closed_until_external_setup(self) -> None: + root = self.onboarding_repository() + shutil.copy2( + ROOT / "release/docker/Dockerfile.scheduling", + root / "release/docker/Dockerfile.scheduling", + ) + with self.assertRaisesRegex( + self.module.CandidateError, + "scheduling advisory baseline is missing", + ): + self.module.check_image_onboarding(root, "0.33.0") + with self.assertRaisesRegex( + self.module.CandidateError, + "CANDIDATE_PACKAGES must contain scheduling-candidate", + ): + self.module.check_image_onboarding( + root, "0.33.0", allow_missing_baseline=True + ) + def test_image_onboarding_rejects_a_noncanonical_version(self) -> None: with self.assertRaisesRegex( self.module.CandidateError, @@ -969,7 +1010,7 @@ def test_image_onboarding_rejects_an_unsupported_build_name(self) -> None: recipe = root / "release/scripts/build-release-image.sh" recipe.write_text( recipe.read_text(encoding="utf-8").replace( - "discovery|evidence|breg|casework|relay", + "discovery|evidence|breg|casework|scheduling|relay", "discovery|evidence|mint|relay", ), encoding="utf-8", @@ -1294,7 +1335,7 @@ def test_v2_security_evidence_archive_requires_every_expected_file( candidate, _, bundle_root, _ = self.make_v2_candidate() members = security_evidence_members() required = self.module._security_evidence_required_files( - self.module.CASEWORK_RUNTIME_IMAGE_NAMES + self.module.SCHEDULING_RUNTIME_IMAGE_NAMES ) for missing in sorted(required): with self.subTest(missing=missing): diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index d306413d56..24f52f435a 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -222,7 +222,8 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: binary_job = document["jobs"]["canonical-linux-binaries"] self.assertFalse(binary_job["strategy"]["fail-fast"]) self.assertEqual( - ["core", "breg", "casework"], binary_job["strategy"]["matrix"]["group"] + ["core", "breg", "casework", "scheduling"], + binary_job["strategy"]["matrix"]["group"], ) self.assertEqual( "${{ github.sha }}", binary_job["steps"][0]["with"]["ref"] @@ -263,6 +264,7 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: self.assertIn("breg-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("bregctl-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("--casework binary-shards/casework", merge) + self.assertIn("--scheduling binary-shards/scheduling", merge) self.assertIn("casework-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("caseworkctl-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn( @@ -272,6 +274,10 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: self.assertIn( "if (( release_major > 0 || release_minor >= 30 )); then", merge ) + self.assertIn( + "if (( release_major > 0 || release_minor >= 33 )); then", merge + ) + self.assertIn("dist/image-bin/scheduling --version", merge) self.assertNotIn("${{ inputs.", merge) for forbidden in ( "npm publish", diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index dfccd7e8d6..8339652133 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -391,6 +391,21 @@ def test_current_release_pipeline_has_no_pre_v0_19_surface(self) -> None: {"discovery", "evidence", "mint", "breg", "relay"}, module._candidate_image_names("0.26.0"), ) + self.assertEqual( + {"breg", "casework", "discovery", "evidence", "relay"}, + module._candidate_image_names("0.32.0"), + ) + self.assertEqual( + { + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + }, + module._candidate_image_names("0.33.0"), + ) self.assertFalse( any( "registry-notary" in name @@ -568,7 +583,7 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( self.assertEqual("validate", shards["needs"]) self.assertFalse(shards["strategy"]["fail-fast"]) self.assertEqual( - ["core", "breg", "casework"], + ["core", "breg", "casework", "scheduling"], shards["strategy"]["matrix"]["group"], ) checkout = shards["steps"][0] @@ -595,9 +610,14 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( downloads = [ step for step in consumer["steps"] if "download-artifact@" in str(step) ] - self.assertEqual(3, len(downloads)) + self.assertEqual(4, len(downloads)) self.assertEqual( - {"binary-shards/core", "binary-shards/breg", "binary-shards/casework"}, + { + "binary-shards/core", + "binary-shards/breg", + "binary-shards/casework", + "binary-shards/scheduling", + }, {step["with"]["path"] for step in downloads}, ) merge = step_run( @@ -608,6 +628,7 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( "--core binary-shards/core", "--breg binary-shards/breg", "--casework binary-shards/casework", + "--scheduling binary-shards/scheduling", '--builder-image "${RELEASE_BUILDER_IMAGE}"', ): self.assertIn(binding, merge) @@ -1993,6 +2014,21 @@ def test_public_verification_selects_the_versioned_image_roster(self) -> None: "evidence", "relay", ], + "v0.32.0": [ + "breg", + "casework", + "discovery", + "evidence", + "relay", + ], + "v0.33.0": [ + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + ], } manifests = {} for tag, image_names in cases.items(): @@ -2046,8 +2082,26 @@ def test_public_verification_selects_the_versioned_image_roster(self) -> None: ) self.assertNotEqual(0, rejected.returncode) - self.assertIn("breg|casework|discovery|evidence|mint|relay)", verify) + missing_scheduling = dict(manifests["v0.33.0"]) + missing_scheduling["images"] = [ + image + for image in missing_scheduling["images"] + if image["name"] != "scheduling" + ] + rejected = subprocess.run( + ["jq", "-e", "--arg", "tag", "v0.33.0", jq_filter], + input=json.dumps(missing_scheduling), + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, rejected.returncode) + + self.assertIn( + "breg|casework|discovery|evidence|mint|relay|scheduling)", verify + ) self.assertIn("`casework` from\n`v0.30.0`", verify) + self.assertIn("Registry Scheduling joins at `v0.33.0`", verify) def test_operator_docs_match_the_latest_non_prerelease_contract(self) -> None: operations = (ROOT / "release/OPERATIONS.md").read_text(encoding="utf-8") diff --git a/release/scripts/test_zig_glibc_compiler.py b/release/scripts/test_zig_glibc_compiler.py index 684cb6a4da..74a69bc3e1 100644 --- a/release/scripts/test_zig_glibc_compiler.py +++ b/release/scripts/test_zig_glibc_compiler.py @@ -294,7 +294,7 @@ def setUp(self) -> None: "target.mkdir(parents=True, exist_ok=True)\n" "for binary in ('registry-manifest', 'relay', 'relayctl', 'evidence', " "'evidencectl', 'evidence-oid4vci', 'discovery', 'breg', 'bregctl', " - "'casework', 'caseworkctl'):\n" + "'casework', 'caseworkctl', 'scheduling'):\n" " (target / binary).write_text('fixture binary\\n')\n", encoding="utf-8", ) @@ -322,10 +322,11 @@ def setUp(self) -> None: "breg = ['breg', 'bregctl'] if parsed >= (0, 26, 0) else []\n" "selected = (core if group in ('all', 'core') else []) + " "(breg if group in ('all', 'breg') else []) + " - "(['casework', 'caseworkctl'] if parsed >= (0, 30, 0) and group in ('all', 'casework') else [])\n" + "(['casework', 'caseworkctl'] if parsed >= (0, 30, 0) and group in ('all', 'casework') else []) + " + "(['scheduling'] if parsed >= (0, 33, 0) and group in ('all', 'scheduling') else [])\n" "for name in selected:\n" " (bin_dir / f'{name}-{tag}-linux-amd64').write_text(name + '\\n')\n" - "for name in ('discovery', 'breg', 'casework', 'evidence', 'relay'):\n" + "for name in ('discovery', 'breg', 'casework', 'scheduling', 'evidence', 'relay'):\n" " if name in selected:\n" " (image_dir / name).write_text(name + '\\n')\n", encoding="utf-8",