Manifest Inheritance (extends)
A manifest may build on one or more base manifests via the optional top-level extends field. Inheritance lets a family of manifests share a common floor, a base manifest that declares the surface every consuming host has in common, while each consuming host extends that floor with its own additions, fills, and refinements.
This document is the normative reference for the extends model: the canon-as-base premise, resolution order, abstract types, the three inheritance moves, the refines marker, the collision guard, and the abstract-type-unfilled lint.
Canon as a Base Manifest
Section titled “Canon as a Base Manifest”A base manifest is a plain data file: an ordinary, schema-valid manifest with no special status. It is sometimes called canon: a shared floor that a group of related manifests agree to build on. Canon declares the bindings, capabilities, slots, and types the group holds in common, and nothing in the base is a cage; a consuming host is always free to add surface the base never knew about.
A consuming host (an extending manifest) opts in by naming the base:
{ "xript": "0.7", "extends": "./base.json", "name": "consuming-host", "types": { /* additions, fills, and refinements over the base */ }}The base has no knowledge of its extenders. Inheritance flows one way: a base is authored standalone, and any number of siblings may extend the same base independently.
Resolution
Section titled “Resolution”extends is a path string or an array of path strings, resolved before schema validation, identically by loaders and tools. Resolution flattens base-then-child:
- A base is resolved first (recursively, since a base may itself
extendsanother). - The child is then merged on top.
- When
extendsis an array, bases merge left-to-right; the child applies last.
The result is a single flat, schema-valid manifest. The runtime never sees extends after resolution; inheritance is a build-time concern, not a runtime one.
- Maps (
bindings,capabilities,hooks,types) are key-merged. - Slots append, keyed by
id. - Scalars (
name,version,title,description,xript) are child-wins. - Paths are relative to the extending manifest’s location. Remote and URL bases are not supported in this version.
- Cycles error. A transitive
extendschain that loops back on itself is a resolution error.
How a name that appears in both base and child is resolved depends on which of the three moves applies.
Abstract Types
Section titled “Abstract Types”A type definition carrying "abstract": true is declared, described, and contract-bearing, but unpopulated. It supplies neither fields nor values; it is a typed hole the base leaves open for an extending manifest to fill.
{ "types": { "StatusCode": { "description": "A host-defined code classifying the outcome of an operation.", "abstract": true } }}An abstract type is a contract: the base may reference it from concrete surface (a binding return type, a slot’s payload schema, another type’s field) without committing to its shape. Each extending manifest decides what the contract is filled with.
{ "types": { "StatusCode": { "description": "A host-defined code classifying the outcome of an operation.", "abstract": true }, "Envelope": { "description": "A response wrapper every operation returns.", "fields": { "status": { "type": "StatusCode", "description": "The outcome classification." }, "payload": { "type": "string", "optional": true, "description": "The result body, when present." } } } }}Here the base declares an abstract StatusCode and a concrete Envelope whose status field references it. Envelope is complete; StatusCode is a hole. An extending manifest must fill StatusCode before the resolved manifest is sound; see the lint. A slot’s payload schema may likewise reference an abstract type by name; the reference resolves to the concrete fill once the extending manifest supplies it.
The Three Moves: Add, Fill, Refine
Section titled “The Three Moves: Add, Fill, Refine”When an extending manifest declares a name, exactly one of three moves applies. The moves are distinguished by intent, signalled by what the base declared and by the markers the child carries.
1. Add New
Section titled “1. Add New”The child declares a type, slot, or capability name the base never declared. This is purely additive: no marker, no collision, no ceremony. A sibling is free to declare whatever the base does not know about; canon is a shared floor, never a cage.
{ "types": { "RetryPolicy": { "description": "How a consuming host retries a failed operation.", "fields": { "maxAttempts": { "type": "number", "default": 3 }, "backoff": { "type": "string", "enum": ["fixed", "exponential"], "default": "exponential" } } } }}RetryPolicy is unknown to the base, so it simply joins the resolved manifest. This is the existing, unchanged behavior.
2. Fill
Section titled “2. Fill”The child redeclares an abstract base type name, supplying concrete fields and/or values. This is allowed without any marker: the base being abstract is the opt-in signal. The concrete child definition replaces the abstract stub in the resolved manifest.
{ "extends": "./base.json", "name": "consuming-host", "types": { "StatusCode": { "description": "The set of outcome codes this host recognizes.", "values": ["ok", "retry", "denied", "error"] } }}After resolution, StatusCode is the concrete enum above, and the base’s Envelope.status reference now resolves to it. Each sibling that extends the same base may fill StatusCode with a different concrete shape; the contract is shared, the fill is local.
3. Refine
Section titled “3. Refine”The child redeclares a concrete base type (or slot) name carrying "refines": true. The child deep-merges onto the base: child members win key-by-key, and base members the child omits are retained.
{ "extends": "./base.json", "name": "consuming-host", "types": { "Envelope": { "refines": true, "fields": { "payload": { "type": "string", "description": "The result body, always present in this host." }, "traceId": { "type": "string", "description": "Correlation id for this host's tracing." } } } }}After resolution, Envelope retains the base’s status field, takes the child’s overridden payload field, and gains the new traceId field. The child did not have to restate status to keep it.
Without refines: true, redeclaring a concrete base name is an error; see collisions. The marker is a deliberate opt-in: refinement is intentional, so a refine must say so.
Deep-Merge Semantics
Section titled “Deep-Merge Semantics”Refinement deep-merges recursively over fields:
- For each field, a child field replaces the base field of the same key.
- Base fields the child does not mention are retained.
- A field whose value is itself an object is merged recursively by the same rule.
values(enum members) and any other array members are replaced wholesale; there is no element-wise array merge.
The refines marker itself is consumed during resolution and does not appear in the resolved manifest.
Slots refine by the same shape. A child slot redeclaring a base slot id is permitted only with "refines": true, and deep-merges onto the base slot, including its payload JSON Schema. The merged slot keeps the base’s fields the child omits and takes the child’s overrides; the payload contract itself deep-merges key-by-key like any other object. A base declares the payload its slot fills must satisfy, and an extender tightens it:
{ "extends": "./base.json", "name": "consuming-host", "slots": [ { "id": "event.commit", "refines": true, "payload": { "required": ["id", "author"], "properties": { "author": { "type": "string", "description": "Who authored the commit." } } } } ]}If the base slot’s payload was { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } }, the resolved slot keeps type and the base id property, takes the child’s required array wholesale, and gains the author property; the schema merges by the same per-key rules as any other object.
Fragment Formats and Vocabularies
Section titled “Fragment Formats and Vocabularies”A node vocabulary and a fragment format are separate surfaces: vocabularies declares nodes and their props, and formats names a syntax plus the vocabularies in scope. Both merge, and each level has its own refines gate. The node and prop levels are shared machinery — a node declared inline on a format and a node declared in a top-level vocabulary merge by identical rules, differing only in the path a collision error names.
| Level | Collision without refines | With refines: true |
|---|---|---|
Vocabulary id (/vocabularies/<id>) | resolution error | description is child-wins; nodes key-merges |
Format id (/formats/<id>) | resolution error | description / syntax are child-wins; nodes key-merges; vocabularies and schemes replace wholesale when present |
| Node name | resolution error | description / children / bindTo are child-wins; props key-merges |
| Prop name | — | the child’s prop schema replaces the base’s wholesale |
A format’s two arrays, vocabularies and schemes, are the members that follow neither the child-wins scalar rule nor the key-merge rule:
Absent inherits. Present replaces wholesale.
[]means deliberate closure.A child format that carries no
vocabularieskey inherits the base’s array unchanged. A child that carries one replaces the base’s array entirely — never a union, never a merge, never a prepend or append. An empty array is a present value, not an absent one: it closes the scope to the format’s inlinenodesalone.
schemes— the URI schemes the format’suriandimage-urisinks admit — reads identically. Absent inherits the base’s set, or the default["http", "https", "mailto", "data"]when no base declared one. Present replaces it.[]closes the format to absolute URLs.
vocabularies is ordered and the order is semantic — the first vocabulary in scope that claims a node name wins — so there is no defensible union: prepending and appending resolve differently, and either would silently re-inject a vocabulary a child deliberately closed out. That is the exact class of silent clobber refines exists to prevent.
schemes is unordered, and arrives at the same rule by a different road: a union makes narrowing impossible to express. A child declaring ["https"] against a base permitting four schemes means to tighten, and a union would quietly hand back the other three. So both arrays replace, and a child that wants the base’s list plus one entry restates the list, the same way a child widening a prop restates its sink.
{ "extends": "./base.json", "name": "consuming-host", "formats": { "application/x-panel+json": { "refines": true, "nodes": { "Badge": { "props": { "tone": { "type": "string", "enum": ["info", "warn"] } } }, "Link": { "refines": true, "props": { "target": { "type": "string" } } } } } }}Badge is new, so it needs no marker. Link already exists in the base, so it needs refines: true, and it gains target while keeping the base’s href untouched.
A top-level vocabulary refines the same way, and every format that brings it into scope sees the refinement:
{ "extends": "./base.json", "name": "consuming-host", "vocabularies": { "acme.components": { "refines": true, "nodes": { "Badge": { "props": { "tone": { "type": "string", "enum": ["info", "warn"] } } } } } }}A prop is the one level that replaces rather than merges, and that is deliberate. Deep-merging two JSON Schemas produces subtly wrong constraints: a leftover enum narrowing a widened type, a minimum outliving a change to string. Worse, a child that widens Link.href from a string to an object would silently inherit the base’s sink: "uri" onto a prop that is no longer a string. Replacing wholesale forces the child to restate the safety annotation, at exactly the moment it should be thinking about it. Refining a prop means rewriting it.
Collisions
Section titled “Collisions”A concrete-name collision that is not opted into is a hard guard against accidents:
- A child redeclaring a concrete base type name without
refines: trueis a resolution error. - A child redeclaring a base slot
idwithoutrefines: trueis a resolution error. - A child redeclaring a base format media type, a base vocabulary id, or a base node name within a refined format or vocabulary, without
refines: trueis a resolution error. - A child redeclaring a base
binding,capability, orhookname is a resolution error; these maps are collision-as-error throughout, and fill and refine are type and slot concerns. - A cross-base collision (two bases in an
extendsarray declaring the same concrete name) is a resolution error.
These errors are thrown at resolution time, before validation and before lint. They are not warnings and cannot be suppressed; silently overriding inherited surface is exactly the accident the guard exists to catch. To override deliberately, fill an abstract type or refine a concrete one.
Linting
Section titled “Linting”Resolution tracks provenance: which surface in the resolved manifest is local to the child and which was inherited from a base. One lint draws on that provenance.
abstract-type-unfilled(severity: error) — a resolved host that inherits an abstract type and leaves it abstract (never fills it) is a defect. An abstract type is a contract hole; shipping a host with the hole still open means a referenced type has no concrete shape. Fill it, or stop inheriting it.
A locally-declared abstract type is not flagged; declaring an abstract type for one’s own extenders to fill is legitimate authorship, not a defect. The lint fires only when the abstract type was inherited and left unfilled.
A filled or refined inherited surface is legitimately used. Filling an abstract type or refining a concrete one must not trip dead-slot or vestigial-capability findings; the inherited surface is in active use, not vestigial.