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
discriminatorandoneOf; each variant fixes its tag withconst. Generated Kotlin and PHP models get a sealed hierarchy with anUnknownvariant, and an unknown tag is named instead of failing as a generic schema error. - Every choice between shapes is tagged.
principalis{ kind: schema, schema }or{ kind: variants, variants }; a signaturekeyis{ kind: value, value }or{ kind: from, from };unknownKeysof a signed identity is{ policy: ignore | keep, reason }; an enumsourceis{ kind: vocabulary, vocabulary }or{ kind: enum, enum }; aSchemeis tagged on itskind: cookie{ name, csrf? }, header{ name }, query{ name }, bearer{ bearer? }, basic{}, signature{ name?, signature }(the document-only bearer facts areBearerFacts, the verificationSignatureVerify); anIdentityis tagged on itskind: credential{ schemes: { read, write, optionalRead } }or signed{ signed, unknownKeys? }, sounknownKeysexists on signed identities only;idempotencyandcsrfare{ 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.versionis{ 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.
ValueSourceis{ in, name?, prefixBits? }(byin:namefor the named sources, none for the bare ones,prefixBitsforaddressonly; a scope’s{ resolver: true }and a field right’s{ holderField }are theinvaluesresolverandholderField, withname);FieldWireis{ kind, style?, explode?, brackets? }(the five query wire kinds carry all three,fileandjsonnone); a formatmetamember is{ type, fill, source? }(sourceonly forfill: source);HeaderDeclis{ semantic, name?, in?, response?, request? }(versionTokencarriesresponseandrequest, every other semanticnameandin);Cacheis{ family, directives?, anonymous, vary, etag, perCallerKind, noStore, origin, derivedFrom? }(directivesfor every family butnever, which setsnoStore;familyis an open string, not an enum, and the invariant reads the one reserved namenever). 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
checkIrInvariantsin@seampact/spec/ir, owned by the finalize pass (p2-finalize):Name.statusand its role,meta.sourceandfill,ValueSourcemembers byinand their places,HeaderDeclmembers by semantic,Cache.directivesand theneverfamily,csrfon an unsafe cookie-scheme operation,FieldWiremembers, a property’snullable, 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’salways(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 closedenum(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-actmarks the unions and enums of a security boundary:Access,Requirement,RightSubject,Scheme,SignatureVerifyand itsSchemeKey,Identity,KeysPolicy,UnknownQuery,Idempotency,Csrf,RefusedCredential(onRefused),UnknownKeysPolicy,InputPolicy, and from irRevision 4FieldRightAlwaysandValueSourceIn(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 exactirRevisionmatch guards them instead, as forFieldRightAlwaysandValueSourceIn(ADR 0005 section 4). - TypeScript unknown arm. Each generated union is
Known<Name> | UnknownVariant<tag>, the tag typedstring & {}. TypeScript narrows no union holding that arm, soisKnownVariantnarrows to the known variants first; a closed union would let an exhaustiveswitchmiss an unknown tag at run time. A producer types each union value it writes asKnown<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 derivationrule.proof, in requirement order; the list is absent when the operation has none. A proof is identified by itsname: two rules that read the same name with the same schema share one element, whosederivedFrom.fromlists 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 (invariantir.input-proof.name). The input use’s unknown-key policy treats proof names as known, andkeepnever collects them (section 5). Every element is required and sensitive (ADR 0002 section 9), so it carries nooptionalorsensitivemember. 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 fromrules. A proof read in a header stays a derived header parameter. A body proof requires the input to be arefuse of an object schema (ApiEmptyBodyincluded): a union, map, array or scalar input with a body proof is a compile error in access.rights naming the rule and the route (invariantir.input-proof.input). On a route withoutinputthat a body proof applies to (a POST action, ADR 0002 section 9), access.rights writes a proof-only input where tree leftinputabsent:mediaapplication/json,schema{ kind: ref, schema: ApiEmptyBody }(the core empty object schema below),rawBodytrueon a signed identity’s route, as derivationsigned.identitywrites it for a declared input, andfalseelsewhere,originthe first requirement, in requirement order, that brings a body proof, thenproofs; on a signed identity’s route it also copies the identity’sunknownKeys, with itsderivedFrom, onto that input (section 5), so everyrefinput of a signed route carries the identity’s policy; derive writes itsbodyLimitBytesas 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 itsname, 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 carrieswire, theFieldWirean input field of its type use would get, never of kindfilesince a proof is no file: access.rights computes it with the function tree.nodes uses forfields, shared rather than copied; in a JSON input it has none (invariantir.input-proof.wire, partitioned onOperationInput.media). Invariantir.id.scalartypes anInputProof.schemaas it types aRuleProof.schema. Origin.layeris closed and gainsworkspace(section 6), thencore(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). Invariantir.origin.corechecks the entry offormats,errorsorschemasa core pointer starts with exists in the document, unless the origin names anotherapi; 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 inerrors; the default formatbareinformats, when no layer declares a formatbare; and inschemasthe 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 (derivationRolesandderivationCoreSchemaIdsin@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,ApiPayloadTooLargeParamsandApiRateLimitedParams(the ADR 0007 params names ofapi.schema_invalid,api.forbidden,api.sort_key_forbidden,api.payload_too_largeandapi.rate_limited),ApiSchemaInvalidIssue(one item ofissues:{ in, pointer, rule }), andApiEmptyBody(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 withparamstakes the id (its params hoist under it, ADR 0007), and the core schema is not seeded, or is dropped;ApiSchemaInvalidIssuestays core while no layer declares or hoists that id, even afterApiSchemaInvalidParamsis 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 withoutparamskeeps the core params schema (ADR 0006 section 6). A schema declared under a reserved params id is refused even when the code’sparamsnames it: only the inlineparamshoist takes the id. Any other schema declared or hoisted under a reserved id,ApiEmptyBodyincluded, is a compile error naming the reservation and the fix, a schema of another name. The format idbareyields the same way:bareis core when no layer declares it, and a declaredbarereplaces it. A core declaration carriesorigin, neverderivedFrom: no schema hasderivedFrom, which stays on derived operation elements, properties and uses. Name.statusis an integer from 200 to 399, as@seampact/spec/namingchecks. It couples the transport-neutralNameto HTTP statuses; that is accepted until a non-HTTP kind derives response names, which then adds its own qualifier as an IR change.irVersionis 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 anint64bound or adecimalstep 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
minimumand amaximumwithin -(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 whateverapi.wire.int64says (invariantir.value.exact-number): inProperty.default,Property.examples,Param.defaultand a scalar use’sexamples, anint64ordecimaluse’s value is its format’s string form (ADR 0003 section 8), anint64string not-0and within -2^63 to 2^63 - 1, and any otherintegeruse’s value is a safe integer; nested values, an array use’s itemsexamples,FieldRight.params,Requirement.params,SortKey.paramsand extension values follow the rule unchecked. A server target converts the string form when it applies such a default. nullableis 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 acceptsnullonly with it (section 5).- Named formats. The
scalarscatalogue holds{ id, name, type, directions, docs, origin },typea scalar type use; a use of a named format holds its type and refinements inline and names it inscalar, so a target maps it to a native type or checks it with the portable keywords.formatholds the portable formats of ADR 0003 section 8 only.Refinementsis{ minLength?, maxLength?, pattern?: { source, normalized }, minimum?, ... }. - Ids are type uses.
Scope.idSchema, a resource subject’sidSchemaand a rule proof’sschemaare aTypeUse, not aSchemaRef: ADR 0002 writes an id as a scalar or a named format (org: { id: OrgId }), and aSchemaRefnames an object, map or union schema of theschemascatalogue only. Invariantir.id.scalarnarrows them: a scope’s and a resource subject’s id is ascalarorenumuse withoutnullable, and a proof is anything butopaqueandfile.ir.ref.resolveschecks the references inside them (scalar, an enum’s name, a proof’s refschema) like any other. conventionsis informative only: a map from the dotted convention path to{ value, origin }, for provenance,explainand lint. A target never acts on it; every convention a target acts on is a typed member. Theint64wire form, the handling of open output enums and the URL form areapi.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). Underurls: absoluteevery URL a target writes ($self,$itemSelf, the URL roles, the successorLink) starts with one configured public origin (scheme, host, optional port), never the requestHostor forwarded headers; a target without one refuses to generate or to start, naming the setting. A target that cannot act on a value ofapi.wirerefuses 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
nulltype, soCache.directivesis absent fornever;noStoreis authoritative (no response is stored when it is true, whatever the directives hold). - Declarations the sketches leave open.
ExtensionKeyDeclis{ id, prefix, key, schema: TypeUse, places, origin };lint.rulesvalues are{ severity, origin };FieldRestrictionis{ right, params?, inputPolicy?, origin? };Paramis{ name, type, required, wire?, default?, origin?, derivedFrom? };Property.derivedFromis optional;Identity.extensionsis required; a route example’srequestis a boolean; a paging role’sbaseis an integer from 0 to 1; aSlotis 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 withObject.hasOwnor through aMap. AReasonis non-empty, without leading or trailing white space.SourceLocation.fileis relative, with forward slashes, no.or..segment and no:. An access’sschemeslist is never empty.