SchCP conformance

Two tools for testing an implementation that shares no code with this one:

Both target spec v0.4 (spec/SPEC.md, schemas spec/schemas/schcp.schema.json). In v0.4 SchCP never moves money: a host may state a price as a term, and nothing on the wire charges, captures, holds or refunds anything. The runner checks that too.

The runner

npm run conformance -- maplestreetauto.example                  # a host on its own domain
npm run conformance -- https://platform.example/m/x/scheduling.json   # a platform listing URL
npm run conformance -- platform.example --host x            # an operator domain plus host id
npm run conformance -- maplestreetauto.example --dry-run        # read checks only
Option Meaning
--host <id> Host id, when the target is an operator (platform) domain
--service <id> Service to book. Default: the first fixed-length service at the host's own place (an open-ended one cannot be moved by the change checks, and one at the guest's address or elsewhere needs a place the runner cannot invent)
--dry-run Only read checks: discovery, availability and refused signatures. No holds, no bookings
--lead-hours <n> Book at least n hours ahead (default 48, never less than the service's min_notice)
--principal-key <file> Sign mandates with this principal key (a private Ed25519 JWK, as JSON) instead of a fresh one. At a person's calendar, a key on its contact list enables the meeting checks
--json Print the report as JSON

The exit status is 0 when nothing failed and cleanup succeeded, 1 when a check failed or something could not be cleaned up, and 2 for a usage error.

Safety on a real endpoint. Each run uses a fresh agent key and a fresh principal key (a new, unproven guest). SchCP moves no money, so nothing the runner does is paid for. It makes one booking, at least 48 hours ahead by default, after placing and releasing one short hold (5 minutes requested). It moves that booking once (the change offer holds a second slot until it is accepted) and cancels it, which costs nothing and records nothing. At no point does it occupy more than two slots. Whatever fails, it then cancels every booking and releases every hold it created, and it prints what it cleaned up. A booking still reaches the host's system for a few seconds, and staff may be notified, so ask the host before running a full run against a live endpoint. --dry-run needs nobody's permission: it sends only availability queries and requests that must be refused.

A contacts-only host (policies.access: "contacts", typically a person's own calendar) shows its time only to principals on its private contact list. The runner is a stranger, so against such a host it runs the discovery, signature and refusal checks and access.contacts, and reports availability and every booking check as SKIP (not applicable). It never holds anything there. Given the key of someone on the list (--principal-key), it also runs the two meeting checks (§5.12): it places one hold, offers the calendar contracts it must refuse, then books one meeting that waits for the person and cancels it at once.

Testing a simulated or local endpoint. runConformance(options) accepts fetch, resolve (public URL → URL actually dialled; signatures always cover the public URL), now, agentKey and principalKey. test/conformance-runner.test.ts runs it against the simulated world in src/sim/world.ts, and against deliberately broken endpoints to show that each check can fail.

Checks

Check Spec Writes What it does, and what passing means
discovery.document §2.1, §3.1 Fetches /.well-known/scheduling.json (or the URL given; through the operator document with --host). It must validate against DiscoveryDocument, have schcp: "0.4", an https endpoint and Ed25519 public keys
discovery.authorization §2.2, §2.3 Uses the SDK's endpoint authorization: the endpoint is on the host's domain, or both the host and the operator claim the relationship. Operator-asserted listings (no host domain) pass, and the report says so
availability.signed §5.1, §3.1 A signed availability query returns 200, SchCP-Version: 0.4, service_id, timezone, as_of, and slots inside the window. A slot has a start, and an end unless it is open_end. It states a Money price and total exactly when the service states a price, and carries no money-moving field (charge, deposit, payment, refund, …). SKIP at a contacts-only host
availability.open-end §4.2, §5.1 Only where the discovery document advertises a service with duration: "open": its slots have a start, no end, and open_end: true. Otherwise SKIP
access.contacts §7.8, §9.2 Only where policies.access is "contacts": a signed availability query without a mandate is refused with 403 NOT_PERMITTED, reason not_a_contact, and no slots. Otherwise SKIP
auth.unsigned §3.2, §9 A request without signature headers: 401 BAD_SIGNATURE
auth.body-tampered §3.2 A signature over a different body (Content-Digest mismatch): 401 BAD_SIGNATURE
auth.target-uri §3.2 A signature over a different @target-uri: 401 BAD_SIGNATURE
auth.key-mismatch §3.2 Signed by a key other than the one in SchCP-Agent-Key: 401 BAD_SIGNATURE
auth.stale §3.2 created 10 minutes in the past (well beyond the 300 s window, whatever the clocks): 401 BAD_SIGNATURE, reason stale_signature
auth.replay §3.2 The same signed request twice: 200 (at a contacts-only host, 403 NOT_PERMITTED: authenticated, then refused for access), then 401 BAD_SIGNATURE, reason replayed_nonce
hold.mandate-limit §3.3 (6), §9.2 yes A hold under a mandate whose max_price is one minor unit below the slot's all-in total: 403 MANDATE_LIMIT, nothing held. SKIP when the slot states no price (max_price then binds nothing) or a zero one
hold.release §5.2, §5.4 yes A hold is placed and released (200, released). Releasing it again returns it unchanged
hold.offer §5.2 yes The hold carries an offer (typ schcp-offer+jwt, EdDSA) that verifies against the discovery keys and its iss is the host id, is OfferClaims-valid, and agrees with the hold on hold id, service, slot (start, end or open_end), price, total and place. Neither the hold nor the offer carries a money-moving field. Where the slot stated a price, the offer's total equals the availability total and its lines sum to it; where it stated none, the offer has no price, total or lines
confirm.missing-hash §5.3, §3.1 yes Confirm without offer_sha256: 400 INVALID_REQUEST (§3.1, §5.3 and the schema)
confirm.wrong-hash §5.3 yes Confirm naming another offer's hash: 409 CONTRACT_MISMATCH, reason contract_mismatch, details.offer_sha256 = the hold's hash. Nothing is booked
confirm.approval-required §5.3 yes Only where policies.closed_mandate requires the principal's signature (always): an open mandate gets 403 OFFER_APPROVAL_REQUIRED with details.offer_sha256 and an approve_offer remedy. The runner then retries under the same idempotency key with a closed mandate from its key app, which also tests that a 403 is not stored (§9.1). Otherwise SKIP
confirm.receipt §5.3 yes Confirm with the right hash: 201 (or 202 pending_host). The receipt verifies against the discovery keys, is ReceiptClaims-valid (a closed Booking: no member the spec does not define), describes this booking, names offer_sha256, says how the person agreed (consent.approved: offer for a closed mandate, limits otherwise) under the mandate the runner used (consent.mandate, its root jti), and states the offer's retention. The booking has sequence 0, the held start, a JSCalendar event, and no money-moving field
idempotency.replay §9.1 yes The same confirm, same key and body: the same booking, with Idempotent-Replayed: true
idempotency.conflict §9.1 yes The same key with a different body: 409 IDEMPOTENCY_CONFLICT
management.cannot-hold §3.3 yes A management mandate (scope cancel, reschedule; constraints.bookings) cannot hold: 403. Neither can a mandate with hold in scope that carries constraints.bookings: 403 MANDATE_LIMIT
change.offer-chain §5.7 yes change-offer under the management mandate returns a proposal whose offer verifies, has kind: "change", this booking_id, the proposal's slot, and prev = the booking's current offer_sha256
change.stale-sequence §5.7 yes Accepting the change with sequence + 1: 409 STALE_SEQUENCE
change.accept §5.7 yes Accepting with the right sequence and hash moves the booking: sequence + 1, the new start, and a receipt naming the change offer. Where closed_mandate is always, the runner signs the change offer with its key app first
cancel.free §5.6 yes Cancelling under the management mandate succeeds: cancelled, sequence + 1 and a fresh receipt. The cancelled booking carries no money-moving field (no charge: cancelling never costs anything) and none of v0.3's record fields (recorded, records, undo_until, cancellation_policy): SchCP records nothing (§7.6)
meeting.checks §5.12 yes Only at a person's calendar (host.kind: "person"), as a principal on its contact list; a person's calendar that does not advertise the meetings capability FAILs both meeting checks: one hold (at a named test place, for a service that happens elsewhere), whose offer must name the calendar's owner (host.owner). A confirm carrying a meeting contract that names another offer for this calendar: 409 CONTRACT_MISMATCH. One signed by someone other than the guest: 400 INVALID_REQUEST, reason meeting_invalid. Nothing is booked. Otherwise SKIP
meeting.commit §5.12 yes The same hold confirmed with a valid contract (the runner and the calendar's owner as members): 202, status pending_host (or tentative, where a rule the owner signed accepted), and a receipt naming the contract's hash. Nothing is confirmed until every member has accepted: committing with no acceptances gives 409 INVALID_STATE, reason awaiting_members, with the owner's name in details.awaiting. The runner then cancels the booking
refusals.shape §9.2 Every refusal seen during the run has code, reason, message, a contact remedy with a phone or URL, and validates against Refusal. Each refusal check above also checks its own refusal's shape. A person's own calendar (host.kind: "person") has no public line to offer, so there the contact remedy is not required

Checks that cannot apply are reported as SKIP with the reason: --dry-run, no fixed-length service at the host's own place, a slot with no price (for hold.mandate-limit), no open-ended service, a public host (for access.contacts) or a contacts-only one (for availability and every booking check), a host that never needs a closed mandate, a pending_host booking (changes and cancelling apply to final bookings), a single available slot (nothing to move to), a host that is not a person's calendar (for the meeting checks), or a runner principal that is not on the calendar's contact list.

What the runner cannot check

The vectors

File Spec Contents
keys.json §3.2, §3.3 Seven Ed25519 keys (principal, agent, sub-agent, host, stranger, member-2, member-3) from 32-byte seeds, with public JWKs, RFC 7638 thumbprint input and output, and principal ids. Also the RFC 8037 Appendix A key, thumbprint and JWS as an external anchor
content-digest.json §3.2 Content-Digest (RFC 9530, sha-256) for bodies including non-ASCII UTF-8, and two serialisations of the same JSON
http-signatures.json §3.2 signing: sample requests (POST with and without a mandate, a bodiless DELETE, a platform URL with a query) with the exact signature base, Signature-Input and Signature for a fixed created and nonce. verification: requests a verifier must accept or reject (±300/301 s, tampered body, digest recomputed, target URI from Host, method changed, key mismatch, mandate added, swapped or stripped, digest not covered, wrong alg)
mandates.json §3.3, §5.3 Root and child mandates with the outcome a host must reach and the stage (verify → 401 MANDATE_INVALID; action → 403 MANDATE_SCOPE/MANDATE_LIMIT): bad signature, tampered payload, iss not the key, wrong typ, wrong audience, wildcard refused and allowed, iat +59/+60/+61 s, exp −1/0 s, cnf mismatch, scope, price, currency, an offer with no price (always fits), service, window, an open-ended start checked with end = start, malformed claims (including the v0.3 max_deposit, no longer a constraint), management and closed mandates, and chains (valid child, child price binds, child widens scope or a constraint, outlives its parent, duplicate jti, parent without delegate, foreign signer, changed principal, depth 3)
offers.json §4.2, §5.2, §5.3, §5.7 A booking offer and a change offer chained to it by prev, with offer_sha256 (every offer states its retention, and the change offer repeats the booking's); an open-ended, unpriced offer between two people (no price, total or lines, a slot with no end, open_end: true, a place); the same claims re-serialised (a different contract, a different hash); verification with the wrong key; a tampered payload; a host-signed offer whose claims add a cancellation fee, at the top or inside slot, which verifies but fails the closed OfferClaims schema (expect_schema_valid: false). holds: the hold POST /holds returned with the booking offer (valid against the closed Hold schema), the same hold with a charge added, and one without its offer (both invalid)
meetings.json §4.2, §5.12 Meeting contracts (typ schcp-meeting+jwt) signed by the organizer, with contract_sha256 (as for offers, over the exact compact JWS) and the outcome of verifying the contract itself: three people with an open-ended playdate, two with a fixed-length coffee; refused with 400 INVALID_REQUEST (meeting_invalid): one member, an organizer who is not a member, the same person twice, two members on one calendar, both an end and open_end, iss not the signing key, a tampered payload, the wrong typ. A coffee at a café that is a member (business: true, named urn:schcp:host:<id>; its offer is its yes); refused: a business named by a person's principal, an organizer and a business with no other person. revisions: revised contracts of the three-person playdate and whether each may replace it: the same meeting, organizer and members, a higher revision, and for a confirmed meeting (confirmed_start) a deadline no later than its current start; refused: a deadline after the start, the same revision number, another meeting, a member left out
acceptances.json §5.12, §7.9 Acceptances (typ schcp-acceptance+jwt) of the three-person contract and of a single offer, with the rules (typ schcp-rule+jwt) they use, and what the verifier expects (whose yes, which hash, whether a rule may answer, and the window its iat must fall in). Valid: a person's own yes, a yes by a rule. Refused with acceptance_invalid: a yes to other terms (another revision), someone else's yes, a person's yes signed by another key, a yes dated before its terms existed or in the future, a rule that names another agent key, the wrong weekday, another service, another organizer, an expired rule, someone else's rule. Refused with person_required: a rule's yes where the contract asks for the person
moves.json §5.3, §5.7, §5.8, §9.1 A guest's move at a host that approves each booking, as the guest's agent reads it: the booking stands on the offers.json booking offer and the guest accepted its change offer; move is what the agent keeps from the receipt that showed the move waiting. Host-signed receipts (typ schcp-receipt+jwt, each stating its contract's retention and how the guest agreed in consent; move.mandate is the mandate that accepted the move), polled or inside booking.change_approved / booking.change_declined webhooks, each with the state they show: waiting (nothing moved; proposal.awaiting_host, answered before the booking starts), moved, declined, lapsed (host_did_not_answer), dropped (cancelled while waiting), and a webhook whose unsigned booking disagrees with its receipt (the receipt counts). Rejected: a receipt by another key, from another host, of another type, for another booking, or whose booking carries a member the closed Booking schema does not define (a charge); a waiting move whose booking already moved, whose deadline is after the start or that names another change offer; a move made without a new sequence, or still marked waiting, or whose receipt names a mandate other than the one that accepted it; a receipt whose retention differs from its contract's; a declined move whose booking lost its time; a webhook whose type, or refusal, disagrees with its receipt
money.json §2.1, §3.3, §5.2 Parsing and rejecting decimal amounts per currency exponent (CAD, JPY, BHD, KWD), canonical formatting, half-up percentages (checked against exact rational arithmetic), sums and comparisons, including across currencies
durations.json §2.1 ISO 8601 durations with exact units only: accepted lengths in milliseconds, and rejected forms (P1M, P1Y, P, PT, signs, lowercase, comma decimals)

Every file states its own conventions in description. Each case has a name, inputs, an expected output or outcome, and usually a spec reference. reference_message fields are informative: match outcomes and codes, not wording. Every case has exactly one conforming outcome: a child mandate that names a wider limit than its parent (chain/child-widens-constraint) is refused when the action is checked, by the parent's limit, never at verification (§3.3).

Nonce replay and idempotency are stateful, so they are runner checks, not vectors.

To test a client written elsewhere against live hosts, run npm run site and use the map of simulated domains to local ports it prints (also at GET http://127.0.0.1:4600/api/dns); see examples/site/README.md.

Regenerating and drift

npm run conformance:vectors             # rewrite vectors/*.json from the reference implementation
npm run conformance:vectors -- --check  # exit 1 if the files differ from a fresh build

generate-vectors.ts refuses to write a mandate, meeting, acceptance or move vector whose outcome differs from the one the spec requires, or a percentage that differs from exact half-up rounding. test/conformance-vectors.test.ts (part of npm test) requires the files to equal a fresh build byte for byte, and re-checks every case from the file's own data, partly with node:crypto directly rather than the reference JOSE code. A behaviour change in the reference implementation therefore shows up as a vector diff that someone has to regenerate and review on purpose.