#SchCP conformance
Two tools for testing an implementation that shares no code with this one:
- Wire-level vectors (
vectors/*.json): fixed keys, fixed timestamps, exact expected outputs. Use them to unit-test a signer, a verifier, or a mandate checker in any language. - A black-box runner (
run.ts): points the reference SDK at a live host endpoint, hosted or self-built, and reports PASS / FAIL / SKIP for each check, with the spec section it covers.
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
- Clock-dependent behaviour. Hold expiry and TTL capping, reputation-scaled TTLs (§7.3), the exact
300 s signature window and nonce retention until
created + 301 s(§3.2), mandateiatskew andexp(§3.3, check 3), approval timeouts, participant deadlines and reconfirmation (§5.10), and the end of an open-ended booking (§4.2). These need control of the host's clock. The vectors pin the boundaries (http-signatures.json,mandates.json). - Bookings at a contacts-only host (§7.8): they need a mandate from someone on its private
contact list. Without one the runner checks only that a stranger is refused; with one
(
--principal-key) it runs the meeting checks, but not the booking checks. - A whole meeting (§5.12, §7.9): committing every member's acceptance, a member's own signed yes,
a yes by a rule, counters and revisions, a change to a confirmed meeting, and a business as a
member. They need every member's key, and an answer only the calendar's owner can give.
meetings.jsonandacceptances.jsonpin the contract, revision and acceptance rules instead. - Anything only the host can trigger: host cancellations and proposals, approving or
declining a
pending_hostbooking, ending an open-ended booking, marking no-shows and attendance (§4.2, §5.8, §6), and webhooks (they need a public callback receiver). - Abuse controls under load: per-agent and new-guest hold caps, attester pools, rate limits (§7.1, §7.2, §7.5). Testing them means occupying a real host's time.
- Waitlists, participants and attestations (§5.9, §5.10, §7.5): they need several principals, freed slots or a trusted attester.
- Atomicity and recovery: concurrent allocation of one resource (§4) and crash recovery. These are covered by the reference implementation's own tests, not observable black-box.
- Real availability: whether the slots offered are really free in the host's system of record.
- Revocation (§3.3, check 7): the runner's key app publishes no status URL.
#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.