Design decisions
The architecture decision records, each in one plain-English line, with links to the full text.
Seven architecture decision records define the protocol boundaries this project is built on. They are short, dated documents in docs/adrs/, and they are the reason several parts of the API look the way they do rather than the more obvious way. Each one below gets the problem it was written to solve, what was decided, the consequence you can actually see when you use the crate, and its status line exactly as the file carries it.
Readiness is tracked per decision, and the two states are not decoration. Implemented, verification pending means a working reference implementation exists and its required-verification list is written down, while at least one gate on that list has no recorded evidence. Verified means those gates completed and the pull request, commit and CI run are recorded. Guarantees and verification covers what that evidence consists of.
The seven foundation records#
| ADR | Decision | Status |
|---|---|---|
| 0001 | Separate interface, Contract, and source-bundle identities | Implemented, verification pending |
| 0002 | Version schema, semantics, and canonical bytes independently | Verified |
| 0003 | Make validated artifacts and provenance binding explicit | Implemented, verification pending |
| 0004 | Resolve imports through a hermetic capability boundary | Implemented, verification pending |
| 0005 | Bound all untrusted work and avoid recursive execution | Implemented, verification pending |
| 0006 | Use a lossless tagged HostValue ABI | Implemented, verification pending |
| 0007 | Give artifacts whose exact octets must be committed to a detached identity | Implemented, verification pending |
ADR 0001 — Three identities, not one#
Problem. A single digest over the type graph and actor cannot safely serve interface-compatibility caches, artifact registries, provenance binding and human-facing package identity at the same time, because those four uses need different equality claims.
Decision. Expose three domain-separated content identifiers over canonical bytes. interface_id covers only the graph reachable from the actor. contract_id covers the complete canonical Contract, declarations and their names included. source_bundle_id covers the normalized logical source IDs, the source bytes and the import edges. The first two are semantic; the third is a raw-source content identity, which is precisely why formatting and comments move it. No unkeyed content ID authenticates anything by itself.
What you see. contract_id() returns &str and interface_id() returns Option<&str> — an actorless Contract has no interface identity at all, and validation rejects one that claims otherwise. A persisted or cross-process type reference is { contract_id, type_ref } rather than a bare index, and method selection is { contract_id, method_name }, because a TypeRef is document-local. Adding a declaration the actor never uses moves contract_id and leaves interface_id alone; renaming a declaration does the same.
Status: Implemented, verification pending. Read the record.
ADR 0002 — Version the schema, the semantics and the bytes separately#
Problem. The JSON shape, the interpreted Candid type system and the byte-level canonicalization rules change for different reasons; one version number would force every consumer to treat every change as the same kind of break.
Decision. Every persisted Contract envelope declares four markers, each governing its own concern, and unknown versions or profiles fail closed. The canonicalization specification — not the Rust code — is normative, and any observable change to the bytes requires a new profile name rather than an edit to this one.
{
"format": "candid-core",
"format_version": 1,
"semantics_profile": "candid-1",
"canonicalization_profile": "candid-core-canon-1"
}What you see. Those four keys are at the top of every Contract document you will ever read from this project, and a document carrying an unrecognised value for any of them is rejected rather than best-guessed. A parser bug fix can be recorded in producer metadata, which participates in no semantic identity; a semantic change needs a new profile or an explicit compatibility ruling.
Status: Verified — the only one of the seven. Read the record.
ADR 0003 — Validated and merely decoded are different types#
Problem. A type whose name promises validity could be constructed by hand with public fields, and a provenance sidecar could be paired with an unrelated Contract without anything noticing.
Decision. Split the model so that "decoded but unchecked" and "validated" are different Rust types with different names. RawContract and RawSourceInfo are plain serde DTOs with no validity claim; Contract, SourceInfo and Compilation have private fields and are reachable only through constructors that validate, canonicalize and check identities. ContractDraft is the producer-side entry point and carries no identity fields at all, so a draft cannot hold a fake one. Core structs stay closed with unknown keys rejected, and ecosystem metadata moves into a separate namespaced extension envelope.
What you see. Contract, ContractEnvelope, Compilation and HostValue deliberately do not implement Deserialize. A trait implementation has no argument position for a resource policy, so it could only ever decode under limits the library picked for you. Untrusted JSON reaches these types through bounded entry points instead, and a plugin cannot smuggle a UI hint into the core by adding a JSON key.
let compilation = compile_did("service : { ping: () -> (nat) query };")?;
let canonical_json = compilation.contract().to_json_pretty()?;
let accepted = Contract::from_json(&canonical_json)?;
let mut injected: serde_json::Value = serde_json::from_str(&canonical_json)?;
injected["widget"] = serde_json::json!("date-picker");
let rejected = Contract::from_json(&serde_json::to_string(&injected)?).unwrap_err();
println!("unknown core metadata rejected: {rejected}");Status: Implemented, verification pending. Read the record. More on this in The trust boundary.
ADR 0004 — Imports resolve through a capability, never ambient authority#
Problem. The old file compiler walked imports for provenance and then handed the job to a checker that read them again, producing two snapshots of a directory, host-specific paths and no single place to impose import policy. That is unusable for browsers, agents, sandboxes and reproducible builds.
Decision. Compilation with imports requires an explicit SourceResolver capability supplied by the caller. SourceId is a normalized logical URI with a platform-independent / grammar — not an absolute host path — and the resolver produces one immutable bundle that the checker and the provenance collector both consume.
pub fn compile_with_resolver(
entry: &str,
resolver: &dyn SourceResolver,
options: CompileOptions,
context: &RuntimeContext,
) -> Result<Compilation, CompileError>What you see. There is no entry point that quietly reads your working directory. MemoryResolver compiler serves tests, editors, agents and network-fetched bundles; WorkspaceResolver filesystem-compiler is rooted at a directory you explicitly authorize and rejects absolute imports, parent escapes and symlink escapes by default. The same code compiles a multi-file bundle in a browser, and a host can put a real permission prompt in front of the resolver.
Status: Implemented, verification pending. Read the record. More in Sources, imports and provenance.
ADR 0005 — Bound every piece of untrusted work, and never recurse on input#
Problem. Valid structure is not the same as safe cost. Unbounded input, graph refinement, recursive walks and import expansion can exhaust memory, CPU or the call stack even when every byte is well-formed.
Decision. Every public parse, compile, validate and canonicalize entry point accepts a resource policy, directly or through a context. Graph and import algorithms use explicit work queues rather than call-stack recursion. Exhaustion fails closed with a stable resource_limit_exceeded diagnostic carrying resource, limit and the observed value, and no partially validated Contract is ever returned. Default numbers live in a versioned operational profile rather than in the Contract format, so a host can pick different budgets without moving a single identity.
What you see. The *_with_limits and *_with_context pairs across the API, a Limits type with private fields built from a profile plus with_* builders, and a portable serialized policy that names its profile and carries only explicit overrides:
{ "version": 1, "profile": "interactive_v1", "overrides": {} }An override the platform cannot represent is rejected with a structured error, never truncated. LimitsProfile::InteractiveV1 is the only released profile and its numbers are frozen — future tunings become new profile names.
Status: Implemented, verification pending. Read the record. Every value is listed in the limits reference.
ADR 0006 — Values get a lossless tagged ABI, not convenient JSON#
Problem. Ordinary JSON cannot faithfully carry arbitrary-precision nat and int, all 64-bit integers in JavaScript, floating-point bit patterns, principals, option presence, field IDs or variant tags. Picking a convenient shape first would bake lossy coercions into every SDK, form and agent tool built afterwards.
Decision. A closed, explicitly tagged HostValue algebra that round-trips exactly. Floats keep their IEEE bits, so NaN payloads and signed zero survive; arbitrary integers use canonical decimal strings; record fields use authoritative u32 IDs, with labels remaining provenance. The core validator performs no coercion, no UI defaults, no tuple guessing and no missing-option repair.
{ "kind": "nat", "value": "340282366920938463463374607431768211456" }
{ "kind": "float64", "bits": "7ff8000000000001" }
{ "kind": "variant", "id": 24860, "value": { "kind": "text", "value": "ok" } }What you see. The portable ABI is verbose on purpose, and a friendlier JSON view is somebody else's layer. Validation is directed by the Contract graph through a contract-bound selector — validate_host_value(&contract, &selector, &value, &limits) — so a value is never checked against a type that arrived from the same untrusted document. host-value adds only a principal library to the base graph, so a host that validates values but never compiles DID source builds no parser.
Status: Implemented, verification pending. Read the record. More in Host values.
ADR 0007 — A fourth identity, for when the claim is about a file#
Problem. ADR 0001 originally recommended contract_id for "registries, persisted references, signatures, and extension envelopes". Those are not the same claim, and contract_id supports only one of them: it excludes producer, it is computed over the Contract alone so envelope extensions and a provenance sidecar sit outside it, and canonical bytes are not the only encoding of a Contract. A registry entry or signature keyed on it therefore commits to strictly less than the file the publisher believes they shipped.
Decision. Add a fourth identity that is explicitly not semantic: a detached, exact-octet artifact identity over a frozen per-kind domain. Five properties are load-bearing — it is detached (returned to the caller, never written into the artifact), over exact octets (never parsed, canonicalized or re-encoded), kind-separated, bounded like everything else, and deliberately narrow: no signer model, no key format, no registry protocol, no verification helper.
pub fn artifact_id_with_limits(
kind: ArtifactKind,
bytes: &[u8],
limits: &Limits,
) -> Result<String, ContractValidationError>What you see. ArtifactKind is #[non_exhaustive] with three frozen variants, so naming a further kind later is additive. There is deliberately no default-limits convenience function — you pass a policy or a context, and nothing shorter exists. Identical bytes under two kinds are two different identities, and an artifact ID exists for bytes that would fail validation, because computing one is not a validity claim.
Status: Implemented, verification pending. Read the record. All four identities are compared side by side in Content-addressed identities.
Decisions that never became ADRs#
These are not in docs/adrs/, but they govern the shape of the project as much as the seven that did. Each one is recorded in a manifest comment, a module doc or a README where the decision is actually enforced.
Candid parsing and type checking belong to candid_parser#
This crate does not implement the Candid grammar or its type rules. It delegates both to the official candid_parser and projects the checked result into a Contract graph. The manifest says so where it hurts most — the crates.io category list deliberately omits parser-implementations, because claiming it would overstate what the crate is.
The consequence is about ownership. A disagreement about what a .did file means is an upstream question, answered through an exact-pinned dependency rather than by a reimplementation that can drift. What this project owns is the canonical encoding of the validated graph, the graph itself, and the identities over it. One narrow exception is written down and pinned: the Candid label hash is reimplemented in eight lines so base validation can check method IDs without linking a Candid engine, and it is cross-checked against the upstream reference in every feature configuration, including the one where the parser is absent.
The include allowlist is positive, so a new file ships only when named#
The published archive lists what goes in, not what stays out. A file added to the repository tomorrow is outside the archive until somebody names it in Cargo.toml on purpose. The failure direction is the whole point: an exclude list ships a mistake, an include list omits one, and an omission is caught by the packaging gate while an accidental inclusion is permanent once published. docs/**/*.md rather than docs/** is deliberate too — it keeps editor and OS droppings out of a release even when a contributor's working tree has them.
publish = false on the generator crate, because a registry name is permanent#
Three of the four Rust crates in the repository carry publish = false. For candid-core-ts the manifest states the reason directly: candid-core-ts is a working name, not a published one, and nothing may publish the crate until its registry name is decided deliberately, because crate names on crates.io are permanent exactly like versions. candid-core-wasm is unpublishable for a related reason — its real deliverable is an npm package, not a crate. The same caution governs npm: the first publish of a new npm name is treated as the owner's explicit act, which is why each of the two npm packages was prepared in the repository before its first publish.
agent-js wire compatibility is an explicit non-goal#
The generated TypeScript describes the modern domain rather than the runtime shapes agent-js produces. opt T is T | null, variants are { tag, value } discriminated unions, every vec nat8 (blob) is Uint8Array, and nat, int and the 64-bit integers are bigint. Three consequences are deliberate rather than accidental. A principal is its canonical text, a string branded Principal, not an SDK object: plain data serializes, clones and compares with ===, where the object shape could do none of the three, and encoding accepts exactly that text. An opt whose inner type can itself be null — another opt, null, reserved — is boxed as { some: T } | null, because T | null cannot distinguish None from Some(None) there. It failed closed at first; interfaces such as Internet Identity's config use all three states, so the refusal was replaced by the box, and only those opts box. And using these types against a live agent, or an SDK principal, needs a boundary conversion: the types describe the domain, not the transport.
No network access anywhere in the library#
The crate never fetches anything, never resolves asynchronously, never consults a registry and never generates bindings on your behalf. Bytes enter through the resolver capability you supply and through nothing else, and network access is never implicit. The supported boundary is synchronous immutable source data supplied by the host — which is what makes compilation reproducible, testable with no filesystem at all, and safe to put behind a permission prompt.
The fuzz and wasm crates are their own workspace roots#
fuzz/ and crates/candid-core-wasm/ each declare their own [workspace] table and are named in the root manifest's exclude list. That keeps libfuzzer-sys and wasm-bindgen out of the published crate's resolved dependency graph and out of Cargo's feature unification entirely — and it means fuzz/Cargo.lock is its own tracked graph, which CI checks for freshness because cargo fuzz has no --locked flag of its own. The two workspace members are listed explicitly with no globs, so a stray directory can never become a member by accident.
Next: Guarantees and verification shows which of these decisions has independent evidence behind it and which does not. Status, versions and releases covers the pre-1.0 policy that all of them sit under.