Skip to content

13. Schema forms (amendment 2026-10-12, M1)

From M1, the merge of the IR JSON Schema (p1-ir-schema), the schema files in packages/core/spec/schemas/ir are normative for member shapes: names, types, optionality and union tags. The TypeScript sketches of sections 2 to 8 and 11 are informative; where they differ, the schema holds, and a change to it is an IR change PR (ADR 0005 section 1). packages/core/spec/schemas/ir/README.md gives the reader rules. The forms the schema chose, each with its reason:

  • Tagged unions. A union is a wrapper with discriminator and oneOf; each variant fixes its tag with const. Generated Kotlin and PHP models get a sealed hierarchy with an Unknown variant, and an unknown tag is named instead of failing as a generic schema error.
  • Every choice between shapes is tagged. principal is { kind: schema, schema } or { kind: variants, variants }; a signature key is { kind: value, value } or { kind: from, from }; unknownKeys of a signed identity is { policy: ignore | keep, reason }; an enum source is { kind: vocabulary, vocabulary } or { kind: enum, enum }; a Scheme is tagged on its kind: cookie { name, csrf? }, header { name }, query { name }, bearer { bearer? }, basic {}, signature { name?, signature } (the document-only bearer facts are BearerFacts, the verification SignatureVerify); an Identity is tagged on its kind: credential { schemes: { read, write, optionalRead } } or signed { signed, unknownKeys? }, so unknownKeys exists on signed identities only; idempotency and csrf are { kind: derived, ... } or { kind: exempt, reason, origin }; a text template part and an object template member name are { kind: text, text } or { kind: slot, ... }; Api.version is { kind: path, number } or { kind: label, label }. An untagged union becomes an all-optional bag in every generated model, which ADR 0003 refuses.
  • Five structs stay. A definition stays a struct only when one member decides which optional members are present and several of its values share one shape, so a union would repeat that shape once per value; its rule is an invariant that applies to known values only. ValueSource is { in, name?, prefixBits? } (by in: name for the named sources, none for the bare ones, prefixBits for address only; a scope’s { resolver: true } and a field right’s { holderField } are the in values resolver and holderField, with name); FieldWire is { kind, style?, explode?, brackets? } (the five query wire kinds carry all three, file and json none); a format meta member is { type, fill, source? } (source only for fill: source); HeaderDecl is { semantic, name?, in?, response?, request? } (versionToken carries response and request, every other semantic name and in); Cache is { family, directives?, anonymous, vary, etag, perCallerKind, noStore, origin, derivedFrom? } (directives for every family but never, which sets noStore; family is an open string, not an enum, and the invariant reads the one reserved name never). Each partition of a deciding member’s values is tested to equal the schema’s known list, so a new known value cannot miss its invariant.
  • Invariants. What the subset cannot state (no conditional keyword) has a stable id and is checked by checkIrInvariants in @seampact/spec/ir, owned by the finalize pass (p2-finalize): Name.status and its role, meta.source and fill, ValueSource members by in and their places, HeaderDecl members by semantic, Cache.directives and the never family, csrf on an unsafe cookie-scheme operation, FieldWire members, a property’s nullable, a named format’s own type, the kind of an id (ir.id.scalar), every catalogue id naming an existing entry (ir.ref.resolves), a core origin naming its declaration (ir.origin.core), a body proof’s input, wire form and name (ir.input-proof.input, ir.input-proof.wire, ir.input-proof.name), and from irRevision 4 a field right’s always (ir.field-right.always), a sort key’s right members (ir.sort-key.right), exact numbers in opaque values (ir.value.exact-number), the degraded header (ir.degradable.header), the deprecation headers and dates (ir.deprecation.headers, ir.deprecation.since), and the error role bindings and retry header (ir.operation.role-once, ir.error.role-params, ir.error.retry-header), and the no-store of a response that carries a sensitive field (ir.cache.sensitive-no-store), which from irRevision 6 also reaches a sensitive scalar use and a response header’s type, and the no-store of an error code whose params carry a sensitive value (ir.error.sensitive-no-store, irRevision 6). A new invariant is a tightening IR change (ADR 0005 section 4); relaxing one is breaking.
  • Open enums. A string with its known values in x-seampact-known, never a closed enum (ADR 0005 section 3). Every object, union and open enum is a named definition, so every generated type has a name.
  • Must-act definitions. x-seampact-must-act marks the unions and enums of a security boundary: Access, Requirement, RightSubject, Scheme, SignatureVerify and its SchemeKey, Identity, KeysPolicy, UnknownQuery, Idempotency, Csrf, RefusedCredential (onRefused), UnknownKeysPolicy, InputPolicy, and from irRevision 4 FieldRightAlways and ValueSourceIn (every value source is one a target must read: a right subject, a bucket key, a proof, a version). An unknown tag (a union) or value (an enum) there makes the IR invalid for every reader, since one that skipped it would generate a weaker check silently; a new tag or value there always comes with a feature name, and adding must-act to an existing definition is a tightening that names one too; before 1.0 the exact irRevision match guards them instead, as for FieldRightAlways and ValueSourceIn (ADR 0005 section 4).
  • TypeScript unknown arm. Each generated union is Known<Name> | UnknownVariant<tag>, the tag typed string & {}. TypeScript narrows no union holding that arm, so isKnownVariant narrows to the known variants first; a closed union would let an exhaustive switch miss an unknown tag at run time. A producer types each union value it writes as Known<Name> (satisfies), since <Name> also accepts the unknown arm; a closed writer view of the IR for the compiler passes is recorded for p2-pipeline, a package addition and not an IR change. Definition and variant names are the generated type names: renaming one, or switching a struct and a union, is breaking.
  • Body rule proofs (irRevision 2). A rule proof read in the body is an element of OperationInput.proofs, { name, schema: TypeUse, wire?: FieldWire, derivedFrom } with derivation rule.proof, in requirement order; the list is absent when the operation has none. A proof is identified by its name: two rules that read the same name with the same schema share one element, whose derivedFrom.from lists both; a different schema under one name, or a name equal to a property of the input schema or to a form field, is a compile error in access.rights naming both origins (invariant ir.input-proof.name). The input use’s unknown-key policy treats proof names as known, and keep never collects them (section 5). Every element is required and sensitive (ADR 0002 section 9), so it carries no optional or sensitive member. The input schema stays the authored one (a hoisted schema may serve several operations); a target renders the body as that schema’s properties followed by these fields and never re-derives them from rules. A proof read in a header stays a derived header parameter. A body proof requires the input to be a ref use of an object schema (ApiEmptyBody included): a union, map, array or scalar input with a body proof is a compile error in access.rights naming the rule and the route (invariant ir.input-proof.input). On a route without input that a body proof applies to (a POST action, ADR 0002 section 9), access.rights writes a proof-only input where tree left input absent: media application/json, schema { kind: ref, schema: ApiEmptyBody } (the core empty object schema below), rawBody true on a signed identity’s route, as derivation signed.identity writes it for a declared input, and false elsewhere, origin the first requirement, in requirement order, that brings a body proof, then proofs; on a signed identity’s route it also copies the identity’s unknownKeys, with its derivedFrom, onto that input (section 5), so every ref input of a signed route carries the identity’s policy; derive writes its bodyLimitBytes as for any input (ADR 0006 section 3). A reader therefore keeps one rule for every input: the input schema’s properties, then the proofs. A target generates a body carrying proofs under the operation’s input name, { segments: <the operation's name segments>, role: input } (ADR 0007 section 3, the name an inline input of that operation gets, UpdateTaskInput), whether the input is declared or proof-only. When the input is the operation’s own hoisted inline schema, which already has that name, the body type with proofs is that one type, the schema’s properties then the proofs, and no separate schema type is generated. Finalize computes the input name of an operation whose input carries proofs from its name, to run the id collision check of section 9: that name equal to another id, the operation’s own hoisted input excepted, is a compile error naming both (ADR 0006 section 3, finalize). In a form or multipart input a proof is sent as the other fields are, so it carries wire, the FieldWire an input field of its type use would get, never of kind file since a proof is no file: access.rights computes it with the function tree.nodes uses for fields, shared rather than copied; in a JSON input it has none (invariant ir.input-proof.wire, partitioned on OperationInput.media). Invariant ir.id.scalar types an InputProof.schema as it types a RuleProof.schema.
  • Origin.layer is closed and gains workspace (section 6), then core (irRevision 2): a declaration the compiler supplies, its pointer the one its declaration would have in a layer file, with no side-table entry (sections 6 and 11). Invariant ir.origin.core checks the entry of formats, errors or schemas a core pointer starts with exists in the document, unless the origin names another api; the rest of the pointer is not checked.
  • Core declarations (irRevision 2). The compiler supplies some declarations itself, each with origin core (section 6): the default codes of ADR 0006 section 6 in errors; the default format bare in formats, when no layer declares a format bare; and in schemas the params schema of each default code that carries data, the schema of one schema issue and the empty body schema. tree.schemas seeds the core schemas from the role data (derivationRoles and derivationCoreSchemaIds in @seampact/compiler, whose test keeps the list below equal to it), and finalize drops every core declaration no element references (section 2). The core schema ids are reserved: ApiSchemaInvalidParams, ApiForbiddenParams, ApiSortKeyForbiddenParams, ApiPayloadTooLargeParams and ApiRateLimitedParams (the ADR 0007 params names of api.schema_invalid, api.forbidden, api.sort_key_forbidden, api.payload_too_large and api.rate_limited), ApiSchemaInvalidIssue (one item of issues: { in, pointer, rule }), and ApiEmptyBody (an object schema with no property, the schema of a proof-only input). A reserved params id belongs to the params of its default code, whichever layer declares the code: a contract or pack that declares a default code with params takes the id (its params hoist under it, ADR 0007), and the core schema is not seeded, or is dropped; ApiSchemaInvalidIssue stays core while no layer declares or hoists that id, even after ApiSchemaInvalidParams is replaced, so the replacing params can reference it without declaring it, and once they are replaced a layer may take the id (its declaration replaces the core one); a declaration of a default code without params keeps the core params schema (ADR 0006 section 6). A schema declared under a reserved params id is refused even when the code’s params names it: only the inline params hoist takes the id. Any other schema declared or hoisted under a reserved id, ApiEmptyBody included, is a compile error naming the reservation and the fix, a schema of another name. The format id bare yields the same way: bare is core when no layer declares it, and a declared bare replaces it. A core declaration carries origin, never derivedFrom: no schema has derivedFrom, which stays on derived operation elements, properties and uses.
  • Name.status is an integer from 200 to 399, as @seampact/spec/naming checks. It couples the transport-neutral Name to HTTP statuses; that is accepted until a non-HTTP kind derives response names, which then adds its own qualifier as an IR change.
  • irVersion is an integer from 1, not the constant 1, so a reader names a later version (checkIrSupport) instead of failing on a generic schema error.
  • Numeric refinements (minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf) are decimal literal strings (^-?(0|[1-9][0-9]*)(\.[0-9]+)?$), one form for every scalar, so an int64 bound or a decimal step keeps every digit in every language; every IR number is an integer (section 10).
  • Integers in the safe range (irRevision 3). Every integer definition of the schema states a minimum and a maximum within -(2^53 - 1) to 2^53 - 1, the safe range of I-JSON (RFC 7493): a JavaScript reader holds each one exactly, and the Kotlin and PHP models read each JSON number literal as the correctly rounded double, as JavaScript does, refuse other number text, and refuse one that is not integral or lies outside the range, so no two readers read an IR integer differently. Numeric refinements are not IR integers: they stay decimal literal strings. Values held as opaque JSON (examples, default, extension values) are not IR integer members and are not bounded. From irRevision 4 an exact number in an opaque value has one form whatever api.wire.int64 says (invariant ir.value.exact-number): in Property.default, Property.examples, Param.default and a scalar use’s examples, an int64 or decimal use’s value is its format’s string form (ADR 0003 section 8), an int64 string not -0 and within -2^63 to 2^63 - 1, and any other integer use’s value is a safe integer; nested values, an array use’s items examples, FieldRight.params, Requirement.params, SortKey.params and extension values follow the rule unchecked. A server target converts the string form when it applies such a default.
  • nullable is a required member of a property and an optional member of every other type use (array items, map values, parameters, headers, bodies, opaque uses), as ADR 0003 section 6 allows it on any schema; from irRevision 6 an opaque value accepts null only with it (section 5).
  • Named formats. The scalars catalogue holds { id, name, type, directions, docs, origin }, type a scalar type use; a use of a named format holds its type and refinements inline and names it in scalar, so a target maps it to a native type or checks it with the portable keywords. format holds the portable formats of ADR 0003 section 8 only. Refinements is { minLength?, maxLength?, pattern?: { source, normalized }, minimum?, ... }.
  • Ids are type uses. Scope.idSchema, a resource subject’s idSchema and a rule proof’s schema are a TypeUse, not a SchemaRef: ADR 0002 writes an id as a scalar or a named format (org: { id: OrgId }), and a SchemaRef names an object, map or union schema of the schemas catalogue only. Invariant ir.id.scalar narrows them: a scope’s and a resource subject’s id is a scalar or enum use without nullable, and a proof is anything but opaque and file. ir.ref.resolves checks the references inside them (scalar, an enum’s name, a proof’s ref schema) like any other.
  • conventions is informative only: a map from the dotted convention path to { value, origin }, for provenance, explain and lint. A target never acts on it; every convention a target acts on is a typed member. The int64 wire form, the handling of open output enums and the URL form are api.wire (irRevision 4), required and guarded by the exact revision match: { int64: string | number, outputEnums: enum | open, urls: relative | absolute }, the compiler writing the defaults too (string, enum, relative). Under urls: absolute every URL a target writes ($self, $itemSelf, the URL roles, the successor Link) starts with one configured public origin (scheme, host, optional port), never the request Host or forwarded headers; a target without one refuses to generate or to start, naming the setting. A target that cannot act on a value of api.wire refuses to generate, naming the member and the value, so no target serves a wire form other than the one the IR states.
  • Absent, never null. The subset has no null type, so Cache.directives is absent for never; noStore is authoritative (no response is stored when it is true, whatever the directives hold).
  • Declarations the sketches leave open. ExtensionKeyDecl is { id, prefix, key, schema: TypeUse, places, origin }; lint.rules values are { severity, origin }; FieldRestriction is { right, params?, inputPolicy?, origin? }; Param is { name, type, required, wire?, default?, origin?, derivedFrom? }; Property.derivedFrom is optional; Identity.extensions is required; a route example’s request is a boolean; a paging role’s base is an integer from 0 to 1; a Slot is a string.
  • Ids and strings. Catalogue keys carry the grammars of ADR 0002 section 4, derived ids the escaped identifiers of the ADR 0007 amendment (_404GoneError, List_); a right and a principal variant take any name (__proto__ included), so readers always read IR maps with Object.hasOwn or through a Map. A Reason is non-empty, without leading or trailing white space. SourceLocation.file is relative, with forward slashes, no . or .. segment and no :. An access’s schemes list is never empty.