20 KiB
L1: data and contracts (spec)
Status: approved (owner, 2026-10-22), rewritten 2026-10-22 as the single source for a from-scratch implementation
(the first implementation was discarded for code quality). Every rule here is decided; the decision log
(packages/chord/docs/next/decisions.md) has the reasons. Code style: the section "Chord v2 Code Style" of the root
AGENTS.md.
What L1 is
Everything about data that the upper layers need and that does not depend on hosts, objects or links: how an object type is declared and evolves (contracts), what a value is, how params become an identity, how arguments are checked, what a principal key is, what an error is. Pure functions, types and weak caches; no I/O.
Source: packages/chord/src/next/contracts/ (declarations) and packages/chord/src/next/data/ (per-call checks,
principals, errors), split into files by concept. Everything else under src/next/ is the frozen old kernel: never
imported, never edited. Reused: src/context/ (Context, BACKGROUND_CONTEXT, context.telemetry) and
src/json.ts (JsonValue), both unchanged. Schemas were TypeBox 1.3 when L1 was built; since the schema switch (2026-11-01) they are pi-schema's plain JSON Schema (design §3 "Values"). The TypeBox details below are historical.
1. Values
type Value = null | boolean | number | string | Uint8Array | Value[] | { [key: string]: Value };
- JSON plus byte leaves. No
undefinedinside values, no non-finite numbers, no foreign prototypes, no accessors. - Values from links are already strict JSON plus bytes; values from same-host code are plain data by convention, immutable once handed over. L1 never walks, copies or freezes a value to enforce that. State is never checked.
canonicalJson(value: JsonValue, context): sorted object keys,-0written as0. Used only for principal and revocation keys, which are JSON (no bytes), so it lives with principals.
2. Contracts: declarations
A contract is the declared type of an object: name, kind, version, params, members. Two kinds:
const Notes = remote("notes", {
version: 1,
params: { session: str() },
variants: {
editor: { state: { notes: arr(Note) }, methods: { add: method(obj({ text: str() }), str()), clear: method() } },
reader: { state: { notes: arr(PublicNote) } },
},
});
const Info = local("app.session-info", {
version: 1,
params: {},
members: { init: obj({ title: str() }), clear: method() }, // a schema is state, method(...) is a method
});
remotehas variants (per-principal views, at least one);localhas one flatmembersmap and no variants.method(),method(args),method(args, result),method(undefined, result). An omitted side isnullon the wire: a no-argument method is called withnull.- Variants (D11): a member name has the same kind (state or method) in every variant that has it; a method has the
same signature (args and result) in every variant; state schemas may differ per variant (redaction). Enforced in the
types of
remote(...)and at declaration (for untyped callers). - Closed argument objects (Q12): every object schema inside a method's arguments sets
additionalProperties: false, nested ones included, rawType.Objectincluded; record schemas (map) are allowed. Checked once per declared or edited method, at declaration.obj()always produces closed objects and cannot be opened. - Declaration checks (the types cannot express them for JavaScript callers): nonempty name, version a positive safe
integer, no unknown declaration keys, params policy (section 4), D11, closed arguments, the schema vocabulary
(below). Each fails with an
Errornaming the path (notes.editor.methods.add.args). - Declarations are immutable by convention: a contract holds Chord-owned snapshots of its schemas (copied once at
declaration, so later changes to the app's schema objects cannot desync validation from the fingerprint), never the
app's schema objects. Nothing in L1 is frozen (owner, 2026-10-22). Contracts are branded: hand-built or spread copies are refused by
evolve,checkAcceptsandbindProvider(a privateWeakMapfrom contract to its data). - Placement is not part of L1 contracts: the
placementfield arrives with host kinds (L7) and is never part of the fingerprint.
Schema helpers
str({ min, max, pattern }), num({ min, max }), int({ min, max }), bool(), nul(), lit(v), oneOf(...literals),
union([...]), obj(props), arr(items, { min, max }), map(value) (string-keyed record), opt(schema),
bytes({ min, max }), unknown(), and type Static. They return ordinary TypeBox schemas, so Static and TypeBox's
compiler work unchanged. min/max mean string length, numeric bounds or array length.
bytes() is a Uint8Array leaf with optional length bounds, checked by instanceof and length, never enumerated.
Its enumerable form is the data marker { type: "bytes", minLength?, maxLength? }; the TypeBox check is attached
non-enumerably and rebuilt from the marker whenever a schema is snapshotted, so no callback is ever copied from app
data.
Vocabulary (rejected at declaration otherwise): data primitives, arrays and tuples, objects and records, unions and
intersections, $defs/$ref (use $ref for recursion), constraints (pattern, format, bounds, multipleOf,
uniqueItems, property counts), default, readOnly/writeOnly, annotations, bytes. Rejected: conditional and
dependent schemas, not, contains, propertyNames, unevaluated*, contentSchema, definitions, unknown
keywords, TypeBox's executable features (Unsafe other than bytes, refinements, transforms), accessors, symbol keys,
cyclic objects, non-JSON data.
Fingerprint
Every version gets a fingerprint at declaration: hash64 (a portable 64-bit hash, two FNV-style lanes, 16 hex
characters; not a security boundary) of JSON.stringify({ normalFormVersion, value }) over a normal form:
- object keys sorted;
requiredandenumsorted; union, intersection and tuple order kept (it is meaning); - annotations dropped on schema nodes only (
title,description,examples,$comment,deprecated); defaults, formats, patterns, bounds andadditionalPropertieskept; - params as ordered
[fieldName, schema]pairs (declaration order is identity order); members as sorted dictionaries; { kind: "remote", params, variants }or{ kind: "local", params, members }; placement excluded.
NORMAL_FORM_VERSION = 2 is exported; changing the normal form changes it and is a link-protocol change.
One schema-aware walk produces both the snapshot and the normal form.
Evolution (Q10)
A history starts with a base written in full (remote()/local() with an explicit version). evolve(prev, (e) => e...) appends the next version (prev.version + 1) with structured edits:
| Edit | Meaning |
|---|---|
state(variant, name, schema) |
change one state member (local: state(name, schema)) |
addState(variant, name, schema) |
add a state member |
removeState(variant, name) |
remove a state member |
args(name, schema), result(name, schema) |
change one side of a method in every variant that has it |
method(name, args, result) |
replace both sides |
addMethod(variant, name, args, result) |
add a method (local: no variant); to another variant only with the same signature |
removeMethod(name) |
remove a method from every variant |
- Params and the variant set never change by evolution. A change to either is a new history base: a new full declaration with an explicit version and no accepted older versions.
- A member's kind never changes, also not after a removal and re-add. Checks run after every edit.
evolvemust return the builder it was given (no foreign builders). Every version is materialized and fingerprinted at declaration, so an old plugin's copy of v1 has the same fingerprint as the history's v1.- The types track the whole history:
Contractcarries the version, the current description and every step with its edits, socheckAcceptscan require exactly the right mappings at compile time.
Accepted versions and mappings
A provider may accept older versions of its contract with mappings it owns:
const NotesV2 = evolve(NotesV1, (e) => e.state("editor", "notes", arr(NoteV2)).args("add", NoteV2));
const accepts = {
1: {
state: { editor: { notes: (current) => current.map(toV1) } }, // current member -> old member
methods: { add: { args: (old) => toV2(old) } }, // old args -> current args
},
};
checkAccepts(NotesV2, accepts); // { versions: [{ version: 1, fingerprint }] }
checkAccepts(contract, accepts) is pure: it validates the table without calling any mapping and returns the accepted
versions with their fingerprints. Mappings are direct (old to current, never chained). For accepted version v,
considering every edit after v, a mapping is required exactly for:
- a changed state member:
(currentMember) => oldMember; - a removed state member:
(currentVariantState) => oldMember(the whole current state of that variant), or"refuse"; - a method whose args changed:
{ args: (old) => current }; whose result changed:{ result: (current) => old }; both: both functions; - a removed method (from any variant that had it in
v):{ call: async (oldArgs, context) => oldResult }or"refuse";calland"refuse"are also allowed for changed methods.
Added members need nothing. No-op edits and changes made and reverted still require (identity) mappings: the check is
by edit, not by schema comparison. Unknown versions, the current version, missing mappings, unnecessary keys and wrong
shapes fail at declaration with the path (notes accepts[1].methods.add). An absent state or methods section means
empty and is allowed only when nothing is required there. The same rules hold at the type level (AcceptsTable).
Implementation<typeof Contract> derives the provider's shape: per variant, state getters () => value; methods
(args, context) => Promise<result>.
3. Principals
- The app's principal type
Pis plain JSON with akindfield: no schema, no version, never persisted by Chord (objects persist their own projection, e.g. chat'sAuthor). principalKey(principal, context, customKey?): defaultcanonicalJson(principal); an app may pass its own key function, which must be the same on every host. Cached per principal object inWeakMaps; a custom key function's cache is keyed by the function too, so a dropped function is never retained by a live principal.revocationKeys(principal, revokedBy, context): the canonical JSON of each JSON valuerevokedBy(principal)returns (host.revokeis L7).
4. Params and identity
const { params, key } = checkParams(Notes, { session: "S1" }, context); // throws invalid/params
const identity = identityKey(Notes, key, principalKey(principal)); // "notes|S1|<principal key>"
- Declaration policy: params are a flat record of required fields, each
str(),int(),bool()or a union of string literals (oneOf("chat", "agent")), constraints allowed; no defaults, optional, nested, bytes, numbers other than integers. Checked in the types and at declaration. checkParams(contract, raw, context): one compiled closed-object check ofraw(constraints enforced), the compiled validator cached per contract in aWeakMap. Then the key is the field values in declared order, each escaped (\and|), joined with|. It returns the caller's own object: no pre-check walk, no copy, no freeze.identityKey(contract, key, principalKey):name|key|principalKey, each part escaped;localtakes no principal key (by type). The key is an in-process map key; links carry the params themselves.
5. Arguments
const provider = bindProvider(NotesV2, accepts); // once per registration and reload
const checked = checkArgs(provider, "add", raw, boundVersion, ctx); // invalid/args with path
// { kind: "method"; args (current shape); result?: (current) => old } | { kind: "call"; args (old shape); call }
checkArgs(provider, "clear", null, 2, ctx); // no-argument methods take null
bindProvider(contract, accepts)runscheckAcceptsonce and keeps the app's accepts table as it is (immutable by convention; no copy, no freeze). It builds a private dispatch table, held in aWeakMapkeyed by the returned provider: accepted version -> method ->{ schema, validator, mapping }. Validators compile on first use per (version, method); a weak cache keyed by schema object lets equal schema objects share a compiled validator.checkArgs(provider, method, raw, boundVersion, context): three map lookups, one compiled check against the provider's schema for that version, then at most one directargsmapping old -> current. It returns a tagged value (owner, 2026-10-22):{ kind: "method"; args; result? }(args in the current shape;resultmaps the current result back for an old-version caller) or{ kind: "call"; args; call }(a replaced method: the checked old args and the provider'scall, which L4 invokes). Current-version calls are never mapped. Unknown method:invalid/member. Version not accepted, or"refuse":contract_mismatch. Mapping output is trusted.- Every caller is checked,
localincluded (Q15). Unknown fields fail (closed schemas); nothing is stripped, copied, frozen or defaulted. Results and state are never checked. - Memory: a binding lives as long as its registration and the calls in flight. No module-level strong cache holds a contract, binding, schema or validator.
- TypeBox keeps its last build in module-level accumulators: after every
Compile, reset them (ResetFunctions,ResetExternal, infinally), so no build state is retained.
A failed check
A failed compiled check uses TypeBox's public Errors(schema, value) and reports the first error. The error is
invalid/args or invalid/params, message args/text: <TypeBox's message> (fixed text for the two common cases:
args/extra: unknown field, args/text: missing), details: { path } with TypeBox's
instance path (/text, / for the root), including missing (required) and unknown (additionalProperties)
fields at their own path. TypeBox does not escape / and ~ inside keys, so a key containing them makes the path
ambiguous (owner, 2026-10-22: accepted; it is a diagnostic).
6. Errors
type ChordErrorCode = "invalid" | "contract_mismatch" | "forbidden" | "gone" | "not_ready" | "released"
| "too_large" | "unavailable" | "aborted";
| Code | Reasons |
|---|---|
invalid |
required: args, params, member, placement, reentrant, read_after_bulk, cycle |
contract_mismatch |
optional: updating |
unavailable |
optional: locked, park_timeout |
too_large |
optional: part |
| others | none |
- One
ChordErrorclass (anError), withcode,reason,message,details?: JsonValue. The constructor, instances and JSON form are tagged unions: a wrong pairing is a type error, and narrowing oncodenarrowsreason.toJSON()emits{ code, reason?, message, details? }; stack and cause never cross the wire. PROTOCOL_VERSION = 1is exported. Reasons and pairings are link protocol: adding or moving one bumps it. A test pins the table together with the version and fails with "bump PROTOCOL_VERSION" when they diverge. L6 compares the version in the handshake before accepting anything.chordErrorFromJson(raw):rawis fresh JSON from the link decoder (a trust boundary). Checks the envelope only: known code, a reason allowed for that code (required forinvalid), string message, no other keys.detailsis kept by reference: no walk, no copy, no counters.- Helpers:
isChordError(error, code?),invalid(reason, message, details?),asChordError(error, label)(wraps a foreign error asunavailable). FailureCause = { kind: "transient"; error } | { kind: "broken"; error }: the failing stage decides, never the error code.broken(cause)marks an open's failure as broken (bad configuration);openFailure(cause, label)turns an open's thrown value into the cause. Used by L4.- Plugin errors have their own shape (L4); they never use Chord reasons.
Cost per hop
| Hop | Work |
|---|---|
a get |
one compiled closed-object check, one joined key; no walk, no copy |
| a call | three map lookups, one compiled check, at most one direct mapping; no copy |
first get of a contract |
one compilation |
| first call per accepted version and method | one compilation (or a cache hit by schema object) |
| a failed check | one counted diagnostic walk (Errors builds the whole list on this rare path) |
| a first default principal key, a revocation key | one canonical walk |
| declaration | snapshot, closure check per method, fingerprint per version |
| binding | checkAccepts once, dispatch table built, nothing compiled |
| a state change | nothing in L1 |
Counters: chord.data.walks, chord.data.validations, chord.data.compilations are telemetry counters
(context.telemetry.metrics?.counter(name).add(1)), always on. The function doing the work counts it; call sites never
count for others. Tests assert them per hop with the in-memory telemetry adapter.
Footguns
- A provider must not mutate arguments or params it received (caller-owned, immutable by convention).
principalKeymust be the same function on every host; a different one splits identities silently.- Params keys must escape the separator, so two different params never join to one key.
- Mappings are direct: when version 3 arrives, every still-accepted version's mappings are updated (compose step
functions:
(old) => v2to3(v1to2(old))).
Tests (one group per mechanism, every case)
- Contracts: every declaration check with its error path; D11 (kind and signature across variants, at runtime and
in the types); closed arguments (nested, raw
Type.Object, records allowed, edits checked, state not checked); schema vocabulary (each rejected feature); bytes (marker, bounds, check rebuilt after snapshot, no callback copied); snapshots are independent of later changes to the app's schema objects; branding (spread copies refused). - Fingerprints: stable across key order and annotations; sensitive to defaults, bounds, union order, params order,
additionalProperties; the same v1 from a separate declaration; local vs remote differ; a fixed known hash for one fixed contract (protects the normal form). - Evolution: each edit; kind changes refused (also remove then re-add); params and variant set fixed; new bases; long chains (versions beyond 9, 99); history types.
- Accepts: every required-mapping rule above, at runtime and at the type level (
@ts-expect-errorfor missing, unnecessary and wrongly typed mappings);"refuse";call; direct mapping over several versions; no mapping is invoked bycheckAccepts. - Params: missing, extra and wrong fields with their paths; literal membership; constraints; the same key whatever the property order; separator escaping (no two params join to one key); declaration refusals.
- Arguments: unknown fields (nested too); no copy (identity of the passed object);
localandremote; bytes byinstanceofand length; old version checked with the old schema then mapped; current never mapped;nullfor no-argument methods; unknown method; unaccepted version;"refuse"; lazy compilation (counted once per version and method). - Principals: default key, custom key, caching (no second walk), revocation keys.
- Errors: tagged union in types (
@ts-expect-errorfor wrong pairings); JSON round trip; envelope checks (each refusal); details kept by reference; the pinned protocol table;FailureCause,broken,openFailure. - Cost guards: the counters per hop in the table above.
- GC: dropped contracts, bindings, dispatch tables, mappings, schemas and validators are collected after the last call settles; params validators per contract; custom principal-key functions dropped while their principals live; nothing retained by TypeBox's build accumulators.
Size
Target: well below 1,500 source lines for contracts/ and data/ together, tests excluded. The type-level history
and mapping types are the heaviest part; keep the rest lean.