Skip to content

Upgrading to 0.9

Version 0.9 is complete on main and pending release. This page lists its breaking changes: the store and runtime changes, and the HTTP wire change, where CRUD routes, relationships, custom ops, junction ops, event frames, filtered lists and every error move to JSON:API, lists and gets honour include, and every generated list (and any hand-written list that declares one) takes an order, so sort works on every transport. Work through the sections that apply to your project, in this order:

  1. Build configuration: the Pipeline and gen_* calls in your build.rs, the schema and op shapes the generator refuses, and the crates the generated code depends on.
  2. OKF vaults: markdown vaults as OKF bundles, and the markdown I/O configuration.
  3. AppError: the error variants every transport needs, and their HTTP statuses.
  4. HTTP wire: the JSON:API wire of the generated HTTP server.
  5. TypeScript clients: the generated Transport interface and clients.
  6. Sorting: the order every generated list takes, in the store, the API and on every transport.
  7. Calling the HTTP API directly: a checklist for HTTP callers other than the generated clients.
  8. SeaORM data and code: integer columns, data migrations and create behaviour on SeaORM.
  9. Record ids: the id rule both backends apply to new records.
  10. Markdown-store API: the markdown-store runtime API.
  11. Store behaviour: list order, has_many writes and lookups on both backends.

StoreConfig has a new, required id_strategy: IdStrategy field, and IdStrategy has no Default. The markdown I/O types MarkdownIoConfig, MarkdownIoOptions and MarkdownIoOutput no longer carry id_strategy. The store config is the one place the store-wide strategy is set, for both backends.

Before:

.markdown_io("src/persistence/markdown/generated", MarkdownIoOptions {
id_strategy: IdStrategy::SlugFromField("title".into()),
/* .. */
})

After, with the pipeline:

Pipeline::new(/* .. */)
.store_id_strategy(IdStrategy::SlugFromField("title".into()))

After, for direct callers of gen_store:

StoreConfig {
id_strategy: IdStrategy::SlugFromField("title".into()),
..
}

An entity can override the store-wide strategy in the schema with #[ontology(entity, id = "provided" | "uuid" | "slug(<field>)")]. Nothing changes for schemas that do not use it. See Annotations.

EntityDef has a new id_strategy: Option<IdStrategy> field holding the override. Code that builds EntityDef values by hand adds id_strategy: None.

SlugFromField needs a String field on every entity

Section titled “SlugFromField needs a String field on every entity”

The field named in SlugFromField must be a plain String on every entity the strategy applies to, on SeaORM as well as markdown. Otherwise the generator fails. Pick such a field, use Provided or Uuid, or give the entities without one their own #[ontology(entity, id = "...")].

With MarkdownLayout::PerEntityDir, gen_markdown_io fails when an entity’s directory is a Windows device name (con, prn, aux, nul, com0 to com9, lpt0 to lpt9, in any case). That covers #[ontology(entity, directory = "con")] and the default directory of an entity named Con, Nul or Com1. Windows cannot create such a directory. The error names the entity and the attribute, for example entity `Thing`: `directory = "con"` is a Windows device name. Give the entity another directory and move its records there. A Flat vault has no entity directories and is not checked.

gen_store, gen_servers and gen_clients take the schema

Section titled “gen_store, gen_servers and gen_clients take the schema”

gen_store, gen_servers and gen_clients take the parsed schema, a &SchemaOutput, as their first argument. ClientsConfig::schema_entities and ClientsConfig::schema_enums are removed. The stages need the schema’s enums as well as its entities: the store orders an enum field by its stored string, and the MCP and TypeScript sort keys list every sortable field.

Before:

ontogen::gen_store(&schema.entities, &store_config)?;
ontogen::gen_servers(Some(&api), &scan_dirs, &servers_config)?;
ontogen::gen_clients(Some(&api), &scan_dirs, &ClientsConfig {
schema_entities: schema.entities.clone(),
schema_enums: schema.enums.clone(),
..clients_config
})?;

After:

ontogen::gen_store(&schema, &store_config)?;
ontogen::gen_servers(&schema, Some(&api), &scan_dirs, &servers_config)?;
ontogen::gen_clients(&schema, Some(&api), &scan_dirs, &clients_config)?;

Pipeline users change nothing. A direct caller with no schema passes &SchemaOutput::default(); every module is then served as a custom op, as a module with no entity behind it is, and the admin registry has no field metadata.

The same argument is the first parameter of ontogen::store::generate, ontogen::servers::generate and ontogen::clients::generate, which the three functions call.

gen_servers also scans for your AppError; see ServersConfig has error_source_dir.

An entity-backed module must then be servable as a resource, or the stage fails the build: its CRUD functions must take the shapes the generated ones take (get_by_id and delete take an id: &str, create its input, update an id: &str and its input), return the entity (Vec<Entity> for list), and the module that a relationship points at needs a get_by_id, which the create and update handlers use to check linked ids.

ontogen::servers::Config is no longer public

Section titled “ontogen::servers::Config is no longer public”

ontogen::servers::Config and ontogen::servers::generate_transport are no longer public API: a caller outside the crate cannot supply the resource model or the scanned AppError they now need. Call gen_servers with a ServersConfig instead.

// before
ServersConfig {
// ...
rustfmt_edition: "2024".into(),
// ...
}
// after
ServersConfig {
// ...
}

The servers stage never read it; delete the line.

ServersConfig has a new field, error_source_dir: Option<PathBuf>: the directory scanned for your enum AppError. Add it to every ServersConfig literal.

ServersConfig {
// ...
error_source_dir: Some("src/schema".into()),
}

Pipeline sets it to its schema directory when it is None, so Pipeline users can leave it None. With None and no Pipeline, no scan happens and every error from an AppError-typed function is 500 internal_error. A directory that cannot be read or parsed fails the build.

HttpRouteMeta.handler_name names the scoped handler

Section titled “HttpRouteMeta.handler_name names the scoped handler”

Under a route_prefix, a row of ServersOutput.http_routes for a route served under the prefix now names the handler that serves it, task_get_by_id_scoped, as the event and relationship rows already did. Before, a CRUD or custom-op row named task_get_by_id, a function the generated file does not define. A build script that matched those names, or appended _scoped itself, reads handler_name as it is.

Entity structs may not carry shape-changing serde attributes

Section titled “Entity structs may not carry shape-changing serde attributes”

The HTTP wire builds a resource’s attributes from the entity’s serde shape, so a struct or field with an attribute that changes that shape is now a schema error. The refused attributes are rename, rename_all, alias, flatten, skip, skip_serializing, skip_serializing_if, serialize_with and with. #[serde(default)] and deserialize_with change only deserialization and stay allowed. Remove the attribute, or rename the Rust field.

Also refused at build time, when the build generates an HTTP server (HttpAxum) or an HTTP TypeScript client (HttpTs, HttpTauriIpcSplit): an id field that is not a String, an attribute or relationship name that is not a legal JSON:API member name or is type or id, a relationship that shares its name with an attribute or another relationship (or is named relationships), and a relation whose target is not in the schema. 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 a field named _draft or draft_ is refused. IPC-only and MCP-only builds, with or without the admin registry, are unaffected.

A schema or API name that collides with a name the generated code uses bare is refused at build time with a CodegenError that names the entity, fn or argument, says what it would clash with and suggests a rename. The entity names are listed under Entity names. For API modules:

  • An API fn named with a raw identifier (pub fn r#type). Every transport derives names from the fn’s (its handler, IPC command, MCP tool and TS method), and none can carry the r#. A module file named after a keyword (type.rs, module r#type) is fine.
  • An argument without a plain name (_, a pattern), one with no letter or digit (__), whose IPC key and TS parameter would be empty, and one starting with ontogen_, the prefix of the generated handlers’ own bindings (ontogen_state, ontogen_args).
  • With an MCP server, an API module named std or schemars: the server imports API modules by name, and the JsonSchema derive of its tool inputs expands to unrooted std:: and schemars:: paths.

Most of these names never compiled. These did with 0.8.0 and are now refused, so rename them:

  • An entity whose name starts with Ontogen, and an entity field or API fn argument that starts with ontogen_. Both prefixes are reserved for generated items.
  • An entity named after a prelude item (Result, Option, Vec, String, Box, From, Default, Clone, Send, Sync, Fn) in a build whose generated code did not name that item bare: Default in most builds, Send, Sync or Clone without a server. The name is now refused whatever the build generates.
  • An entity named OrderBy in a build with a store. The 0.9 store and API modules name it bare.
  • An argument named __ (or another name with no letter or digit) in a build without a TypeScript client.

These shapes fail gen_servers and gen_clients with a CodegenError that names the module and the function. The checks run only when the build generates an HTTP server (HttpAxum) or a TypeScript client that calls HTTP (HttpTs or HttpTauriIpcSplit). An IPC-only or MCP-only build skips them; the checks those transports run are under IPC commands keep argument names for the wire and Sorting.

  • A CRUD or junction op in a singleton module. A module marked // ontogen:singleton (or listed in NamingConfig::singleton_modules) has no resource, so every op in it is a custom op. A function there that classifies as list, get_by_id, create, update, delete, or as a junction op (list_X, add_Y, remove_Y) is refused. Rename it so it classifies as a custom op, or move it to a module that is not a singleton.
  • A parameter whose type name ends in Input on an op served without a body. An #[ontogen::http::get] op, get_by_id, delete, list_X and remove_Y carry no request body, so such a parameter has nowhere to arrive. Change the op to a POST, or pass the fields as separate parameters.
  • A CRUD-named op in a module with no entity that takes arguments beyond its route’s. Such an op takes exactly get_by_id(id), delete(id), create(input) or update(id, input). Rename it, or drop the extra parameters. A list with extra arguments is a filtered list instead, not an error.
  • An Option argument of a bodyless op that one query value cannot carry. On a custom GET, an entity-less get_by_id or delete, or a junction list or remove, an Option whose inner type has generic arguments (Option<Vec<String>>), is a tuple, or is a schema entity cannot be read from one opArg value. Take it in a POST body instead.
  • Junction ops in a module named after an entity that do not make a relationship. There, list_X, add_Y and remove_Y now define a to-many relationship named X, served on the relationship endpoints, so they must fit one. Each of these is refused:
    • a relationship name that is already taken: by an attribute, by a relation field’s relationship, by another junction op’s relationship, or type, id or relationships. The common case is list_tags beside a tags relation field;
    • an add_Y or remove_Y without the list_X of the same relationship (add_tag without list_tags);
    • an id parameter of list_X, add_Y or remove_Y that is not &str, String or &String (a Uuid or an integer);
    • a list_X that returns anything but Vec<Target> or Vec<String>, or whose target is neither its element type nor the entity whose plural is X;
    • junction ops in a module that does not serve get_by_id.
  • A relationship whose target’s module serves no get_by_id. An entity whose module serves get_by_id and that has a relationship gets relationship endpoints, and they read related resources through the target module’s get_by_id. Every target, of a relation field or a junction op, must therefore have one. Before, only create and update needed it.
  • Under a route_prefix, a resource served in two scopes. A function that takes the store is served only under the prefix and any other only outside it, and every resource links to its type’s get_by_id route in the scope of the handler that answers it. So a module’s list, create and update must take the store exactly when its get_by_id does (delete may differ). When get_by_id takes the state, the type’s relationship endpoints are outside the prefix, so its junction ops and the get_by_id of every type it relates to must take the state too. A create or update that takes the state checks the ids it links outside the prefix, so the get_by_id of every type it relates to must take the state too, whether or not the module has a get_by_id of its own. Before, such a build linked to routes it did not serve, for example /api/tags/… when tags were served only under /api/projects/{project_id}/tags, and opened the store outside any scope, as an unscoped create did to check a linked tag. The error names both functions, for example ontogen: `workout_set::get_by_id` takes no store, so the relationship routes of the JSON:API resource `workout-sets` are served outside the route prefix `projects/:project_id`, but its relationship `tag` links `tags` resources, which `tag::get_by_id` serves only under the prefix (it takes a store); …. For a write, it reads ontogen: `workout_set::create` takes no store, so it is served outside the route prefix `projects/:project_id`, but it checks that the `tags` resources its relationship `tag` links exist with `tag::get_by_id`, which takes a store that only a handler under the prefix opens; …. Give the functions the same first argument. The reverse stays accepted: a get_by_id that takes the store may relate to a type whose get_by_id takes the state, its junction ops may take the state, and a create or update that takes the store may link a type whose get_by_id takes the state.

For the name clash, rename the junction ops. If they only edit the relation field, delete them instead: the field’s own relationship endpoints add and remove its members.

// Before: the relationship `tags` clashes with the `Task.tags` relation field.
pub async fn list_tags(store: &Store, id: &str) -> Result<Vec<Tag>, AppError> { /* … */ }
pub async fn add_tag(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }
pub async fn remove_tag(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }
// After: the relationship `labels`, whose target is `tags` (the element type).
pub async fn list_labels(store: &Store, id: &str) -> Result<Vec<Tag>, AppError> { /* … */ }
pub async fn add_label(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }
pub async fn remove_label(store: &Store, id: &str, tag_id: &str) -> Result<(), AppError> { /* … */ }

The rename also renames the IPC commands, MCP tools and TypeScript methods (task_list_labels, taskAddLabel).

A list_X without add_Y or remove_Y is a custom op

Section titled “A list_X without add_Y or remove_Y is a custom op”

A function list_X with one parameter after the state or store used to be a junction list wherever it was. It is now a junction list only when its module also has an add_Y or remove_Y for the same relationship (add_tag or remove_tag beside list_tags, add_sub_task beside list_sub_tasks). Otherwise it is a custom GET, on every transport and in every build, so a plain list_by_status(store, status) no longer becomes a junction list named by_status. A junction list on a paginated surface was paged in memory; a custom GET is not paged.

For task::list_by_status(store: &Store, status: &str) -> Result<Vec<Task>, AppError> on a paginated surface:

TransportBeforeAfter
HTTPGET /api/tasks/open/by-status?limit=20&offset=0, {items, total, limit, offset}GET /api/tasks/list-by-status/open, {"jsonapi": …, "meta": {"result": [ … ]}}
Tauri IPCtask_list_by_status { status, limit, offset }, PaginatedResult<Task>task_list_by_status { status }, Vec<Task>
MCPtool task_list_by_status with status, limit, offset, answering {items, total, limit, offset}the same tool with status only, answering the array; limit and offset are Unknown argument
TypeScripttaskListByStatus(status, limit?, offset?): Promise<PaginatedResult<Task>>taskListByStatus(status): Promise<Task[]>

Callers that relied on the page slice the array themselves. Without pagination, only the HTTP route and its document change. To keep a junction list, give the module the add_Y or remove_Y that makes it a relationship.

IPC commands keep argument names for the wire

Section titled “IPC commands keep argument names for the wire”

A generated Tauri command names each parameter after the function’s argument, because that name is the invoke key. The command’s own bindings are now prefixed (ontogen_state, ontogen_store), so an argument named state or store compiles; before, it collided with the command’s State parameter or store binding. Code that calls the generated command functions directly passes the state in the same position, so only code that named those bindings is affected.

Some names are wire keys the command itself uses, so an argument with one of them fails gen_servers when it generates IPC, with an error that names the command, the function and the argument: query on a list that also takes a *Query struct, limit or offset on the parent id of a paginated junction list, channel on an event op, and, under a route prefix, the prefix parameter’s name (project_id) on any op. Before, the generated command did not compile. Rename the argument.

A scoped MCP tool reads the route prefix parameter from its arguments to pick the store, so an argument the tool would read under the same name (a custom op’s or junction tool’s parameter, or a list’s bare filter) fails gen_servers when it generates MCP, with the same kind of error. Before, the scope and the argument silently shared one key.

A hand-written list or count replaces the generated one

Section titled “A hand-written list or count replaces the generated one”

In a module named after an entity, a hand-written list now replaces the generated list, and a hand-written count replaces the generated count. Before, the generated fn was kept and the hand-written one was silently dropped, so a filtered list could not be the module’s list. The API stage scans your hand-written modules before it emits, so the replaced fns are not generated at all.

  • When the module is paginated, a hand-written list also drops the generated count, because the generated one counts the whole table. The module must define a count that takes the same filter parameters as the list (and no page). Without one the build fails with a message saying the hand-written list replaces the generated count.
  • Only list and count may be hand-written beside the generated module. Any other name the generated module defines (get_by_id, create, update, delete), written again by hand in the same module, fails the build as defined twice in that API directory. Before, the hand-written fn was silently dropped. Give it another name, or exclude the entity from the api stage and write the whole module.
  • Pipeline now scans the servers stage’s api_dir, every extra surface’s api_dir and the clients stage’s api_dir for the api stage. Directories given to api_scan_dirs are scanned too, ahead of those; before, api_scan_dirs was the whole list. A direct gen_api caller lists its scan_dirs and must include every directory that holds a hand-written list or count.
  • When the api stage does not scan the directory holding a hand-written list or count, the generated one is still emitted and the servers stage finds the fn twice in one API directory. The build then fails with an error that names the fn and this rule. Before, it reported the fn as defined by two API surfaces.
  • A #[ontogen::stateless] list or count never replaces the generated one, so it fails the build the same way. Give it the state or the store.
  • A directory listed in api_scan_dirs or ApiConfig::scan_dirs that does not exist is an error. A transport’s api_dir that does not exist is skipped by the api stage with a warning, and the servers stage reports it.
  • gen_api no longer prints warnings about scan records it skips; the servers and clients stages print the same ones.
  • The HTTP generator refuses, as a build error, a list whose filter it cannot serve: a second *Query struct, a *Query struct taken neither by value nor by & (Option<ListTasksQuery>, &mut ListTasksQuery), a bare filter whose type one value cannot carry (Vec<_>, a tuple, a schema entity), and an *Input parameter on a list, which has no body. IPC and MCP generation does not apply these checks.

The MCP tools keep their names and their flat payloads, with these changes:

  • Every tool refuses an argument it does not read. task_list {"colour": "red"} is the tool error Unknown argument: colour, and so is task_capture {"input": {…}, "extra": 1}; before, the key was ignored and a list tool listed everything. This holds for list, get, create, update, delete, custom ops and junction tools. A tool reads exactly the arguments its input schema lists: for a list, the filter struct’s fields, the bare filters, limit and offset when paginated, and the route-prefix parameter when scoped. A serde alias the schema does not name is refused too. A tool whose schema is your own type and names no closed set of arguments (an input that flattens a map, or an enum body) refuses nothing.
  • A list tool reads its *Query struct strictly, from its arguments without the ones the tool reads itself (bare filters, the page and the route-prefix parameter), so a #[serde(deny_unknown_fields)] struct works. An argument object the struct cannot be read from ({"status": 5} for a String field) is the tool error Invalid filter: …; before, it was silently read as Default::default(), which listed everything, so the struct no longer needs Default.
  • A list tool’s input schema lists its bare filters beside the struct’s fields and the page, so a bare filter’s type must implement JsonSchema, as a custom op’s MCP argument already must.
  • A custom op reads each argument as its declared type. Before, an Option<bool> or Option<u32> argument was read as a string and the generated code did not compile. A custom op that takes an *Input beside other arguments takes the input under its parameter name, as the IPC command does: {"input": {…}, "status": "open"}. Before, that op did not compile either. An op whose only argument is its input still takes the input’s fields flat.
  • A required string argument of the wrong type is Invalid parameter id: invalid type: integer `5`, expected a string, not Missing required parameter: id, which now means only that the key is absent.
  • Create and update read their input without the route-prefix parameter (and update without id), so a deny_unknown_fields input works there too.
  • An argument named after a Rust keyword travels under serde’s name for it. A custom op’s r#type argument is the key type, as its input schema already said; before, the tool read the key r#type. Send type.
  • The generated schema-only structs are named Ontogen{Module}{Fn}Filter and Ontogen{Module}{Fn}Input so they cannot collide with your types; the HTTP query-spec types are likewise Ontogen{Handler}FilterParams and Ontogen{Module}{Fn}OpArgs.

A list’s *Query struct is named through types_import_path

Section titled “A list’s *Query struct is named through types_import_path”

The generated HTTP and MCP handlers name every type an op takes through types_import_path. Before, a hand-written list in a module named after an entity was dropped, so its *Query filter struct could live in the hand-written API module alone. Now that list is served, and a struct defined only there fails to build with unresolved import crate::schema::ListTasksQuery. Re-export it from the module types_import_path names.

Before, in src/api/v1/task.rs only:

#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct ListTasksQuery {
pub status: Option<String>,
pub epic_id: Option<String>,
}

After, the same struct, and in src/schema/mod.rs:

pub use crate::api::v1::task::ListTasksQuery;

Depend on ontogen-jsonapi, ontogen-core and serde_json

Section titled “Depend on ontogen-jsonapi, ontogen-core and serde_json”

The generated HTTP file uses the ontogen-jsonapi crate for documents, links, errors and extractors, ontogen_core::id::validate_id to check a client-supplied id, and serde_json. Add all three to the crate that contains the generated server, as you add markdown-store. Generated SeaORM stores call ontogen_core::id too; see Creating records. The examples use path dependencies; a consumer names the crates:

ontogen-jsonapi = "…"
ontogen-core = "…"
serde_json = "1"

ontogen-jsonapi is part of the release set with ontogen, so use the version released alongside the ontogen you build with. A server with no resource module (gen_servers called with an empty schema) does not use these.

Code that calls ontogen-jsonapi 0.1 directly

Section titled “Code that calls ontogen-jsonapi 0.1 directly”

ontogen-jsonapi 0.1.0 was published before this release; 0.2.0 ships with ontogen 0.9. Code that calls the crate directly, rather than through the generated server, changes as follows:

  • QuerySpec has a new field filter_fields, so a struct literal must set it or end with ..QuerySpec::NONE. QuerySpec no longer implements PartialEq or Eq, and the crate no longer depends on serde_urlencoded.
  • QueryParams gained filter, filter_member, required_filter_member, link_query and include_paths, and the crate exports filter_fields::<T>(). link_query takes the parsed include (Option<&[&str]>, from QueryParams::include_paths).
  • response::created takes Option<&str>, the Location (None for a module that serves no get_by_id), and ResourceObject::links() returns Option<&Links> (None for such a module’s resources).
  • ErrorCode::RelatedResourceNotFound and ErrorCode::RelationshipNotFound are renamed NoSuchRelatedResource and NoSuchRelationship, and their codes are no_such_related_resource and no_such_relationship. No code the server raises ends in a suffix of the store’s AppError variants (_not_found, _id_required, _already_exists, _parent_required), so an entity named Relationship or RelatedResource can be served. ErrorCode::ALL is a &'static [ErrorCode]; it was a [ErrorCode; 22].

Links and Location headers are built from the route templates ontogen generated (/api/...), not from the request URI. Mount the generated router at the root of your Axum app. If you nest it under another prefix, the links it emits lack that prefix.

The HTTP server’s PaginatedResult is private

Section titled “The HTTP server’s PaginatedResult is private”

The generated HTTP file no longer exports PaginatedResult. Its page type is now the private OntogenPaginatedResult, and the file names nothing else bare but your types and API modules: what it defines is Ontogen- or ontogen_-prefixed, and what it uses from axum, ontogen-jsonapi, serde and std is imported under an Ontogen alias or named by its ::-rooted path. So an entity may be called Document, Response or State, and an API module request. If your code named the HTTP module’s PaginatedResult, declare the struct yourself; its wire shape, {items, total, limit, offset} in meta.result, is unchanged.

This section applies to projects that use the markdown store. A vault is an OKF 0.2 bundle: every record carries a type, index and log are reserved file names, and some frontmatter keys have fixed meanings. Index files and generated stamps are opt-in and off by default. The rules are in the markdown backend guide.

EntityDef.type_name, the type value the markdown backend writes into every record, defaults to the struct name (WorkSession). It used to default to the snake_case name (work_session). It is set only by the entity attribute; no build configuration option sets it. To keep the old value:

#[ontology(entity, type_name = "work_session")]
pub struct WorkSession { /* .. */ }

0.8 wrote no type key, so the change matters for a vault whose records already carry type values in the old form, and for code that reads EntityDef::type_name or MarkdownEntityMeta::type_name.

The generated store reads and writes through VaultHandle::entity(dir, type), a markdown_store::EntityRecords.

  • A create writes type as the first frontmatter key.
  • An update, or the foreign-key write to a child that a has_many create or update makes, that changes the record also stamps it: a missing type is inserted as the first key, and a different value is overwritten in place. An update that changes no value writes nothing. Existing records gain type on their next real write, which shows in a diff as an added type: line.
  • A record with no type still reads.
  • A type value other than the entity’s, a hand-added type key included, is replaced on the record’s next real write. Move such values to another key before upgrading.
  • With MarkdownLayout::PerEntityDir the directory decides what a record is. A record whose type differs is still listed and read.
  • With MarkdownLayout::Flat the type decides. A record whose type is a string other than the entity’s, the empty string "" included, is left out of the entity’s list and count, and its get, update and delete answer {Entity}NotFound. A record whose type is a number, list or mapping is listed by no entity. A record with no type, or with a type: that has no value (YAML null), is untyped: it is listed by every entity, as in 0.8, until a write through one entity stamps that entity’s type on it. Before upgrading a Flat vault, add type: <Entity> (the struct name, or your type_name) to every record.

The untyped VaultHandle methods (create_record, create_record_derived, modify_record, remove_record, read_all) are unchanged: they write no type and do not filter by it. Hand-written hooks and API functions that write records through them can use vault.entity("<dir>", "<type>"), whose create, modify, remove, read_opt, read_all and count behave as the generated store’s do.

gen_markdown_io checks the key each field is stored under against the keys OKF reserves.

  • A field stored under type (including a field named r#type) fails the build: the generator owns that key. Store it under another key with #[ontology(frontmatter_name = "kind")], and rename the key in existing records before their next write, which would otherwise overwrite the value with the entity’s type.
  • A field stored under status, resource, stale_after, generated, usage_window, verified or sources whose type cannot hold what OKF means by the key prints a cargo:warning= naming the entity, field and key. The build continues. Each key’s expected type is listed under Reserved frontmatter keys. To clear a warning, move the field with frontmatter_name and rename the key in existing records; a record that still holds only the old key fails to read when the field is required.

gen_markdown_io takes the parsed schema, a &SchemaOutput, where it took the entities, because the status check needs the schema’s enums. So does ontogen::persistence::markdown::generate, which it calls. Pipeline users change nothing for this signature.

Before:

ontogen::gen_markdown_io(&schema.entities, &markdown_io_config)?;

After:

ontogen::gen_markdown_io(&schema, &markdown_io_config)?;

MarkdownIoConfig and MarkdownIoOptions have okf

Section titled “MarkdownIoConfig and MarkdownIoOptions have okf”

Both have a new public field, okf: OkfOptions. Neither implements Default, so every struct literal sets it. OkfOptions::default() turns both OKF options off, and then no index file or stamp is written:

use ontogen::{MarkdownIoOptions, MarkdownLayout, OkfOptions};
MarkdownIoOptions {
vault_root: "data/vault".into(),
layout: MarkdownLayout::PerEntityDir,
list_cap: 10_000,
okf: OkfOptions::default(),
}

MarkdownIoOutput has no vault_root, layout or list_cap field (nor id_strategy; see The id strategy moved to the store). It keeps module_path and entities. The three settings reach the runtime through the generated open_vault; code that read them from the output reads them from its MarkdownIoConfig. A hand-built MarkdownIoOutput drops them.

ontogen::persistence::markdown::gen_frontmatter::generate(entities, output_dir) is removed. Call gen_markdown_io, which writes the same per-entity frontmatter modules and a mod.rs that also holds VAULT_ROOT and open_vault. gen_frontmatter::generate_frontmatter_module(entity) still returns one entity’s module source.

Open the vault with the generated open_vault

Section titled “Open the vault with the generated open_vault”

The generated mod.rs in the markdown I/O output directory has pub const VAULT_ROOT, the configured vault_root, and pub fn open_vault(root) -> markdown_store::VaultHandle, which applies the configured layout, list cap and OKF options.

Before:

let vault = VaultHandle::new("data/vault", VaultLayout::PerEntityDir, IdStrategy::SlugFromField("title".into()));

After:

use my_app::persistence::markdown::generated::{VAULT_ROOT, open_vault};
let vault = open_vault(VAULT_ROOT);

A hand-built VaultHandle::new(root, layout) still works (its signature is under Markdown-store API), but it gets only what you pass it: the configured list cap and OKF options reach it only through open_vault, or by repeating them with with_list_cap and with_okf(OkfPolicy { .. }). In 0.8 the configured layout and list_cap reached no runtime code, so check that they match the values you passed by hand before you switch. A relative VAULT_ROOT resolves against the working directory the program runs from. See The generated open_vault.

The vault walk skips every file named index or log, in any letter case and with any record extension, at every depth, so list, count and get never return one. In 0.8 an index.md or log.md in an entity directory was listed as the record index or log. Neither id can be created (see Record ids), and a lookup of either is {Entity}NotFound (see Store behaviour). Rename such records, and update every [[wikilink]] and foreign key that names them.

Index files and generated stamps are opt-in

Section titled “Index files and generated stamps are opt-in”

With OkfOptions::default() neither is written. Turning one on changes files:

  • okf.generated_by: Some("<producer>/<version>") stamps generated: { by, at } on every create and every update that changes a record, through the generated store and the untyped VaultHandle methods alike. at is UTC to the second, so every write of a record changes that line; an update that changes nothing leaves the file alone. Existing records gain the stamp on their next real write. The value must be <producer>/<version> or process:<id>; anything else, a human: actor included, fails the build, as does a field stored under generated while the option is set. See Generated stamps.
  • okf.index: true keeps an index.md in the vault root and in every directory that holds records, regenerated after each real write. The store owns those files and overwrites a hand-written index.md there. Turning the option on writes nothing until the next write or VaultHandle::rebuild_indexes(). See Index files.

FieldDef has a new public field, frontmatter_name: Option<String>, set by #[ontology(frontmatter_name = "...")]. Code that builds FieldDef values by hand adds frontmatter_name: None, or starts from FieldDef::new(name, field_type, role), which sets it to None. FieldDef::frontmatter_key() returns the key a field is stored under.

Your AppError must declare new variants. They are typed on IPC and MCP as well as HTTP.

For an entity Task:

pub enum AppError {
TaskNotFound(String),
TaskIdRequired(String), // the reason no id could be had
TaskAlreadyExists(String), // the duplicate id
// ...
}

Add {Entity}AlreadyExists(String) for every entity, next to the existing {Entity}NotFound(String). Add {Entity}IdRequired(String) for every entity whose generated store can return it: every entity of a markdown store, and every entity of a SeaORM store whose id strategy is Provided or SlugFromField. A SeaORM entity under Uuid always gets an id and does not need it.

  • {Entity}NotFound(id): no record has that id, or the id can never exist in the store.
  • {Entity}IdRequired(reason): a create had no id and none could be derived. That is Provided with no id, or a slug source that slugs to empty.
  • {Entity}AlreadyExists(id): a create supplied an id that is already taken.

Add {Child}ParentRequired(String) for a has_many child whose foreign key is a plain String rather than an Option. It carries the id of the child that would be left without a parent. See has_many writes.

The HTTP generator scans ServersConfig::error_source_dir for enum AppError and gives each variant a status from the suffix of its name. The code of the error is the variant name in snake_case.

VariantStatusExample code
*NotFound404task_not_found
*IdRequired400task_id_required
*AlreadyExists409task_already_exists
*ParentRequired403task_parent_required
any other variant500db_error for DbError

Before this change every AppError was a 500 with {"error": "<Display text>"}, whatever the variant. Clients that branched on 500 for a missing record now see 404, and so on.

A variant whose snake_case name equals one of ontogen’s own error codes fails the build, so a code always means one thing. The codes are listed in Server Transports; for example a variant named InvalidDocument or NotAcceptable clashes. Rename it. A second enum AppError declaration in the scanned directory also fails the build.

Every generated HTTP route speaks JSON:API 1.1. CRUD routes of a module backed by a schema entity carry resource documents. Custom ops, junction ops and the CRUD ops of a module with no entity behind it carry meta-only documents, described under Custom op responses and the sections after it. The examples use the tasks-tracker example and iron-log.

Responses carry Content-Type: application/vnd.api+json and Vary: Accept. A request with a body must send Content-Type: application/vnd.api+json: application/json is now 415 (code: unsupported_media_type, source.header: "Content-Type"). A request whose Accept rules out application/vnd.api+json is 406 (not_acceptable). A missing Accept and */* are fine. Clients must set both headers.

A bare entity becomes a resource inside a document. The id is lifted out of the attributes, and relation fields move into relationships.

Before, GET /api/tasks/ship-the-emitter:

{
"id": "ship-the-emitter",
"title": "Ship the emitter",
"status": "closed/done",
"created": "2026-06-06",
"epic_id": "markdown-backend",
"tags": ["codegen"],
"body": "## Goal\n…"
}

After:

{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/ship-the-emitter" },
"data": {
"type": "tasks",
"id": "ship-the-emitter",
"attributes": {
"title": "Ship the emitter",
"status": "closed/done",
"created": "2026-06-06",
"body": "## Goal\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" } ]
}
},
"links": { "self": "/api/tasks/ship-the-emitter" }
}
}
  • type is the module’s kebab-case plural, the same string as the URL segment.
  • A belongs_to field named epic_id becomes the relationship epic; other relation fields keep their name. A to-one is an identifier or null; a to-many is an array of identifiers.
  • Each relationship object carries links to its relationship endpoints and its data. Relationships are listed in field order, followed by those of the module’s junction ops, which have links only.
  • A list is {"jsonapi": …, "links": {"self": …}, "data": [ … ]}, with [] when empty, ordered by id unless the request gives sort.

Before, POST /api/tasks took a flat body and answered 201 with the bare entity. Now:

POST /api/tasks
Content-Type: application/vnd.api+json
{
"data": {
"type": "tasks",
"attributes": { "title": "Review the stack", "status": "open/ready", "created": "2026-06-06", "body": "…" },
"relationships": {
"epic": { "data": { "type": "epics", "id": "markdown-backend" } },
"tags": { "data": [ { "type": "tags", "id": "codegen" } ] }
}
}
}

The status is still 201 Created; the response now has Location: /api/tasks/review-the-stack and carries the resource in a document. data.id is optional and honoured under every IdStrategy. An attribute the type does not have, including a relation field written as an attribute (epic_id), is 400 unknown_attribute: the old flat body is refused, not ignored.

Request bodies are checked member by member

Section titled “Request bodies are checked member by member”

A body is a single JSON object with a data resource object. A type that is not the route’s is 409 type_mismatch, as is a linked identifier of the wrong type. A required attribute that is absent from a create is 400 missing_attribute, a required to-one relationship 400 missing_relationship, an unknown relationship 400 unknown_relationship, and a body that is not a valid document 400 invalid_document with a pointer. A client-supplied id that is already taken is 409 with source.pointer: "/data/id". A body over the server’s size limit is 413 content_too_large.

PUT /api/tasks/{id} is gone and answers 405 with Allow: GET, HEAD, PATCH, DELETE. Use PATCH, with data.type and a data.id equal to the URL id (a mismatch is 409 id_mismatch):

PATCH /api/tasks/ship-the-emitter
Content-Type: application/vnd.api+json
{ "data": { "type": "tasks", "id": "ship-the-emitter",
"attributes": { "status": "open/ready" },
"relationships": { "epic": { "data": null } } } }

Members that are absent are unchanged. null clears an Option attribute or an optional to-one (403 relationship_required for a required one, 400 invalid_attribute for a required attribute). A to-many data array replaces the whole list. The response is 200 with the full resource.

Section titled “A module with no get_by_id has no resource links”

A resource module that serves no get_by_id has no route at /api/{type}/{id}, so the generated server no longer emits a link to one. Its resource objects carry no links member, a create answers 201 with no Location header, and its create and update documents carry no top-level links. A list document keeps its top-level links, because the list itself serves the collection. A module that serves get_by_id is unchanged.

Before, POST /api/notes on a module with create and list but no get_by_id:

HTTP/1.1 201 Created
Location: /api/notes/standup
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/notes/standup" },
"data": {
"type": "notes",
"id": "standup",
"attributes": { "title": "Standup" },
"links": { "self": "/api/notes/standup" }
}
}

After:

HTTP/1.1 201 Created
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "notes",
"id": "standup",
"attributes": { "title": "Standup" }
}
}

A client that read Location or links.self from such a module must take the id from data.id. Add a get_by_id to the module to serve the links again.

DELETE answers 204 with no body. A missing resource is still 404.

limit and offset are replaced by page[offset] and page[limit], and the PaginatedResult body becomes meta plus links.

Before, GET /api/tasks?limit=10&offset=20:

{ "items": [ … ], "total": 45, "limit": 10, "offset": 20 }

After, GET /api/tasks?page[offset]=20&page[limit]=10:

{
"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": [ … ]
}

meta holds exactly the old fields (limit and offset are the effective values). pagination belongs to an API surface and its primary surface’s value covers every module on it, so setting it pages every entity module of that surface. The tasks-tracker example now sets .api_paginated(..) for task, epic and tag, so its three lists page, over HTTP and as MCP tools. A scoped list (route_prefix) pages like an unscoped one: the page goes to the store. prev is null on the first page and next on the last. page[limit]=0, a non-integer, and any other page[…] member (page[number], page[size]) are 400.

Link queries. The top-level self link and the pagination links repeat the request’s query parameters in one order: each filter[…] member in ascending byte order of its name, then sort, then include, then page[offset] and page[limit]. [ and ] are written %5B and %5D, and every other byte outside A-Z a-z 0-9 - . _ ~ is percent-encoded, except a , in a value (a filter value, or between sort and include items), which stays literal. A paginated list’s links always carry both page parameters, with the effective values. Equal requests get equal links, and following next keeps the filter, the sort and the include.

Before, any include on a list or a get was 400 invalid_include_path. After, GET /api/{type} and GET /api/{type}/{id} honour include: one level deep, a comma-separated list of relationship names, with the related resources in a top-level included member after data.

After, GET /api/tasks?include=epic,tags:

{
"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": [ { "type": "tasks", "id": "ship-the-emitter", … } ],
"included": [
{ "type": "epics", "id": "markdown-backend", … },
{ "type": "tags", "id": "codegen", … }
]
}
  • Every link carries include, in the link query order, so following next keeps it. On a get, the top-level self is /api/tasks/ship-the-emitter?include=epic and the resource’s own links.self carries no query.
  • included holds each resource once, in include order and then in order of first appearance in data. A resource already in data is not repeated, and a dangling markdown wikilink is left out of included while its identifier stays in the linkage. include= is "included": [].
  • Each included resource is read through the target module’s get_by_id, one read per distinct id, so a hand-written get_by_id applies.
  • A field relationship (belongs_to, many_to_many, has_many) can be included. A junction op relationship cannot, because it has no linkage. A relationship whose target module serves no get_by_id cannot be included either.
  • An unknown name, a junction-op relationship, a relationship that cannot be included, a dotted path (epic.tasks) and an empty item (epic,) are each 400 invalid_include_path with source.parameter: "include". The check runs before the store is read, so GET /api/tasks/nope?include=owner is 400, not 404.
  • Relationship and related routes still refuse include as 400 invalid_query_parameter.
  • The generated TypeScript transport does not send include: its flat return shape has nowhere to put included resources.

A hand-written list that takes filter parameters is a JSON:API list like any other. Each filter parameter is a filter[…] member, and meta.total is the result of the module’s hand-written count, which takes the same filter. See Filtered lists for how to write the pair.

A *Query struct. Before, GET /api/tasks?status=closed/done&limit=20&offset=0:

{ "items": [ … ], "total": 1, "limit": 20, "offset": 0 }

After, GET /api/tasks?filter[status]=closed/done, which answers the same document as any paginated list, with the filter carried in every link:

{
"jsonapi": { "version": "1.1" },
"links": {
"self": "/api/tasks?filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"first": "/api/tasks?filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20",
"prev": null,
"next": null,
"last": "/api/tasks?filter%5Bstatus%5D=closed%2Fdone&page%5Boffset%5D=0&page%5Blimit%5D=20"
},
"meta": { "total": 1, "limit": 20, "offset": 0 },
"data": [ … ]
}

Each serde field of the struct is a member, so a ListTasksQuery { status, epic_id } takes filter[status] and filter[epic_id]. The member names are the field names (as serde reports them, renames included), not relationship names. Values are read as axum::extract::Query reads one field: true is a bool, 5 a number, anything a string, and an Option that is present is Some, so filter[status]= is Some("").

Bare parameters. Before, every request to a list such as list(store, skill_id: &str) failed with 400 invalid type: map, expected a string, because the parameter was extracted as Query<String>. After, GET /api/notes?filter[skill_id]=abc works. A bare parameter is filter[{parameter name}], optional when it is an Option and required otherwise. Its type, under & and one Option, must be one that a single value can carry: &str, String, numbers, bool or a unit enum.

A list with no entity behind it. Before, GET /api/reports?status=open&limit=20&offset=0 answered the flat {items, total, limit, offset}. After, the list is a custom op at GET /api/reports: the filter is filter[…] and the page is opArg[limit] and opArg[offset], and the answer is a meta-only document.

GET /api/reports?filter[status]=open&opArg[limit]=20&opArg[offset]=0
{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "items": [ … ], "total": 3, "limit": 20, "offset": 0 } }
}

An unpaginated list with no entity behind it answers the bare array as meta.result. The page goes to the store, and count gets the same filter. A scoped list (route_prefix) behaves the same under its prefix.

Errors. Each of these is 400 invalid_query_parameter with source.parameter naming the parameter:

  • a filter[x] that is not a member of the list’s filter, and any filter[…] on a list that takes none;
  • the nested and array forms filter[x][], filter[x][y] and filter[x.y];
  • a member given more than once;
  • a value serde rejects, for example filter[done]=maybe for a bool;
  • a required filter that is missing.
{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "400",
"code": "invalid_query_parameter",
"title": "Bad Request",
"detail": "`filter[colour]` is not a query parameter of this route",
"source": { "parameter": "filter[colour]" }
} ]
}

A struct whose fields serde does not report, for example one with #[serde(flatten)], accepts no members. When several things are wrong the first error wins, in a fixed order: names the route does not accept, in request order, then the struct’s members in byte order, then its first missing required field, then the bare filters in byte order of name, then the page.

Links carry the filter. The links repeat the request’s filter[…] members, in the link query order, so following next keeps the filter.

A query parameter a route does not accept is 400 invalid_query_parameter, with source.parameter naming it. That includes limit and offset (use page[…]), page[…] on a list that is not paginated, fields[…], and filter[…] on a list that takes no filter. A request that sends stray parameters and used to work now fails. include on a list or a get is honoured (see Compound documents). sort is read on a list that takes an order argument (see Sorting); on any other list it is 400 invalid_sort_field.

A create or update that links a resource that does not exist, for example an epic or a tags id, is 404 no_such_related_resource before anything is written. Before, a markdown vault accepted it and wrote a dangling wikilink.

Each relationship of an entity type is now reachable on its own, through two new routes. They exist for an entity whose module serves get_by_id and that has at least one relationship, a relation field or a junction op:

RouteMethodsAnswers
/api/{type}/{id}/relationships/{rel}GET, PATCH, POST, DELETEthe linkage on GET; 204 on a change
/api/{type}/{id}/{rel}GETthe related resource, null, or a collection of resources

{rel} is the relationship name (epic, tags, subtasks). Every relationship in a resource object now carries links to both routes, before its data:

"epic": {
"links": {
"self": "/api/tasks/ship-the-emitter/relationships/epic",
"related": "/api/tasks/ship-the-emitter/epic"
},
"data": { "type": "epics", "id": "markdown-backend" }
}

Add a tag to a task, then read the task’s tags, against the tasks-tracker example:

Terminal window
curl -i -X POST http://127.0.0.1:3002/api/tasks/ship-the-emitter/relationships/tags \
-H 'Accept: application/vnd.api+json' \
-H 'Content-Type: application/vnd.api+json' \
-d '{"data": [{"type": "tags", "id": "release"}]}'
# HTTP/1.1 204 No Content
curl http://127.0.0.1:3002/api/tasks/ship-the-emitter/tags -H 'Accept: application/vnd.api+json'
{
"jsonapi": { "version": "1.1" },
"links": { "self": "/api/tasks/ship-the-emitter/tags" },
"data": [
{ "type": "tags", "id": "codegen", "attributes": { "title": "Codegen" }, "links": { "self": "/api/tags/codegen" } },
{ "type": "tags", "id": "release", "attributes": { "title": "Release" }, "links": { "self": "/api/tags/release" } }
]
}
  • GET …/relationships/{rel} answers the linkage: an identifier or null for a to-one, an array for a to-many, with links.self and links.related.
  • PATCH sets or clears a to-one ({"data": null}) and replaces a to-many ({"data": []} clears it), with the same rules as a resource PATCH of that one field, has_many included.
  • POST and DELETE add and remove one member of a to-many. The body holds exactly one identifier; more is 403 relationship_batch_unsupported, and {"data": []} changes nothing. Adding a member already present, or removing one that is absent, is 204 and writes nothing. On a to-one both are 403 relationship_update_unsupported.
  • A relation field is read through the module’s get_by_id and written through its update, so its hooks run. A module with no update answers every write 403 relationship_update_unsupported.
  • The related route loads each resource with the target module’s get_by_id. A dangling id is skipped, and a to-one whose target is missing is "data": null.
  • An unknown {rel} is 404 no_such_relationship, a missing parent 404 {entity}_not_found, and a missing linked resource on PATCH or POST 404 no_such_related_resource. Another method is 405, with Allow: GET, HEAD, PATCH, POST, DELETE or Allow: GET, HEAD.
  • Neither route takes query parameters, except page[offset] and page[limit] on a junction op’s relationship in a paginated module.
  • An entity whose module has no get_by_id gets neither route, and its relationships carry data only.
  • Under a route_prefix, both routes exist where the type’s scoped get_by_id does, with the prefix in every link.

Every error from a generated HTTP route, CRUD or not, is a JSON:API error document with one error object. Before:

{ "error": "Task not found: nope" }

After, with Content-Type: application/vnd.api+json:

{
"jsonapi": { "version": "1.1" },
"errors": [ {
"status": "404",
"code": "task_not_found",
"title": "Not Found",
"detail": "Task not found: nope"
} ]
}

detail is the old error text. status, code, title and source are stable; do not parse detail. source is present for request problems and holds a pointer into the body, a parameter, or a header. Besides the AppError codes above, the server raises its own codes for malformed requests (invalid_document, unknown_attribute, missing_attribute, invalid_attribute, unknown_relationship, missing_relationship, type_mismatch, id_mismatch, no_such_related_resource, method_not_allowed, content_too_large and others); the full table is in Server Transports. Axum’s own plain-text extractor rejections are replaced by these documents. Every route also answers a method it does not serve with 405, an Allow header and a method_not_allowed document. An event-stream function that fails before the stream opens maps its error like any other op (see Event stream frames).

A custom op (CustomGet and CustomPost) used to answer with the bare value its function returned. It now answers with a meta-only document: jsonapi plus meta.result, with no links and no data. The status is 200, never 201. A function that returns () still answers 204 with no body.

Before, GET /api/stats/workout:

{ "total_count": 12, "total_duration_minutes": 540 }

After, with Content-Type: application/vnd.api+json:

{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "total_count": 12, "total_duration_minutes": 540 } }
}

result is the same value serde produced before, even when it is an entity or a list of entities: it is not turned into a resource object. Errors are unchanged from the section above: AppError-typed functions map to their status and code, and any other error type is 500 internal_error.

A custom POST body is {"meta": {"args": {…}}}, with one member per Rust parameter name. That includes an *Input argument and Option arguments. Before, an op with a single *Input parameter took that struct as the bare body, an op with several parameters took a {field: …} object, and Option arguments went on the query string.

Before, for workout::start(state, input: StartWorkoutInput, note: Option<String>):

POST /api/workouts/start?note=warm%20up
Content-Type: application/json
{ "template_id": "push-day" }

After:

POST /api/workouts/start
Content-Type: application/vnd.api+json
{ "meta": { "args": { "input": { "template_id": "push-day" }, "note": "warm up" } } }

For workout::rename(state, id: &str, name: String), the before body {"id": "w1", "name": "Push day"} becomes {"meta": {"args": {"id": "w1", "name": "Push day"}}}. The success status and body follow Custom op responses.

  • A body needs Content-Type: application/vnd.api+json, like every other body. An op with no required arguments can be called with no body at all.
  • A body that is not a JSON object is 400 invalid_document. A missing meta is 400 invalid_document with source.pointer: "", and a missing meta.args is the same with "/meta". A meta or meta.args that is present but not an object has the pointer /meta or /meta/args. An op with no required arguments reads a missing member as {}. Other top-level members are ignored.
  • An argument name the function does not have is 400 invalid_document with source.pointer: "/meta/args/note", and so is a value that does not deserialize into its parameter type. A missing required argument is 400 invalid_document with source.pointer: "/meta/args". An absent or null Option argument is None.
  • A custom POST accepts no query parameters. Any query parameter is 400 invalid_query_parameter.

The Option arguments of a custom GET are opArg[name] query parameters. A bare name is now 400 invalid_query_parameter. Required arguments are still path segments.

Before, GET /api/workouts/summary/w1?verbose=true. After:

GET /api/workouts/summary/w1?opArg[verbose]=true
Accept: application/vnd.api+json

Values deserialize the way the old query extraction did (true, 5, plain strings). An unknown opArg[…] name or a value that does not deserialize is 400 invalid_query_parameter with source.parameter naming the parameter (opArg[verbose]). The brackets can be percent-encoded.

A module that no schema entity backs (for example when you scan hand-written API files with gen_servers(&SchemaOutput::default(), …)) serves its CRUD functions as custom ops, on the same paths as before. Their requests and responses follow the custom op rules, and one verb changes:

OpBeforeAfter
listGET /api/reports?limit=10&offset=0, bare list or {items, total, limit, offset}GET /api/reports?opArg[limit]=10&opArg[offset]=0, the same value under meta.result
get_by_idGET /api/reports/{id}, bare valuesame route, meta.result
createPOST /api/reports, bare input, 201 with the bare valuePOST /api/reports, body {"meta": {"args": {"input": …}}}, 200 with meta.result
updatePUT /api/reports/{id}, bare inputPATCH /api/reports/{id}, body {"meta": {"args": {"input": …}}}, 200 with meta.result
deleteDELETE /api/reports/{id}, 204unchanged

PUT is not routed any more and answers 405. opArg[limit] and opArg[offset] are unsigned 32-bit integers. An absent limit is the configured default_limit, a limit above max_limit is clamped to it, and an absent offset is 0. A malformed value is 400 invalid_query_parameter. A list that is not paginated accepts no query parameters.

After, GET /api/reports?opArg[limit]=10 on a paginated surface:

{
"jsonapi": { "version": "1.1" },
"meta": { "result": { "items": [ … ], "total": 45, "limit": 10, "offset": 0 } }
}

In a module named after an entity, junction ops (list_X, add_Y, remove_Y) no longer have routes of their own. They define a to-many relationship named X, spelled as list_X spells it, and the relationship endpoints serve it. Its target is the element type of list_X (Vec<Tag> targets tags), or for a Vec<String> list, the entity whose plural is X. The ops must pass the build checks, and a lone list_X is not a junction op at all.

For task::list_labels(store, id) -> Vec<Tag>, add_label(store, id, tag_id) and remove_label(store, id, tag_id):

OpBeforeAfter
list_labelsGET /api/tasks/{id}/labels, the bare array, or {items, total, limit, offset} with ?limit=…&offset=… when paginatedGET /api/tasks/{id}/labels, a JSON:API collection of tags resources; when paginated, page[offset] and page[limit] with meta and pagination links, like any list
add_labelPOST /api/tasks/{id}/labels, body {"tag_id": "codegen"}POST /api/tasks/{id}/relationships/labels, body {"data": [{"type": "tags", "id": "codegen"}]}
remove_labelDELETE /api/tasks/{id}/labels/codegenDELETE /api/tasks/{id}/relationships/labels, body {"data": [{"type": "tags", "id": "codegen"}]}

Add and remove still answer 204.

  • GET /api/tasks/{id}/relationships/labels answers the linkage, built from the listed entities’ ids. A list_X that returns Vec<String> is read there; its related route loads each id with the target’s get_by_id and skips ids that are gone.
  • The parent is read with get_by_id before your op runs, so a missing task is 404 task_not_found. An add also checks that the tag exists (404 no_such_related_resource); a remove does not, so a member whose tag was deleted can still be removed.
  • Add and remove call list_X first. A tag already listed is 204 without calling add_label, and one not listed is 204 without calling remove_label.
  • PATCH is 403 relationship_update_unsupported, and so is POST when the module has no add_Y and DELETE when it has no remove_Y.
  • A resource object lists the relationship after its relation fields, with links only: "labels": {"links": {"self": "/api/tasks/ship-the-emitter/relationships/labels", "related": "/api/tasks/ship-the-emitter/labels"}}. Sending it in a create or update body is 403 relationship_update_unsupported.
  • The route segment is the name exactly as list_X spells it: list_sub_tasks is served at …/relationships/sub_tasks and …/sub_tasks, where the old route was …/sub-tasks.

In a module with no entity behind it, junction ops keep their routes, /api/{m}/{parent_id}/{segment} and /api/{m}/{parent_id}/{segment}/{child_id}, and use the custom op wire:

  • list_X answers its list under meta.result. When the module is paginated, the page is opArg[limit] and opArg[offset] (as above) and the result is the {items, total, limit, offset} object.
  • add_Y takes the child id in meta.args, keyed by the function’s child parameter name. Before, the body was {"tag_id": "codegen"}; now it is {"meta": {"args": {"tag_id": "codegen"}}}.
  • remove_Y has no body, and add and remove still answer 204.

Only the data: payload of an SSE frame changes. Routes, parameters, id:, resume, event: lag frames ({"skipped": 3}) and the keep-alive comment are as before. The frame carries no links, because it is not tied to a request URL.

When the item type is a schema entity, data: is its resource object. Before, a task_changed event yielding Task:

event: task-changed
data: {"id":"ship-the-emitter","title":"Ship the emitter","epic_id":"markdown-backend","tags":["codegen"]}

After:

event: task-changed
data: {"type":"tasks","id":"ship-the-emitter","attributes":{"title":"Ship the emitter"},"relationships":{"epic":{"data":{"type":"epics","id":"markdown-backend"}},"tags":{"data":[{"type":"tags","id":"codegen"}]}}}

relationships is left out when the entity has none. Any other item type is wrapped as {"meta": {"result": …}}. Before, an Activity frame:

event: activity-for-kind
data: {"seq":4,"kind":"workout","id":"w1"}
id: 4

After:

event: activity-for-kind
data: {"meta":{"result":{"seq":4,"kind":"workout","id":"w1"}}}
id: 4

IPC frames stay flat.

A subscribe call that fails before the stream opens now answers an error document like any other op: an error of type AppError maps to its status and code (404 task_not_found), and any other error type is 500 internal_error. It used to be 500 for every error. EventSource cannot read that body; fetch and curl can.

A scoped list (route_prefix) that is not served as a resource, meaning a list in a module with no entity behind it, used to fetch every record and slice the page in memory. It now passes the page to the store’s list and calls count, like an unscoped one. A hand-written scoped list must therefore honour its limit and offset. Scoped custom ops follow the rules above, under the prefix.

Scoped junction ops lose their action-style routes. Before, they were custom ops at /api/projects/{project_id}/tasks/list-tags/{task_id}, POST …/tasks/add-tag and …/tasks/remove-tag, with both ids in the body. Now every scoped route has its unscoped shape under the prefix:

  • in a module named after an entity, the scoped relationship endpoints: /api/projects/{project_id}/tasks/{id}/relationships/labels and /api/projects/{project_id}/tasks/{id}/labels;
  • in a module with no entity behind it, GET and POST /api/projects/{project_id}/{m}/{parent_id}/{segment} and DELETE /api/projects/{project_id}/{m}/{parent_id}/{segment}/{child_id}, with only the child id in meta.args. A scoped junction list on a paginated surface pages like an unscoped one, with opArg[limit] and opArg[offset], where it used to return the whole list.

The split transport calls these routes when given a projectId; before, it called nested routes that the server did not serve under a prefix. An op whose first parameter is the state rather than a store, or that takes neither, is not scoped on the server, and the split transport now calls its unscoped route even when given a projectId; its prefix parameter is named _projectId.

The generated Transport interface keeps its method names, parameters and return types, flat entities, and PaginatedResult<T> for paginated lists, except for these changes:

  • A paginated module has no xCount() (below).
  • A junction add or remove is declared Promise<null> (below).
  • A lone list_X takes no page (below).
  • A list whose *Query struct has a required field takes query as a required parameter (below).
  • A list’s bare filter whose type name contains Query or Input without ending in it (mode: QueryMode) is declared under its own name. It used to be declared as query? (for Query) or left out (for Input).
  • A list that takes an order gains a trailing options parameter. See TypeScript lists take options under Sorting.

HTTP transport. The HTTP transport applies and removes the JSON:API shape internally (flattenTask and unflattenTask per entity, from a per-entity resource table, with the entity’s own id field name). A paginated list sends page[offset] and page[limit] and rebuilds PaginatedResult from data and meta. A filtered list sends filter[…]: the query struct’s fields and the bare parameters, for example toQueryString({ filter: { ...query, skill_id: skillId }, page: { offset, limit } }), and a filtered list with no entity behind it sends filter[…] with opArg[limit] and opArg[offset]. Method signatures change only as listed above.

IPC transport. A list whose query parameter is required sends it as given rather than query ?? {}. The IPC transport now sends a list’s bare filters under their own names (invoke('queue_list', { mode })), as the Rust command takes them; before, a bare filter whose type name contained Query was sent as query.

Bindings and EmitConfig. In the generated bindings, an Option field of a list’s *Query struct is now optional (status?: string | null, was status: string | null), so taskList({ status: 'open/ready' }) type-checks; other types keep every field required. When the filter struct has a required field, the list’s query parameter is required too (query: ListTasksQuery, was query?:), on the Transport interface and every client. The HttpTs client imports its types from the configured bindings file; before, it always imported ./bindings. If you build ontogen_ts::EmitConfig with a struct literal, add the new deserialize_only field or end it with ..Default::default().

Errors. The error type changes too. Any non-2xx response throws a JsonApiError with status and errors fields instead of a plain Error. Its message is the same text as before, but String(e) now reads JsonApiError: Task not found: nope where it read Error: Task not found: nope. Code or tests that match the Error: prefix must change. Read e.status and e.errors[0]?.code to branch on a failure. The httpPut helper is gone, because no generated route uses PUT any more; updates use httpPatch. JsonApiError, JsonApiErrorObject and JsonApiResource are exported from the generated file. Regenerate the TypeScript. The IPC transport changes only as described above, in a lone list_X (below) and in passing sort (see Sorting), and the MCP changes are listed under MCP tools. The changes to the client’s custom, junction and subscription methods follow below.

The generated calls for custom ops, and for junction ops in a module with no entity behind it, follow the wire above, and every one of them returns meta.result (null on 204). Each keeps its signature, except for the Transport changes listed at the top of this section and the HttpTs client’s paginated lists, which take limit? and offset? and return PaginatedResult<T> (see the end of this section). For example:

async workoutStart(input: StartWorkoutInput, note: string | null): Promise<Workout> {
return callOp<Workout>('POST', '/workouts/start', { input, note });
}

Before, the same method posted input bare and put note on the query string.

A custom GET sends its Option arguments as opArg[name], and a paginated entity-less list, or a junction list in a module with no entity, sends opArg[limit] and opArg[offset]. subscribeX hands the callback a flat entity for an entity item and the meta.result value for any other item, and the legacy on{Event} listeners decode frames the same way. SubscriptionHandlers.onEvent still receives flat items.

For a module whose list is paginated, the generated xCount() method is removed from the Transport interface and from the clients (both HTTP clients and the IPC transport). The server never served it: there was no HTTP route and no IPC command, because the count is the total of the paginated list. Read PaginatedResult.total instead. A junction add or remove op is declared Promise<null> on the Transport interface whatever its Rust return type, matching the 204 the server answers.

Junction methods of a module named after an entity call the relationship endpoints, with the same signatures:

taskListLabels(taskId: string, limit?: number, offset?: number): Promise<PaginatedResult<Tag>>;
// before: GET /api/tasks/{taskId}/labels?limit=…&offset=…
// after: GET /api/tasks/{taskId}/labels?page[offset]=…&page[limit]=…, flattened like taskList
// (a list of ids reads GET …/relationships/labels and maps each identifier to its id)
taskAddLabel(taskId: string, tagId: string): Promise<null>;
// before: POST /api/tasks/{taskId}/labels with { tag_id: tagId }
// after: POST /api/tasks/{taskId}/relationships/labels with { data: [{ type: 'tags', id: tagId }] }
taskRemoveLabel(taskId: string, tagId: string): Promise<null>;
// before: DELETE /api/tasks/{taskId}/labels/{tagId}
// after: DELETE /api/tasks/{taskId}/relationships/labels with { data: [{ type: 'tags', id: tagId }] }

Given a projectId, every junction method calls the same path under the prefix; there is no separate scoped form. A lone list_X is a custom GET (see A list_X without add_Y or remove_Y is a custom op): on both HTTP clients and the IPC transport it loses limit? and offset? and returns its plain array.

The HTTP-only client (HttpTs) changes in two ways. Its custom op methods now call the server’s actual paths, kebab-cased and singleton-aware, where some used the module’s Rust name. Its paginated lists, including junction lists, take limit? and offset? and return PaginatedResult<T>; before, they took no page arguments and returned the server’s default first page.

The IPC transport sends each argument under the key Tauri reads it from, the name without its r# lowerCamelCased with heck, where it sent the name camelCased: an argument _kind travels as kind, which the command reads, where it travelled as Kind, which the command never read. A subscribeX method’s args members are named by the same key, so an event op argument _kind is args.kind, where it was args.Kind; rename the member where you pass one. A TS parameter is named by that key too, with a trailing _ for a reserved word (class_, new_, in_ for r#in); positional callers do not change. The bindings name a raw field as serde writes it (loop for r#loop).

Every generated list takes an order, and sort works on HTTP, Tauri IPC, MCP and the generated TypeScript. The order is one argument to the store and the API, built from sort by ontogen_core::order::parse_sort, so the transports accept the same keys. name sorts ascending and -name descending. The id is always the last key. See Sorting a list for the rules and ADR 0006 for the design.

Every generated crate depends on ontogen-core

Section titled “Every generated crate depends on ontogen-core”

The generated store and API modules name ontogen_core::order (OrderBy, SortField) on both backends. Before, a markdown consumer with no event ops and no HTTP server did not need ontogen-core; now every crate that holds a generated store adds it:

ontogen-core = "…"

StoreOutput.methods now records each entity’s count_{plural} as StoreMethodKind::Crud(CrudOp::Count), beside the other five, and the list_{plural} entry’s params start with order. A match over CrudOp adds an arm for Count.

list_{plural} takes order: &[OrderBy<{Entity}SortField>] first. Pass &[] for the default order, which is id ascending. count_{plural} is unchanged. Each entity gets a generated {Entity}SortField enum, in its store module beside {Entity}Update.

Before:

let tasks = store.list_tasks(Some(20), Some(0)).await?;

After:

use ontogen_core::order::OrderBy;
use crate::store::task::TaskSortField;
let tasks = store.list_tasks(&[], Some(20), Some(0)).await?;
let newest = store
.list_tasks(&[OrderBy::desc(TaskSortField::Created)], Some(20), Some(0))
.await?;

The generated list of an entity module takes order after the store, before the page parameters when the module is paginated. Before, task::list(&store, Some(20), Some(0)). After, task::list(&store, &[], Some(20), Some(0)), and task::list(&store, &[]) for an unpaginated module. A hand-written caller adds &[].

A hand-written list answers sort only when it declares an order. Put it after the filter parameters and before the page parameters:

pub async fn list(
store: &Store,
query: ListTasksQuery,
order: &[OrderBy<TaskSortField>],
limit: Option<u64>,
offset: Option<u64>,
) -> Result<Vec<Task>, AppError> {
// Filter in memory, then `sort_tasks(&mut items, order)` before the page is cut.
// A SeaORM list filters in SQL and calls `order_tasks_query(query, order)` instead.
}

The paginated count takes the filters and no order. The parameter is found by its type, &[OrderBy<…>], whatever it is named. Order through list_*, sort_{plural} (both backends) or order_{plural}_query (SeaORM only) so your list follows the same rules as the generated one; the parity guarantee covers it only then.

A hand-written list that declares no order still answers sort with 400 invalid_sort_field, as before.

gen_servers and gen_clients fail the build, naming module::fn, for an order parameter that is:

  • on a fn that is not a module’s list;
  • not the last parameter, or on a paginated list not the last before limit and offset;
  • the second one on the fn;
  • on a list in a module that no schema entity backs;
  • another entity’s (&[OrderBy<EpicSortField>] in the task module).

gen_store fails the build, with entity `Task` has no `#[ontology(id)]` field; the generated store needs one, for any schema entity that has no id field. Every generated store method reads the record’s id, and the id is the last key of every order. Before, gen_store did not check, and such an entity generated a store whose methods read an id the entity does not declare. After, the build stops at gen_store. Mark the entity’s id field:

// Refused by gen_store: no #[ontology(id)] field
#[ontology(entity, directory = "tasks")]
pub struct Task {
pub id: String,
pub title: String,
}
// After
#[ontology(entity, directory = "tasks")]
pub struct Task {
#[ontology(id)]
pub id: String,
pub title: String,
}

A store create_* or update_* that sets a float field (f32 or f64, optional or not) to NaN is an error on both backends. It fails after the before_* hook and before anything is stored, and it answers 500 over HTTP. Before, a hook or a direct caller could store a NaN. No transport can send one, since JSON cannot carry it, and the order has no place for it. An update checks only the fields it sets. On SeaORM, create also checks #[ontology(skip)] float fields, which SeaORM stores; markdown does not store skipped fields and does not check them.

Before, GET /api/tasks?sort=-created was 400 invalid_sort_field on every list. After, it sorts, and the response links repeat sort, in the link query order:

GET /api/tasks?sort=-created,title&page[limit]=2 HTTP/1.1
{
"links": {
"self": "/api/tasks?sort=-created,title&page%5Boffset%5D=0&page%5Blimit%5D=2",
"next": "/api/tasks?sort=-created,title&page%5Boffset%5D=2&page%5Blimit%5D=2"
}
}

An unknown field, a field named twice and an empty item (sort=, sort=title,,id) are each 400 invalid_sort_field with source.parameter sort. A repeated sort is 400 invalid_query_parameter.

A list command whose list takes an order gains sort: Option<Vec<String>>:

await invoke('task_list', { sort: ['-created', 'title'] });

A bad key is the string error unknown sort field `priority` . A bare filter named sort on such a list now fails gen_servers when it generates IPC, because the command reads its sort keys from that name. Rename the filter.

The list tool’s input schema gains an optional sort array whose items enumerate the keys: {"sort": ["-created", "title"]}. Anything but an array of strings is the tool error Invalid sort: …, and a bad key is the same text as on IPC. A bare filter named sort fails gen_servers when it generates MCP. A field of a *Query struct that serializes as sort is not read from the arguments, so it cannot be set over MCP; rename it with #[serde(rename = …)]. IPC and MCP read sort before they open the store.

A Transport list method of a list that takes an order gains a trailing options parameter, after every existing one (projectId included):

taskList(query?: ListTasksQuery, limit?: number, offset?: number, options?: ListOptions<TaskSortKey>): Promise<PaginatedResult<Task>>;
await transport.taskList(undefined, 20, 0, { sort: ['-created', 'title'] });

TaskSortKey ('id' | '-id' | 'title' | '-title' | …) and ListOptions<K> are exported from each generated client file, beside PaginatedResult. Positional callers do not change. The HTTP transport sends sort=-created,title, and the IPC transport passes sort in the invoke arguments. toQueryString now writes an array value as one comma-joined parameter and skips an empty array. Regenerate the TypeScript.

The clients stage refuses colliding parameter names

Section titled “The clients stage refuses colliding parameter names”

A sorted list’s TS method takes options as its last parameter, so a bare filter on that list that camelCases to options (options, options_) fails the build on every TS client; rename it. A route prefix parameter named options is refused on the Transport of a sorted list. More generally, a bare filter whose TS name equals query, limit, offset, the route prefix parameter or an earlier filter is refused, where it used to emit a method with a duplicate parameter that tsc rejects.

With an HttpTauriIpcSplit client, the clients stage applies the IPC wire-key rules itself, even when the build generates only an HTTP server: a bare filter named sort on a sorted list, query beside a *Query struct, limit or offset on a paginated junction list, channel on an event op, or an argument named like the route prefix parameter fails the build instead of generating TypeScript that does not compile. Names are compared by the invoke key they travel under, the name without its r# lowerCamelCased as Tauri derives it, so sort_ and _sort collide with sort, and project_id_ and _project_id with the prefix parameter project_id; this applies to the IPC server’s own check too. An unsorted list may still take filters named sort or options.

A type the TS clients import may not take the name of a global they use unqualified (Array, JSON, Object, Promise, Record, String, and on the split transport Math and ReturnType) or of a name a client declares (PaginatedResult, ListOptions, an {Entity}SortKey, a JsonApi… type, and on the split transport Transport, SubscriptionHandlers, EventFrame and IpcChannel). The build fails naming the type, where it generated TypeScript that tsc rejects; rename the type.

Clients-stage errors are CodegenError::Client

Section titled “Clients-stage errors are CodegenError::Client”

gen_clients and the pipeline’s clients stage report every failure as CodegenError::Client, where they reported CodegenError::Server. The message prefix changes from server codegen error: to client codegen error:. Code that matches the variant, or text that quotes the prefix, follows the change.

If you call the generated HTTP routes from something other than the generated TypeScript client:

  • Send Accept: application/vnd.api+json, and Content-Type: application/vnd.api+json on requests with a body.
  • Wrap request bodies in {"data": {"type": …, "attributes": …, "relationships": …}} and read responses from data.
  • Use PATCH instead of PUT to update.
  • Create answers 201 with a Location header, except for a module that serves no get_by_id, whose resources carry no links either.
  • Add include=epic,tags to a list or get to receive the related resources in included.
  • Replace limit and offset with page[limit] and page[offset], and read meta.total, meta.limit and meta.offset instead of the PaginatedResult fields.
  • Read errors from errors[0] (status, code, detail) instead of error.
  • Drop any query parameter the route does not accept.
  • A create whose client data.id breaks the id rule is 400 invalid_document with source.pointer: "/data/id", and the rule’s message is the detail. An id that no strategy can supply is 400 {entity}_id_required, whose detail is the store’s reason.
  • Custom ops, junction ops of a module with no entity behind it, and the CRUD ops of such a module: send arguments as meta.args (a POST or PATCH body) or opArg[name] (a GET), and read the result from meta.result.
  • Junction ops of a module named after an entity: use the relationship endpoints. List with GET /api/{type}/{id}/{rel} (or …/relationships/{rel} for ids), add with POST /api/{type}/{id}/relationships/{rel} and remove with DELETE on the same path, each with {"data": [{"type": …, "id": …}]}.
  • Relationship objects carry links.self and links.related; follow them instead of building URLs. A lone list_X is a custom GET at /api/{type}/{action}/{param}.
  • Event streams: an entity item is a resource object, any other item is {"meta": {"result": …}}.
  • A filtered list: send filter[name] for each query-struct field and bare parameter, and read meta.total as for any list. A filtered list with no entity behind it takes filter[…] plus opArg[limit] and opArg[offset], and answers meta.result.

For an integer field that the parser files under OptionEnum or Other (u8, u16, u32, i8, i16, and now usize, isize, u128, i128), the generated model field is i64 or Option<i64> instead of i32. An out-of-range value now fails with AppError::DbError naming the field, where it used to wrap.

u32 and Option<u32> need a data migration. A u32 above i32::MAX was stored as a wrapped negative number, and reading such a row now fails. For each u32 or Option<u32> column, run this once, before the first start of the new binary, with <table> and <column> replaced by the table’s and the column’s names:

UPDATE <table> SET <column> = <column> + 4294967296 WHERE <column> < 0;
  • This is SQLite SQL.

  • It is safe on rows already in range: only negative values change.

  • Run it once per column. Running it a second time changes nothing.

  • On SQLite, that UPDATE per u32 column is the whole migration. u8, u16 and the signed types need nothing.

  • On Postgres, the driver reads an i64 only from a bigint column. Widen every column that changed, u8, u16, u32, i8 and i16 (and their Option forms), then run the UPDATE for the u32 columns only:

    ALTER TABLE <table> ALTER COLUMN <column> TYPE bigint;
    UPDATE <table> SET <column> = <column> + 4294967296 WHERE <column> < 0;
  • On MySQL, the narrower integer columns read as i64 unchanged. Widen the u32 columns with ALTER TABLE <table> MODIFY <column> BIGINT; (keep any NULL or DEFAULT clauses the column has), then run the same UPDATE.

usize, isize, u128 and i128 had no working SeaORM column before, so there is no data to migrate.

A u64 or Option<u64> schema field used to be filed as i64, so the generated DTOs, the {Entity}Update struct and the markdown frontmatter struct declared it i64, and a SeaORM store did not compile. They now declare u64 and Option<u64>, like the schema. Code that builds a Create{Entity}Input or Update{Entity}Input with an i64 value for such a field changes to u64. The SeaORM column stays i64; a value above i64::MAX fails the write with AppError::DbError, and the markdown store refuses it the same way (see The Other Fallback). No SeaORM data can hold such a field, since the store did not compile. In a markdown vault, a negative value written through the old i64 type now fails the read; fix those records by hand.

{Entity}::from_model and to_active_model return Result<_, AppError>. Hand-written callers add ?.

On SeaORM, after before_create runs:

  • An empty or whitespace-only id counts as absent. It is derived by the strategy, or is IdRequired under Provided. It used to be inserted as given.
  • An id that breaks the id rule is refused with DbError. The rule is in Creating records.
  • A duplicate id is {Entity}AlreadyExists. It used to be DbError.

Before relying on create, rename existing rows whose ids break the rule. Rows with the ids ., .. or "" stay listed and reachable through the store, the API, IPC and MCP, like any other id that breaks the rule (see Record ids). Over HTTP, "" matches no route, and . and .. reach their record only from a client that sends the path as given (curl --path-as-is): a client that normalises URLs, as fetch and so the generated TypeScript do, resolves them away before sending. The same was true in 0.8. Rename such rows if HTTP clients must reach them. A vault cannot hold such records, in 0.8 or now.

Your load_junction_ids must return ids in the order they were written, because a many_to_many list keeps its written order. On SQLite that means adding ORDER BY rowid to the query. See Junction Sync.

Ids being created follow a stricter rule, the same on both backends. It applies to a client id, an id before_create assigns, and a derived id.

  • An id consists only of lowercase ASCII letters, digits, ., _, ~ and -. It is at most 200 bytes, does not start or end with ., and is not index or log. An uppercase letter, a non-ASCII character, a space, other punctuation, or a length over 200 bytes now fails the create with the backend’s error (DbError on SeaORM, Md on markdown). The rule used to allow all of these except /, \, : and NUL.
  • An id is not a Windows device name: con, prn, aux, nul, com0 to com9 or lpt0 to lpt9, in any case, either whole or before its first .. So con, nul.x and com1.backup are refused, while console, con-2 and xcon are fine. Windows cannot hold a file with such a name (con.md), so a vault holding one could not be checked out there. A title that slugs to one derives the next free probe, con-2, as index derives index-2.
  • The error message names the clause the id broke, then states the whole rule: invalid id "Ship": must not contain uppercase letters; an id is 1-200 bytes of a-z, 0-9, '.', '_', '~', '-', not starting or ending with '.', and not a reserved name: choose another id.
  • SlugFromField folds Latin letters with diacritics to ASCII instead of treating them as separators: Crème brûlée slugs to creme-brulee (it used to be cr-me-br-l-e), and Straße to strasse. Other non-ASCII characters still separate. A slug longer than 190 bytes is cut to 190, so a probe base-N stays within 200 bytes. Ids already stored are not rewritten; a new record with the same title gets the new slug.
  • Lookups are not validated against the new rule. A record whose id breaks it (Draft, café, con, a 250-byte id) is still listed, readable, updatable and deletable, on SeaORM and in a vault, under its stored id.
  • A markdown lookup now matches the stored file name exactly, on every filesystem. On a case- or normalisation-insensitive filesystem (macOS, Windows), a lookup or relation id that differs from the stored name only in letter case or Unicode normalisation (KEPT for kept.md, a decomposed café for a composed café.md) used to open that file. A PUT or DELETE there then changed or removed the other record. Such a lookup is now NotFound and changes nothing. Migration: look records up, and write relation ids, using each record’s stored id exactly.
  • On a Windows host, which cannot hold a device-name file, a markdown lookup of a device-name id (con, nul.x, com¹) is NotFound without opening a file, because opening con.md opens the console device.

Migration: rename records whose ids break the rule before you rely on creating them under those ids, and before you move SeaORM data into a markdown vault. On SQLite, this lists them. Run it once per entity table, with <table> replaced by the table’s name:

SELECT id FROM <table>
WHERE id GLOB '*[^a-z0-9._~-]*' OR length(CAST(id AS BLOB)) > 200
OR id GLOB '.*' OR id GLOB '*.' OR lower(id) IN ('index', 'log') OR id = ''
OR substr(lower(id), 1, instr(id || '.', '.') - 1) IN ('con', 'prn', 'aux', 'nul',
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9');

In a vault, rename the file and update every [[wikilink]] and foreign key that names the old id.

  • VaultHandle::new(root, layout) takes no strategy, and the id_strategy() accessor is removed. Drop the third argument, or use the generated open_vault (see Open the vault with the generated open_vault).
  • create_record_derived and make_record_id take &IdStrategy as their second argument, as does the new EntityRecords::create. Pass the store’s strategy on each create.
  • A missing id is the new Error::IdRequired { reason }. It used to be InvalidId.
  • Error::InvalidId has a third field, create_rule: true when the create rule refused the id, whose message then states the whole rule, and false when a lookup’s path-safety check did. Add it where you construct the variant; a { .. } pattern needs no change.
  • layout::validate_id is the create rule (see Record ids). create_record checks it too, so it refuses an id that record_path accepts. record_path, and every read, update and delete, use the looser path-safety check layout::validate_lookup_id, so a record whose file stem breaks the create rule stays reachable.
  • layout::validate_segment, and so record_path and entity_dir under PerEntityDir, refuses a directory segment that is a Windows device name (con, NUL, lpt0, and the superscript forms com¹-com³, lpt¹-lpt³) with Error::InvalidSegment, on every platform.
  • id::candidates(base) yields the probe sequence base, base-2, … that derived ids use. New constants: layout::MAX_ID_LEN (200), layout::MAX_STEM_LEN (252), id::SLUG_MAX_LEN (190), layout::DEVICE_NAMES, layout::ID_RULE, layout::RESERVED_REASON and layout::DEVICE_NAME_REASON; layout::is_device_name tells whether an id is a device name.
  • Markdown list order is id byte order. VaultHandle::read_all, list_ids and EntityRecords::read_all guarantee it. It differs from the old path order only for nested layouts.
  • has_many updates clear dropped children. An update that sets the list clears the foreign key of every child it drops, or fails with {Child}ParentRequired when the key is not an Option. A listed child that does not exist fails the create or update with {Child}NotFound, with nothing written; SeaORM used to ignore it. Child ids are id-ascending, and a record is never its own child. See has_many writes.
  • A cross-entity has_many fails the build. A has_many whose target is not the declaring entity used to generate a store that wrote the wrong records. It is now a CodegenError. Declare the belongs_to on the child and list the children in a hand-written API function. See has_many writes.
  • Lookups of an id the vault can never hold (index, a/b, .hidden, whitespace) answer {Entity}NotFound from the generated markdown get_*, update_* and delete_*, instead of an internal error.