#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.)
mandates.jsonchain/child-widens-constraintfirst passed on analso_conformingoutcome (refused at verification). The spec now picks one stage (§3.3: refused at the action, by the parent's limit) and the vector has one outcome; this implementation follows it.- In
moves.jsonthe reason strings (informative) also all match the reference's. - A mutation check (breaking one rule at a time:
iatskew, the 300 s window, a rule's issuer, a move's sequence, a child'sexp, digest coverage, the "another person" rule, a move's deadline,date-timeassertion) makes the runner fail every time, so the vectors do discriminate.
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
- L-1. §9.2 vs the reference: replay reason. The registry has
replayed_nonceandstale_signature, but the reference answers a replay withbad_signature(probed). Either say BAD_SIGNATURE reasons are advisory or require the specific ones. - L-2. §5.2 vs §5.8: lines without a price. "
price,totalandlinesappear only when the host states a price", but an extension at an unpriced host makes "any extra amount ... the only line". Did: apricerequirestotalandlines, atotalrequireslinessumming to it,linesalone are tolerated. Say which is meant. - L-3. §5.3 example. The booking's
eventlacksstatus, whichJSCalendarEventrequires, so the example fails the schema it illustrates. - L-4.
Booking.offer_sha256may benullin the schema; the spec never says when (a booking made off-protocol?). Likewise the receipt names the contract twice (offer_sha256andbooking.offer_sha256) without saying they must agree. Did: refuse a receipt where both are present and differ. - L-5. §5.12 acceptances. Whether
meeting_idmust equal the contract's, whether a rule must already exist at the acceptance'siat(rule.iat ≤ iat), whether the rule'siatgets the 60 s skew, and whether "unexpired" meansiat < expare not stated. Did:rule.iat - 60 ≤ iat < rule.exp;meeting_idnot compared. - L-6. §3.3 check 6 window bounds are not said to be inclusive. Did:
inclusive (
start ≥ not_before,end ≤ not_after). - L-7. §3.2 "bodiless" is undefined for a zero-length body. Did: an
empty body is bodiless (the reference answers such a POST
400). - L-8. §3.2
SchCP-Agent-Key: the JSON serialisation (member order,kidallowed or not) and the nonce's format and length are not specified; only byte-exact vectors depend on it. - L-9. §2.1 durations. Retention is "a duration in weeks or days", but the
Durationschema accepts hours and seconds;P2W3Dmixes weeks with days, which ISO 8601 basic forbids; the canonical form (P7D, notP1W) is only in the vectors. Also, the schema's patterns use$, which Python'sre(and sojsonschemain Python) lets match before a trailing newline:"PT1H\n"passes theDurationschema (checked). Did:fullmatchin the duration parser. Suggest patterns that cannot match a trailing newline (for example[^\n]guards), or a rule that string values never contain control characters. - L-10. §2.2 rule 1 "on the host's own domain": subdomains (
api.host.example) are not addressed. Did: exact host match. - L-11. Duplicate JSON member names in a signed payload are not addressed; two parsers can read different claims. Did: refuse them. Suggest "MUST refuse a JWS whose header or payload has duplicate member names".
- L-12. Testing against the sandbox. Neither README says that the sample
site's hosts listen on ephemeral localhost ports, or how an outside client
reaches
https://*.example; a--print-portsoption or a fixed port map would make independent clients testable. - L-13. §3.1 "Every response carries
SchCP-Version": the sandbox's/m/{host}/scheduling.jsoncopies on port 4600 do not (the hosts' own/.well-known/scheduling.jsondo). Say whether discovery documents count. - L-14. Percentages are JSON numbers (
TaxRule.percent, vector12.5), while money is "never a float". An implementation that multiplies with binary floats can misround; say percentages are decimal values to be used exactly.