Guidance for automated agents (and humans) working in this repository.
polymorph:webcrypto: a WIT interface plus multiple implementations that run the
same guest component against real cryptography: a Wasmtime host (RustCrypto)
and two JS hosts over the platform Web Crypto API — polyengine (runtime-linked;
the primary JS path) and jco (transpile-based). It is a sibling of
polymorph:webrtc-datachannels and deliberately mirrors its architecture — prefer
clarity and correctness over features, and keep the implementations
behaviourally in sync (the conformance tests and the crypto-demo guest's
checks are the cross-implementation gate).
The package tracks the WebCrypto standard: extensions to it do not enter
this package, however shaped — they belong in a sibling package if
anywhere.
See README.md for the design.
Before designing WIT or touching async/stream plumbing, consult
lann/wasm-component-starter
(especially OUTLINE.md) — treat it as a living knowledge base and re-read it
rather than relying on a cached summary.
wit/ # the polymorph:webcrypto package, one file per layer:
# webcrypto.wit holds the structural types and
# the founding generic primitive kinds; later
# generic kinds live in their own files
# (agreement/derivation/encryption/wrapping.wit);
# family files (aes.wit, rsa.wit, …) hold the
# minting interfaces plus any family-shared
# parameterization interface, and grow as
# algorithms are added;
# extension-conditions.json is the registry of
# the package's named extension conditions (see
# wit/README.md, "Error contract")
rust/ # the Rust library surface (directory = crate name
# minus the `polymorph-webcrypto-` family root)
core/ # polymorph-webcrypto-core: the shared RustCrypto core of
# both Rust implementations: cipher/digest
# dispatch, key validation and generation, error
# rendering,
# signature keys (ECDSA signing is compiled out of
# wasm builds — class D)
wasmtime/ # polymorph-webcrypto-wasmtime: Wasmtime host crate,
# modeled after wasmtime_wasi_http::p3;
# add_to_linker + WasiWebcryptoView
guest/ # polymorph-webcrypto-guest: guest-side Rust library over
# the polymorph:webcrypto imports: typed wrappers with
# a byte-source abstraction, so consumers do not
# re-implement the feed-a-stream-and-await
# plumbing; the Rust counterpart of
# @polymorph/webcrypto-componentize
guest-provider/ # polymorph-webcrypto-guest-provider: wasm COMPONENT,
# RustCrypto in-guest, EXPORTS the package
# surface; composable via `wac plug`; buffer.rs
# makes input buffering fallible, so allocation
# failure is the operation's error rather than the
# instance's trap; the instance memory limit the
# embedder sets is the retention bound,
# deliberately (see the module doc); see its
# README for the timing-channel classification and
# export policy
js/ # the JS library surface (directory = npm name minus
# the `@polymorph/webcrypto-` family root)
polyengine/ # polyengine host MODULE (src/mod.ts): the same
# reference host over polyengine's embedder API,
# runtime-linked — no transpile step; the
# canonical module polyengine-family consumers pin
# by URL (deno.lock frozen, release-pinned
# import maps). Gate: `just polyengine-module-check`
jco/ # @polymorph/webcrypto-jco: jco host LIBRARY.
# webcrypto.js implements the imports over the
# browser-compatible Web Crypto API ONLY; no
# runtime dependencies, no demo code.
# wit/world.wit names the interfaces it serves;
# `jco-transpile` derives their definitions from
# it and interface-check.js asserts the host
# against them (`just jco::typecheck`); test/
# covers the admission subsystem conformance
# cannot reach
componentize/ # @polymorph/webcrypto-componentize: JS guest library for
# componentize-js (dicej's ComponentizeJS reboot):
# webcrypto.js exposes a crypto.subtle subset over
# the polymorph:webcrypto imports — its header is the
# registry of the served algorithm/format sets
# (SERVED_ALGORITHMS) and of every deviation; the
# toolchain revision is pinned in
# componentize-js.rev; interface-check.js asserts
# the exported subset against the SubtleCrypto and
# CryptoKey definitions TypeScript ships
# (`just componentize::typecheck`); wpt/
# vendors the WebCryptoAPI web-platform-tests and
# gates in CI, componentizing its runner from the
# tree with a digest-pinned componentize-js build
# (wpt/component.sh, componentize-js.sha256); the
# run's census is pinned by wpt/expected.js;
# wpt/web/ is the browser parity page on the
# Pages site (serve with `just wpt::web`)
examples/
crypto-demo/ # guest component exercising the primitive kinds end
# to end (reaches polymorph:webcrypto via polymorph-webcrypto-guest)
demo-driver/ # CLI driver (async wasi:cli/run) for the composed
# fully in-guest demo
wasmtime-demo/ # thin native host over polymorph-webcrypto-wasmtime's add_to_linker
# + the integration test (tests/demo.rs)
jco-demo/ # Node 24+ driver for the jco host: transpiles
# crypto-demo with jco (one wildcard --map; async
# is read from the component) and runs it against
# js/jco/webcrypto.js
componentize-demo/ # JS guest (componentize-js) exercising the
# webcrypto-componentize library; exports the same demo
# interface as crypto-demo, composed and run via
# `just componentize::test` (gates in CI)
conformance/ # cross-implementation conformance tests, on the
# polymorph:test stack (a git dependency
# pinned by rev in the root Cargo.toml) — see
# conformance/README.md for the architecture
vectors/ # vendored Wycheproof JSON + the translation
# policy; its README records the upstream
# revision each file came from
harness/ # world-independent suite infrastructure:
# probe table, feature names, error rendering,
# assertion helpers, stream delivery, feature
# validation (crate: conformance-harness)
guest-ct/ # the shared suite (vectors compiled in;
# feature-tagged cases on the component-test
# guest SDK, pinned by its tests.lock and the
# frozen incumbent census fixture)
signing-guest-ct/ # the host-only suite for surfaces the in-guest
# provider does not export (ecdsa-sign)
driver-ct/ # the wasmtime host driver (ct-driver), the
# polyengine children (polyengine/) serving the
# polyengine-deno and polyengine-browser targets, the
# jco runner (jco/) serving jco-node plus the
# browser-engine targets jco-browser
# (Chromium), jco-firefox, and jco-webkit
# (macOS CI only; jco-browser/polyengine-browser
# gate in CI and are locally opt-in via
# CONFORMANCE_BROWSER=1, jco-firefox via
# CONFORMANCE_FIREFOX=1), targets.toml +
# targets-signing.toml (target capability
# manifests + expected-fail ledgers), the
# conformance-ct justfile
# module, the committed matrix.md +
# matrix-signing.md aggregates, the staged
# results viewer (`just conformance-ct::web`),
# and compat/ — the generated MDN-style
# support matrix (registry + page + the
# `compat` binary's spec, compat/README.md)
class-d/ # the class-D gate's negative-composition probe
# worlds: dedicated dummy consumers of withheld
# minting interfaces (see conformance/README.md)
timing-lab/ # dudect-style statistical timing tests of the
# composed in-guest provider (non-gating; see its
# README for methodology and detection limits)
experiments/ # quarantined exploratory consumers of the package:
# own Cargo workspace, wired into no justfile or
# CI, no stability — delete-at-will (see
# experiments/README.md)
scripts/setup.sh # one-shot dependency setup (idempotent; used by CI)
The polymorph:webcrypto package is defined exactly once, at the root
wit/. Components pull it in through wit/deps/polymorph-webcrypto
symlinks back to the root. Do not copy the package into a component or
replace those symlinks with real directories.
The layering is a design invariant, not a convention:
- Generic primitive-kind interfaces (
mac,aead) own the algorithm-agnostic resources. Adding an algorithm must not change them. - Algorithm interfaces (
hmac-sha2,aes-gcm,sha2,ed25519-verify/-sign,ecdsa-verify/-sign) contain only minting; operations hang off the key resources, which are capabilities (see the WIT doc comments for the exact contracts, including extractability and the "input streams are fully drained even on error" rule forseal/open). - Operations are one-shot calls on immutable key resources (
sign/verify,seal/open) — no stateful computation objects — so misuse is unrepresentable and theerrorvariant carries no misuse cases. Keep it that way. - A key resource must not promise material the provider may not hold. A
signing-keytherefore cannot yield its public half:generate-keyreturns the pair, importers useimport-verifying-key-raw. Browser WebCrypto has no derive operation (recovering the point from a private-only import is an unspecified spec gap, w3c/webcrypto#356) and keystore-resident keys sign without yielding anything else, so an infallible derive would make those keys unservable. A fallible per-algorithm derive remains possible additively (semver-minor) if a seed-only-import need ever materializes.
Two evolution rules govern the package surface. Adding a resource method
is a semver-minor package bump: new methods are subtyping-compatible for
existing compositions, but providers must update to serve them. Adding a
types.error case is always semver-major: the variant sits in return
position, so a new case flows toward consumers whose bindings cannot
represent it — there is no compatible path for variant growth. The variant
is therefore designed never to need growth: the closed cases are frozen
(the conditions the package's contracts named when the variant was
designed), other(string) carries operational
conditions (never semantic conditions callers must branch on), and
extension(extension-error) carries every named condition outside the
closed set — kind-level and algorithm-level alike — by (origin, name)
pair, recorded in wit/extension-conditions.json and gated against the
implementation spellings — see wit/README.md, "Error
contract". A new named condition is never a closed case: check whether the
fail-closed design maps it onto an existing case (it usually does);
otherwise it is an extension pair.
The evolution rules describe the cost of a change, not a prohibition — and they bind only once the package has external consumers, which it does not yet. Until then, a shape regret is fixed in place (signatures change, names change, the error variant may grow), never designed around additively: working around a constraint that does not yet bind produces the wart without buying the compatibility. What ends this regime is publishing the package for consumption; the change that does so should say it does.
sha1-checked, rsassa-pkcs1-v15-sign/rsa-pss-sign, and
rsa-oaep-decrypt are additionally gated
@unstable (features sha1-checked, rsa-sign, and
rsa-oaep-decrypt — see wit/README.md,
"Stability gates"): tooling hides them unless the feature is enabled, and
only test builds enable them by default. The conformance guest, the demo
and WPT componentize-js builds (--features), the jco types script
(--feature), and the standalone Wasmtime embedding all
opt in; the library surfaces default off — the guest SDK behind its
sha1-checked, rsa-sign, and rsa-oaep-decrypt cargo features, the
Wasmtime host behind
add_to_linker_with_options's LinkOptions (plain add_to_linker serves
no gated interface). A world line importing or exporting a gated interface
carries the same gate. Adding a WIT-resolving build without the flags
silently drops the interfaces rather than erroring, so a "missing
import/export" for a gated interface usually means a missing feature
flag.
Changing an interface identifier means updating everyone who names it as a
string: the guest bindings (examples/crypto-demo/src/lib.rs), the host
bindgen configs (rust/wasmtime/src/bindings.rs,
examples/wasmtime-demo/src/lib.rs), the in-guest provider world and bindings
(rust/guest-provider/), the driver's inline world
(examples/demo-driver/src/lib.rs), and the camelCased named export in
js/jco/webcrypto.js (the transpile invocations carry one wildcard
--map and enumerate nothing per interface; async-ness is read from the
component — see the conventions note in that file's header).
rust/guest-provider/README.md carries the timing-channel classification (classes
A–D) and this provider's policy: only class A–C algorithms are exported,
via constant-time-variant implementations with the recorded CBC-unpadding
exception; class D algorithm
interfaces (RSA private-key ops, ECDSA signing, …) are never exported by
the in-guest provider, so compositions requiring them fail at wac plug
time. Secret-free operations (hashing public data, signature verification)
are exempt from the classes. Keep the classification table in sync when
adding algorithms, and keep class D out of the provider's world.
just conformance-ct::class-d (a dependency of just conformance-ct::all)
gates that
last sentence: it asserts the conformance signing suite, whose world imports
ecdsa-sign, does not compose with the provider. Adding a class-D export
turns that composition green and fails the gate. The failure mode it guards
against is subtle — see rust/guest-provider/README.md, "What the failure looks like":
wac plug tolerates imports it cannot satisfy, so the composition breaks
only because the provider exports the generic interface owning the key
resource that the withheld minting interface mints.
js/jco/webcrypto.js uses only globalThis.crypto.subtle and
globalThis.crypto.getRandomValues. No node:crypto, no Node-only APIs: the
same file must be loadable in a browser unchanged. Node is just the current
runner (24+ for JSPI).
js/componentize/webcrypto.js re-exposes the package as crypto.subtle,
and the WPT harness (js/componentize/wpt/) runs the platform's own test
suite through it. That round trip — WPT → shim → WIT → implementation — is
the repository's instrument for a question the conformance suites cannot
ask: whether crypto.subtle's observable semantics survive the WIT shape.
Its coverage is first-class, like the conformance vectors: growing the
package surface includes vendoring the WPT groups that observe it.
A WPT-observable behavior the shim does not exhibit is one of two things, and the difference is the signal:
- Unserved: the WIT carries the semantics; the shim does not serve them yet (for example algorithms beyond its documented set). Backlog, not a design problem.
- WIT-forced: no shim could express the behavior through the interface
shape. Keeping the set small is the goal, and every member must be a
recorded ruling, never a silent consequence of whatever shape was
convenient. The set is currently empty; the historical members were
each resolved rather than kept — the fixed AES-GCM IV/tag contract by
carrying both as per-call
aead-key.seal/openparameters, the ChaCha JWK decline by serving the proposal's alg-lessoctform, and the empty-HKDF-IKM rejection by accepting empty KDF secrets package-wide (its recorded rationale did not survive scrutiny).
The shim header's deviations list is the registry: every deviation appears
there with its classification, so the WIT-forced set — the true cost of the
interface shape, in platform-conformance terms — is enumerable at a glance.
When designing or changing WIT, read the WPT groups for the affected
algorithm the way you read Wycheproof: they define what a platform
observes, including the exact DOMException names the shim must reconstruct
from types.error (mapWitError), which bounds how much an error-variant
design may collapse.
wit/README.md, "Portability contract" states the consumer promise: the
ungated surface behaves identically on every implementation, qualified
only by gated features, structural absence, and recorded latitude. On
this side of that promise sits the process. When an implementation
difference appears — a platform gap, an engine quirk, a policy
constraint — resolve it with the first strategy that fits, and leave the
strategy's artifact. A divergence with no artifact is a bug.
- Design it out. Reshape the surface so the divergent question is unaskable — the no-derive rule, mint-bound ECDSA digests, per-call GCM IV/tag parameters, ECDH's strict point admission. The first resort whenever WIT is being designed or can still be fixed in place.
- Enhance the deficient implementation transparently, where the enhancement is logic and shape — never crypto, and never key-material synthesis (the jco host's raw-import strictness checks and Ed25519 verification normalization are the precedents). Artifact: conformance cases pinning the now-uniform behavior. Distinguish cost-infeasible from principle-infeasible when declining this strategy: a maintenance veto (sha1dc in JS) is revisable; secret material transiting an attacker-observable timing domain (HChaCha20 in JS) is not.
- Narrow uniformly where the outlier capability is questionable on its own merits (the AES-GCM IV window). The capable implementations reject what the weakest cannot serve; the contract states the narrowed domain. Artifact: uniform must-reject conformance cases.
- Record latitude when neither is feasible at acceptable cost: shrink the contract, not the implementations. State the portable core (guaranteed-import, guaranteed-reject) and mark the middle "do not rely on either behavior" at the definition site. Artifact: the WIT clause plus the conformance exclusion's recorded rationale.
- Isolate behind a gate or a withheld export when a whole
capability cannot be uniform:
@unstableplus aconformance/driver-ct/targets.tomldeclaration (sha1-checked), or world-level absence failing atwac plug(class D). Artifact: the gate and the targets.toml line. Everymissing-featuresentry must name a gated or structural feature — an ungated runtime divergence is strategy 1–4's job, not a declaration's.
Format admission (wit/README.md, "Design notes") is a default, and
strategies 2 and 3 may deviate from it on a risk ladder, provided the
deviation only ever narrows admission — a host pre-check may reject
what the platform would accept, and must never admit, transform, or
manufacture material the platform would refuse. In ascending order of
scrutiny: constant-shape checks (lengths, prefix bytes, fixed-offset
compares against known encodings) are free and precedented; shallow
fail-closed structural checks need a recorded risk note and vector
coverage for the check itself; full-format re-validation is presumed too
costly (the checker becomes a second implementation with its own
divergence); transforming or synthesizing key material is prohibited
absent an exceptional recorded ruling.
Prerequisites: Rust via rustup (toolchain + wasm target pinned in
rust-toolchain.toml), wasm-tools, just, Node 24+ with npm for the
jco path. Run ./scripts/setup.sh once (idempotent; SKIP_NODE=1 to
skip the npm install). The polymorph:test stack arrives two ways, both
naming one release: the crates are a git dependency pinned by rev in
the root Cargo.toml (enforced by Cargo.lock; conformance-ct::_ct-tools
cargo-installs the component-test/ct-runner binaries at the same
locked rev), and the JS runner core is jsr:@polymorph/test's
npm-compat package as @jsr/polymorph__test dependencies (scoped to
npm.jsr.io by each tree's .npmrc) in the five npm trees. To bump: component-test pins bump <rev> --cargo-toml Cargo.toml --workflow .github/workflows/ci.yml with the
release-tag commit, then the follow-ups it prints (cargo update), and
set the matching release version on each @jsr/polymorph__test dependency + npm install; the
_ct-tools pins gate, conformance-ct::runner-js-pin-check, and the
actions' own ref checks fail on skew.
To develop against a local component-test checkout, add a temporary
override to the root Cargo.toml (do not commit it):
[patch."https://github.com/polymorph-components/polymorph-test"]
component-test-sdk = { path = "../component-test/crates/component-test-sdk" }
component-test-runner = { path = "../component-test/crates/component-test-runner" }and point CONFORMANCE_CT_TOOLS at a directory with your own
component-test and ct-runner builds for the driver-ct recipes.
The justfile is the single entry point; run just to list
recipes. Component-scoped recipes live in module justfiles colocated with
their component (conformance/driver-ct/justfile, js/componentize/wpt/justfile, …),
declared as modules at the root: invoke them as just <module>::<recipe>,
list one module with just --list <module>, or work from inside the
component's directory, where just resolves its local justfile directly.
Shared building blocks (the wac plug composition, the guest/provider
builds, the CI/opt-in predicates) live in justfile.shared.just, imported
by the root and module justfiles; its recipes anchor every path on the
repository root, so they behave identically from any importer.
.github/workflows/ci.yml runs the same recipes (the gha module —
.github/justfile — holds the CI job entry points and other
workflow-only plumbing).
.github/workflows/timing-lab.yml runs the weekly lab — the timing lab and
the mutation run (just mutants) — schedule-only, because a statistical
experiment cannot gate pull requests (see timing-lab/README.md,
"Automation") and a full mutation run costs hours.
Run the recipes that cover what you changed, and fix anything they report.
just check is the fast gate; just ci runs the core CI jobs' checks.
(CI runs more than just ci can: the dedicated engine-leg jobs — the
Firefox conformance leg, the macOS WebKit conformance and parity legs —
and the cross-target conformance-aggregate job.)
| Recipe | Run it when you change… |
|---|---|
just fmt-check |
any Rust source (formatting). |
just clippy |
any Rust source (lints the guest on its wasm target too). |
just validate-wit |
any .wit file. |
just test |
any Rust host/guest code (includes the guest-under-Wasmtime integration test). |
just demo::build-component |
the crypto-demo guest or its WIT. |
just demo::test-composed |
the polymorph-webcrypto-guest-provider provider, the demo driver, or any WIT (composes guest + provider + driver with wac plug and runs under wasmtime). |
just componentize::typecheck |
the webcrypto-componentize library, or wit/extension-conditions.json. Asserts its exported surface against the Web Cryptography API definitions TypeScript ships, and its extension-condition table against the wit/ registry; no component build, nothing generated. |
just componentize::test |
the webcrypto-componentize library, the componentize-demo guest, the in-guest provider, or any WIT. Gates in CI. Componentizes the JS demo guest from your tree (with the downloaded, digest-verified componentize-js — see the WPT row for the pin mechanics), composes it with the in-guest provider and driver, and runs it under wasmtime. The behavioral gate on the shim's checks the WPT census cannot observe (the SHA-1 collision postures, the extension-error transport). |
just wpt::test |
the webcrypto-componentize library, its wpt/ harness or vendored files, the in-guest provider, or any WIT. Gates in CI. The runner is componentized from your tree in seconds; the componentize-js build it needs is downloaded and digest-verified (js/componentize/wpt/component.sh), never compiled here. Changing js/componentize/componentize-js.rev triggers the componentize-js-toolchain workflow; this check then fails until that publishes and just componentize::update-toolchain-digest records the new digests. Intentional changes to the test census also need just wpt::update-expectations. |
just conformance-ct::all |
any host/guest behavior the tests assert — the WIT surface, an implementation, the conformance suites/vectors/translation policy, or driver-ct/targets.toml. Runs the wasmtime-rustcrypto, composed, jco-node, and polyengine-deno targets always (Node 24+, Deno), the jco-browser and polyengine-browser legs under CI or CONFORMANCE_BROWSER=1, and the jco-firefox leg under CONFORMANCE_FIREFOX=1 (in CI it is the dedicated conformance-firefox job — Firefox needs a runner to itself), aggregating against the committed lockfiles and target manifests and building the compat matrix (results/compat.json); the jco-webkit leg runs only as the macOS CI job, and CI's conformance-aggregate job re-aggregates all eight targets, diffs the committed matrices (matrix-check), and gates the compat registry (compat-check --require-all). Intentional case changes also need just conformance-ct::lock-update and just conformance-ct::matrix-update — the latter from a full run, which only CI can produce (the WebKit leg needs macOS), so in practice just gha::update-matrices-from-ci (copies the matrices from the branch's CI conformance-results artifact). |
just demo::transpile |
anything affecting the component's interfaces, or the transpile flags in examples/jco-demo/package.json. |
just jco::test-host |
the jco host's input-buffering admission subsystem (configure, the admission queue). Runs webcrypto.js directly under node --test; the conformance suite cannot reach this code, since its workers each run their cases sequentially against their own host instance. |
just jco::typecheck |
the jco host (webcrypto.js), its world, or any WIT. Regenerates the interface definitions and type-checks the host against them; no component build. |
just demo::test-node |
the jco host (webcrypto.js) or the component it runs. |
just wpt::parity |
the webcrypto-componentize library, its wpt/ harness or vendored files, the jco host, or any WIT. Gates in CI (the jco job). Runs the vendored WPT suites against the platform's own crypto.subtle and through the jco-transpiled shim, holding the round trip to the baseline's pass set; the known losses are pinned in js/componentize/wpt/parity/losses.js. Intentional loss-set changes need just wpt::update-losses. Needs Node 24+ and the pinned componentize-js (downloaded, like the composed WPT gate). |
just wpt::parity-firefox |
the same surfaces as just wpt::parity. Gates in CI (the jco job); locally opt-in via WPT_PARITY_FIREFOX=1. The same two legs run in headless Firefox (Playwright's pinned build, Gecko's JSPI pref) against the engine's own ratchet, js/componentize/wpt/parity/losses-firefox.js — loss sets are per-engine facts, so intentional changes need just wpt::update-losses-firefox. Needs Playwright Firefox (cd js/componentize/wpt/parity && npx playwright-core install --with-deps firefox). |
just wpt::parity-chromium |
the same surfaces as just wpt::parity. Gates in CI (the jco job); locally opt-in via WPT_PARITY_CHROMIUM=1. Like the Firefox row, in Playwright's pinned Chromium against js/componentize/wpt/parity/losses-chromium.js; intentional changes need just wpt::update-losses-chromium. |
just wpt::parity-webkit |
the same surfaces as just wpt::parity. Gates in CI as its own macOS job pair (no componentize-js toolchain exists for darwin, so an ubuntu job builds the page artifacts and hands them over). The ratchet js/componentize/wpt/parity/losses-webkit.js is recorded from Playwright WebKit on macOS — Apple's crypto backend, the mobile-Safari proxy; the Linux port serves less and crashes, so record intentional changes without a mac via just gha::update-webkit-losses-from-ci (the CI job's records artifact) or optimistically via just wpt::predict-losses-webkit (the Chromium delta; a miss fails the next run, never mispins) — just wpt::update-losses-webkit needs a mac. |
just check |
broad Rust/WIT changes — the quick gate for most commits. |
just ci |
anything touching the guest, jco host, or WIT. |
Behavioral changes must keep all three implementations in sync: the
conformance tests (just conformance-ct::all) gate the wasmtime-rustcrypto,
composed, and jco targets, and the same guest component must report every check
passing under just test (Wasmtime), just demo::test-node (jco), and
just demo::test-composed (in-guest). When adding behavior, extend the
conformance suites (vectors or
probes), not just the demo guest — an algorithm interface is not done until
its vector cases exist (see conformance/README.md, "Growing the suites")
and the WPT groups observing it are vendored with their in-subset tests
passing (see "WPT fidelity is a first-class design constraint" above).
Requests arrive with a reason attached — this is inefficient, this leaks, this type would make the mistake unrepresentable. The reason is a claim about the code, and it can be false while the request still points at something real. Establish that it holds before writing the change, and if it does not, say so first.
What this guards against is silent repair: noticing the premise is wrong, quietly designing around it, and shipping something that works. Working code then reads as confirmation of reasoning that was never tested, and the next decision builds on it. A contradiction turned up while researching is a result to report, not an obstacle to route around.
Two claims usually need separating, because a request tends to fuse them: what is wrong with the code now, and what the proposed remedy fixes. They are often both true of different problems. A wrapper type that makes an unsafe read impossible does not thereby remove a redundant copy — and adopting it can preserve the copy untouched while appearing to answer the complaint. Name which property the change actually buys.
Every WIT comment is a doc comment: bindings generators project it into library documentation, so its audience is the package's consumers — from experienced cryptographic engineers to junior general software engineers — not this repository's contributors.
- Package-wide contracts live in
wit/README.md, not in doc comments: the streaming contract, the key-options contract, extractability, getter conventions, the JWK contract, the error contract, the timing-channel policy, design notes, and the terminology glossary. A doc comment states what is specific to its item and links to the README section by name (e.g.see `README.md`, "Streaming contract") for the rest. Never restate a shared contract in full at a use site; never let a package-wide contract live only inside one item's doc. - Order within a doc comment: basic usage first; then the
crypto-safety-critical contracts (as a
Security:bulleted block when there is more than one point — the bullets themselves visually bracket the section); then other details. The highest-impact caveat (nonce uniqueness, verify semantics, unverified-plaintext rules) must never sit mid-paragraph behind mechanics. - Use Simplified Technical English as guidance: short sentences, active voice, one instruction per sentence, consistent terms. Dense security terminology buries the contract it is meant to convey.
- Terminology (mint, capability, unrepresentable, IKM, …) is defined
once in
wit/README.md's glossary — brief descriptions linking to authoritative web sources (Wikipedia, RFCs, the W3C spec) — and doc comments rely on it rather than re-explaining terms inline. - No repository-internal content on the package surface: doc comments
must not name this repository's implementations, shims, test harnesses,
issues, or design history. Implementation-specific facts are phrased
neutrally ("providers in attacker-observable timing domains…"); design
rationale goes to
wit/README.md's "Design notes" or the issue tracker. The "answers to an objection" rule below applies with extra force here: a consumer never saw the review that prompted the sentence.
Code comments describe what something is or does, not the process by which it was arrived at. Rationale like "we removed X because Y" belongs in commit messages or PR descriptions, not in source files.
A comment defending the presence of ordinary code is the same mistake in a
subtler form. Conventional things — a Debug impl, a prefixed error string,
a derived trait, an attribute the API guidelines call for — need no defence;
explaining why one is there implies it is unusual and sends the reader
looking for a catch that is not there. Comment what a reader could not
predict: an invariant, a hazard, a deliberate departure from the obvious
choice, a constraint imposed from outside the file.
The giveaway is the shape of the sentence. "Without this, a consumer cannot…", "otherwise a caller has no indication…", "this is not merely…" are answers to an objection, and the place to answer an objection is where it was raised — the pull request. "This holds because…", "X must be Y since…" state what is true of the code as it stands, which is what survives once the discussion is forgotten. If a comment would read oddly to someone who never saw the change that introduced it, it is in the wrong place.
Guards are the exception that proves it. A test, a lockfile, an assertion exists because of the failure it prevents, so saying what it catches describes what it is — and reads the same to someone who never saw it added.
Docs state invariants, not inventories. Never embed values a build or test
run computes — case counts, check counts, probe indexes. If a number
matters, a gate asserts it (e.g. the demo harness's expected-summary check);
if it doesn't, omit it. Machine-derived counts belong only in generated
artifacts like conformance/driver-ct/matrix.md.
Three factors decide how much lands in one PR. They pull in different directions, so they bind in this order.
-
Necessity. Changes that cannot land separately without leaving
mainworse between them — a stated contract the tree violates, a fix that activates a latent defect elsewhere, a gate red until the counterpart arrives — go in one PR, whatever that does to its size. This repository has a standing instance: the conformance suites gate all implementations against one behavior, so a change to the package surface is co-dependent across the WIT, every implementation, and the SDKs by construction. Name the co-dependence in the description; a reviewer who cannot see why the pieces are inseparable will reasonably ask for the split. -
Cohesion. One decision per PR: the description should be a single ruling plus its consequences, however many files those touch. "And also" is the tell that two PRs are sharing a branch. Cohesion caps what a PR may contain — it never forces changes together. One decision whose consequences land safely apart (say, in two implementations that do not gate each other) is two PRs, not one.
-
Review time. Within what the first two allow, smaller is better: the budget being spent is a human's attention on the diff. The converse also holds and is not an exception — many nearly identical changes (a getter added to every key resource, a signature migrated across its call sites) are one PR, not many, because near-identical diffs review sublinearly: the reviewer verifies the pattern once and scans the instances, while a PR apiece pays full cost in CI and context each time and lets the pattern drift between them. The test is textual similarity of the diffs, not thematic similarity of the work — two subsystems getting "the same treatment" through different mechanisms are two PRs.
Open review findings and design decisions live in this repository's GitHub
issue tracker (gh issue list), not in a TODO file. Before starting work
that touches an area, search the open issues — some encode contract
decisions (e.g. stream-failure semantics) that the change should resolve,
not work around.
Close issues through PRs. When a PR fully resolves an issue, put a standard
closing-keyword line (e.g. Fixes #N, Closes #N) in the PR description so
the merge closes it automatically and the cross-reference is recorded. When
a PR resolves only part of an issue, do not close it: tick the resolved
checklist items and leave a comment naming the PR, so the issue always
reflects what actually remains. File new issues for new findings rather
than adding TODO comments or files. Issue numbers are never reused, so
closed numbers remain stable references.
- WPT platform parity through the jco path: the measuring harness exists
(
just wpt::parity— see js/componentize/wpt/README.md, "The parity gate") and pins the loss set; what remains is driving that set down. Growing toward parity is tiered — first behaviors the WIT already carries but the shim does not serve (more hashes, the usages model), then additive WIT surface (the RSA family and public-key wrapping — see the bullets below), and only then any future WIT-forced deviations, each of which needs an explicit ruling (the historical example, the GCM IV/tag contract, was resolved by enriching theaeadkind with per-call parameters). Class D is not implicated: the crypto runs host-side on the platform. A browser leg exists as the live parity page on the Pages site (js/componentize/wpt/web/ — see that README's "The browser parity page"), and gating Firefox, Chromium, and WebKit legs run in CI, each against its own pinned loss set (just wpt::parity-firefox/-chromium/-webkit; the WebKit leg runs on a macOS runner, where Playwright's WebKit uses Apple's crypto backend — the mobile-Safari proxy). - More algorithms per kind — each is a new minting interface plus constructors, never a generic change.
- More
signaturealgorithms; the per-algorithm-verify/-signminting split already carries the class-D policy (the in-guest provider exportsecdsa-verifybut notecdsa-sign). - Extending the timing lab (
timing-lab/) toward the class B/C surfaces' fine-grained leaks (its README documents the current detection limits). - Platform-backed key storage, so a guest can keep a non-extractable key
across instantiations instead of exporting and re-importing material (see
the design issue). Browser WebCrypto already supports it:
CryptoKeyhas structured-clone steps that carry[[extractable]]and[[handle]]into IndexedDB without exposing material. Two consequences already reach the stable surface. A retrieved key is a handle, so it may be usable and unreadable at once — every WebCrypto export operation can fail with "key material cannot be accessed" where sign and verify cannot, which is whyverifying-key.export-key-rawis fallible. And loading is a minting path whose caller supplied noextractableargument, which is why every gated key resource exposes anextractablegetter. Storage is also the first place where the implementations may differ in capability rather than in algorithm coverage — jco has IndexedDB, the in-guest provider has no store at all and would decline the interface atwac plugtime — so expect an optional target capability inconformance/driver-ct/targets.toml. - A FIPS 140-3 profile, kept possible though weakened (not implemented):
the remaining pieces are additive — a
moduleinterface for ISO 19790's mandatory services (show version, show status, self-test, zeroization) plus approved-service indication, and wrapped key export. The approved-mode AES-GCM seal is no longer expressible: SP 800-38D forbids externally supplied GCM encryption IVs in approved mode, and theaead-internal-noncekind that carried the module-internal-IV seal was cut with the scope narrowing — its design is preserved in issue #272. Do not reintroduce WIT contracts that mandate non-approved behavior (the HMAC import doc deliberately permits policy-based rejection of short keys, for this reason); a FIPS profile is then a provider exporting only approved algorithm interfaces — enforced atwac plugtime like the timing-channel class D policy.