schcp (Python): an independent implementation of SchCP v0.4

A second implementation of SchCP written from the written specification only: spec/SPEC.md, spec/schemas/schcp.schema.json, conformance/README.md, conformance/vectors/*.json and the top-level README.md. No reference code (src/, test/, conformance/*.ts, examples/, scripts/) was read. The point was to find out whether the spec alone is enough; the list of places where it is not is under Spec findings.

Python 3.14, standard library plus cryptography and jsonschema.

Running it

From this directory (implementations/python/):

python3 -m schcp.vectors            # every vector file; exit 1 on any disagreement
python3 -m schcp.vectors -v         # print every case
python3 -m schcp.vectors --json     # machine-readable report
python3 -m unittest                 # unit tests (36), including a run of all vectors

# Live client against a running host (see "Live client" below):
python3 -m schcp.client maplestreetauto.example \
    --resolve maplestreetauto.example=http://127.0.0.1:PORT

The schema is read from ../../spec/schemas/schcp.schema.json (override with SCHCP_SCHEMA), the vectors from ../../conformance/vectors (override with --dir).

What it covers

Module Spec What
schcp/jose.py §3.2, §3.3, §3.4 Compact JWS sign/verify, EdDSA (Ed25519) and ES256 (P-256, raw r‖s); the algorithm follows from the key, none and mismatched alg are refused; RFC 7638 thumbprints (OKP and EC); self-certifying principal ids; strict base64url; duplicate JSON members refused; key selection by kid only (§2.1)
schcp/httpsig.py §3.2 RFC 9421 profile: signing (byte-exact with the vectors) and verification (label schcp, components and parameters in the one order §3.2 fixes, alg="ed25519", ±300 s, SchCP-Agent-Key thumbprint = keyid, Content-Digest recomputed, mandate header covered, nonce cache until created + 301) and RFC 9530 Content-Digest
schcp/money.py, schcp/duration.py §2.1 Decimal money in minor units (ISO 4217 exponents), half-up percentages with exact arithmetic, no cross-currency arithmetic; exact-unit ISO 8601 durations
schcp/schema.py all Validation against the published schema with date-time and uri formats asserted
schcp/mandate.py §3.3 Building root, child and management mandates; chain verification (checks 1-4 on every link, root first; delegate; child signed by the parent's cnf key; same principal (iss and sub); no longer-lived child; scope no wider (constraints are not compared: every link's bind every action); distinct jti; depth 2; closed MandateClaims); action checks 5-6 on every link (scope, bookings, closed offer_sha256, services, window with open-ended start, max_price against the all-in total); a max_bookings counter keyed by (root iss, jti)
schcp/offer.py §5.2, §5.3, §5.7 offer_sha256 / contract_sha256; offer verification (signature against discovery keys, closed OfferClaims, iss, lines summing to the total, open-ended slots, change offers' booking_id/prev); holds (closed Hold, and agreement with their offer)
schcp/receipt.py §5.3, §9.1 Receipt verification: typ, host key, iss, closed ReceiptClaims/Booking, booking id, contract hash, consent.mandate, consent.approved, retention equal to the contract's; the closed BookingResponse
schcp/meeting.py §5.12, §7.9 Meeting contracts (all member rules, businesses, end xor open_end); a calendar's check of its own entry at confirm; revisions (incl. confirmed meetings' deadline); rules; acceptances (by person, by rule, person_required, iat window)
schcp/moves.py §5.7, §5.8 The guest's-move outcome as an agent reads it from receipts and booking.change_approved / booking.change_declined webhooks: waiting, moved, declined, lapsed, dropped, or refused
schcp/client.py §2-§5 A minimal consumer agent: discovery with endpoint authorization (§2.2 rules 1 and 2), availability, hold, confirm (retrying under the same idempotency key with a closed mandate on OFFER_APPROVAL_REQUIRED), receipt checks, cancel under a management mandate
schcp/vectors.py The vector runner

Not implemented: the SD-JWT VC mandate profile (§3.4, optional, and no vector covers it), and a host endpoint (state machine, holds, idempotency store). The host-side checks that are pure functions (mandate chains and actions, contract checks at confirm, acceptances, revisions, request signatures with a nonce cache) are implemented and unit-tested.

Results

Vectors

ok   acceptances.json          21 passed,  0 failed
ok   content-digest.json        5 passed,  0 failed
ok   durations.json            35 passed,  0 failed
ok   http-signatures.json      23 passed,  0 failed
ok   keys.json                  8 passed,  0 failed
ok   mandates.json             46 passed,  0 failed
ok   meetings.json             19 passed,  0 failed
ok   money.json                46 passed,  0 failed
ok   moves.json                24 passed,  0 failed
ok   offers.json               12 passed,  0 failed

TOTAL 239 cases: 239 passed, 0 failed, 0 spec-unclear

(239 rather than the 233 the top-level README counts: the runner also counts the six rules in acceptances.json and checks each of the 7 keys plus the RFC 8037 anchor.)

No case had to be marked spec-unclear, but not because the spec text was enough. About 75 of the 238 cases could only be decided with information that is in the vectors' own data and descriptions, or only in the schema, and not in the spec prose:

File Cases What only the vectors / schema said
money.json ~30 currency exponents, "no more decimals than the minor unit", canonical form, half-up rounding of percentages in minor units, no cross-currency comparison
durations.json 8 the canonical serialisation (format)
http-signatures.json 4 (signing) the label schcp, the parameter order, the SchCP-Agent-Key JSON member order
mandates.json 11 (chain/*) how a child is built: issuer_jwk (schema only), iss kept from the root, exp no later than the parent's
moves.json ~20 the classification rules (what a waiting/moved/declined receipt must look like) are spread over §5.7, §5.8 and §6; several details (no sequence bump on a decline, the event refusal equal to the booking's) only in the vector description

Live client

Worked against the reference sample site (npm run site), at three hosts:

Host Policy exercised Result
Maple Street Auto (maplestreetauto.example) instant, open mandate discover, availability, hold (offer verified), wrong hash refused 409 CONTRACT_MISMATCH, confirm 201 (receipt: consent.approved: limits, root jti, retention P365D), cancel 200 (sequence 1)
Rosie's Nails (rosiesnails.example) host_approval the same, confirm 202 pending_host, then cancel
Harbourview Dental (harbourviewdental.example, endpoint on operator chairly.example) closed_mandate: "always", §2.2 rule 2 confirm got 403 OFFER_APPROVAL_REQUIRED, retried under the same idempotency key with a closed mandate, 201 with consent.approved: offer

A spoofed discovery document the sandbox also serves (Maple Street Auto's keys with endpoint: https://collector.evil.example/schcp/v0) is refused by §2.2.

Reaching the hosts took black-box probing, not the spec: the site on port 4600 does not expose SchCP itself (every POST is 405), and the discovery documents it links to name endpoints on simulated *.example domains. The site process also listens on about a dozen ephemeral localhost ports, one per simulated domain; GET /.well-known/scheduling.json on each shows which is which. Once mapped (--resolve), the spec was enough: nothing else was needed to book. Nothing in the conformance README or top-level README says how to reach the sandbox's hosts from outside the process (finding L-12).

Black-box probes of the reference host, which settle some of the questions below empirically:

Probe Reference host's answer
The same signed request twice 401 BAD_SIGNATURE, reason bad_signature (not the registered replayed_nonce)
Signature label sig1 instead of schcp 401 BAD_SIGNATURE
Covered components in another order 401 BAD_SIGNATURE
Signature parameters in another order 401 BAD_SIGNATURE
No alg parameter 401 BAD_SIGNATURE
A compact mandate signed by a P-256 principal (ES256) 401 MANDATE_INVALID
A hold carrying payment / a confirm without offer_sha256 400 INVALID_REQUEST, reason malformed
GET /holds/{id}, DELETE twice same offer bytes; both deletes 200 released

Spec findings

Status, 3 November 2026: every finding below is now settled in the spec, the schema or the sample site (design notes §8.20). The list is kept as written, as the record of what an implementation from the text alone could not decide. The results above are after those changes; the "about 75 cases" paragraph describes the first run.

Ranked by how much each would trip up an implementer. Each says where, what an implementer would expect, what this implementation did, and a suggested fix.

High: an independent implementation fails to interoperate, or cannot be written

H-1. §3.2: the signature label and the order of components and parameters are not specified, and the reference enforces them. The spec lists the covered components and "the created, keyid, nonce and alg="ed25519" parameters" but never names the signature label, and RFC 9421 lets a signer choose the label and the order. The reference host refuses a label other than schcp, components in another order, parameters in another order, and a missing alg (all 401, probed live). A correct RFC 9421 signer that picks sig1 cannot talk to it. Only the vectors reveal schcp and the order. Did: sign exactly as the vectors do; verify leniently (any single label, any order), as RFC 9421 allows. Fix: "The signature's label is schcp. Covered components appear in the order "@method" "@target-uri" "content-digest" "schcp-mandate" (omitting those that do not apply), and the parameters in the order created, keyid, nonce, alg. Verifiers MUST refuse other labels, orders or a missing alg" (or, better, require verifiers to accept any order and only signers to use this one).

H-2. §3.3 Attenuation: the shape of a child mandate is not specified. The spec says only that the child is "signed with the agent key" and "includes the parent as parent". It does not say how the verifier learns the agent's public key (the schema has an issuer_jwk claim the prose never mentions), that the child's iss stays the root principal's (chain/child-changes-principal), that sub and aud are kept or narrowed, that the child must not outlive its parent (chain/child-outlives-parent: nothing in §3.3 says so), that the child's signer must be the key the parent's cnf.jkt names, or which checks (audience, iat/exp) apply to every link rather than to "the mandate". No child mandate can be verified from the prose alone. Did: issuer_jwk must be present on a child and absent on the root, its thumbprint must equal the parent's cnf.jkt, iss must equal the parent's, exp ≤ parent exp, scope ⊆ parent scope, every constraint no wider, aud ⊆ parent aud, no attestations on a child; checks 1-4 on every link, cnf against the request key on the leaf. Fix: add a "Child mandates" paragraph listing exactly these claims and checks, and name issuer_jwk.

H-3. §2.1 / §5.2: money arithmetic is only in the vectors. The spec says a money amount is "a decimal string plus an ISO 4217 currency code". It does not say that an amount may not carry more decimals than the currency's minor unit (79.001 CAD, 125.0 JPY are refused in money.json), where the minor units come from, how a percentage tax or surcharge is rounded (half up, in minor units, with exact arithmetic), what the canonical form is (79 normalises to 79.00), or that comparing or adding across currencies is an error (only the vector check6/price-other-currency says a USD total is refused against a CAD max_price). A host computing an offer's tax lines, or an agent checking that lines sum to the total, will disagree on rounding without these. Did: as the vectors. Fix: a "Money" paragraph in §2.1: ISO 4217 minor units; amounts have at most that many decimals; percentages are computed in minor units and rounded half up; canonical form has exactly the minor-unit decimals; any cross-currency comparison or sum is an error (and a max_price in another currency never fits).

H-4. §3.3 "Claims are validated": JSON Schema format must be asserted, and the spec does not say so. "Unparseable dates ... make the mandate invalid" depends on the schema's format: date-time, but in JSON Schema 2020-12 format is an annotation unless the validator opts in, and Python's jsonschema silently skips date-time unless an optional package is installed. An implementation that "validates against the schema" would accept "not_before": "next thursday" (claims/unparseable-window) and then crash or misbehave when comparing times. Did: registered strict RFC 3339 date-time (offset required) and uri checkers. Fix: "Validators MUST assert format (date-time as RFC 3339 date-time, with a time-zone offset; uri as RFC 3986)", or replace format with explicit patterns in the schema.

Medium: likely disagreements between implementations

M-1. "Claims are closed" (§5.2, §5.3) is only true at the top level. The spec says offers, mandates, contracts, acceptances, rules and receipts allow "no member [the schema] does not define", so a term SchCP does not define is never put before the human. But many nested objects are open in the schema: OfferClaims.host, .service, .slot, PriceLine, Jwk, MandateClaims.cnf, .consent, MeetingClaims.members[], .attendees[], RuleClaims.cnf, Refusal, Remedy, TaxRule. A signed offer whose slot carries "cancellation_fee": "25.00" passes the "closed" OfferClaims schema. Did: followed the schema (and rendered only known fields). Fix: add additionalProperties: false to every nested object of the signed claims, or say in §5.2 that only top-level members are closed.

M-2. §3.4 allows P-256 principals; the schema and the reference do not, for compact mandates. §3.4 "Keys" says "Principals and agents may use Ed25519 (EdDSA) or P-256 (ES256) ... The algorithm follows from the key". The paragraph sits in the SD-JWT profile but reads as general. MandateClaims.principal_jwk is a Jwk, which is const OKP / Ed25519, and the reference host refuses a compact mandate signed by a P-256 principal (401 MANDATE_INVALID, probed). Did: JOSE supports ES256; compact mandates, contracts, rules and acceptances are Ed25519-only, following the schema. Fix: say "In the SD-JWT profile" at the start of that paragraph, or widen Jwk and §3.3 to P-256.

M-3. §3.3: "equal to or narrower" is not defined per constraint, nor when it is checked. What is narrower for a window (both bounds inside?), for a child that omits a constraint its parent has (wider, surely, but unsaid), for max_price in another currency, for aud, shared_limit, offer_sha256? And is an over-wide child invalid at verification (401) or only refused when an action exceeds the parent (403)? The vectors accept both (also_conforming), which means two conforming hosts return different status codes for the same request. Did: per-constraint rules above, refused at verification. Fix: define narrowing per constraint and pick one stage.

M-4. §5.7 step 3 / §6: what a guest's-move receipt looks like in each state is under-specified. To read a move's outcome (moves.json) an agent needs rules the spec gives only in pieces: a waiting move keeps the booking's offer_sha256, start and sequence and the receipt's top-level consent stays the original booking's (the move's consent is in proposal.consent); a declined or lapsed move records declined_change without bumping sequence (§6 shows no seq+1, yet it is a guest-visible change with a fresh receipt, which everywhere else bumps it); the refusal in booking.change_declined must equal the booking's declined_change.refusal; a moved booking's consent.mandate is the mandate that accepted the move. Did: as the vector description. Fix: a short table in §5.7 of the receipt each outcome produces (status, contract, start, sequence, proposal, declined_change, consent), and say whether recording declined_change bumps sequence.

M-5. §3.3 closed mandates vs reads and releases. "A mandate whose constraints include offer_sha256 authorizes accepting only the offer with that hash. Any other action fails with MANDATE_LIMIT." But §3.3 also says reads (GET /holds/{id}) are checked against every constraint, and releasing a hold is checked against the hold. Does a closed mandate let the agent read or release the hold it approves? Did: the hash binds only actions with a scope; reads are not refused for naming no offer. Fix: say which operations a closed mandate covers.

M-6. §5.2, §5.3: how a verifier picks the discovery key is unspecified. Offers and receipts in the vectors carry a kid header matching the discovery key's kid, but the spec never says host-signed JWS carry kid, that discovery keys must have one, or what to do when kid is absent or unknown. Did: use the key with that kid if there is one, otherwise try every discovery key. Fix: "Host-signed JWS carry the kid of the discovery key that signed them; verifiers use that key".

Low: wording, examples and edge cases