Status: normative contract for the HTTP wire that epic E0004 implemented, recorded as ADR 0004. Sorting rests on ADR 0006. This document specifies the shipped wire. Where code and this document disagree, the code is wrong, or this document is amended in the same PR.
The spec is JSON:API 1.1. MUST, SHOULD and MAY in this document carry their RFC 2119 meaning and bind the generated server and the generated TS transport. Rules quoted from the spec are not repeated unless ontogen makes a choice inside them.
"Decision N" means item N of the epic's Decisions (2026-10-03) list. Choices this document makes itself are listed with their reasons in §16.
| Operation | Method and path | Success | Section |
|---|---|---|---|
| List | GET /api/{type} |
200 |
§7 |
| Get | GET /api/{type}/{id} |
200 |
§8.1 |
| Create | POST /api/{type} |
201 |
§8.2 |
| Update | PATCH /api/{type}/{id} |
200 |
§8.3 |
| Delete | DELETE /api/{type}/{id} |
204 |
§8.4 |
| Fetch relationship | GET /api/{type}/{id}/relationships/{rel} |
200 |
§9 |
| Change relationship | PATCH, POST, DELETE on the same path |
204 |
§9 |
| Fetch related | GET /api/{type}/{id}/{rel} |
200 |
§9.3 |
| Custom op | GET or POST /api/{module}/{action}… |
200, or 204 for () |
§10 |
| Event stream | GET /api/events/{name}… |
200 text/event-stream |
§12 |
- Scope
- The running example
- Media type and negotiation
- Documents
- Resource objects
- Query parameters
- Collections: list, pagination, filter, sort, include
- Single resources: get, create, update, delete
- Relationship endpoints and related links
- Custom ops and singletons
- Route prefix and extra surfaces
- Event streams
- Errors
- TS transport mapping
- IPC and MCP
- Decision index
- Phase mapping
This contract covers every route the Axum generator
(ServerGenerator::HttpAxum) emits, and the generated TS HTTP transport
that consumes them. It replaces the ad-hoc dialect described in the epic's
gap analysis: bare entities, bare arrays, PaginatedResult,
limit/offset, PUT, and {"error": string}.
It also specifies the store-layer behaviour the wire depends on:
- the
orderargument and sort parsing (ADR 0006); has_manywrites that clear dropped children (decision 9);- the typed store errors
{Entity}AlreadyExists,{Entity}IdRequired,{Child}ParentRequiredand{Child}ParentCycle(§13.4); - the store's checks on the ids a write lists, and the atomicity of a write (§5.4);
- the schema reaching the servers and clients stages (§5.1).
Tauri IPC and MCP payloads stay flat. Beyond the store fixes above, their
only addition is the optional sort argument (decision 8, §15). The TS
Transport interface takes one trailing optional argument on list methods
(§14).
Out of scope, as the epic records: sparse fieldsets, the atomic operations
extension, lid, 202 Accepted, profiles, and OpenAPI emission. Each is
rejected on the wire as described below, never silently ignored.
Resource examples use examples/tasks-tracker (markdown backend,
IdStrategy::SlugFromField("title")) and its checked-in vault.
tasks-tracker has no custom ops, event ops or junction ops, so §9.1, §10
and §12 take their examples from iron-log where one exists, and otherwise
say the example is illustrative.
| Rust entity | Module | Resource type |
Base path |
|---|---|---|---|
Epic { id, title, status, body } |
epic |
epics |
/api/epics |
Tag { id, title } |
tag |
tags |
/api/tags |
Task { id, title, status, created, epic_id, tags, parent_id, subtasks, body } |
task |
tasks |
/api/tasks |
Task.epic_id is belongs_to Epic (an Option<String>). Task.tags is
many_to_many Tag. Task.parent_id is belongs_to Task (an
Option<String>), and Task.subtasks is has_many Task through
parent_id. The vault holds task ship-the-emitter, which has no parent
and no subtasks, epic markdown-backend, and tags codegen and release.
Some sections rely on these parts of tasks-tracker, which keep every example in this document live:
| Part | Used by |
|---|---|
pagination: Some(PaginationConfig { default_limit: 20, max_limit: 100 }), paginated: ["task", "epic", "tag"] |
§7.2 |
A hand-written task::list(store, query: ListTasksQuery, limit, offset) with ListTasksQuery { status: Option<String>, epic_id: Option<String> } and a matching count, replacing the generated list (§7.3) |
§7.3 |
order: &[OrderBy<TaskSortField>] on that hand-written list |
§7.4 |
Task.parent_id: Option<String> (belongs_to Task) and Task.subtasks: Vec<String> (has_many Task, foreign_key = "parent_id"), as in crates/markdown-pilot |
§5.4, §9 |
A second tag, release |
§9.2 |
epic and tag are paginated too because pagination belongs to a surface,
not a module: the primary surface's pagination covers every module on it,
and tasks-tracker has one surface.
The task body is abbreviated as "## Goal\n…" after its first appearance.
Response headers common to every response (§3.3) are shown once per
section, not on every example.
- Every response that carries a body from a JSON:API route MUST have
Content-Type: application/vnd.api+json, with no media type parameters. This includes error responses. Ontogen applies no extensions or profiles, so it has no parameter to state. - A
204 No Contentresponse has no body and noContent-Type. - Event-stream routes (§12) respond with
text/event-stream. They are not JSON:API documents.
Content-Type. A request that carries a body MUST have
Content-Type: application/vnd.api+json. Bodies are carried by:
POSTto a collection;PATCHto a resource;- a custom
POST, or aPOSTorPATCHrow of §10.4, with a body (one may also carry none, §10.2); POST,PATCHandDELETEon a relationship endpoint (§9).
The server responds:
Request Content-Type |
Response |
|---|---|
application/vnd.api+json |
processed |
application/vnd.api+json; profile="…" |
processed; unknown profiles are ignored, as the spec requires |
application/vnd.api+json; ext="…" |
415. Ontogen supports no extension, so every ext URI is unsupported |
application/vnd.api+json with any other parameter (e.g. charset=utf-8) |
415, as the spec requires |
anything else, including application/json, or the header absent while a body is present |
415 |
Every 415 carries source.header: "Content-Type". A body on GET, or on
a DELETE other than a relationship endpoint's (a resource DELETE, or a
DELETE row of §10.4), is ignored, and its Content-Type is not checked.
Accept. Media types, parameter names and the q parameter are
compared case-insensitively. A range with q=0 is "not acceptable", as
RFC 9110 defines it. The rule is evaluated in this order:
-
Acceptabsent: acceptable. -
If
Acceptlistsapplication/vnd.api+jsonat least once, the request is acceptable only when at least one of those instances:- has a non-zero
q, and - has no parameters other than
profileandq.
Wildcards do not rescue it.
application/vnd.api+json; charset=utf-8, */*andapplication/vnd.api+json; ext="https://…", */*are both406, as the spec requires when every JSON:API instance carries a disallowed parameter or an unsupported extension. So isapplication/vnd.api+json;q=0, */*. - has a non-zero
-
Otherwise the request is acceptable when a listed range with a non-zero
qis*/*orapplication/*. -
Anything else is
406withsource.header: "Accept".
Every response from a JSON:API route, including 204 and every error,
carries Vary: Accept. The response depends on Accept (it can be 406),
so a cache must key on it. The spec's SHOULD applies because the server
honours the profile parameter by ignoring it.
Examples below show the request line and the headers that matter. A
response written as 200 OK implies:
Content-Type: application/vnd.api+json
Vary: AcceptThe server MUST accept [ and ] in parameter names both raw and
percent-encoded (%5B, %5D), treating them identically, as the spec
requires. Links the server emits always use the percent-encoded form
(§4.3). Examples in this document write raw brackets in request lines for
readability.
Every response document has these top-level members, in this order. Order is fixed so that insta snapshots and example diffs are deterministic.
| Member | When present |
|---|---|
jsonapi |
always, exactly {"version": "1.1"} |
links |
on every document with primary data (§4.3), except a create or update of a type whose module serves no get_by_id (§5.2) |
meta |
paginated collections (§7.2, §9.1) and custom ops (§10) |
data |
every success document except custom ops |
included |
when the request carried include (§7.5) |
errors |
error documents only (§13) |
jsonapi is emitted on every document, including errors. The contract
relies on 1.1 semantics: the ext and profile negotiation rules in §3.2,
and the 1.1 error and link members.
Every link the server emits, and the Location header, is a relative
reference: an absolute path plus an optional query, e.g.
/api/tasks/ship-the-emitter. RFC 3986 permits a URI-reference, and
RFC 9110 §10.2.2 permits a relative Location.
Links are built from the route template ontogen generated (/api/...), not
from the request's original URI. The generated router MUST therefore be
mounted at the root, as every example does. A consumer that nests it under
another prefix gets links without that prefix.
Ids and other path parameters are percent-encoded as RFC 3986 path
segments: every byte outside the unreserved set (A-Z a-z 0-9 - . _ ~) is
encoded.
Top-level links.self and the pagination links repeat the request's
JSON:API query parameters, in a canonical form, so that equal requests
produce byte-equal links:
- Parameter order: every
filter[…]parameter in ascending byte order of its member name, thensort, theninclude, thenpage[offset], thenpage[limit]. - Names:
[and]are written%5Band%5D. - Values: a value is written item by item, every byte of an item
outside the unreserved set percent-encoded and the items joined by a
literal
,, so a comma inside an item is%2C. The items ofsortandincludeare its keys and paths as parsed; those of afilter[…]value are the decoded pieces of the value as sent between its literal commas, so a link reads back the sequence a member was sent (§7.3). A value with no literal comma is one item. The spec allows a value serialization that differs fromapplication/x-www-form-urlencodedas long as it parses back. sortandincludeare written as parsed: duplicates inincluderemoved, request order kept.- Paginated collections always carry both
pageparameters, with the effective values after clamping (§7.2), whether or not the client sent them. - A parameter the client did not send, and that rule 5 does not add, is
absent. A request with no JSON:API query parameters on an unpaginated
collection has
selfequal to the bare path.
Building a resource object needs the schema. The generator must know:
- which fields are attributes and which are relationships;
- each relationship's kind and target, and whether a to-one is
Option; - whether an event's item type is an entity;
- the serde attributes that §5.3 restricts.
The parsed schema is an explicit first argument of both stages:
pub fn gen_servers(schema: &SchemaOutput, api: Option<&ApiOutput>, scan_dirs: &[PathBuf], config: &ServersConfig) -> Result<ServersOutput, CodegenError>;
pub fn gen_clients(schema: &SchemaOutput, api: Option<&ApiOutput>, scan_dirs: &[PathBuf], config: &ClientsConfig) -> Result<(), CodegenError>;Pipelinepasses the schema it parsed.- Standalone callers pass
parse_schema's output, or&SchemaOutput::default(). ClientsConfighas noschema_entitiesorschema_enumsfield; the argument is the only route by which the schema reaches a stage. The stages need its enums for sort keys (§7.4), andgen_storetakes the schema for the same reason (ADR 0006).
A module is a resource module when its name is the module name of one
of the schema's entities. Its CRUD ops are served as resources (§7, §8). A
module with CRUD-classified ops (list, get_by_id, create, update,
delete) but no entity behind it is served entirely as custom ops (§10.4).
That module is what a scan-dirs-only consumer, or a standalone caller
passing an empty schema, has. The alternative, a CodegenError, would
break the scan-dirs-only use case the servers stage supports, and those ops
have no schema from which to build a resource.
{
"type": "tasks",
"id": "ship-the-emitter",
"attributes": { "…": "…" },
"relationships": { "…": { "links": { "…": "…" }, "data": "…" } },
"links": { "self": "/api/tasks/ship-the-emitter" }
}Member order is type, id, attributes, relationships, links.
typeis the module'surl_plural, kebab-case:tasks,workout-sets(decision 3). It is the same string as the URL segment. Plural overrides inNamingConfigapply to both.idis the value of the entity's#[ontology(id)]field, whatever that field is called. The member is always namedid. The field MUST be a RustString(ADR 0001 contract item 1). The generator raises aCodegenErrorfor any other id type.attributesis always present (§5.3).relationshipsis present when the type declares at least one relationship, a relation field (§5.4) or a junction-op relationship (§9.1), and absent otherwise (epicsandtagsin the example).links.selfis present when the type's module servesget_by_idas the type's resource (§9). It equals theLocationheader on create (§8.2), as the spec requires when both exist. Otherwise the resource object has nolinksmember, because no route serves/api/{type}/{id}.- There is no
metamember.
attributes is the entity's serde serialization with the id field and
every relation field removed. Concretely:
- Member names are the Rust field names, in declaration order.
- Every non-relation field is present on every response, including
Optionfields, which serialize asnullwhenNone. - The
#[ontology(body)]field is an ordinary attribute named after its field (body). - Fields with role
Skipare not attributes. - Both stores hold an integer field as
i64(ADR 0006 §4). An attribute whose type reaches past it (u64,usize,isize,u128,i128, or anOptionof one) keeps its type, but a request body's value outside-9223372036854775808..=9223372036854775807is400 invalid_attribute(§8.2).
The generator raises a CodegenError when:
- an attribute name is not a legal JSON:API member name, or is
typeorid. A legal member name has only ASCII letters and digits, non-ASCII characters,-,_and spaces, and starts and ends with an ASCII letter or digit or a non-ASCII character, so_draftanddraft_are refused; - an entity struct or field carries a serde attribute that changes its
serialized shape:
rename,rename_all,alias,flatten,skip,skip_serializing,skip_serializing_if,serialize_withorwith.
#[serde(default)] and deserialize_with change only deserialization, so
they stay allowed. They are the only serde field attributes any in-tree
schema uses.
Every field with FieldRole::Relation leaves attributes and becomes a
relationship:
| Relation kind | Rust field | Relationship name | Arity | Linkage |
|---|---|---|---|---|
belongs_to |
epic_id: Option<String> |
epic |
to-one | {"type": "epics", "id": "…"}, or null when None |
belongs_to |
workout_id: String |
workout |
to-one | always an identifier |
many_to_many |
tags: Vec<String> |
tags |
to-many | array of identifiers, in stored order |
has_many |
subtasks: Vec<String> |
subtasks |
to-many | array of identifiers, id ascending (ADR 0006 §3) |
Naming. A belongs_to field whose name ends in _id loses that suffix.
Every other relation field keeps its name. The target type is url_plural
of the relation's target entity.
Relationship names are also URL segments (/relationships/{rel}, §9),
used verbatim: /api/tasks/{id}/relationships/subtasks, and for a
two-word field, …/relationships/sub_tasks. Links percent-encode the name
as a path segment (§4.2), which leaves an ASCII name unchanged.
Order. A resource object lists its relation fields first, in field
declaration order, then its junction-op relationships (§9.1) in the
declaration order of their list_X. A task's relationships are epic,
tags, parent, subtasks.
Collisions. The generator raises a CodegenError when:
- a relationship name is not a legal JSON:API member name (§5.3);
- a relationship name equals an attribute name,
type,idorrelationships; - two relationships share a name.
This applies to junction-op relationships (§9.1) as well. The spec gives
fields one namespace, and relationships is a URL segment.
The build-time rules of §5.2–§5.4 (the id type, legal and distinct member names, relation targets in the schema) apply to builds that generate an HTTP server or an HTTP TypeScript client. IPC-only and MCP-only builds, with or without the admin registry, are unaffected: their payloads are flat (§15).
Writes. Every relationship is writable on the wire except a junction-op relationship (§9.1), which is changed only through its own endpoint.
- to-one: set, or cleared to
nullwhen the field isOption. many_to_many: full replacement throughPATCH; add and remove through the relationship endpoint (§9). A listed id that does not exist fails a create or update with{Target}NotFoundbefore anything is written: the first missing id, in list order. An update checks only the ids it adds, those not already in the record's list, so a record that holds an id whose target was deleted (§7.5) still gains and loses members, and keeps that id until a write drops it. Over HTTP the step-8 check catches a missing id first, as404 no_such_related_resource, for every id the request names: a relationshipPOSTorDELETEnames one member, aPATCHthe whole list (so aPATCHmust leave out an id whose target was deleted; see §9.1). The store's check covers IPC, MCP and direct store callers. On SeaORM the junction's foreign key would refuse the id too, but only once the record's row is written; the markdown store has no foreign key. The store checks it on both, so both answer alike. A self-referentialmany_to_manymay list the record's own id on create when the caller gave that id.has_many(decision 9):- writes go through the children's foreign keys;
- a listed child gets its foreign key set to this resource, and moves from any previous parent;
- a child dropped from the list gets its foreign key cleared;
- when the child's foreign key field is not
Option, a child cannot be dropped. The write fails with403 {child}_parent_required(§13.4) before anything is written. The spec requires403when a server refuses a relationship removal or a full replacement; - a list that names the record itself would make it its own parent,
and is refused before anything is written. Over HTTP that is step 7,
403 relationship_cycleat the identifier's first occurrence, before anything is read (§8.2, §9.2). The store refuses it as{Child}ParentCycle(§13.4), which covers IPC and MCP. On create this applies to an id the caller gave; a derived id cannot be listed, since the checks run before it is derived. The resource is never in its own list, so a relationshipDELETEnaming it writes nothing. A record that is its own parent (a root over a non-Optionforeign key) says so through itsbelongs_to, which may name the record's own id; - a listed child that does not exist fails a create or update with
{Child}NotFoundbefore anything is written: the first missing id, in list order. Over HTTP the step-8 check (§8.2, §8.3) catches it first, as404 no_such_related_resource. The store's check covers IPC and MCP; - longer cycles are not refused. An update that makes
aa child ofbwhilebis a child ofasucceeds. Refusing it would need an ancestor walk on everybelongs_toandhas_manywrite, and on the markdown backend, which has no transactions, a concurrent write could still close a cycle the walk did not see, so the backends would disagree under concurrency. A consumer that needs acyclic trees walks the ancestors in abefore_createorbefore_updatehook (backlog B-HMCY).
Store check order. A create or update checks, after its before_*
hook and before it writes anything, in this order; the first failure is
the error:
- a
has_manylist that names the record itself:{Child}ParentCycle; - the listed ids of every
has_manyandmany_to_many, relations in field declaration order and ids in list order: the first missing one is{Target}NotFound({Child}NotFoundfor ahas_many). On update only the ids the write adds are checked; an id already in the record's list is not (a storedhas_manylist names only children that exist); - on update, a dropped child whose foreign key is not
Option:{Child}ParentRequired; - a value the backend cannot store: an integer outside
i64, then a floatNaN, each the backend's catch-all error (500over HTTP); - on create, the id:
{Entity}IdRequired, an invalid id (the catch-all), then{Entity}AlreadyExists.
So a list that names a missing child and drops a required one reports the missing child, and a create with an empty-slug title and an out-of-range integer reports the integer on both backends.
Atomicity. On SeaORM a create or update makes all its writes (the
record's row, the junction rows, each child's foreign key) in one
transaction, committed before the read-back, the change event and the
after_* hook. A write that fails part-way, a junction row refused by a
foreign key say, leaves nothing written, and a derived-id create that
failed leaves no row for its retry to probe past.
On the markdown backend a has_many write rewrites one file per affected
child. Multi-record writes are best-effort there (ADR 0001 contract
item 2). Only an I/O failure or a concurrent delete part-way can leave
some children changed, because every check runs before the first write.
Duplicate identifiers in a to-many data array are collapsed to their
first occurrence before anything else uses the array. This holds for
create and update bodies (§8.2, §8.3) and for relationship PATCH (§9),
and matches the add-once rule of relationship POST.
Relationship objects have members in the order links, data:
"epic": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/epic",
"related": "/api/tasks/ship-the-emitter/epic"
},
"data": { "type": "epics", "id": "markdown-backend" }
}linksholdsself, the relationship endpoint, andrelated, the related-resource endpoint (§9). It is present on every relationship of a type whose module serves those endpoints: the module servesget_by_idas the type's resource (§9). The spec requires a server to serve every link it emits, so a type whose module serves noget_by_idhas no relationship routes, and its relationship objects carrydataonly.- A junction-op relationship has no field on the entity, so no
data. It appears withlinksonly, and never in event frames, which carry no links (§12). A relationship object with neither member would be empty, which the spec forbids. A junction-op relationship always haslinks, because its module must serveget_by_id(§9.1).
{
"type": "tasks",
"id": "ship-the-emitter",
"attributes": {
"title": "Ship the emitter",
"status": "closed/done",
"created": "2026-06-06",
"body": "## Goal\n\nEmit markdown CRUD matching the golden spec. ^summary\n\n## Outcome\n\nMatched on the first conformance run.\n"
},
"relationships": {
"epic": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/epic",
"related": "/api/tasks/ship-the-emitter/epic"
},
"data": { "type": "epics", "id": "markdown-backend" }
},
"tags": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/tags",
"related": "/api/tasks/ship-the-emitter/tags"
},
"data": [ { "type": "tags", "id": "codegen" } ]
},
"parent": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/parent",
"related": "/api/tasks/ship-the-emitter/parent"
},
"data": null
},
"subtasks": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/subtasks",
"related": "/api/tasks/ship-the-emitter/subtasks"
},
"data": []
}
},
"links": { "self": "/api/tasks/ship-the-emitter" }
}Below, this object is written ‹task ship-the-emitter›. The epic and tag
are:
{ "type": "epics", "id": "markdown-backend",
"attributes": { "title": "Markdown backend", "status": "in-progress",
"body": "Implement ADR 0001 as a stacked-PR campaign.\n" },
"links": { "self": "/api/epics/markdown-backend" } }
{ "type": "tags", "id": "codegen",
"attributes": { "title": "Codegen" },
"links": { "self": "/api/tags/codegen" } }These are written ‹epic markdown-backend› and ‹tag codegen›. epics
and tags declare no relationship, so neither has relationships, and
neither serves relationship routes.
Each JSON:API route accepts a fixed set of query parameters, listed below.
Anything else is 400 invalid_query_parameter with source.parameter
naming it. That covers:
- a parameter the route does not accept;
- an unknown member of an accepted family;
- a repeated parameter;
- a parameter whose name is malformed.
The spec requires a 400 for any parameter it reserves that the server
does not support, and for any parameter that follows none of its naming
rules. Ontogen defines one implementation-specific family, opArg, and
only on custom ops (§10.2).
| Route | Accepted parameters |
|---|---|
GET /api/{type} |
filter[…] when the list takes a filter (§7.3); sort (§7.4); include (§7.5); page[offset], page[limit] when paginated (§7.2) |
GET /api/{m}, a filtered list with no entity behind it (§10.4) |
filter[…] (§7.3); opArg[limit], opArg[offset] when paginated |
GET /api/{type}/{id} |
include (§7.5) |
POST /api/{type}; PATCH and DELETE /api/{type}/{id} |
none |
GET /api/{type}/{id}/{rel} (related link) |
page[offset], page[limit] for a junction-op relationship in a paginated module (§9.1); otherwise none |
GET /api/{type}/{id}/relationships/{rel} |
as for the related link |
PATCH, POST, DELETE /api/{type}/{id}/relationships/{rel} |
none |
custom op, GET |
opArg[…] only (§10.2) |
custom op, POST |
none |
| event stream | outside these rules (§12) |
sort and include are accepted names on the routes listed. A route that
cannot honour them answers with their own codes (§7.4, §7.5), not
invalid_query_parameter. On every other route they fall under the general
rule.
fields[…] (sparse fieldsets) is not supported. It falls under the general
rule and is 400 on every route, because the spec forbids sending fields a
client excluded.
This is an unpaginated configuration. It is not tasks-tracker as shipped, which paginates every list (§2).
GET /api/tasks HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
Content-Type: application/vnd.api+json
Vary: Accept
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks" },
"data": [ ‹task ship-the-emitter› ]
}- An empty collection is
"data": [], still200. - No
metaand no pagination links: the whole set is indata. page[…]is400 invalid_query_parameter, because the list is not paginated.- Order: id ascending, unless
sortis given (ADR 0006 §3).
With default_limit: 20 and max_limit: 100 on task. Assume the vault
holds 45 tasks for this section.
GET /api/tasks?page[offset]=20&page[limit]=10 HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": {
"self": "/api/tasks?page%5Boffset%5D=20&page%5Blimit%5D=10",
"first": "/api/tasks?page%5Boffset%5D=0&page%5Blimit%5D=10",
"prev": "/api/tasks?page%5Boffset%5D=10&page%5Blimit%5D=10",
"next": "/api/tasks?page%5Boffset%5D=30&page%5Blimit%5D=10",
"last": "/api/tasks?page%5Boffset%5D=40&page%5Blimit%5D=10"
},
"meta": { "total": 45, "limit": 10, "offset": 20 },
"data": [ "…10 resource objects…" ]
}Parameters. The strategy is offset-based, using page[offset] and
page[limit] from the reserved page family. They map directly onto the
list's offset and limit arguments (§7.3).
| Input | Effective value |
|---|---|
page[limit] absent |
default_limit |
page[limit] > max_limit |
max_limit, silently |
page[limit]=0 |
400 |
page[offset] absent |
0 |
page[offset] ≥ total |
allowed; data is [] |
a value that is not one or more ASCII digits, or is above 4294967295 (-1, +5, empty, 1.5, ten) |
400 |
page[number], page[size], page[cursor] or any other page[…] |
400 |
Leading zeros are accepted (page[offset]=020 is 20). Links always write
the effective value in plain decimal.
All of these 400s use code invalid_query_parameter, with
source.parameter naming the parameter, e.g. "page[limit]".
meta. total is the filter-aware count
(#172), so it counts the
filtered set, not the table. limit and offset are the effective values.
Together they are exactly the fields of PaginatedResult, which lets the
TS transport rebuild it (§14).
Links. A paginated document carries self plus the four pagination
links (first, prev, next, last). All five keys are always present;
a pagination link that is unavailable is null. Every link carries the
request's filter, sort and include in canonical form (§4.3).
With effective limit L, offset O and total T, and
last_offset = T == 0 ? 0 : floor((T - 1) / L) * L:
| Link | Offset | null when |
|---|---|---|
self |
O |
never |
first |
0 |
never |
prev |
last_offset when O >= T, otherwise max(0, O - L) |
O == 0 |
next |
O + L |
O + L >= T |
last |
last_offset |
never |
An offset at or past the end gets a prev pointing at the last page, so a
client that overshoots steps straight back to real data. With T = 45 and
L = 10, O = 45 gives prev offset 40. An offset inside the last page
(O = 42) gives prev offset 32, the page just before it.
No link carries an offset a client cannot request, that is, one above the
page[offset] ceiling of 4294967295. In a collection that large, last
is capped at the last multiple of L at or below 4294967295, and next
is null when it would start beyond that.
Ordering. Pages are cut from the ADR 0006 §3 order: the requested
sort, then id ascending as the final tie-break, or id ascending alone.
Page boundaries are therefore stable while the data is unchanged. Both
backends apply this order, the id-ascending default included.
Consistency. count and the page query are separate store calls, so a
concurrent write can make total disagree with the pages by the size of
that write. The contract does not promise a snapshot.
Edge examples:
GET /api/tasksreturns offset 0 and limit 20.selfis/api/tasks?page%5Boffset%5D=0&page%5Blimit%5D=20.previsnull.nextis…offset%5D=20…andlastis…offset%5D=40….
GET /api/tasks?page[limit]=500is clamped to 100.meta.limitis100andnextisnull.lastis…offset%5D=0&page%5Blimit%5D=100.
- For an empty vault,
T = 0.datais[]andmeta.totalis0.firstandlastare offset 0;prevandnextarenull.
GET /api/tasks?page[limit]=-1is400:
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "400",
"code": "invalid_query_parameter",
"title": "Bad Request",
"detail": "page[limit] must be an integer between 1 and 4294967295",
"source": { "parameter": "page[limit]" }
} ]
}Filters are hand-written: the store takes none, and the generated CRUD
list takes none.
A hand-written list replaces the generated one. A list written in
api_dir/{module}.rs replaces the generated list for that module, and
gen_api does not emit it. When the module is paginated, the generated
count goes too, so the module must define a count that takes the same
filter parameters; a paginated module whose hand-written list has no
count beside it is a build error. A hand-written count replaces the
generated one in any module. The API stage scans the hand-written fns
before it emits, and the merge keeps a scanned list or count over a
generated one of the same name. The hand-written list's parameters are, in
this order:
- the store;
- its filter parameters;
order: &[OrderBy<XSortField>], if it supportssort;limit,offset, if paginated.
The matching count takes the same filter parameters and no order
(ADR 0006 §1). The store has no filter, so gen_api cannot write a
filter-aware count: it is hand-written beside the hand-written list, and
meta.total is its result. IPC and MCP forward the same filter to list
and count (§15).
Filter parameters. The filter of a list is every parameter after the
store except the trailing limit and offset. Each is one of:
- at most one
*Querystruct, taken by value (ListTasksQuery) or borrowed (&ListTasksQuery), not&mutor in anOption. Each serde field of the struct is a memberfilter[field]. A second struct, or one taken another way, is aCodegenError. A borrowed struct is lent to bothlistandcount; one taken by value is cloned intolist, so it must beClone. The generated handlers name it throughtypes_import_path, as they name every type an op takes, so it must be reachable there (apub usein the schema module will do); - a bare parameter, which is the member
filter[{param name}]. Its type, under&and at most oneOption, must be one a single value can carry:&str,String, numbers,bool, unit enums.Vec<_>, tuples and schema entities are aCodegenError; a sequence can be a struct member instead (below).Option<T>is optional, and any other bare parameter is required.
An *Input parameter on a list is a CodegenError, since a list has no
body. These errors come from the HTTP generator only: IPC and MCP accept the
same list.
The order parameter. A parameter whose type is &[OrderBy<…>] (the last
path segment is OrderBy, qualified or not, whatever the parameter is
named) is the list's order, not a filter. Every transport and the clients
stage check it, and each of these is a CodegenError naming
module::fn:
- it is on a fn other than a module's
listserved as its collection; - the list takes a second one;
- it is not the last parameter, or, on a paginated list, the last before
limitandoffset; - its module has no schema entity behind it, so the
listis a custom op (§10.4), which takes nosort; - its
{Entity}SortFieldis not the module's entity's.
The paginated-count rule ignores it.
Reading the filter. A value is deserialized as axum::extract::Query
reads one field: true is a bool, 5 a number, and anything is a string.
An Option that is present is Some(value), so an empty value is
Some("") for a string. An Option that is absent is None.
Sequence members. A struct field serde reads as a sequence
(Vec<T>, Option<Vec<T>>) takes one filter[x] listing its items:
- The value as sent, still percent-encoded, is split at each literal
,, and each piece is percent-decoded and read asTas above. A comma inside an item is sent as%2C, which the split leaves alone:filter[tags]=a,b%2Ccis["a", "b,c"]. The generated TS client sends exactly that (§14.2), and IPC and MCP read the same items from a JSON array. - An item
Trefuses is400 invalid_query_parameternaming the member (filter[sizes]), as any other bad value is. An empty piece is an item:a,is["a", ""], which a number refuses. filter[x]=is the empty sequence, so a sequence holding one empty string cannot be sent.- An absent member is read as serde reads a missing field, as IPC and MCP
read one:
Nonefor anOption, the default under#[serde(default)], and otherwise a missing required filter. - Items are not sent as repeats:
filter[x]=a&filter[x]=bis a repeated member, and400.
Unknown members are detected from the struct's serde field names (the names
serde derive declares, renames and aliases as serde reports them), read at
runtime by ontogen_jsonapi::filter_fields::<T>(). The generated code does
not rely on serde ignoring unknown fields, and does not use serde_ignored.
An unknown member is refused in request order together with every other name
the route does not accept (§13.2 step 5). A struct whose fields serde does
not report (for example one with #[serde(flatten)]) accepts no members.
The read order, after the unaccepted names, is:
- the struct's members in byte order of member name, where a repeat or a
value serde rejects is
400for that member; - then the struct's first missing required field, in declaration order;
- then the bare filters in byte order of name: a repeat, a bad value or a
missing required filter is
400.
A bare parameter is read from its own filter[…] member: skill_id: &str
is filter[skill_id].
With tasks-tracker's hand-written
ListTasksQuery { status: Option<String>, epic_id: Option<String> }:
GET /api/tasks?filter[status]=closed/done&filter[epic_id]=markdown-backend HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": {
"self": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"first": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"prev": null,
"next": null,
"last": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20"
},
"meta": { "total": 1, "limit": 20, "offset": 0 },
"data": [ ‹task ship-the-emitter› ]
}Rules:
- Filter member names are the struct's field names, not relationship
names. The filter above is
filter[epic_id], notfilter[epic], because the struct is user-authored and ontogen does not rewrite it. - Each of these is
400 invalid_query_parameternaming the parameter:- a
filter[x]that is not a field of the struct or a bare parameter, detected from the struct's serde field names as above; - a value serde rejects;
- a missing required filter (a non-
Optionfield or bare parameter); - a nested or array form (
filter[x][],filter[x][y],filter[x.y]); - any
filter[…]on a list that takes no filter.
- a
- Links carry every filter member as sent, in canonical order (§4.3). A
repeated member is
400. - The generated CRUD
listtakes no filter. Generic field filters are not part of this epic.
sort is honoured on a list whose API fn takes an
order: &[OrderBy<{Entity}SortField>] argument (ADR 0006 §1):
- The generated CRUD
listalways takes one. - A hand-written list (§7.3) takes one when its author adds it, and orders
with the store's
sort_{plural}ororder_{plural}_queryhelpers so the order rules hold.
tasks-tracker's hand-written list takes one.
Sort fields are id plus every scalar attribute (ADR 0006 §2). id names
the #[ontology(id)] field whatever it is called. Relationship names and
dotted paths are not sort fields.
For tasks the sort fields are id, title, status and created.
body is excluded, epic is a relationship, and tags is not a scalar.
GET /api/tasks?sort=-created,title&filter[status]=open/ready HTTP/1.1
Accept: application/vnd.api+jsonThe response holds the tasks with status open/ready, ordered by created
descending, then title ascending, then id ascending (the implicit final
key). Its self is
/api/tasks?filter%5Bstatus%5D=open%2Fready&sort=-created,title&page%5Boffset%5D=0&page%5Blimit%5D=20.
Rules:
- Keys apply in the order given. A leading
-means descending; otherwise ascending. idis appended as the last key, ascending, unless the request namesid, in which case the requested direction stands.nullsorts before every value in ascending order and after every value in descending order. Strings compare by byte. ADR 0006 §3 fixes both rules for parity between the backends.- The value is split on
,and parsed byontogen_core::order::parse_sort, the same function IPC and MCP use (decision 8). - Each of these is
400with codeinvalid_sort_fieldandsource.parameter: "sort":- an unknown field (
sort=priority), a relationship (sort=epic) or a dotted path (sort=epic.title); - a field named twice (
sort=title,-title); - an empty item (
sort=title,,status,sort=); - any
sorton a list that takes noorderargument. The spec requires400from a server that does not support the requested sort.
- an unknown field (
- A repeated
sortparameter is400 invalid_query_parameter, as for every other parameter. - The detail for a duplicate is "
titleis named twice insort" and for an empty item "sorthas an empty item" (not normative, §13.1). The unknown-field detail lists the sort fields from the entity's{Entity}SortField::ALL. - Handlers read the query in §13.2's order: filters,
sort,include, then the page, so a badsortis reported before a badinclude.
{
"status": "400",
"code": "invalid_sort_field",
"title": "Bad Request",
"detail": "`priority` is not a sort field of `tasks`; sort fields are: id, title, status, created",
"source": { "parameter": "sort" }
}include is honoured on the two routes that accept it (§6):
GET /api/{type} and GET /api/{type}/{id}. A relationship of the primary
type can be included, to-one and to-many alike, one level deep, when it is
a field relationship (§5.4) whose target module serves get_by_id as the
target's resource, reachable in the handler's scope (§11.1). A junction-op
relationship is never includable: it has no linkage (§9.1). For a type that
serves relationship routes, every field relationship is includable (§9).
The extra conditions matter in practice for a type that serves list but
not get_by_id, and under a route prefix (§11.1). Relationship and related
routes do not accept include (§6), which the spec makes optional.
GET /api/tasks?include=epic,tags HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": {
"self": "/api/tasks?include=epic,tags&page%5Boffset%5D=0&page%5Blimit%5D=20",
"first": "/api/tasks?include=epic,tags&page%5Boffset%5D=0&page%5Blimit%5D=20",
"prev": null,
"next": null,
"last": "/api/tasks?include=epic,tags&page%5Boffset%5D=0&page%5Blimit%5D=20"
},
"meta": { "total": 1, "limit": 20, "offset": 0 },
"data": [ ‹task ship-the-emitter› ],
"included": [ ‹epic markdown-backend›, ‹tag codegen› ]
}The get route is GET /api/tasks/ship-the-emitter?include=epic. Its
document is shown in §8.1: top-level self carries the query, and the
resource's own links.self does not.
Rules:
includedholds each distinct(type, id)once. It is ordered by relationship inincludeorder, then by first appearance indataorder, each resource's linkage taken in linkage order.- A resource already present in
datais not repeated inincluded. The spec forbids two objects for one(type, id). WithGET /api/tasks?include=parent,subtasks, a task whose parent is also on the page does not repeat that parent. - A resource reached under two paths (
parentandsubtasksare bothtasks) appears once, at its first position, and is fetched once. - Included resources are fetched through the target module's
get_by_id, as related links are (§9.3), so a hand-writtenget_by_idand the scope rules of §11.1 apply. One call is made per distinct id. - A linkage id that
get_by_idreports as not found (a dangling markdown wikilink) is left out ofincluded, and is fetched once however often it is linked. Its identifier stays in the linkage. This is not an error, because the markdown backend tolerates dangling links by design (ADR 0001 amendment 5). Any other error fails the request. - On a paginated list, only the page's relationships are included.
- Each included resource is the target's own resource object, with the target's links (§5.2).
include=(empty value) is"included": [], and links carryinclude=. The spec requires the member wheneverincludeis given.include=epic,epicis the same asinclude=epic.- Each of these is
400 invalid_include_pathwithsource.parameter: "include", for the first offending item in request order:- an empty item in a non-empty value (
include=epic,,include=,); - a name that is not a relationship of the primary type
(
include=owner); - a junction-op relationship;
- a field relationship that is not includable here, because its target
module serves no
get_by_idreachable in the handler's scope; - a dotted path (
include=epic.tasks), since nested inclusion is out of scope.
- an empty item in a non-empty value (
- On every other route,
includeis not an accepted parameter, and is400 invalid_query_parameter(§6).
{
"status": "400",
"code": "invalid_include_path",
"title": "Bad Request",
"detail": "`owner` is not a relationship of `tasks`; it can include: epic, tags, parent, subtasks",
"source": { "parameter": "include" }
}Cost: one get_by_id per distinct included id, so at most max_limit
times the relationships' fan-out for one page. That is acceptable at the
page sizes max_limit allows. Batching is a follow-up, backlog
B-BGMN.
Error tables in this section list, for each check, its step in the check order of §13.2. Within a step, rows apply in table order.
GET /api/tasks/ship-the-emitter HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/ship-the-emitter" },
"data": ‹task ship-the-emitter›
}With ?include=epic, the self link is
/api/tasks/ship-the-emitter?include=epic, and
"included": [ ‹epic markdown-backend› ] follows data.
Errors.
- A path
{id}is a lookup key and is not checked against the id-validity rule (§8.2). An id that does not exist, including one that could never be created, is404from the store (step 9). - A query parameter other than
includeis400 invalid_query_parameter; a badincludevalue is400 invalid_include_path(step 5). - When the id does not exist (
GET /api/tasks/nope), the store returnsAppError::TaskNotFound("nope")and the response is404(step 9):
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "404",
"code": "task_not_found",
"title": "Not Found",
"detail": "Task not found: nope"
} ]
}POST /api/{type}, body is a resource object as primary data.
Without an id (filled by IdStrategy; tasks-tracker slugs the title):
POST /api/tasks HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{
"data": {
"type": "tasks",
"attributes": {
"title": "Review the stack",
"status": "open/ready",
"created": "2026-06-06",
"body": "## Goal\n\nReview.\n"
},
"relationships": {
"epic": { "data": { "type": "epics", "id": "markdown-backend" } },
"tags": { "data": [ { "type": "tags", "id": "codegen" } ] }
}
}
}HTTP/1.1 201 Created
Location: /api/tasks/review-the-stack
Content-Type: application/vnd.api+json
Vary: Accept
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/review-the-stack" },
"data": {
"type": "tasks",
"id": "review-the-stack",
"attributes": { "title": "Review the stack", "status": "open/ready",
"created": "2026-06-06", "body": "## Goal\n\nReview.\n" },
"relationships": {
"epic": { "links": { "…": "…" }, "data": { "type": "epics", "id": "markdown-backend" } },
"tags": { "links": { "…": "…" }, "data": [ { "type": "tags", "id": "codegen" } ] },
"parent": { "links": { "…": "…" }, "data": null },
"subtasks": { "links": { "…": "…" }, "data": [] }
},
"links": { "self": "/api/tasks/review-the-stack" }
}
}With a client id, "id": "review-q3" beside "type", the response is
201 with Location: /api/tasks/review-q3. A client id is honoured under
every IdStrategy (decision 4), so the server never answers 403 for a
client-generated id.
Location is sent when the type's module serves get_by_id, since
links.self names that route (§5.2). Otherwise the 201 has no
Location and the document has no top-level links, because the spec's
SHOULD does not apply to a URL nobody serves. An update document of such a
type has no top-level links either.
The spec asks clients to generate globally unique ids, preferably UUIDs. Ontogen accepts any valid id:
-
Validity. One rule binds both backends. The HTTP handler sits above the store and must be byte-identical across backends (
tests/backend_parity.rs), so SeaORM ids are held to it too. It isontogen_core::id::validate_id, and markdown's create checkmarkdown_store::layout::validate_idmatches it. An id is valid when it:- consists only of lowercase ASCII letters, digits,
.,_,~and-(the URL unreserved set without uppercase), so it needs no escaping in a URL path; - is 1 to 200 bytes long;
- does not start with
.(this also excludes.and..); - does not end with
.; - is not
indexorlog. OKF reserves those stems (ADR 0005); - is not a Windows device name (
con,prn,aux,nul,com0tocom9,lpt0tolpt9), in any case, either whole or as the part before its first.:con,nul.xandcom1.backupare refused,console,con-2andxconare not. Windows opens the device for such a filename, so nocon.mdcan exist there.
These clauses make every valid id a filename on Linux, macOS and Windows. An invalid id is reported with the clause it broke (for example
must not contain uppercase lettersormust be at most 200 bytes), followed by the whole rule andchoose another id. A reserved name is reported as reserved even when it has uppercase letters (Index,CON), since lowercasing it would not help. - consists only of lowercase ASCII letters, digits,
-
Derived ids. The slug function lowercases ASCII letters, folds the letters of the Latin-1 Supplement and Latin Extended-A blocks to ASCII (
étoe,ßtoss,ætoae,łtol), drops combining accents, turns every run of anything else into one-, and trims-from both ends. A slug longer than 190 bytes is cut to 190 (and a-left at the end of the cut is trimmed), so the probesbase-2,base-3, … stay within 200 bytes. No probe is ever longer than 200 bytes. A derived slug that would be reserved dedupes like a collision, toindex-2(per ADR 0005) orcon-2. A slug has no., so only the unsuffixed slug can be reserved. -
Scope. The rule governs ids being created: a client
data.id, and a derived or hook-assigned id, which the store checks. A path{id}is only a lookup key and is never checked against it (§8.1). The server must serve every link it emits, so a SeaORM row whose id fails the rule (an id containing:or uppercase letters,Index,café, or one over 200 bytes, say) is listed, and is readable, updatable and deletable at itslinks.self; no create can make a row with that id. The exception is ids.,..and"": they are listed but unreachable, because clients resolve the dot segments of/tasks/.and/tasks/..away (RFC 3986 §5.2.4; WHATWG URL parsing also decodes%2E), and/tasks/is the collection path. Such rows must be renamed to be reachable. ADR 0004 carries the migration note. A markdown vault keeps the same promise through its lookup check, below. -
Markdown lookups. A markdown lookup goes through a looser path-safety check (
markdown_store::layout::validate_lookup_id): no path separator,:or NUL, no dot path or leading., no trailing.or space, not whitespace-only, at most 252 bytes (a 255-byte filename less.md), and notindexorlogin any case. A record whose file stem breaks the create rule but passes this check (Draft.md,café.md) is listed and reachable. A file whose stem fails it, or is not UTF-8, is not a record: it is not listed or counted and is no record's child (a:b.md,Index.md,draft .md), so no list emits a link that a lookup refuses. Such a file must be renamed to become a record. The store answers a lookup of an id that fails the check with{Entity}NotFoundinstead of passingmarkdown_store::Error::InvalidIdthrough as a500.A lookup matches the stored file name byte for byte on every filesystem. macOS and Windows filesystems resolve a name to a file stored under another letter case, and macOS also across Unicode normalization forms (
caféwith a precomposedé, or witheand a combining accent). There aget,updateordeleteof such a variant (KEPTfor a storedkept.md) is{Entity}NotFound, as on SeaORM, and reads, rewrites or removes nothing; a relation id spelled as a variant does not resolve either. A create counts an id as taken when its file name resolves to an existing file under any spelling, because writing it would replace that file: a client id is409and a derived id probes on to-2. On a case-sensitive filesystem the variant is a different name and the create proceeds. -
An invalid client id is
400, not403. The spec's403is for a server that does not support client ids, and this one does. -
Uniqueness within the type is the store's
409. The id's format is otherwise the consumer's choice; slugs are the norm in markdown vaults.
The response is always 201 with the full resource, never 204. The
server can change the resource (the id fill, before_create hooks).
Body mapping:
data.idbecomesCreateXInput.id, or""when absent, which is the store's "derive one" signal.attributesbecome the remaining non-relation fields.- Each to-one
relationships.x.databecomes the foreign-key field (epic_id). - Each to-many
relationships.x.databecomes the id vector (tags,subtasks). Ahas_manyvector sets those children's foreign keys (§5.4). - A relationship absent from the body takes the field's default (
Noneor empty), as the field's#[serde(default)]onCreateXInputdoes. A non-Optionto-one has no default, so it cannot be omitted.
Errors, by step of §13.2:
| Step | Condition | Status | code |
source |
|---|---|---|---|---|
| 2 | Accept not satisfiable |
406 | not_acceptable |
header: "Accept" |
| 3 | Content-Type not acceptable |
415 | unsupported_media_type |
header: "Content-Type" |
| 5 | any query parameter | 400 | invalid_query_parameter |
parameter |
| 7 | body is not JSON, or its top level is not an object | 400 | invalid_document |
none |
| 7 | data missing |
400 | invalid_document |
pointer: "" |
| 7 | data not an object |
400 | invalid_document |
pointer: "/data" |
| 7 | data.type missing |
400 | invalid_document |
pointer: "/data" |
| 7 | data.type not a string |
400 | invalid_document |
pointer: "/data/type" |
| 7 | data.type is not this collection's type ("epics" posted to /api/tasks) |
409 | type_mismatch |
pointer: "/data/type" |
| 7 | data.id present but not a string, or not a valid id |
400 | invalid_document |
pointer: "/data/id" |
| 7 | data.lid present (unsupported) |
400 | invalid_document |
pointer: "/data/lid" |
| 7 | attributes present but not an object |
400 | invalid_document |
pointer: "/data/attributes" |
| 7 | an attribute name the type does not have, including a relation field written as an attribute (epic_id) |
400 | unknown_attribute |
pointer: "/data/attributes/{name}" |
| 7 | a required attribute missing | 400 | missing_attribute |
pointer: "/data/attributes", or "/data" when attributes is absent |
| 7 | an attribute value serde rejects, or an integer outside i64 for a type that reaches past it (§5.3) |
400 | invalid_attribute |
pointer: "/data/attributes/{name}" |
| 7 | relationships present but not an object |
400 | invalid_document |
pointer: "/data/relationships" |
| 7 | a relationship name the type does not have | 400 | unknown_relationship |
pointer: "/data/relationships/{name}" |
| 7 | a junction-op relationship present (§9.1) | 403 | relationship_update_unsupported |
pointer: "/data/relationships/{name}" |
| 7 | a relationship object that is not an object or has no data; data of the wrong arity; an identifier without string type and id |
400 | invalid_document |
pointer at the offending value: …/{name}, …/{name}/data or …/{name}/data/{i} |
| 7 | data: null on a non-Option to-one |
403 | relationship_required |
pointer: "/data/relationships/{name}/data" |
| 7 | a non-Option to-one absent |
400 | missing_relationship |
pointer: "/data/relationships", or "/data" when relationships is absent |
| 7 | an identifier of the wrong type | 409 | type_mismatch |
pointer: "/data/relationships/{name}/data" (or …/data/{i}) |
| 7 | a has_many of the type's own type naming the client data.id (§5.4) |
403 | relationship_cycle |
the identifier's first occurrence, /data/relationships/{name}/data/{i} |
| 8 | a linked resource that does not exist | 404 | no_such_related_resource |
the identifier's pointer |
| 9 | no id after before_create and the IdStrategy (Provided with no id; a slug source that slugifies to empty) |
400 | {entity}_id_required |
none |
| 9 | the id already exists | 409 | {entity}_already_exists |
pointer: "/data/id" when the request carried data.id; otherwise none |
| 9 | any other AppError |
per §13.4 | §13.4 | none |
Notes:
-
Member order within step 7 follows §13.2 step 7. Within one declared relationship the order is its shape, then
null, then absence, then identifier types, then, for ahas_manyof the type's own type, an identifier naming the resource itself. -
Pointers only ever name a value present in the request, as the spec requires. For something missing, a pointer names the nearest enclosing member that exists.
-
Unknown attributes are refused, not ignored. The commonest cause is a client still sending the flat shape. The
detailsays so: "epic_idis not an attribute oftasks; it is theepicrelationship". -
Linked-resource checks (step 8). The handler checks every linked id with the target's store
getbefore creating, in step-7 order. The spec requires404for a reference to a resource that does not exist, and the markdown backend would otherwise write a dangling wikilink. -
No id (step 9) is detected by the store, after
before_createhooks have run, so a hook may assign the id. The store returnsAppError::{Entity}IdRequired(reason). The handler does not know theIdStrategyand does not pre-check. -
Duplicates (step 9) are detected by the store as
AppError::{Entity}AlreadyExists(id):- on markdown, from
markdown_store::Error::AlreadyExists; - on SeaORM, from a unique-constraint violation on the primary-key insert.
A derived id never reaches this error:
- markdown probes
-2,-3and so on; - SeaORM probes the same way, and a derived-id insert that loses a race retries with the next suffix.
A client id, or an id a hook assigned, can collide. Only the first carries a pointer, because only it is in the request.
- on markdown, from
Example: POST /api/tasks with "type": "epics":
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "409",
"code": "type_mismatch",
"title": "Conflict",
"detail": "`epics` cannot be created at /api/tasks, which holds `tasks`",
"source": { "pointer": "/data/type" }
} ]
}PATCH /api/{type}/{id}. PUT is not routed and answers 405 (§13.5).
PATCH /api/tasks/ship-the-emitter HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{
"data": {
"type": "tasks",
"id": "ship-the-emitter",
"attributes": { "status": "open/ready" },
"relationships": {
"epic": { "data": null },
"tags": { "data": [] }
}
}
}HTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/ship-the-emitter" },
"data": {
"type": "tasks",
"id": "ship-the-emitter",
"attributes": { "title": "Ship the emitter", "status": "open/ready",
"created": "2026-06-06", "body": "## Goal\n…" },
"relationships": {
"epic": { "links": { "…": "…" }, "data": null },
"tags": { "links": { "…": "…" }, "data": [] },
"parent": { "links": { "…": "…" }, "data": null },
"subtasks": { "links": { "…": "…" }, "data": [] }
},
"links": { "self": "/api/tasks/ship-the-emitter" }
}
}Semantics, which map one to one onto UpdateXInput and its double_option
fields:
| In the body | UpdateXInput field |
Effect |
|---|---|---|
| attribute absent | None |
unchanged |
| attribute with a value | Some(v) / Some(Some(v)) |
set |
attribute null, field is Option<T> |
Some(None) |
cleared |
attribute null, field is not Option |
— | 400 invalid_attribute |
| relationship absent | None |
unchanged |
to-one data: {type, id} |
epic_id: Some(Some(id)) |
set |
to-one data: null, field Option<String> |
epic_id: Some(None) |
cleared |
to-one data: null, field String |
— | 403 relationship_required |
many_to_many data: [...] |
tags: Some(vec) |
full replacement; [] clears |
has_many data: [...] |
subtasks: Some(vec) |
full replacement through the children's foreign keys (§5.4) |
has_many data naming the resource itself |
— | 403 relationship_cycle |
An update with no attributes and no relationships is a no-op. It
returns 200 with the current resource, and the markdown backend performs
no write.
The response is always 200 with the full resource: before_update hooks
may change fields the request did not mention, and the spec then requires
200.
Errors, by step of §13.2:
| Step | Condition | Status | code |
source |
|---|---|---|---|---|
| 2, 3 | as in §8.2 | |||
| 5 | as in §8.2 | |||
| 7 | §8.2's step-7 rows up to and including type_mismatch |
|||
| 7 | data.id missing |
400 | invalid_document |
pointer: "/data" |
| 7 | data.id not a string |
400 | invalid_document |
pointer: "/data/id" |
| 7 | data.id differs from the URL id |
409 | id_mismatch |
pointer: "/data/id" |
| 7 | §8.2's remaining step-7 rows, except its data.id row (replaced by the three above), missing_attribute and missing_relationship |
|||
| 8 | a linked resource that does not exist | 404 | no_such_related_resource |
the identifier's pointer |
| 9 | the resource does not exist | 404 | {entity}_not_found |
none |
| 9 | a has_many replacement drops a child whose foreign key is not Option |
403 | {child}_parent_required |
none |
| 9 | any other AppError |
per §13.4 | §13.4 | none |
§8.2's relationship_cycle row compares the identifiers with the URL id,
which data.id equals by then.
A body id is compared with the URL id exactly. A string that differs is
409, whatever it contains ("" included), as the spec requires for an id
that does not match the endpoint. Neither the body id nor the URL {id} is
checked against the validity rule on update; it applies only to ids being
created.
A missing resource is detected by the store, after the body checks. So a
PATCH to a missing id with a malformed body gets the body's 400, not
404.
Example, PATCH /api/tasks/ship-the-emitter with "id": "other":
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "409",
"code": "id_mismatch",
"title": "Conflict",
"detail": "body id `other` does not match URL id `ship-the-emitter`",
"source": { "pointer": "/data/id" }
} ]
}DELETE /api/tasks/ship-the-emitter HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 204 No Content
Vary: Accept- A missing resource is
404 task_not_found. The spec's SHOULD is taken. - A request body is ignored.
- Query parameters are
400.
A resource type has relationship and related routes when its module
serves get_by_id as the type's resource (§8.1) and the type has at least
one relationship, a relation field (§5.4) or a junction-op relationship
(§9.1). For every relationship of such a type the server serves:
| Method | Path | To-one | many_to_many |
has_many |
Junction op |
|---|---|---|---|---|---|
GET |
/api/{type}/{id}/relationships/{rel} |
linkage | linkage | linkage | linkage |
PATCH |
same | set or clear | replace | replace | 403 |
POST |
same | 403 |
add | add | add, or 403 without add_Y |
DELETE |
same | 403 |
remove | remove | remove, or 403 without remove_Y |
GET |
/api/{type}/{id}/{rel} (related link) |
resource or null |
resource collection | resource collection | resource collection |
- Which types. A type whose module serves no
get_by_id, or that has no relationship, registers neither route, and its relationship objects carry nolinks(§5.4). The routes exist under a route prefix exactly where the type'sget_by_idroute does (§11.1). 403s. The spec definesPOSTandDELETEonly for to-many relationships, and requires403for an unsupported relationship update. Every403in the table uses coderelationship_update_unsupported, and so does aPATCH,POSTorDELETEon a relation field of a type whose module serves noupdateas its resource, since relation fields are written throughupdate(§9.1). Each of these is decided from the route and method alone, before the body is read (§13.2 step 6).- One identifier per
POSTorDELETE. A body with more than one is403 relationship_batch_unsupported."data": []is a successful no-op. The spec allows403for an unsupported relationship update. Allowing only one identifier makes every relationship write a single store or junction call, which either succeeds or fails, so no request can be partly applied. The TS transport sends one id per request. {rel}is captured. It is the relationship name verbatim (§5.4). Both route templates capture it, so a name that is not a relationship of{type}, or a segment that does not percent-decode to UTF-8, reaches the generated handler and is404 no_such_relationship. It does not fall through to the consumer's router.- Other methods are
405, withAllow: GET, HEAD, PATCH, POST, DELETEon the relationship route andAllow: GET, HEADon the related route. That holds whatever{rel}names, because routing happens before{rel}is looked up. - Consumer routes. The two templates match every path of the shapes
/api/{type}/{x}/{y}and/api/{type}/{x}/relationships/{y}under such a type. A consumer route of the same shape merged into the generated router, such as/api/tasks/{task_id}/{view}, conflicts with it, and Axum panics when the routers are merged. A consumer route with a static last segment, such as/api/tasks/{id}/history, is accepted and wins over{rel}for that segment (§9.4). - Build-time checks. For a type that serves these routes, the target
type of every relationship, relation field or junction op, must have a
module that serves
get_by_id: the handlers read linked and related resources through it. A target whose module serves none is aCodegenErrornaming the relationship. The junction-op checks are in §9.1. Like the other HTTP checks (§10.3), they run in builds that generate an HTTP server or an HTTP TypeScript client.
Relation fields (belongs_to, many_to_many, has_many on the
entity) are served through the module's own get_by_id and update, the
functions its resource routes call, so they work on both backends with no
user code and pass through the same hooks and errors as a resource
PATCH:
GET …/relationships/{rel}reads the field fromget_by_id(id).PATCHbuilds the module's update input with only that field set (UpdateXInput { field: Some(…), .. }) and callsupdate(id, input), with the semantics of §8.3.POST(to-many) reads the current ids fromget_by_id. If the requested id is absent, it appends it and writes throughupdate. If it is present, it writes nothing.DELETE(to-many) reads the current ids. If the requested id is present, it removes it and writes throughupdate. If it is absent, it writes nothing. Forhas_many, removing a child clears its foreign key, or fails with403 {child}_parent_required(§5.4).- A
POSTorDELETEthat writes sends every id the record holds, an id whose target was deleted (§7.5) included. The store checks only the ids an update adds (§5.4), so such an id does not block the write. APATCHnames every id, and step 8 checks each. A full-replacementPATCHof a to-many relationship (the relationship endpoint, or the relationship in a resourcePATCHbody) must therefore leave out an id whose resource no longer exists, or step 8 refuses it with404 no_such_related_resource. A relationshipPOSTorDELETE, and a resourcePATCHthat omits the relationship, still succeed on a record holding such an id. "data": []onPOSTorDELETEwrites nothing. The parent is still read, so a missing parent is still404.- On a
has_manyof the type's own type, aPATCHorPOSTnaming the parent itself is403 relationship_cycleat step 7, before the parent is read (§9.2). ADELETEnaming it writes nothing, since the parent is never in its own list. - When the module serves no
update,PATCH,POSTandDELETEare403 relationship_update_unsupported(§9).
POST and DELETE are a read followed by one write, not one transaction.
A concurrent writer to the same relationship can lose an update. That
matches every other read-modify-write in the store.
Junction ops are user-authored functions in a resource module:
list_X(parent_id), classifiedJunctionList;add_Y(parent_id, child_id), classifiedJunctionAdd;remove_Y(parent_id, child_id), classifiedJunctionRemove;
where Y is the singular of X. Together they define a to-many
relationship named X, exactly as written in the fn name: list_tags /
add_tag / remove_tag define tags, and list_sub_tasks defines
sub_tasks.
- Classification. A one-parameter
list_Xis a junction op only when its module also has anadd_Yor aremove_Yfor the same relationship:add_tagorremove_tagbesidelist_tags,add_sub_taskbesidelist_sub_tasks. Without one it is a customGET(§10), served at/api/{type}/{action}/{param}. That keeps a plainlist_by_status(status)from becoming a relationship namedby_status. The rule holds on every transport: over IPC and MCP and in both TS transports, a lonelist_Xreturns its plainVec, with no page (§14, §15). - Outside a resource module. Junction ops in a module that is not a
resource module (§5.1) have no resource type to hang a relationship on.
This includes the test fixture
destination_skills. They are served as custom ops (§10.4), scoped or not. - Target type. The relationship's target is the element type of
list_X'sVec<…>when that is an entity. Otherwise it is the entity whoseurl_pluralisXwith_replaced by-. When neither names an entity, the generator raises aCodegenError. - Signatures.
list_XreturnsVec<Target>(the entities) orVec<String>(their ids), inside the fn'sResult. Each id parameter oflist_X,add_Yandremove_Yis a&str,Stringor&String. The generator raises aCodegenErrornaming the fn for any other return or parameter type. list_Xis required. Anadd_Yorremove_Yin a resource module with nolist_Xfor its relationship is aCodegenError: linkage and membership are read throughlist_X.get_by_idis required. A resource module with a junction-op relationship must serveget_by_idas its resource, which the routes use to check the parent. Otherwise the generator raises aCodegenError.- Collisions. The name rules of §5.4 apply. A junction-op
relationship's name must be a legal member name, must not be
type,idorrelationships, and must not equal an attribute, a relation field's relationship, or another junction-op relationship. Solist_tagsbeside atagsrelation field is aCodegenError; ops for a second set of tags take another name (list_labels,add_label,remove_label).
Each junction endpoint behaves as follows. The checks ahead of it run in
§13.2 order: the 403s below at step 6, the identifier's shape and type
at step 7, then the parent and, for POST, the target at step 8.
GET …/relationships/Xcallslist_X(parent_id). When it returns entities, linkage is built from their ids. When it returnsVec<String>, linkage is built from the strings.POSTis403 relationship_update_unsupportedwhen the module has noadd_Y. Otherwise, with the target known to exist, it callslist_X(parent_id). If the id is already a member, the response is204without callingadd_Y; otherwise it callsadd_Y(parent_id, child_id).DELETEis403 relationship_update_unsupportedwhen the module has noremove_Y. Otherwise it does not check that the target exists, so a member whose target was deleted can still be removed. It callslist_X(parent_id). If the id is not a member, the response is204without callingremove_Y; otherwise it callsremove_Y(parent_id, child_id).PATCHis403 relationship_update_unsupported, because no junction op replaces a set.
The membership read is what makes a repeated POST, or a DELETE of an
absent member, succeed as the spec requires, whatever add_Y does with a
duplicate.
Pagination. When the module is paginated, junction GETs page the
result of list_X in memory:
- The relationship linkage
GETand the related-linkGETacceptpage[offset]andpage[limit]with §7.2's rules. - They return
meta {total, limit, offset}, plusselfand the four pagination links at the top level, where they paginate the primary data: the relationship's members. The linkage document'slinksalso carriesrelated. - Relation-field relationships are never paginated. Their linkage is already loaded with the resource.
In resource objects. A junction-op relationship appears in the
resource object's relationships with links only and no data, which
the spec allows. Linkage would mean calling user code once per resource on
every get and list. It follows that:
- it cannot be included (§7.5);
- it cannot be written in a create or update body (§8.2,
403 relationship_update_unsupported); - the TS flattener leaves it out of the flat entity, since the entity struct has no such field.
No routes of their own. Junction ops of a resource module are reached
only through these endpoints. They have no /api/{type}/{parent_id}/…
route, and the TS transport calls the endpoints (§14.2).
Fetch to-one linkage:
GET /api/tasks/ship-the-emitter/relationships/epic HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/ship-the-emitter/relationships/epic",
"related": "/api/tasks/ship-the-emitter/epic" },
"data": { "type": "epics", "id": "markdown-backend" }
}An empty to-one is "data": null, still 200.
Fetch to-many linkage. GET /api/tasks/ship-the-emitter/relationships/tags
returns 200 with links.self and links.related, and
"data": [ { "type": "tags", "id": "codegen" } ]. An empty to-many is
"data": [].
Set and clear to-one:
PATCH /api/tasks/ship-the-emitter/relationships/epic HTTP/1.1
Content-Type: application/vnd.api+json
{ "data": { "type": "epics", "id": "markdown-backend" } }HTTP/1.1 204 No Content
Vary: Accept{ "data": null } clears it, also 204.
Add, remove and replace to-many. With the release tag in the
vault:
POST /api/tasks/ship-the-emitter/relationships/tags HTTP/1.1
Content-Type: application/vnd.api+json
{ "data": [ { "type": "tags", "id": "release" } ] }- The response is
204. Posting an id already present is a no-op and still204. DELETEwith the same body removesrelease. Removing an absent id is also204.PATCHwith{"data": []}clears the relationship.PATCH /api/tasks/ship-the-emitter/relationships/subtaskswith{"data": [...]}makes exactly those tasks the subtasks. Dropped subtasks getparent_id: null.
A has_many listing the parent. A child of subtasks is a task, so a
list naming the parent itself would make it its own parent:
PATCH /api/tasks/ship-the-emitter/relationships/subtasks HTTP/1.1
Content-Type: application/vnd.api+json
{ "data": [ { "type": "tasks", "id": "write-docs" },
{ "type": "tasks", "id": "ship-the-emitter" } ] }{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "403",
"code": "relationship_cycle",
"title": "Forbidden",
"detail": "`tasks` `ship-the-emitter` cannot be in its own `subtasks`: it would be its own parent",
"source": { "pointer": "/data/1" }
} ]
}Nothing is written. A POST naming the parent is refused the same way, at
/data/0. Only the resource itself is refused here: a longer cycle
through other resources is not detected on the wire.
Responses. Every successful relationship mutation is 204 No Content.
The server makes no change beyond the request, so the spec allows it.
Errors, by step of §13.2:
| Step | Condition | Status | code |
source |
|---|---|---|---|---|
| 1 | a method other than GET, HEAD, PATCH, POST, DELETE |
405 | method_not_allowed |
none |
| 2 | Accept not satisfiable |
406 | not_acceptable |
header: "Accept" |
| 3 | Content-Type not acceptable (PATCH, POST, DELETE) |
415 | unsupported_media_type |
header: "Content-Type" |
| 4 | {rel} is not a relationship of {type}, or does not percent-decode |
404 | no_such_relationship |
none |
| 5 | any query parameter not accepted by §6 | 400 | invalid_query_parameter |
parameter |
| 6 | a write the relationship does not support (§9: the table, a relation field without update, a junction op without add_Y or remove_Y) |
403 | relationship_update_unsupported |
none |
| 7 | body not JSON, or top level not an object | 400 | invalid_document |
none |
| 7 | data missing |
400 | invalid_document |
pointer: "" |
| 7 | data of the wrong arity for the relationship |
400 | invalid_document |
pointer: "/data" |
| 7 | POST or DELETE with more than one identifier |
403 | relationship_batch_unsupported |
pointer: "/data" |
| 7 | an identifier without string type and id |
400 | invalid_document |
pointer: "/data" (to-one) or "/data/{i}" |
| 7 | null on a non-Option to-one |
403 | relationship_required |
pointer: "/data" |
| 7 | an identifier of the wrong type | 409 | type_mismatch |
the identifier's pointer |
| 7 | PATCH or POST on a has_many of {type} naming the parent ({id}) |
403 | relationship_cycle |
the identifier's first occurrence, /data/{i} |
| 8 | parent resource missing | 404 | {entity}_not_found |
none |
| 8 | a linked resource that does not exist (PATCH, POST; never DELETE) |
404 | no_such_related_resource |
the identifier's pointer |
| 9 | a has_many write drops a child whose foreign key is not Option |
403 | {child}_parent_required |
none |
| 9 | any other AppError from the store or a junction op |
per §13.4 | §13.4 | none |
The data missing row covers a junction add without its child id: it is a
body with no data, so it is 400 invalid_document.
GET /api/tasks/ship-the-emitter/epic returns
{ "jsonapi", "links": { "self": "/api/tasks/ship-the-emitter/epic" }, "data": ‹epic markdown-backend› }.
- An empty to-one is
"data": null, as forGET /api/tasks/ship-the-emitter/parent. GET /api/tasks/ship-the-emitter/tagsreturns"data": [ ‹tag codegen› ], ordered as the linkage.- Resources are loaded with the target module's
get_by_id, one call per id. Each is the target's own resource object, with the target's links (§11.1 says when they carry a route prefix). - A dangling id is skipped (§7.5). A to-one whose target is missing is
"data": null. - When the parent is missing, the response is
404 {entity}_not_found. - For a junction op whose
list_Xreturns entities, those entities are the data, with no extraget. - Related collections are not filtered or sorted. They are paginated only
for a junction op in a paginated module (§9.1), and then carry
selfand the four pagination links. Otherwise the document's only link isself. A paginated junction op that lists ids is paged over its ids:meta.totalcounts them, dangling ones included, and the page is cut before the dangling ids in it are skipped, so a page can hold fewer thanlimitresources.
A custom op's static segment wins over {id} in Axum's router. For
illustration:
- A custom
POST /api/workouts/startshadowsPATCHon a workout whose id isstart. - A custom
GET /api/workouts/summary/{id}shadows the related link of every relationship on a workout with idsummary.
The contract records this shadowing and does not fix it.
Custom requests and responses are fully conformant JSON:API documents (decisions 1 and 7).
Every custom op (CustomGet, CustomPost) responds with a meta-only
document. iron-log's stats::get_workout, with illustrative values (the
example's handler returns zeros):
GET /api/stats/workout HTTP/1.1
Accept: application/vnd.api+jsonHTTP/1.1 200 OK
{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "total_count": 12, "total_duration_minutes": 540 } }
}resultis the fn'sOkvalue as serde serializes it. It is a plain value even whenTis an entity or aVecof entities; the client types come from the Rust fn.- A fn returning
()responds204 No Content. - The status is
200, never201. - There is no
linksand nodata. A meta-only document has no primary data, so it needs noselflink. - Errors use §13.
AppError-typed fns get the status mapping. Other error types get500 internal_error.
Routes are /api/{url_for_module}/{action}, plus one path segment per
required non-Input parameter on GET.
POST bodies are meta-only documents. Every argument that is not
state, store or a path parameter is a member of meta.args, keyed by its
Rust parameter name. That includes an *Input argument and Option
arguments. For illustration, a
workout::start(state, input: StartWorkoutInput) op (the shape
src/servers/tests.rs uses):
POST /api/workouts/start HTTP/1.1
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
{ "meta": { "args": { "input": { "template_id": "push-day" } } } }Rules for POST bodies, all within step 7 of §13.2:
- A request with no body is read as
{"meta": {"args": {}}}. It needs noContent-Type; §3.2's check applies only when a body is present. - A body that is not a JSON object is
400 invalid_documentwith nosource. - When
metaormeta.argsis missing or is not an object, the request is400 invalid_document. A missing member's pointer names the nearest member that exists:""whenmetais missing,/metawhenargsis missing. A member that is present but not an object is named itself:/metaor/meta/args. If the fn has no required arguments, a missing member is read as{}instead. - Other top-level members of the request document are ignored.
- Members are checked in §13.2 step 7's order.
- An unknown member of
meta.argsis400 invalid_documentat/meta/args/{name}. - For declared arguments:
- a missing required argument is
400 invalid_documentat/meta/args; - a value serde rejects is
400 invalid_documentat its member; - an absent or
nullmember isNonefor anOptionargument.
- a missing required argument is
An *Input parameter on an op served without a body (a custom GET,
get_by_id, delete, JunctionList, JunctionRemove) is a
CodegenError: there is no body to carry it.
GET optional arguments use the opArg family. Option arguments of a
CustomGet are opArg[{name}] query parameters. For illustration,
get_summary(state, id: &str, verbose: Option<bool>) is served as
GET /api/workouts/summary/w1?opArg[verbose]=true.
- Each value is deserialized by serde from the string, as
axum::extract::Queryreads one field (§7.3). - An unknown
opArg[…], a malformed value, or any other parameter is400 invalid_query_parameter. - The spec requires
400for an all-lowercase name it does not itself define, such as a bare?verbose=true. An implementation-specific family must contain a non-a-zcharacter, and the spec recommends a capital letter.opArgis that family.
A singleton module (// ontogen:singleton, or
NamingConfig::singleton_modules) has no resource type. Its routes stay
/api/{singular}/{action}. Every op in it is a custom op and follows
§10.1 and §10.2.
An op in a singleton module that classifies as List, GetById,
Create, Update, Delete or a junction op is a CodegenError. A
singleton module is opted into explicitly, so a CRUD op there is a
mistake. A module with no entity behind it is the case §10.4 serves.
The build-time rules of §10.2–§10.4 apply to builds that generate an HTTP
server or an HTTP TypeScript client: this singleton check, the *Input rule
for ops served without a body, the exact arguments of a CRUD-named op in a
module with no entity, and the types an Option argument read from opArg
may have. IPC-only and MCP-only builds are unaffected.
Two kinds of op have no resource to serve and are served as custom ops:
- CRUD-classified ops in a module that is not a resource module (§5.1);
- junction ops outside a resource module (§9.1).
The §10.1 and §10.2 rules apply, with these routes, scoped or not (§11.1):
| Op | Route | Request | Response |
|---|---|---|---|
list |
GET /api/{m} |
filtered: filter[…] (§7.3); paginated: opArg[limit], opArg[offset] |
meta.result: the list, or {items, total, limit, offset} when paginated |
get_by_id |
GET /api/{m}/{id} |
— | meta.result |
create |
POST /api/{m} |
meta.args.input |
200, meta.result |
update |
PATCH /api/{m}/{id} |
meta.args.input |
meta.result |
delete |
DELETE /api/{m}/{id} |
— | 204 |
JunctionList |
GET /api/{m}/{parent_id}/{segment} |
paginated: opArg[limit], opArg[offset] |
meta.result, as for list |
JunctionAdd |
POST /api/{m}/{parent_id}/{segment} |
meta.args.{child param} |
204 |
JunctionRemove |
DELETE /api/{m}/{parent_id}/{segment}/{child_id} |
— | 204 |
update uses PATCH here too, so that no generated route uses PUT.
{segment} is the kebab-case plural the junction fn names: list_tags,
add_tag and remove_tag all use tags, and add_sub_task uses
sub-tasks.
A CRUD-named op in a module with no entity takes exactly its row's
arguments: get_by_id(id), delete(id), create(input),
update(id, input), and list() or list(limit, offset). Any other
parameter is a CodegenError, because the generated clients call these ops
with those arguments only. The exception is list, where the extra
parameters are its filter (§7.3): the list is GET /api/{m} with
filter[…] and, when paginated, the opArg page, and it answers
meta.result. The store gets the page, and count gets the same filter.
Junction rows keep their fixed arguments.
A body on a DELETE row (delete, JunctionRemove), and its
Content-Type, is ignored, as for a resource DELETE (§3.2).
An Option argument of a bodyless row (a custom GET, entity-less
get_by_id or delete, JunctionList, JunctionRemove) is read from a
single opArg value, so its inner type must be a plain path: one with
generic arguments (Option<Vec<String>>), a tuple, or a schema entity is a
CodegenError. A struct the schema does not declare cannot be told from its
name and is not refused; serde rejects its value at request time.
On a paginated row, opArg[limit] and opArg[offset] are unsigned 32-bit
integers. An absent limit is default_limit and an absent offset is
0. limit is clamped to max_limit; opArg[limit]=0 is not an error and
yields a page of zero items. A meta.result page has no links, so an empty
page needs no next or last, and the IPC list command takes limit: 0
the same way. The values use the grammar of page[…] (§7.2): one or more
ASCII digits, at most 4294967295. +5, -1, an empty value and 1.5 are
400 invalid_query_parameter, with source.parameter naming the
parameter. A list
that is not paginated accepts no query parameters.
With RoutePrefix { segments: "projects/:project_id", state_accessor: "store_for", … },
every route in this contract moves under the prefix: resource,
relationship, related, custom and event routes alike.
- Paths are
/api/projects/{project_id}/tasks,/api/projects/{project_id}/tasks/{id}/relationships/epic, and so on. - Every link and
Locationcarries the prefix:"self": "/api/projects/7c0e…/tasks/ship-the-emitter". - The resource
typedoes not carry the prefix (tasks). Each scope is served from its own store, and no document mixes scopes. Each scope therefore acts as a separate API in the spec's sense, and(type, id)is unique within it. - A prefix parameter that fails to parse (e.g. a non-UUID
project_id) is400 invalid_path_parameter, with nosource. The spec has no source member for path segments. - A handler binds the prefix parameter's value as
ontogen_scope, never under its configured name, which could be a name the handler binds itself (query,id). The name appears only in the route. - A failing scope accessor (
state.store_for(&ontogen_scope)) stays500 internal_error, as E0003 phase 1 keeps it. Mapping it to404waits on E0003's store-accessor contract.
Scoped and unscoped routes MUST have identical wire behaviour, and a scoped route has the shape of its unscoped one under the prefix:
- A scoped filtered list behaves as an unscoped one, whether it is served
as a resource or, with no entity behind it, as a custom op (§10.4). A
scoped list that is not served as a resource passes the page to the
store's page-taking
listand callscount, like an unscoped one, so a hand-written scopedlistmust honour its page. - A type's relationship and related routes (§9) exist under the prefix
wherever its
get_by_idroute does, which is whenget_by_idtakes the store. Their links carry the prefix, and so do the links of a related resource whose own type'sget_by_idroute exists under the prefix. - Every link names a served route (§5.4). A resource's
links.selfand relationship links name itsget_by_idroute in the scope of the handler that emits them, so a type'slist,createandupdatetake the store exactly when itsget_by_iddoes (deleteanswers no document). A type whoseget_by_idtakes no store serves its relationship routes outside the prefix, where no store is open and only unscoped routes exist, so its junction ops and theget_by_idof every type it relates to take no store either. Acreateorupdatechecks each id it links with the target type'sget_by_idin its own scope (§13.2), so one that takes no store links no type whoseget_by_idtakes the store, whether or not its own type servesget_by_id. The reverse is allowed: scoped relationship routes link a target whoseget_by_idtakes no store at its unscoped collection and call junction ops that take no store as they are, and a scopedcreateorupdatechecks such a target with the state. The generator refuses every other combination at build time.include(§7.5) follows the same reach without a build check: an unscopedlistcannot include a type whoseget_by_idtakes the store, and answers400 invalid_include_pathfor it, while a scoped one includes a target whoseget_by_idtakes no store, linked at its unscoped collection. - Junction ops outside a resource module are served at their §10.4 routes
under the prefix:
GET/POST /api/projects/{project_id}/{m}/{parent_id}/{segment}andDELETE …/{segment}/{child_id}. An add carries only the child id inmeta.args, and a scopedJunctionListpages withopArg[limit]/opArg[offset]like the unscoped one.
Routes and types come from module names exactly as for the primary surface:
no path prefix per surface, and one type per module even when the module
spans surfaces. Each surface's store_accessor and pagination apply. The
wire is indistinguishable from a single-surface server.
Routes, parameters, resume and lag are those of #184 and #185:
GET /api/events/{event-name}plus/{param}per required parameter. iron-log'sactivity_for_kindisGET /api/events/activity-for-kind/{kind}.sse_route_overridesstill apply. Aroute_prefixputs the routes under the prefix.Optionparameters andresumeare plain query parameters (?resume=4). TheLast-Event-IDheader wins over?resume=.- These routes respond
text/event-stream, not a JSON:API document, so the §3 negotiation and §6 query rules do not apply to them.Acceptis not checked.EventSourcesendstext/event-stream.
Frames. Only the data: payload is JSON:API-shaped (decision 2).
tasks-tracker has no event ops. For illustration, a resumable
task_changed op yielding Task, with ids from seq_id:
event: task-changed
data: {"type":"tasks","id":"ship-the-emitter","attributes":{"title":"Ship the emitter","status":"closed/done","created":"2026-06-06","body":"## Goal\n…"},"relationships":{"epic":{"data":{"type":"epics","id":"markdown-backend"}},"tags":{"data":[{"type":"tags","id":"codegen"}]},"parent":{"data":null},"subtasks":{"data":[]}}}
id: 0:17
When the event's item type T is an entity (known from the schema, §5.1),
data: is its resource object (§5): type, id, attributes,
relationships, in that order.
- Relationship objects carry
dataonly, and the frame carries nolinks. A frame is not tied to a request URL, and links would double the size of every frame for no reader. - Junction-op relationships, which have no
data, are left out.
When T is not an entity, data: is {"meta":{"result":T}}, the same
rule as custom ops (§10.1). An example is iron-log's Activity, whose
event_id is its seq:
event: activity-for-kind
data: {"meta":{"result":{"seq":4,"kind":"workout","id":"w1"}}}
id: 4
The rest of the stream follows those PRs:
id:is emitted for resumable ops only, with the sameseq_id/no_idrules and the same newline guard.event: lagwithdata: {"skipped":N}keeps the stream open. It is a control frame, not a payload, so it gets no envelope.event: erroron a serialization failure, and the:keep-alive comment every 15 s.
A subscribe call that fails before the stream opens returns a JSON:API
error document (§13) with the mapped status, scoped or not. It is mapped by
§13.4's routing on the event fn's own error type, which the parser records
as it does for API fns: AppError gets its status and code, any other
type is 500 internal_error. EventSource cannot read that body, but a
fetch-based client and curl can.
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "404",
"code": "task_not_found",
"title": "Not Found",
"detail": "Task not found: nope"
} ]
}- Exactly one error object per response. The server stops at the first failure in §13.2's order. The spec allows this, and it keeps the response status and the error status equal.
- Members, in this order:
status: the HTTP status as a decimal string ("404"). Always present.code: a snake_case string from §13.3 or §13.4. Always present.title: the status's reason phrase from RFC 9110 ("Not Found","Conflict"). Always present. It is constant per status, as the spec asks.detail: an occurrence-specific sentence. For anAppErrorit is the error'sDisplaytext. Always present.source: present only when one of the three members below applies.
sourcehas exactly one member:pointer: an RFC 6901 pointer into the request body. It names only a value that exists in the request, as the spec requires. For something missing, it names the nearest enclosing member that exists.parameter: the query parameter's name, decoded, with raw brackets ("page[limit]").header:"Content-Type"or"Accept".
- Error objects carry no
id,linksormeta. status,code,titleandsourceare normative.detailis not. Its wording may change between releases, and clients must not parse it. Thedetailstrings in this document are examples; generator tests may pin them.
This is the only statement of the order. The per-operation tables (§8, §9) refer to its steps. Every route checks in this order, and the first failure is the response:
-
Routing. A method the route does not serve is
405(§13.5). -
Accept(§3.2):406. -
Content-Type, when the request has a body (§3.2):415. -
Path parameters, in path order:
- a typed prefix parameter that fails to parse is
400 invalid_path_parameter; - on relationship and related routes, an unknown
{rel}is404 no_such_relationship.
- a typed prefix parameter that fails to parse is
-
Query parameters.
- First, a name the route does not accept (§6): the first such name in request order.
- Then the accepted parameters, in canonical order (§4.3):
filter[…],sort,include,page[offset],page[limit], thenopArg[…]in byte order of member name. Withinfilter[…](§7.3): the struct's members in byte order (a repeat or a bad value, per member), then its first missing required field in declaration order, then the bare filters in byte order of name (a repeat, a bad value or a missing required filter).
-
Route-level refusals decidable without the body: the
403s of §9. -
The request body, in the order of the operation's table. A body larger than the server accepts is
413 content_too_large, ahead of every row of the table. Members are checked in schema order, so no order-preserving parser is needed. The rule applies per member family, families in this order:- unknown attribute names, in byte order;
- declared attributes, in declaration order;
- unknown relationship names, in byte order;
- declared relationships, in declaration order. Within one: its
shape, then
null, then absence, then identifier types, then, for ahas_manyof the type's own type, an identifier naming the resource itself (403 relationship_cycle). A relationship route's body is one relationship, read in the same order.
Custom-op
meta.args(§10.2) is one family: unknown names in byte order, then declared arguments in declaration order. -
Store reads the handler makes before acting. First the parent resource on relationship and related routes (
404 {entity}_not_found). Then each linked resource, in step-7 order (404 no_such_related_resource). -
The operation itself: the store call or the custom op, and its
AppError.
So:
GET /api/tasks/nope?include=owneris400 invalid_include_path, not404.GET /api/tasks?sort=priority&page[limit]=0is thesorterror.- A
PATCHto a missing id with a malformed body is the body's400.
These are errors the generated server raises itself, independent of the
consumer's AppError:
| Status | code |
Raised when |
|---|---|---|
| 400 | invalid_query_parameter |
an unknown, repeated or malformed query parameter, including page, filter, opArg and fields (§6, §7, §10.2) |
| 400 | invalid_sort_field |
a bad or unsupported sort (§7.4) |
| 400 | invalid_include_path |
a bad include on a route that accepts it (§7.5) |
| 400 | invalid_path_parameter |
a typed prefix parameter fails to parse (§11.1) |
| 400 | invalid_document |
the body is not JSON, or not a valid request document for the route |
| 400 | unknown_attribute / missing_attribute / invalid_attribute |
attribute problems, including an integer outside i64 (§5.3, §8.2, §8.3) |
| 400 | unknown_relationship |
a relationship name the type lacks (§8.2) |
| 400 | missing_relationship |
a non-Option to-one absent from a create body (§8.2) |
| 403 | relationship_required |
null on a non-Option to-one (§8.2, §8.3, §9) |
| 403 | relationship_update_unsupported |
a relationship write the relationship does not support: PATCH on a junction op, POST/DELETE on a to-one, a write to a relation field of a type whose module serves no update, POST on a junction op without add_Y or DELETE without remove_Y, or a junction-op relationship in a create or update body (§8.2, §9) |
| 403 | relationship_batch_unsupported |
a relationship POST or DELETE with more than one identifier (§9) |
| 403 | relationship_cycle |
a has_many of the type's own type naming the resource itself, which would become its own parent: in a create (its client data.id) or update body, or a relationship PATCH or POST (§5.4, §8.2, §9.2) |
| 404 | no_such_related_resource |
a linked id that does not exist (§8.2, §8.3, §9) |
| 404 | no_such_relationship |
{rel} is not a relationship of the type, or does not percent-decode (§9) |
| 405 | method_not_allowed |
a method the route does not serve (§13.5) |
| 406 | not_acceptable |
§3.2 |
| 409 | type_mismatch |
a type that is not the endpoint's or the relationship's (§8.2, §8.3, §9) |
| 409 | id_mismatch |
a PATCH body id differs from the URL id (§8.3) |
| 413 | content_too_large |
the body is larger than the server's body-size limit (Axum's DefaultBodyLimit: 2 MB unless the consumer's router sets another) |
| 415 | unsupported_media_type |
§3.2 |
| 500 | internal_error |
a store-construction or scope-accessor failure; any op or event subscribe whose error type is not the primary surface's AppError; an AppError-typed site in a consumer with no AppError in its schema directory |
The detail for internal_error is the error's Display text.
The mapping has three parts, designed in E0003 phases 0 and 1 (decision 6):
- the
ApiFn.error_typecapture; - the
AppErrorscan over the schema directory; - the call-site routing predicate (below).
Routing predicate. A call goes through ontogen_app_error iff its error
type is the primary surface's {types_import_path}::AppError; every
other error type, another surface's AppError included, is
500 internal_error. The error type is resolved in the function's own
surface:
- through its file's
useitems:use a::b::{AppError, X as Y},use a::b::{self as c}, and a leading segment oneusebinds (schema::AppErrorafteruse crate::schema;); - a
crate::path is in the crate the surface'sservice_import_pathnames (fitness::apiputscrate::schema::AppErroratfitness::schema::AppError); - a path the surface's own
{types_import_path}::AppErrorends with (a bareAppErrornousebinds,schema::AppError) is that surface's.
Not resolved, so taken as written: a type alias (type E = AppError;), a
re-export (crate::schema::error::AppError is not proven to be
crate::schema::AppError), self::/super:: paths, and names a glob
brings in (a bare AppError under use x::* is read as the surface's
own). An error type named AppError that resolves to no surface's
{types_import_path}::AppError is 500 internal_error with a
cargo:warning naming the function.
The scan maps variants by name suffix:
AppError variant |
Constructed by | Status |
|---|---|---|
{Entity}NotFound(id) |
store get, update, delete; a create or update that lists a missing related id (§5.4) |
404 |
{Entity}IdRequired(reason) |
store create, when no id exists after before_create and the IdStrategy |
400 |
{Entity}AlreadyExists(id) |
store create, on a duplicate id |
409 |
{Child}ParentRequired(child_id) |
a parent's has_many write that would drop a child whose foreign key is not Option (§5.4) |
403 |
{Child}ParentCycle(id) |
a has_many write whose list names the record itself; id is the record's own id (§5.4) |
403 |
any other variant (Md, DbError) |
— | 500 |
A value no store can hold is refused by the store's catch-all error, a
500: an integer outside i64, and on markdown a float NaN. Neither
arises over HTTP for an integer, because step 7 refuses one outside i64
as 400 invalid_attribute first (§5.3, §8.2), and JSON cannot carry a
NaN. IPC and MCP deserialize their payloads straight into the input type,
so an out-of-range integer sent there reaches the store and is its 500;
so does one a hook or a direct caller supplies.
-
codeis the variant name, converted to snake_case at generation time:task_not_found,task_id_required,task_already_exists,task_parent_requiredandtask_parent_cycle(the child ofTask.subtasksis aTask),md,db_error. One rule covers every variant, mapped or not. -
Declaring the variants. The store generator constructs every variant in the table, so a consumer
AppErrormust declare each one the generated store uses:NotFoundandAlreadyExistsfor every entity.IdRequiredfor every markdown entity, and for a SeaORM entity whoseIdStrategyisProvidedorSlugFromField; a SeaORM entity underUuidalways gets an id. tasks-tracker declares all three forTask,EpicandTag.ParentRequiredonly for an entity that is the child of ahas_manywhose foreign key is notOption. tasks-tracker needs none.ParentCyclefor an entity that is the child of ahas_many, which is always the declaring entity (only the self-referentialhas_manyis supported). tasks-tracker declaresTaskParentCycle.
A variant the generated store uses but the
AppErrorlacks fails the consumer's build with rustc'sE0599("no variant or associated item namedTaskParentCyclefound for enumAppErrorin the current scope", for aTask) in the generated store module. The scan reads only what the enum declares, so the build is where a missing variant shows. -
Code clashes. When the build generates the HTTP server, a variant whose snake_case name equals a §13.3 code (an
InvalidDocumentvariant, say) is aCodegenError, so a code always means one thing. No §13.3 code may end in a store suffix (_not_found,_id_required,_already_exists,_parent_required,_parent_cycle), so no entity's store variants clash; the testno_ontogen_code_ends_in_a_store_suffixholds every new code to this. A new §13.3 code can still equal a consumer's own non-store variant, and the check then refuses that build. -
source.AppError-derived errors carry nosource, except*AlreadyExistson a create that carrieddata.id(§8.2). -
No
AppErrorin the schema directory (the scan-dirs-only case): everyAppError-typed site maps to500 internal_error, in the sameerrors[]document as every other error. -
E0003 phases 2 and 3 stay in E0003 and slot into this document unchanged:
#[http(status = N)]annotations override the suffix rule. Thetitlebecomes N's reason phrase, and thecodestays the variant name.- An
error_handleroverride owns the whole response, and with it conformance.
| Status | Spec trigger | Where in this contract |
|---|---|---|
| 400 | unknown or unsupported query parameter; unsupported include or sort |
§6, §7 |
| 403 | unsupported relationship update, including a refused removal or full replacement | §5.4, §8, §9 |
| 404 | missing resource, relationship or related resource | §8, §9 |
| 406 | unsatisfiable Accept |
§3.2 |
| 409 | duplicate client id; type or id mismatch | §8.2, §8.3, §9 |
| 415 | bad Content-Type |
§3.2 |
405. A generated route that does not serve a method answers 405, with
an Allow header listing the methods it does serve, and an errors[] body
with code method_not_allowed. HEAD is listed wherever GET is, because
Axum serves it. Examples:
PUTon/api/tasks/{id}:Allow: GET, HEAD, PATCH, DELETE.PUTon/api/tasks:Allow: GET, HEAD, POST.PUTon a relationship route: see §9.
POST or DELETE on a to-one relationship is not a 405. The route
serves those methods, and the spec makes an unsupported relationship update
403 (§9).
The generated router installs this as its method-not-allowed fallback. It
installs no path fallback: an unmatched path falls through to the
consumer's router, which owns its own 404.
Axum extractor rejections. These never reach the client in Axum's
plain-text form. The ontogen-jsonapi extractors replace Json, Query
and Path in generated handlers and produce the documents above.
The generated HTTP transport keeps the flat Transport interface:
- every method name, parameter and return type is unchanged, with three
exceptions: a paginated module has no
xCount()method, because no route or IPC command serves itscount(the page carriestotal); a junctionxAddYorxRemoveYis declaredPromise<null>whatever its Rust return type, matching the204its route answers; and a lonexListX, a customGET(§9.1), takes nolimit?oroffset?and returns its plain array; - list methods take one trailing optional argument (§14.2).
JSON:API is applied and removed inside the transport. The admin layer needs no source change.
- Every request sends
Accept: application/vnd.api+json. - Every request with a body sends
Content-Type: application/vnd.api+json. - Updates go through the
httpPatchhelper. No generated route usesPUT, so the transport has nohttpPut.
Transport method |
HTTP | Flat result built from |
|---|---|---|
xList(…, options?) (unpaginated) |
GET /api/{type}, plus filter[k]=v per defined key of query and sort from options.sort |
data.map(flatten) → R[] |
xList(…, limit?, offset?, …, options?) (paginated) |
adds page[offset], page[limit] when defined |
{ items: data.map(flatten), total: meta.total, limit: meta.limit, offset: meta.offset }, which is PaginatedResult<R> |
xGetById(id) |
GET /api/{type}/{id} |
flatten(data) |
xCreate(input) |
POST /api/{type}, body unflatten(input) |
flatten(data) (201) |
xUpdate(id, input) |
PATCH /api/{type}/{id}, body unflatten(input, id) |
flatten(data) |
xDelete(id) |
DELETE /api/{type}/{id} |
null (204) |
xListX(parentId) (junction op of a resource module) |
entities: GET /api/{type}/{parentId}/{rel}; ids: GET …/relationships/{rel} |
data.map(flatten) or data.map(i => i.id) |
xListX(parentId, limit?, offset?) (the same, paginated module) |
as above, plus page[offset], page[limit] when defined |
PaginatedResult from data and meta, as for xList |
xAddY(parentId, childId) (the same) |
POST …/relationships/{rel}, body {data:[{type, id: childId}]} |
null (204) |
xRemoveY(parentId, childId) (the same) |
DELETE …/relationships/{rel}, same body |
null (204) |
custom GET |
GET /api/{m}/{action}/{path…}, Option args as opArg[name] |
meta.result, or null on 204 |
custom POST |
body {meta:{args:{<rust_param_name>: value, …}}} |
meta.result, or null on 204 |
| op served as custom (§10.4), junction ops outside a resource module included | its §10.4 route | meta.result, or null on 204 |
lone xListX(param) (§9.1) |
the custom GET /api/{type}/{action}/{param} |
meta.result, its plain array |
subscribeX(args, handlers) |
unchanged URL, ?resume= and lag |
entity T: flatten(JSON.parse(data)); other T: .meta.result |
legacy on{Event}(callback) |
as subscribeX |
the same decoding; the callback gets the flat item |
{rel} is the relationship name as the server matches it (§9),
percent-encoded at generation time when it is not plain ASCII. The target's
flatten is emitted whenever a junction list returns entities. When a
prefix value is given, every method calls the same path under the prefix;
no junction method has a separate scoped form (§11.1).
Sort (decision 8). Every list method whose Rust fn takes an order
argument takes a trailing optional argument, after every other parameter
(projectId included):
export type TaskSortKey = 'id' | '-id' | 'title' | '-title' | 'status' | '-status' | 'created' | '-created';
export interface ListOptions<K extends string> { sort?: K[] }
taskList(query?: ListTasksQuery, limit?: number, offset?: number, options?: ListOptions<TaskSortKey>): Promise<PaginatedResult<Task>>;- A trailing optional argument breaks no positional caller, so the admin layer is unaffected.
- The argument is on the shared
Transportinterface, so the IPC transport implements it too (§15). - The HTTP transport sends
options.sortassort=the keys joined by,. An absent or empty array sends nothing. includeis not exposed. The flat return shape has nowhere to put included resources. The shared interface is the constraint, since IPC has noincludeeither.
Queries. toQueryString has a family form.
toQueryString({ filter: query, page: { offset, limit } }) emits
filter%5Bstatus%5D=… and so on, skipping null and undefined. An array
value is one parameter, each item encoded with encodeURIComponent and
the items joined by a literal ,: toQueryString({ sort: ['-created', 'title'] }) is ?sort=-created,title, and a filter's sequence member
{ filter: { tags: ['a', 'b,c'] } } is filter%5Btags%5D=a,b%2Cc, which
the server splits back into the same items (§7.3). An empty array is
skipped at the top level, so an empty sort sends nothing, and sent with
an empty value inside a family, so tags: [] is filter%5Btags%5D=: the
empty sequence, as IPC and MCP read [].
Placement. The ListOptions<K> interface and each {Entity}SortKey
union are emitted once, beside PaginatedResult, in each generated client
file, and only when some emitted list takes an order. The {Entity}SortKey
union lists the keys in sort-field order, ascending then descending for
each field.
Filtered lists. Method signatures are unchanged: bare parameters first
(camelCase, typed), then query?, then limit? and offset? when
paginated. The filter sent is query when the list takes only a struct,
{ skill_id: skillId } when it takes only bare parameters, and
{ ...query, skill_id: skillId } when it takes both:
httpGet<JsonApiPageDocument>(`/tasks${toQueryString({ filter: query, page: { offset, limit } })}`)A filtered list with no entity behind it is the §10.4 custom op:
callOp('GET', /m${toQueryString({ filter: query, opArg: { limit, offset } })}),
and reads meta.result.
The generator emits one pair of functions per entity, from the same
relationship table as the server (§5.4). For Task:
const TASK_REL = { epic: { field: 'epic_id', many: false },
tags: { field: 'tags', many: true },
parent: { field: 'parent_id', many: false },
subtasks: { field: 'subtasks', many: true } } as const;
function flattenTask(r: JsonApiResource): Task {
return {
id: r.id,
...r.attributes,
epic_id: r.relationships?.epic?.data?.id ?? null,
tags: (r.relationships?.tags?.data ?? []).map((i) => i.id),
parent_id: r.relationships?.parent?.data?.id ?? null,
subtasks: (r.relationships?.subtasks?.data ?? []).map((i) => i.id),
} as Task;
}unflatten(input, id?) builds { data: { type, id?, attributes, relationships } }:
- Id. It goes in
data.idwhen the argument is given (update), or wheninput.idis a non-empty string (create). It never goes inattributes, whatever its value, including"". - Relation fields move to
relationships,has_manyincluded:- a to-one value
vbecomes{ data: v == null ? null : { type, id: v } }; - a to-many array becomes an identifier array.
- a to-one value
- Every other key goes to
attributes. - Undefined and null. Keys whose value is
undefinedare omitted; on update that means "unchanged", matchingUpdateXInput.nullis kept, and clears the field.
Any non-2xx response throws a JsonApiError:
export class JsonApiError extends globalThis.Error {
readonly name = 'JsonApiError';
constructor(
readonly status: number,
readonly errors: JsonApiErrorObject[], // [] when the body was not a JSON:API error document
message: string, // errors[0]?.detail ?? errors[0]?.title ?? statusText
) { super(message); }
}messageis the first error'sdetail, else itstitle, else the response's status text; for anAppErrorthedetailis itsDisplaytext (§13.1).String(e)is"JsonApiError: Task not found: nope", and the admin layer only rendersString(e).- Callers that want the status or the code read
e.statusande.errors[0]?.code. - A non-JSON error body (a proxy's HTML
502) still throws aJsonApiError, witherrors: [].
- Every existing method name, parameter and return type on
Transport, except the three in §14's introduction:xCount()of a paginated module is removed, a junction add or remove is declaredPromise<null>, and a lonexListXtakes no page and returns a plain array. PaginatedResult<T>: same declaration, same fields, and same values.limitandoffsetare the effective values.- Entities in and out are flat, with the same field names,
nullfor cleared optionals, and id arrays for to-many relations. nullfrom delete, junction add and remove, and()custom ops.SubscriptionHandlers<T>:onEventreceives a flatT, andonLag,onOpenandonErrorare unchanged.- The admin registry flags (
paginated,defaultLimit,maxLimit,listHasQuery).
The admin layer's tests/fixtures/event-transport.generated.ts and
tests/fixtures/jsonapi-transport.generated.ts are copies of generated
output. ts_event_transport_fixture_is_current (src/servers/tests.rs)
and ts_jsonapi_transport_fixture_is_current (src/clients/tests.rs) fail
when either drifts from the generator.
Payloads stay flat. JSON:API exists only at the HTTP boundary.
A Tauri command names each parameter after the function's argument, since
Tauri reads the argument from the invoke payload under a key derived
from that name (the name without its r#, lowerCamelCased with heck:
_kind is kind), and prefixes its own bindings with ontogen_. An argument named after a key the command itself uses is a
build error naming the argument: query beside a list's *Query struct,
limit or offset on a paginated junction list's parent id, channel on
an event op, sort on a list that takes an order, and, under a route
prefix, the prefix parameter's name (project_id) on any op. Names are
compared by key, so _sort and sort_ collide with sort. The servers
stage refuses these when it generates IPC, and the clients stage when it
emits the IPC transport (HttpTauriIpcSplit), even in a build that
generates no IPC server, because that transport invokes the commands with
those keys; commands in ts_skip_commands are not emitted, so the clients
stage does not check them. A scoped MCP tool reads the prefix parameter
from its arguments, so an op argument it would read under the same name (a
custom op's or junction tool's parameter, a list's bare filter, or id)
is a build error too. A bare filter named sort on a list that takes an
order is a build error on IPC and on MCP, since both read the list's sort
keys from that name. Over HTTP it is not, because it travels as
filter[sort].
Every generated TS client names a list method's parameters after its bare
filters (by their invoke keys, a reserved word taking a trailing _), then query, limit, offset, the route prefix
parameter (on Transport methods only) and options (on a list that
takes an order). A bare filter, or the route prefix parameter, that would
take one of those names, or the name of an earlier bare filter, is a build
error from the clients stage naming the TS method, the fn and the
argument: a sorted list's filter named options (or options_) is
refused on every TS client.
- Tauri IPC: same commands, same
invokeargument objects, same flat entities,PaginatedResultfor paginated lists,Stringerrors, and the sameEventFramechannel ({kind:"event", id, data}with a flatdata, and{kind:"lag", skipped}). - MCP: same tool names, the same flat argument schemas
(
schema_for_with_str_idfor update), and the same results:{"success": true}for create and update, entities for get, and bare arrays or{items, total, limit, offset}for lists. Event ops have no MCP tool.
A filtered list's filter parameters are forwarded to list and count on
both transports since #172. The
TS IPC transport sends each bare filter under its own name, as the command
takes it. The MCP tools read their arguments strictly, as described here
and in the upgrading guide's "MCP tools" section. A bare filter keeps its
declared type on both (an Option one is optional). The MCP list tool's
input schema lists the bare filters beside the *Query struct's fields and
the page. The tool reads the struct from its arguments without the ones it
reads itself (bare filters, the page, the route-prefix parameter); an
argument object the struct cannot be read from is the tool's error
(Invalid filter: …), not an empty filter.
Every MCP tool refuses an argument its input schema does not list
(Unknown argument: …), as HTTP refuses an unknown member: list, get,
create, update, delete, custom ops and junction tools. A body field sent
beside an *Input taken under its parameter name is refused at the top
level. The check reads the same schema the tool advertises, so a serde
alias the schema does not name is refused too. A tool whose schema is a
user type that names no closed set of arguments (additionalProperties
other than false, or a top-level allOf, anyOf, oneOf or $ref,
for example an input that flattens a map, or an enum body) refuses nothing;
this can only be create, update, a custom op whose one argument is its
*Input, or a list whose schema is its *Query struct. An MCP custom op
reads each argument as its declared type, and one that takes an *Input
beside other arguments takes the input under its parameter name, as the IPC
command does.
Six rules govern IPC and MCP beyond the flat payloads above. Only the sixth bears on a payload's shape:
sorton list (decision 8). The IPC list command takes an optionalsort: Option<Vec<String>>argument, and the TS IPC transport passesoptions.sortinto it. The MCP list tool schema has an optionalsortarray whose items enumerate the sort keys. Both parse withontogen_core::order::parse_sort(ADR 0006 §1) and return its error text on a bad key. They read it before they open the store, so a bad key is reported whether or not the store would open. The MCP tool readssortstrictly: absent ornullis no keys, and anything but an array of strings is the tool errorInvalid sort: …. The tool skipssortwhen it reads the*Querystruct, so a struct field serialized assortcannot be set over MCP.has_manywrites clear dropped children (decision 9). An update that drops a child clears the child's foreign key in the store, so IPC and MCP behave as HTTP does. A listed child ormany_to_manytarget that does not exist is{Target}NotFound, ahas_manylist that names the record itself is{Child}ParentCycle, and nothing is written (§5.4).- Typed store errors.
{Entity}AlreadyExists,{Entity}IdRequired,{Child}ParentRequiredand{Child}ParentCyclename these failures. On these transports they are strings. - No
xCount()for a paginated module. The servers stage takes a paginated module'scountoff its module list, since the list's page reportstotal, so no IPC command or MCP tool serves it. The TS IPC transport and theTransportinterface do not declare it (§14). - Junction add and remove resolve
null. The TS IPC transport declaresxAddYandxRemoveYasPromise<null>and resolvesnullwhatever the Rust fn returns, as the HTTP transport does (§14). The IPC command itself returns what the Rust fn returns. - A lone
list_Xis a custom op (§9.1). A one-parameterlist_Xwhose module has noadd_Yorremove_Yfor its relationship is not a junction list on any transport. In a paginated module its IPC command and MCP tool take nolimitoroffsetand return the plain list. The TS IPC transport'sxListXdoes the same (§14). Junction ops themselves are served on IPC and MCP as commands and tools of their own, in a resource module or not.
Choices this contract makes where the spec or the epic left one open, with the section that states each and its reason.
| Choice | § | Why |
|---|---|---|
jsonapi: {"version":"1.1"} on every document |
4.1 | Without it clients assume 1.0, and the member costs nothing |
application/json request bodies are 415 |
3.2 | One media type, and a client sending a flat body fails loudly instead of mis-parsing |
A JSON:API instance in Accept outranks wildcards; otherwise */* and application/* satisfy it |
3.2 | The spec requires 406 when every JSON:API instance is unusable, and browsers and curl send */*. The event routes (§12) skip this negotiation: they answer text/event-stream and never check Accept, which is what the generated client's EventSource sends |
Vary: Accept on every response |
3.3 | The response depends on Accept (406) |
Links and Location are relative |
4.2 | The server cannot know its public origin behind proxies, dev servers and tunnels |
| Canonical link query order and encoding | 4.3 | Byte-stable links for snapshots and caches |
| The schema is an explicit input of the servers and clients stages | 5.1 | Nearly every rule needs it |
opArg[limit]=0 is an empty page, not 400 |
10.4 | A link-less meta.result page needs no next or last, and the IPC list command takes limit: 0 the same way |
| A CRUD-named op with no entity behind it takes exactly its row's arguments | 10.4 | The generated clients call these ops with those arguments only, so extra parameters would be routes no client can reach. list with extra parameters is a filtered list instead |
| CRUD ops with no entity behind them are served as custom ops | 5.1, 10.4 | Keeps the scan-dirs-only use case working, and without a schema there is no resource to build |
links.self on every resource object whose module serves get_by_id; none otherwise, and no Location on create |
5.2, 8.2 | Location must match it, and the spec requires a server to serve every link it emits |
relationships omitted when a type has none |
5.2 | Avoids an empty object on every tags and epics resource |
Shape-changing serde attributes on entities are a CodegenError |
5.3 | Attribute, sort and filter names all assume field name = member name |
belongs_to loses _id; other relation fields keep their name |
5.4 | Matches how the field reads; JSON:API names relationships, not keys |
| Duplicate identifiers collapse to their first occurrence | 5.4 | Matches the add-once rule of relationship POST |
Offset pagination with page[offset]/page[limit] |
7.2 | Same semantics as the store's limit/offset arguments |
page[limit]=0 is 400 |
7.2 | A zero page has no next or last |
meta: {total, limit, offset} on paginated lists only |
7.2 | Exactly rebuilds PaginatedResult, and is redundant when unpaginated |
self plus the four pagination links, always present, null when unavailable |
7.2 | One shape to read |
prev at or past the end points at the last page |
7.2 | An overshooting client steps back to data |
A hand-written list replaces the generated one; order goes after filters, before page params |
7.3 | Filter and sort must combine, and the store has no filter |
Filter names are the *Query struct's field names |
7.3 | The struct is user-authored, and ontogen does not rename it |
| Unknown filter members are detected from the struct's serde field names | 7.3 | Serde ignores unknown fields, and a probe of the declared names refuses them in request order with every other unaccepted name |
| The struct's members are read before the bare filters | 7.3, 13.2 | One fixed order, so the first error a client sees is deterministic |
A hand-written paginated list brings its own count |
7.3 | The store has no filter, so the generated count would count the table, not the filtered set |
id is the implicit last sort key |
7.4 | A total order makes pages stable (ADR 0006) |
A misplaced, repeated, foreign or entity-less order parameter is a CodegenError |
7.3 | sort is read into exactly one slot of the module's own list, so any other place has no meaning and would be silently dropped |
A bare filter named sort on a sorted list is a build error on IPC and MCP |
15 | Both transports read the sort keys from the sort argument, so the filter would share its key |
IPC and MCP read sort before opening the store |
15 | A bad key is the caller's error and should not depend on the store |
MCP reads sort strictly, and skips it when reading the *Query struct |
15 | One rule with the other arguments: a wrong type is a tool error, and a struct field named sort cannot be set over MCP |
toQueryString writes an array as one comma-joined parameter |
14.2 | sort and a filter's sequence member take a comma-separated value, and the server refuses a repeated parameter |
A filter's sequence member is its value as sent split at each literal ,, each item decoded |
7.3 | It is what the TS client sends; an item's own comma travels as %2C, and IPC and MCP read the same items from an array |
filter[x]= is the empty sequence; an absent member is serde's missing field |
7.3, 14.2 | IPC and MCP read a missing field the same way, and the TS client sends [] as an empty value |
The clients stage refuses an order it cannot send |
7.3 | A client's sort keys are those of the module's resource, so any other order has no options type |
| The clients stage applies the IPC wire-key rules when it emits the IPC transport, and refuses TS method parameter names that collide | 15 | It emits that TypeScript whether or not this build generates the server, and a duplicate key or parameter does not compile |
Dangling linkage is skipped in included and related links, not an error |
7.5 | Markdown tolerates dangling wikilinks by design |
included is fetched through the target's get_by_id, one call per distinct id, unbatched |
7.5 | Same lookup as related links (§9.3), so a hand-written get_by_id and scope rules apply; the store has no batch primitive, and a store-level batch read would bypass a hand-written get_by_id |
A relationship that cannot be included is a runtime 400 invalid_include_path, not a build error; junction-op relationships are never includable |
7.5 | JSON:API requires 400 for an unsupported include path, and a list-only module is a legitimate configuration; included resources must be reachable by linkage (full linkage), and junction relationships carry links only |
An empty item in a non-empty include is 400 invalid_include_path; only include= is included: [] |
7.5 | sort refuses empty items the same way, and include=epic, is a malformed list, not an empty one |
One id-validity rule on both backends, applied to ids being created: lowercase [a-z0-9._~-], at most 200 bytes, no leading or trailing ., not index/log, not a Windows device name (con, nul.x, …); an invalid one is 400 |
8.2 | A malformed id is a bad request, not a store 500; the backends agree, and every id is a filename on Linux, macOS and Windows and a URL segment |
A path {id} is a lookup key, never validated |
8.1 | Every row the store lists stays servable at its links.self, including rows whose id fails the rule |
Unknown attributes are 400 |
8.2 | Catches clients still sending the flat shape |
| Body members are checked in schema order, unknown names in byte order | 8.2, 13.2 | Deterministic without an order-preserving parser |
Missing ids are detected by the store, after hooks, as {Entity}IdRequired |
8.2, 13.4 | Hooks may assign the id, and the handler need not know the IdStrategy |
Duplicates are {Entity}AlreadyExists, and SeaORM retries a derived id that loses a race |
8.2, 13.4 | Atomic detection on both backends, typed for every transport |
Create is always 201 with the document |
8.2 | Ids and hooks change the resource, and TS returns the entity |
PATCH is always 200 with the document |
8.3 | Hooks may change the resource, and TS returns the entity |
A PATCH body id is compared exactly; any difference is 409 |
8.3 | The spec requires 409 for an id that does not match the endpoint |
Delete and relationship mutations are 204 |
8.4, 9 | Nothing to report, and TS returns null |
has_many is writable; dropping a required-foreign-key child is 403 |
5.4, 9 | Decision 9; the child cannot be orphaned, and the spec requires 403 for a refused removal or replacement |
A has_many write naming the resource itself is 403 relationship_cycle at step 7 |
5.4, 9.2 | The resource would become its own parent; the request alone decides it, and the spec requires 403 for a refused relationship update |
An integer attribute outside i64 is 400 invalid_attribute |
5.3, 8.2 | Both stores hold integers as i64, so the value is the client's error, not the store's 500 |
Unsupported relationship updates are 403, not 405 |
9 | The spec requires 403 for an unsupported relationship update |
One identifier per relationship POST/DELETE |
9 | Every write is one call, so no request is partly applied; TS sends one id |
A lone list_X is a custom op |
9.1 | A plain filtered list must not become a relationship |
Relationship routes and relationship links only for a type whose module serves get_by_id and that has a relationship |
5.4, 9 | The spec requires a server to serve every link it emits, and the routes read the parent through get_by_id |
Relation fields are read and written through the module's get_by_id and update; without update a write is 403 |
9, 9.1 | A relationship write is a resource PATCH of one field, with the same hooks and errors; a module that does not update its resources does not update their relationships |
A junction op without add_Y refuses POST, without remove_Y refuses DELETE, with 403 |
9.1 | The spec's 403 for an unsupported relationship update; the route exists because the relationship does |
A junction-op relationship needs list_X returning Vec<Target> or Vec<String>, string id parameters, and get_by_id in its module |
9.1 | Linkage and membership are read through list_X, the ids come from the URL and identifier strings, and the parent is checked through get_by_id |
Junction-op relationships follow the relation fields, in list_X order |
5.4 | One deterministic order for snapshots |
| Junction ops outside a resource module have the same route shape scoped and unscoped | 10.4, 11.1 | Scoped and unscoped routes must not diverge |
Junction DELETE skips the target-existence check |
9.1 | A member whose target was deleted must still be removable |
| Paginated junction lists stay paginated on the relationship routes | 9.1 | Keeps xListX's PaginatedResult signature |
Custom POST bodies are {meta:{args:{…}}} |
10.2 | Decision 7; one rule, and a valid JSON:API request document |
Custom GET optional args use the opArg[…] family |
10.2 | Decision 7; the spec reserves all-lowercase names |
CRUD ops in a singleton module are a CodegenError |
10.3 | A singleton is opted into, so a CRUD op there is a mistake |
An *Input parameter on an op served without a body is a CodegenError |
10.2, 10.4 | A custom GET, get_by_id, delete, JunctionList and JunctionRemove have no body to carry it |
Non-entity event payloads and custom results are {meta:{result}} |
10.1, 12 | One rule for every non-resource payload |
| Event frames carry no links | 12 | A frame has no request URL, and links would double its size |
| One error object per response, first failure in §13.2 order | 13.1, 13.2 | Every request has exactly one correct error |
A body over the size limit is 413, not 400 |
13.2, 13.3 | RFC 9110 defines the status for it, and a client can tell a body that is too big from one that is malformed |
code is the AppError variant in snake_case, or a fixed ontogen code |
13.3, 13.4 | Machine-readable without parsing detail |
title is the reason phrase of the status |
13.1 | Constant per problem, as the spec asks |
detail is not normative |
13.1 | Wording can improve without a contract change |
No path fallback; 405 via a method fallback |
13.5 | The generated router is merged into a consumer router it must not hijack |
sort is a trailing optional on the shared Transport; include stays HTTP-only |
14.2 | Decision 8; the shared interface has no flat shape for included resources |
JsonApiError carries status and errors, and its message is the detail |
14.4 | The admin layer renders the server's detail, and callers can branch on status |
Every phase ships in the unreleased 0.9.0.
| Phase | Sections |
|---|---|
| 1a | Store and runtime prerequisites. The ontogen-jsonapi crate (documents, link building, error document, extractors). The id-validity rule and slug function shared by both backends. IdStrategy on SeaORM, with one build-time source of truth and derived-id retry. {Entity}AlreadyExists and {Entity}IdRequired (§13.4). The has_many fix and {Child}ParentRequired (§5.4). The markdown id-ascending default order, many_to_many order and the parity fixture's default cases (ADR 0006 §6). The SeaORM i64 field for integer primitives under OptionEnum/Other (ADR 0006 §4). Markdown lookups of an uncreatable id answer {Entity}NotFound (§8.2) |
| 1b | CRUD over JSON:API. Schema input (§5.1). §3 media type, §4 documents, §5 resource objects (relationship data only), §6 query rules, §7.1–§7.2 list and pagination, §8 get, create, update and delete, §13 errors with the E0003 phase 0-1 scan, §13.5 405. §14 for CRUD methods, JsonApiError. Scoped CRUD routes, including their pagination (the page goes to the store). Modules with no entity behind them are left unchanged until 1c |
| 1c | Everything else on the 0.9.0 wire. §10 custom ops (meta.args, opArg, singleton check, §10.4 ops served as custom, junction ops included). §12 event frames. §11.1 scoped pagination of what 1b leaves, the lists not served as resources. §14 for custom, junction and subscription methods |
| 2 | §7.3 filter, including the hand-written-list precedence (the API stage scans before it emits, and the transports' api dirs when api_scan_dirs is unset) and the bare-parameter fix. Filtered lists as resources and as custom ops (§10.4, §11.1). The TS filter family (§14.2) |
| 3a | §9 relationship endpoints, related links and relationship links (§5.4). The junction classification change and the junction-op build checks (§9.1). Scoped junction routes (§10.4, §11.1). TS junction methods (§14.2). tasks-tracker's parent_id, subtasks and release tag (§2) |
| 3b | §7.5 include, with get_by_id as its fetch and the empty-item rule. The §5.2 and §8.2 follow-up: a type whose module serves no get_by_id has no resource links, no Location on create and no top-level links on create and update documents |
| 3c | §7.4 sort, the order argument (ADR 0006), and sort on TS, IPC and MCP (§14.2, §15) |
Every phase has shipped:
| Phase | PRs |
|---|---|
| 0 | #195 |
| 1a | #197, #198 |
| 1a follow-ups (decisions 10 to 12) | #199 |
| 1b | #200 |
| 1c | #201 |
| 2 | #202 |
| 3a | #203 |
| 3b | #205 |
| 3c | #206 |
| 4, docs and examples | #207 |