Changelog

0.9.0, 2026-10-11

Stable APIs for other systems: they read and sync reference data and pick up where they stopped.

Releases are numbered by plan, not by date, so a higher number can ship before a lower one. All releases

Full changelog

Released 2026-10-11. This release was not tagged in the repository. This is the changelog's own text for the release; its links to files point at those files at the tag.

Base 0.9, integration-grade APIs, and its schema language 0.9. Build acceptance (R) reviewed every change since 0.8.0 and ran every gate; its fixes are under Fixed (R-F1–R-F28).

Added

  • Base 0.9 J6a: workbooks people fill in (D191–D199; J27.18–J27.21). people.ownedColumns lays an owned relationship out as a row per record and a column per parent and field (parents holding records plus those added; headers naming the parent and the ancestor the records are unique within); a filled cell creates the record under its parent, owned by its row's record, an edited one updates it under a version claim. Cells naming one record offer an Excel list from a reference sheet of the records the relationship may choose (people.referenceLists); every cell has a border. Import profiles gain relatedRecord.versionColumn. The Export dialog's Advanced offers Rows or Columns per parent, the parents to add and the fields, and the lists. Browser runner test:browser:j6a.
  • Base 0.9 J6: a workbook for people (D179–D190; J27.15–J27.17). POST /exports takes layout: "people" with people (columns, sheets, columnFields) for one type as XLSX: the table's columns and labels, typed cells, relation columns as "; " lists of names or a field, hidden ID and version, a sheet per chosen relationship (owned records, or relationships with their values), the type hues, a frozen, filtered header, and the import profile that reads it on a hidden sheet; 422 INVALID_EXPORT_LAYOUT otherwise. An XLSX imported without a profile is read through the profile it carries (parameters.profile.embedded). Import profiles gain key.versionColumn, key.nameColumn and a relation binding's idColumn, versionColumn and attributes. The Export dialog offers For people with an Advanced section; the import page names the workbook; the job reads "Workbook for people". Browser runner test:browser:j6.
  • Base 0.9 K7: which records a relationship may choose (D168–D178; J13.9, J13.10, J03.9, J10.11, J01.11). A binding may carry candidates, criteria for the records at its from end, its to end or both, over their name, any non-JSON attribute, and an ancestor's name or attribute (ancestor:<type>.<field>), with the list filter's operators. Every picker for the relationship offers only those and says so; a record that does not meet its own end is not offered the relationship, with the reason. The per-type list takes candidateFor with anchorTypeKey; the relationship view's and matrix's candidates apply the criteria. A new relationship outside them is refused with 422 RELATION_CANDIDATE_REFUSED on every write path (batches check a chunk's targets in one statement per restricted end); relationships made before are kept. The model-aware query takes ancestor:<type>.<field> criteria. Browser runner test:browser:k7.
  • Base 0.9 K6a: GET /relations/held, the records a set of records holds through one relationship, each with how many of them hold it (D165; J32.7), which Change relationships' Remove lists.
  • Base 0.9 K6: filtering a list by its records' relationships (D153–D159; J01.10, J32.6, J27.6, J08.12). A table view's filterFields may name related:<key>; its Filter menu then keeps the records related to any of chosen records (picked with the record search), to none or to any, the chip naming the records, also after a reload. The per-type list takes filter.related:<key>.eq=<record ID> (repeatable) and .isNull=true|false for any relationship the type has, built by Q3's relationship hop in every context; INVALID_FILTER otherwise. The selection's "all matching", the bulk actions and Export carry it. The starter is content release 0.9.5 with these filters on system values and master values. Browser runner test:browser:k6.

Fixed

Found by the 0.9 release review (R step 0, D200):

  • A workspace deleted and created again under its key could never be written: its old feed entries kept their positions. Deleting a workspace now takes its feed with it: its audit entries stay as history without a feed position, and its idempotency keys go (R-F1, D208).
  • Activating a release could deadlock with a webhook delivery, a subscription change, or a write of an attribute whose unique the release changes. Activation now locks the active subscriptions, and when unique changes the unique values, before the runtime state (R-F2, R-F3).
  • A relationship's candidate criteria (K7) treated a record a batch had just created without a value as meeting isNull: false (R-F4).
  • An import column with replace: true whose cell was empty left the relationships in place; the set is now replaced with none, as in a workbook for people whose relation cell is emptied (R-F5).
  • A filter on createdAt, updatedAt or a date-time attribute takes a day (YYYY-MM-DD), compared by the whole day in UTC; the collection's date filter sent one and was refused (R-F6).
  • A workbook for people whose type or relationship name held & or < was reported damaged by Excel: the list rules are now escaped (R-F7).
  • A webhook's stateAt and its object are read in one snapshot, as the pull feed reads them (R-F8).
  • A preview past its retention was confirmed until the hourly sweep marked it; it is now refused 410 JOB_EXPIRED, and the sweep skips a job a confirmation holds (R-F9).
  • A file sweep could remove an import's file before its job was committed; a file of no job is kept for ten minutes (R-F10).
  • One workspace whose events cannot be derived no longer holds up the others (R-F11).
  • An atomic job's chunk, a preview and an export's set statements renew the job's lease while they run, so a second process never takes a running job (R-F12).
  • The pull feed's ETag changes with live data as well as with derived events (R-F13).
  • Three route descriptions named error codes in lower case (R-F14).
  • An ancestor:<type>.<field> value shows the ancestor of that type nearest the root everywhere: the workbook for people and consumer API projections now agree with lists and Q3 (R-F15, R-F16, D209).
  • New webhook offered only the first 100 active workspaces (R-F19).
  • Three colours were written outside the tokens, skipping the dark theme's shadows (R-F20).
  • Each consumer API's document states the engine's own read limits rather than copies (R-F21).

Found by the 0.9 release's browser runners (R step 3):

  • In a relation matrix cell, Create pressed before the cell's containers had loaded did nothing and said nothing: once a single container loaded, the notice that none was chosen was not shown. The button now waits for the containers (R-F22).

Found by the 0.9 release's deletion timing (R step 8, D124):

  • Deleting a large selection, about 100,000 records, could not finish: three statements stopped scaling once a run's IDs no longer fit in PostgreSQL's work_mem. The run's relationships are found through its joins, not IN (run) OR IN (run) (R-F23); a change set's matrix walk leaves out the rows the change set replaces with NOT EXISTS, not NOT IN (R-F25); and reading the records of a change set takes any number of IDs, a statement at a time, where more than about 32,000 failed with "number of parameters must be between 0 and 65535" (R-F24). In a change set the closure's walk finds a record's required owned records by their keys, where it read every record of their type for each record reached (R-F26), and the matrix states' upkeep no longer walks the cells under the records the change set just deleted (R-F27). Deleting 100,000 records now previews in 49 s live and in a change set, and runs in a change set in 51 s.
  • A query's or a list's expansion in a publication counted its total once per row of its page: 20,000 related records expanded took 103 ms, now 68 ms (R-F28).
  • A filter or sort naming an attribute the type does not bind answered invalid_filter and invalid_sort in lower case; they are INVALID_FILTER and INVALID_SORT (found in 0.9 K6).
  • An export with a related criterion failed in the worker (INVALID_QUERY, "A related criterion names no field, operator or value"): the stored request carries every field, null ones too, and the check counted a null value as named. It now checks values (found in 0.9 K6).

Changed

  • The record tree's leftovers from UA9's flat scrolling are removed: the windowed version kept in comments, the unused wheel routing (useTreeWheel, treeWheel) and the group frame's glow (R-F17). Documentation and comments name no example-model content (R-F18, D210).

  • 0.9 R: the accessibility and visual gates cover every 0.9 surface approved by screenshot (D202), light and dark, on a harness of their own with webhooks on: Settings › Integration, a webhook's configuration and deliveries, relation columns, the filter editor, the selection bar, the three bulk dialogs, exporting and importing with their notifications and job pages (36 new baselines). 34 baselines were re-recorded for changes delivered since UA7 (UA8, UA9, K1, K6a, J1u, J4, U4, U5). The browser runners that had drifted from later packages were brought up to date, and the comprehensive command set names all 38. volume_baseline.py --only-deletion times deleting about 100,000 records (D124).

  • Schema 0.9 is the released schema language from 0.9.0 (D206): APPLICATION_MODEL describes it as such, with what it adds to 0.8 and how a 0.8 document using it is refused.

  • Base 0.9 K6a: collection filters redesigned (D160–D164): active filters as pills, + Filter's field menu, one compact editor per field type, a bottom sheet with "Show N records" on phones. The selection bar's actions are icon buttons with an indigo tint on the light theme, and Change values and Change relationships were redesigned (D166, D167): Change values as cards per field, Change relationships with pills, a segmented action and a checklist whose Remove lists only what the selection holds. D133 revised by D167: Add and Remove are separate actions.

  • D142 revised (0.9 K6a): the per-type list's expand takes as many keys as the type has relationships, each once; the fixed maximum of 5 is gone.

  • Base 0.9 K5b: a relation column shows as many related records as its width holds (D143 revised by the user): whole chips, then "+N more" for the rest, and no "+N more" when they all fit; resizing a column shows more or fewer at once without reading the list again. Lists ask for up to 20 related records per row (expandLimit=20); a 20-row page costs 11–34 ms on the 0.1 volume fixture.

Added

  • Base 0.9 K5a: a field of the related records in a relation column (D148–D152; J01.9, J08.12). related:<key>.<field> shows each related record's value of a field instead of its name: name, an attribute the types at the other end bind, or ancestor:<type>.<field>, written as in a consumer API definition ("Has translation · Language code": ar cs en +4 more). A record without a value shows "—"; such a column never sorts. expand takes the same text; the entry names its field and each record carries the value beside its name (INVALID_EXPAND for a field that does not resolve). Q3's answers are unchanged.

  • Base 0.9 K5: relation columns (D140–D147; J01.8, J08.12). A table view's columns, and a children or table panel's, may name related:<key> in schema 0.9, <key> a relation type's key (read outgoing) or reverse key (read incoming): each row shows its first three related records as chips in name order, then "+N more", kept on one line; a column hidden in the table's display settings is not read. Where a row holds at most one (one binding, whose listed end holds one), the column sorts by the related name, rows without one last; elsewhere it is refused in sortFields, and it is never a filter. GET /objects/{objectTypeKey} takes expand (repeatable, at most 5) and expandLimit (1–20, default 3), answering each item's expanded in Q3's shape, 422 INVALID_EXPAND otherwise, and sort=related:<key>; the list's response is now TypedObjectPage (QueryObject items). The starter is content release 0.9.4 with relation columns on system values and master values. Q3's expansions read a page in one LATERAL statement per expansion (a page of 100 with two keys: 45 ms live, was 611 ms). Browser runner test:browser:k5.

  • Base 0.9 UA9: the record tree scrolled as one list, several records selected and moved together (U-D21–U-D28; J14.9). The sidebar's tree and the hierarchy lens scroll with the sidebar or the page, their type groups start folded and open like records, and a long group reads more as its end comes into view (Show earlier records above a group opened partway down; Ctrl+Home and Ctrl+End within a group). Records are selected with Cmd/Ctrl, Shift, Ctrl+Space and Shift+arrows, without changing the page, and dragged together onto a new parent, in the sidebar as well as the lens, or moved with Move to…: one confirmation, one atomic batch (update items with parent and each record's version, live or in the change set). A target that cannot take one refuses them all, naming it; several records are never moved where their type would change. Browser runner test:browser:ua9.

  • Base 0.9 UA8: actions in place on the record (U-D15–U-D20; J02.6). A record's header edits its title (usually the name) and subtitle in place, as its details fields are, on the full page and in the inspector, and offers Add for an empty subtitle; one component, EditableField, draws and edits a field in both places. The header's Edit record button is gone; Edit record (was Edit all fields) is the first item of the record's ⋯ menu, also on every collection row. A relationship row's actions are icon buttons with tooltips (change to another record, edit values, delete, ui/IconButton), and a row managed through the parent or required shows a lock with the reason. Browser runner test:browser:ua8.

  • Base 0.9 K3a: many-to-one relationships (D136–D139; J08.11, J11.12, J32.5). A binding may declare "cardinality": "many-to-one" in schema 0.9: each from record holds at most one relationship of it, and a to record serves any number. A second one is 409 CARDINALITY_VIOLATION on every write path; a release tightening a binding to it needs a migration while a from record holds more than one (RELATION_CARDINALITY_VIOLATED at the binding's cardinality, with the count). It cannot be required (D137, in TODO), and is refused in schema 0.8. Which end holds one is one rule in the engine and one in the browser (D138); the relation matrix, the mapping grid's candidates and the consumer API read it, so a field via a many-to-one binding answers one object or null. On a record, where it may hold only one relationship of a kind, its Relationships panel offers Set while it holds none and Change and Delete on the one it holds (D139); Change replaces it in one write through the mapping replacement, claiming the set as read, and is refused if the relationship changed after the list was read. The selection's Change relationships… offers Set to and Clear from that side. The example model 0.9.1 uses it (its rdm consumer API at contract version 2).

  • Base 0.9 K3: changing the relationships of a selection, and relationships named by their ends (D127–D135; J11.10, J11.11, J32.4). A batch item ensureNoRelation (relationTypeKey, from, to, optional expectedVersion) deletes the relationship of that type joining the two records as DELETE /relations/{id} does, with its refusals, or answers unchanged when there is none, so a removal sent again changes nothing (D129). A replaceRelationSet in a preview needs no expectedRelationSetVersion: its outcome names the version it found (previousRelationSetVersion, also in its refusals' details), and confirming carries it into the stored item, so execution refuses RELATION_SET_VERSION_CONFLICT only the sets changed since the preview (D130). In the browser, Change relationships… in the selection bar offers every relationship the model allows the type, in both directions, except containment and required ownership (D127), with the actions its cardinality allows: Add and Remove together, Replace with, Set to, Clear; never Remove or Clear on a required one (D128, D133). The other records are chosen with the record picker (ui/RecordListField); the dialog states how many changes the selection makes and explains a change over BACKGROUND_JOB_MAX_ITEMS, or a replacement over JOB_PREVIEW_MAX_ITEMS, before sending (D131, D134); relationship values are not edited (D132, after 1.0 in TODO). A batch's job page says relationship outcomes in a relationship's words. Relation columns in collections are K5 (D135).

  • Base 0.9 K2b: deleting a selection, and cascading batch delete items (D120–D126; J11.6, J32.3; PG-D18). A delete item takes cascade and deletionPreviewToken as the single DELETE does: without cascade a record with dependants is refused OBJECT_HAS_DEPENDANTS with its impact; with it the item deletes the record with its dependants under the impact its token confirms. A batch preview gives each delete item's impact (records by type, relationships, those reaching records that stay, and the token) and the preview's deletion totals; confirming carries each token into the stored item, so execution deletes exactly what was previewed and refuses only an item whose impact changed (DELETION_PREVIEW_CHANGED). Items are decided in order: a record an earlier item of the job deleted answers unchanged with deletedWithItem (D122). Deletion is one path for the single DELETE and a batch's delete items, live and in a change set, as set statements in PostgreSQL whatever the impact's size or depth (D123); its token is the same from either. In the browser, Delete… in the selection bar previews what deleting removes (the selected records, those going with them by type, the relationships) before a destructive Delete N records naming the whole (D126); the deleted records leave the selection, the refused ones stay for Preview again, all matching is cleared (D125). A batch's job page shows what deleting removes and each delete's impact.

  • Base 0.9 K2: changing values of a selection, and previewing a batch (D112–D119). POST /batches takes stage: "preview" for a background batch (inline is refused): its items run without writing anything, as an import's preview runs, and the job waits in awaiting_confirmation with preview (no rows); POST /jobs/{id}/confirm runs the stored items as an execution, which is authoritative. In the browser, Change values… in the selection bar sets or clears fields of the selected records (not read-only or hidden ones; a required one is never cleared), all or nothing up to atomicJobMaxItems or the records that can change; "all matching" is read with progress and Stop; the preview's counts and refused records, each openable, come before Apply; the result, Preview again and the selection keeping the versions the change left (D118) follow. A batch's job page lists its items' outcomes, linked to their records, and applies or cancels a waiting preview. Leaving the dialog before Apply (Back, Cancel, ✕, Escape, a click outside) cancels the preview; View job keeps it waiting for its page (D115 amended). Every change is a previewed background job (D114, amending D104); deleting a selection moves to K2b (D116).

  • Base 0.9 K1: selecting records in a collection (D102–D111). Where the data can be written and jobs can be created, a table view has a checkbox column: rows by hand, a Shift range over the loaded rows (pointer or Shift+Space), the loaded rows from the header checkbox, then Select all N matching, also with Cmd+A (Ctrl+A) in the table; a row unchecked under all matching stays an exception. A bar above the table states the selection and clears it; a polite status region announces each count. A selection over backgroundJobMaxItems is refused with an explanation naming the count, the limit, BACKGROUND_JOB_MAX_ITEMS and what to do. Sorting, scrolling and opening records keep it; a new search, filter, scope or data context clears it. No writes yet (K2, K3); no backend or contract change.

  • Base 0.9 J5: another workspace's export loads as this workspace's own records (D96–D101). otherWorkspaceIds: "counterparts" on POST /imports and POST /batches makes an ID held by another workspace's record stand for its counterpart here, whose ID is derived from the original ID and the workspace key (a name-based UUID in a documented namespace, marked as version 4); the same file sent again finds what it created, types load file by file, a change set takes the live IDs, and a workspace's own IDs and free IDs are used as given. Every record ID an item names is translated, a profile's matchBy: {"field": "id"} too; relationship IDs are not. The file's versions are ignored on counterparts (onExisting decides); outcomes name sourceId; the notice reads RECORD_IDS_AS_COUNTERPARTS; a reference to a counterpart not loaded yet says to load its file first. The import dialog offers the choice, loading them by default. Amends D49.

  • Base 0.9 J4a: an import says why rows cannot load when the file is another workspace's export. Record IDs are unique across the instance, so such a file loads only into the workspace it came from (D49); until now every row was refused with no reason a user could act on. The job carries notices once bound (RECORD_IDS_OF_ANOTHER_WORKSPACE, with the workspace and the rows), OBJECT_ALREADY_EXISTS names the workspace holding the ID (details.workspaceKey, live and in a change set), and REFERENCE_NOT_FOUND names what the reference is for and that workspace. The import's page shows the notices above the preview, and a row's reasons wrap instead of being cut off. Loading another workspace's export into a workspace is planned separately.

  • Base 0.9 J4: the import screen (SC-10 in the browser, D89–D95). Import on a collection page that can be written loads into its data; New import on Settings › Jobs and feed chooses a workspace and its live data or a change set. The dialog takes a file, a stored profile (one no longer valid listed and not choosable, a keyless one warned) or none, the policy, and whether records the file finds are updated (by default with a profile, not without one, D92); it always previews (D90) and opens the import's page at once (D91). The job page of an import shows the preview with Load the file and Cancel import, the refused rows paged with every failing cell and its column, the binding report per source and column, then the result and how to load refused rows (D94); a refused confirmation keeps the preview waiting. Notifications say Preview ready with Review, and none shows while the job's own page is open; a waiting preview's stream is closed until it is confirmed. A collection reloads after an import this tab started loaded into it. Jobs and feed's limits list the import file size and preview items. ui/FileField is the one file input (the model draft dialog uses it). No backend or contract change. New runner test:browser:j4.

  • Base 0.9 C18: consumer API definitions written for readers, as schema 0.9 (CONSUMER_API C-D132–C-D141). A definition names what it exposes (public names default to the key in PascalCase), the operations it offers (objects, xref, resolve, lists, events: listed means offered), object.fields with roles, related blocks by a relation's key or reverseKey (role fields may stand anywhere and are output first), operations.xref.matchOn and alsoMatchThrough (field: "code" for each code source), filters with one verb each (byType, under, linkedTo, onRelated), lists within a filter, events perType or custom (watching outputs and related blocks), and optional descriptions carried into the OpenAPI document. Every reader works on one internal form, so for a definition offering every operation nothing a consumer reads changes. An operation not offered answers 404 OPERATION_NOT_OFFERED and leaves the OpenAPI document; dropping one needs a new contractVersion. Mistakes are refused at the pointer the author wrote. GET /api/v1/model/consumer-apis/{consumerApi} answers a definition with every default written out, for 0.8 and 0.9 releases; the Webhooks page reads it. Install, draft compile and POST /model-validations refuse a new schema 0.8 document that declares consumerApis (UNSUPPORTED_SCHEMA_VERSION at /schemaVersion), and startup refuses such a file named by RDM_MODEL_PATH; stored 0.8 releases read and activate as stored. The starter is content release 0.9.3 and the example model 0.9.0, both on schema 0.9.

  • Base 0.9 J3: import preview and confirmation. POST /api/v1/imports takes stage: "preview": the file is bound, its first IMPORT_PREVIEW_MAX_ITEMS (10,000) items run independently against the data in a transaction rolled back whole, and the job waits in awaiting_confirmation with preview (coverage complete or sample, items and rows covered, counts by action, the result under the request's policy) (D80, D82, D83). POST /api/v1/jobs/{jobId}/confirm queues it to bind the stored file again and execute (D81); it is idempotent, checks the context again and refuses with 409 JOB_NOT_AWAITING_CONFIRMATION or 410 JOB_EXPIRED; cancel ends a waiting preview at once and retention expires it (D87). Every import carries bindingReport per source and column (D86); GET /api/v1/jobs/{jobId}/rows reads its outcomes by row and /items takes ref= (D84); a row refusal lists every failing cell in details.cells (D85). Revision 20261007_08 adds jobs.report, jobs.confirmed_at and the source and row of inputs and outcomes; integrationJobs.limits.importPreviewMaxItems reports the setting.

  • Base 0.9 J2a: the database holds unique attribute values. unique_attribute_values keeps each live value of an attribute the active release declares unique once per workspace, keyed by the SHA-256 of its canonical JSON under a deferred unique constraint (D75, D77); PostgreSQL triggers on attribute_values and model_runtime_state keep it, so it follows every write and every activation (D76). A write that wrote a duplicate past the rule is refused at its end with the rule's 409 DUPLICATE_ATTRIBUTE_VALUE, and at commit by the database; values handed between records within one batch or one publish pass. Live data only (D78). Revision 20261007_07 adds the table and fills it from the stored values. No API change.

  • Base 0.9 J2: imports. Import profiles (/api/v1/import-profiles: create, read, list, replace at a version, delete; instance-wide, D65) say how a file's sheets and columns become records and relationships, checked against the model with every problem at its JSON pointer and read as no longer valid when a release breaks them. POST /api/v1/imports takes a file and its request as multipart/form-data and queues a job of kind import: XLSX or CSV through a profile, or JSON Lines batch items, or an export's CSV or XLSX tables without one (the records table gains applicationKey, D71). The worker binds the file in one transaction, converting values through the attribute registry and resolving keys, matches (also to records the same file creates), hierarchy order and related records (D70) by set statements in PostgreSQL (D68); rejected rows are the job's first outcomes, named {sheet}!{row} with their column, and the items then run as a background batch (D72, D73). Rows repeating a key load as one record when they agree and are all refused otherwise (D69). IMPORT_MAX_BYTES (200 MB) caps a file. Revision 20261007_06 adds import_profiles. openpyxl becomes a runtime dependency and python-multipart is added (D67).

  • Base 0.9 J1u: export in the browser and pushed job notifications. Export on a collection page opens a dialog prefilled with the page's type, data context, filters and parent narrowing (the search is not carried); New export on Settings › Integration › Jobs and feed exports any types of a workspace. A notification says an export started, and another when it ends: completed with Download and View job, failed or cancelled with View job. Each job has its own page (/settings/jobs/{jobId}) with its state, progress, error, Cancel and, for an export, the file's facts and Download. GET /api/v1/jobs/{jobId}/events streams a job (server-sent events) at once and after each committed change until it ends; a trigger on jobs (revision 20261007_05) notifies the engine at commit, so nothing is polled (D57–D63).

  • Base 0.9 J1: export jobs. POST /api/v1/exports exports a stated scope of one workspace (live, a change set or a publication; object types each with their own criteria, a subtree, asOf, relationships none, withinScope or all) as JSON Lines, CSV or XLSX, in the background from one database snapshot. The job's new output field states what the file holds (counts per type, context, the feed position it equals, model release, format, size, SHA-256), and GET /api/v1/jobs/{jobId}/output serves the file only once the job is completed. JSON Lines holds upsert and ensureRelation batch items with each record's version, so the file sent back as a batch changes nothing; CSV and XLSX share one table layout (D49–D56). Exports run on a worker of their own. New setting JOB_FILE_DIR (default var/job-files); revision 20261007_04 adds jobs.output. The feed's 410 FEED_POSITION_EXPIRED names the export route beside the publication (details.export).

  • The example model 0.8.0 declares events on its consumer APIs: one per exposed object type (<type>-changed), each for created, updated and deleted, with no watch, so any change to the object's output counts. The contract versions stay, as adding events is compatible.

  • Webhooks › Deliveries: an event's object ID opens its record, in the webhook's workspace and the first application showing that type (none for a deleted record).

  • Base 0.9 U5a revision: resend a delivered event by hand. POST /api/v1/consumer-subscriptions/{id}/deliveries/{deliveryId}/resend sends one event of a request again exactly as it was sent (a notice as it was), alone in a new request with the same eventId and the header RDM-Redelivery: manual; once, during the call, on any subscription; the new request names the one it repeats (resendOf). Revision 20261007_02. In Webhooks › Deliveries, the event's dialog has Resend, asking first when the event was already delivered. The edit, suspend and delete buttons are on the Configuration tab only.

  • Base 0.9 U5a: webhook names and the delivery log.

    • A subscription has a name (required on create, editable; existing ones are named after their URL's host and path). Renaming alone keeps a batch being retried.
    • Every request the dispatcher sends is logged with its events, the body as sent and each attempt's start, duration, status code or error and up to 4 KiB of the answer: GET /api/v1/consumer-subscriptions/{id}/deliveries (a row per event, newest first, narrowed by status) and …/deliveries/{deliveryId}. Kept for CONSUMER_WEBHOOK_LOG_RETENTION_DAYS (7, in capabilities); a pending batch longer; deleted with its subscription. Revision 20261007_01.
    • The Webhooks page: the new-webhook dialog rebuilt in sections, with events as all or a chosen few (a searchable list, chosen ones as chips) and filters added from a menu, their values as chips; the webhook's page has Configuration and Deliveries tabs above its name; an event opens with its attempts and the request and response bodies as coloured JSON.
  • Base 0.9 U5: Settings › Integration › Webhooks. Subscribe a receiver to a consumer API's events from the page: the consumer API and workspace, the events (each with what it covers), the URL (the allowed hosts named), filters per declared parameter, payload and delivery mode; a refusal is shown at its field. The signing key is shown once after creating. Subscriptions are listed with Active, Retrying or Suspended beside the selected one, which shows the last delivery, a failing batch's attempts, receiver answer and next attempt, and when suspended the reason, its details and what fixes it. Edit, suspend, resume (from the first event not acknowledged, or with reset; a refusal lists what to edit, expired events choose the reset) and delete. With subscriptions off, the page names the setting. No API change.

    • A workspace that cannot be deleted because of its webhooks lists them, each linked to its page.
    • ui/CatalogueLayout and CatalogueEntry replace the catalogue markup Workspaces and Model each had; Webhooks uses them too.
  • Base 0.9 U4: Settings › Integration. Consumer APIs shows each consumer API of the active release, read only: its name in the address, contract version, base URL and OpenAPI document (with copy buttons), its source in the API reference, exposed types under their public names, lists and events, and the release that declares it. Jobs and feed lists the jobs of every workspace newest first with state, result and counts, cancels a queued or running job after confirming, and shows the batch, job and feed limits. API reference lists the /docs sources with their versions and documents. No API change; API.md states the ?api= slug's form.

    • The starter model is content release 0.9.2: it declares a consumer API rdm (domains, codelists and values; three lists; the event values-changed), so a fresh installation has one (U-D5). An existing database installs it beside 0.9.0 without activating it.
    • ui/SettingsTable, the shared job-limits list and settings read hook replace Deployment's own copies; CopyValue can link its value; ConfirmDialog takes a label for its dismiss button.
  • Base 0.9 U3: the Settings area. /settings has its own frame without workspace or data context: a top bar reading "{product} / Settings" with Back to applications (to the last application route of the session) and the account, the settings navigation in the rail (in the navigation drawer below 760px, opened from a Settings: {page} bar), and pages as wide as the application pages, built from new ui/ components (SettingsLayout, Section, DescriptionList, FormField, EmptyState, Skeleton, Toast).

    • About: engine and management API version, schema languages, the active model release and when it was activated, the database revision. Deployment: the capability groups on or off with their settings and limits, then every setting with its value, default, source and what it controls; secrets only as Set or Not set, the cursor-key warning when it is ephemeral. Read only: settings change with a restart (U-D2). Appearance: theme and the default table density for this browser; a view's own density (now with Default) overrides it.
    • Workspaces and Model moved under Administration, as the screens they were: the catalogue beside the selected workspace, release or draft, each at its own address (U-D10 as revised). /models and /workspaces addresses redirect. Confirmations are toasts; a workspace with webhook subscriptions says why it cannot be deleted.
    • The application top bar has a Settings link and an Account popover (theme, All settings) in place of Models, Workspaces, the model version and the settings popover. Only built pages are listed (U-D11). The workspace switcher is back at the top of the rail, where it was before U2.
    • GET /api/v1/system answers the same facts. Settings come from a catalogue in config.py (group, description, secret) held against Settings and OPERATIONS by a unit test; the engine version has one home, config.ENGINE_VERSION. Journey J31 (operator: check what a deployment runs).
    • Planned next: the Atlas visual direction (ADR-040), today's shell with a colour for every kind of record, applied to every surface in UA before U4 and U5.
  • Base 0.9 C13: the events' acceptance. The CONSUMER_API event scenarios are walked end to end, from a write through the deriver and the dispatcher to a receiver, on two generated models: a publish delivered in batches exactly as the pull feed has it, work that never reaches live data (a rolled-back batch, a refused write, a validated and discarded change set) sending nothing, filters scoping deliveries as the lists do, a reset and re-sync after expired events missing nothing, and a running application delivering on its own. Webhook delivery has a volume budget (C-D113): the dispatcher's claim, a delivered batch and a coalesced round hold their statements as the data grows, with full-volume figures in POSTGRESQL_BUDGETS §7. CONSUMER_API, OPERATIONS and the journeys now say that a filtered subscription is not told when a record leaves its filter's scope, and how a receiver handles it (C-D114).

  • Base 0.9 C12: webhook delivery. While CONSUMER_SUBSCRIPTIONS_ENABLED is on, a dispatcher thread in the API process sends each active subscription's events to its URL, in position order, one request at a time per subscription.

    • Per change: up to 100 events and at most 1 MiB of body per request, an event alone over 1 MiB sent alone (C-D104); each event as the pull feed has it, with the object as get by ID answers it (full) or without it (identity). Coalesced: one notice per event once the newest matching event is debounceSeconds old, or the oldest 10 minutes old (C-D105).
    • Every request is signed: RDM-Signature: sha256=<hex>, the HMAC-SHA256 of "{RDM-Timestamp}.{body}" keyed with the subscription's signingSecret; RDM-Delivery-Id stays the same on every retry of a batch (C-D12).
    • Any 2xx acknowledges. A failed batch (another status, a redirect, which is never followed, no answer in 10 s, a connection or TLS failure, a refused address) is retried with the same events after 10 s, 30 s, 1, 2, 5, 10 and 30 min, then hourly, each ±20 %, for 24 hours from its first failure; then the subscription is suspended DELIVERY_FAILED (C-D103). Nothing later is sent past a failing batch.
    • The host is resolved at each attempt and the request goes to the address checked: link-local addresses are always refused, loopback ones unless the URL names a loopback host (C-D107).
    • A subscription answers delivery: lastDeliveredAt, and pending while a batch fails, with its attempts, first failure, last error and next attempt; delivery never changes version (C-D108). GET /capabilities reports the limits in consumerSubscriptions.delivery.
    • The dispatcher also suspends a subscription whose host left the allowlist (HOST_NOT_ALLOWED), one with a filter value no record matches any longer, before sending (FILTER_MATCHES_NOTHING, C-D109), and one whose next events expired (EVENTS_EXPIRED, new, C-D110).
    • Several processes share delivery through a 60 s lease per subscription (C-D106).
    • Migration 20261006_03 adds the delivery state to consumer_subscriptions.
  • Base 0.9 U2: the UI foundation and the shell (ADR-039, Direction B "Open").

    • Design tokens are in three layers in styles/tokens.css: primitives, semantic roles (dark mode redefines only these) and component tokens.
    • A contrast test holds every text pair to WCAG AA, and control borders, focus and state markers to 3:1 (U-D9).
    • Instrument Sans and JetBrains Mono are bundled and served with the UI (OFL-1.1).
    • A top bar holds the workspace switcher, the applications as underlined tabs, and Settings.
    • A data-context band names the data in view: a green Live pill, an amber band in a change set with its Review, Validate and Publish actions, and a violet band in a publication.
    • ui/PageHeader now draws all ten page headers; there are also ui/StatusPill and ui/Card.
    • Every screen takes the new colours and type through the older token names, kept as aliases until U6 (U-D7). All visual baselines were re-recorded, and change-set-editing-dark was added.
  • Base 0.9 C11: webhook subscriptions. With CONSUMER_SUBSCRIPTIONS_ENABLED (off by default), an operator manages subscriptions at /api/v1/consumer-subscriptions: create, list, read, edit with expectedVersion, suspend, resume and delete. A subscription names a consumer API, a workspace, some of its events, filters in the definition's parameters, an HTTPS URL on a host in CONSUMER_WEBHOOK_ALLOWED_HOSTS, perChange or coalesced delivery and the full or identity payload. Each filter value must match a record (C-D100). It starts after the feed's head, or after an earlier startAfter (C-D97). Its signing key is shown once and stored sealed (AES-256-GCM) under CONSUMER_WEBHOOK_KEY_ENCRYPTION_KEY, which startup requires with the capability on; python -m app.cli.generate_key makes one (C-D96). A subscription is active or suspended with a reason (C-D98). Activating a release that drops what it names suspends it MODEL_CHANGED, one that raises the contract version CONTRACT_VERSION_CHANGED, and retiring its workspace WORKSPACE_RETIRED (C-D99). A resume checks it again and, after unacknowledged events expired, needs acknowledgeLoss (C-D93). A workspace with subscriptions cannot be deleted (deleteBlockedBy: subscribed). GET /capabilities gains consumerSubscriptions. New codes: FILTER_MATCHES_NOTHING, INVALID_WEBHOOK_URL, WEBHOOK_HOST_NOT_ALLOWED, SUBSCRIPTION_NOT_FOUND, SUBSCRIPTION_ALREADY_SUSPENDED, SUBSCRIPTION_NOT_SUSPENDED, SUBSCRIPTION_NEEDS_EDIT, WORKSPACE_HAS_SUBSCRIPTIONS. Migration 20261006_02. Nothing is delivered yet (C12).

  • Base 0.9 C10: consumer events and the pull feed. After a live write commits, a deriver thread in the API process turns the workspace's feed entries into the events each consumer API declares, without the write waiting: created, updated naming the watched outputs that changed (also for a change to a record the object shows: its parent, an ancestor's name, a projected record, a projection's items) and deleted; a type change as seen from each event (C-D82, C-D83, C-D87). GET /api/consumer/{consumerApi}/v{contractVersion}/events reads them in position order from after or a lasting cursor, with caughtUp, narrowed by event and the definition's parameters (C-D90, C-D91); payload=full (default) carries each object as get by ID answers it, labelled stateAt, payload=identity leaves it out (C-D7 revised). Every event of the API is served, each naming its contract version (C-D88). Events expire with the change feed (410 EVENTS_EXPIRED naming the publication to re-read) and start, for an existing workspace, at its active publication's position (C-D92). New codes: EVENTS_NOT_DECLARED, UNKNOWN_EVENT, INVALID_PAYLOAD, EVENT_POSITION_AHEAD, EVENTS_EXPIRED. payload becomes a reserved parameter name. A record's feed entry now also keeps its parent and previous parent, not shown in the feed (C-D95). Migration 20261006_01 adds consumer_events and consumer_event_checkpoints. Renaming a group with 20,000 items below it tells 20,001 objects in 0.7 s on the deriver's thread.

  • Base 0.9 C9: consumer API event definitions. A definition declares events: exposed types, change kinds (created, updated, deleted) and an optional watch of the outputs and projections that count as an update, named as consumers read them (C-D81). Each mistake is refused at /consumerApis/<key>/events/<event>/… (C-D84); an event that tells consumers less needs a higher contractVersion, one that tells them more is compatible (C-D85). What updated means and how a type change is reported are settled for C10 (C-D82, C-D83). event and after become reserved parameter names, refusing a definition parameter so named (C-D86). No event is produced yet.

  • Base 0.9 C16: business dates on the consumer API. asOf=YYYY-MM-DD on get by ID, lists, XREF and batch resolve keeps the records effective that day by their type's declared effective period, live or pinned to a publication. Types without a period are left unfiltered in a lookup and refused as 400 AS_OF_NOT_SUPPORTED on a list or record; a malformed date is 400 INVALID_AS_OF. Only the returned records are dated, not projection items or ancestors (C-D76). Lists, XREF pages and batch resolve carry asOfApplied, and every dated answer the RDM-As-Of-Applied header (C-D78). Each API's OpenAPI document declares asOf where it applies. SC-11 passes through the consumer API.

  • Base 0.9 C8: consumer API parity acceptance. A test-only fixture shaped like the reference deployment (the starter with locations, availability, durations and a one-to-one self relation) and CONSUMER_API's definition written out answer the parity scenarios, each answer validated against the API's own OpenAPI document. A projection field may read objectType, the related record's public type name, where the definition exposes every type the projection reaches (C-D73). The consumer read budgets are collected in POSTGRESQL_BUDGETS §7 (C-D74).

  • Base 0.9 V2: the API reference page /docs lists every consumer API of the active release beside the management API, labelled {name} · v{contractVersion}, built per request. A link to a source (Scalar's ?api=<slug>) opens it; before, the parameter was refused.

  • Base 0.9 C6b: the consumer API index GET /api/consumer (each API's consumerApi, name, contractVersion and openapi URL, paged, C-D69) and each API's own OpenAPI 3.1 document at /api/consumer/{consumerApi}/v{contractVersion}/openapi.json: paths written out with the name and version, info.version the contract version, schemas from the contract, the limits stated. Both answer an ETag. Dev dependency openapi-spec-validator checks the document and real answers against it (C-D70).

  • Base 0.9 C6a: consumer API reads pinned to a publication. publication={id} or latest (the workspace's active publication) on get by ID, lists, XREF and batch resolve's URL reads the publication's records, values and relationships, with the definition of the release it was published under (409 PUBLICATION_CONTRACT_MISMATCH when that release declares another contract version, C-D60); 404 PUBLICATION_NOT_FOUND for an unknown publication, another workspace's, or latest with none current (C-D61, C-D62). RDM-Publication and an envelope publication field name it (C-D63); a cursor keeps its publication, also with latest (C-D64). GET /api/consumer/{consumerApi}/v{contractVersion}/publications lists them newest first with eventSequence and status (C-D66). Every consumer GET answers an ETag and 304 to a matching If-None-Match (C-D65). Publication lists sort by an attribute through value-order indexes, on the management API too (D47, C-D67). Migration 20261005_03: publication read indexes (C-D68).

  • Base 0.9 C15: consumer API crosswalk. to.{name} on get by ID, lists, XREF and batch resolve names a projection parameter declared targetable and a value; the projection shows the items it matches, and each object carries them again as target, before matchScore. It changes no object, score, order or total, and cursors do not bind it (C-D54). A read of one object answers one target: 404 TARGET_NOT_FOUND for none, 409 AMBIGUOUS_TARGET for several, each naming the object; a page refuses nothing (C-D55). One to. name per read (400 MULTIPLE_TARGETS); a to. name of anything but a targetable parameter is 400 UNKNOWN_PARAMETER (C-D56, C-D57). Turning targetable off is version_required at /parameters/<name>/targetable (C-D58). No migration.

  • Base 0.9 C14b: a consumer read's ancestors are walked by ID and read by the walked list (170–192 ms to 10 ms at full volume), and contains reads are built without table aliases; a 1,000-lookup batch resolve takes 236 ms (was 817 ms).

  • Base 0.9 C14a: batch resolve resolves each distinct lookup once (same terms and parameters, in any order); a batch sending one heavy lookup 100 times fell from 1.7 s to 0.8 s at full volume.

  • Base 0.9 C14: consumer API batch resolve: POST /api/consumer/{apiKey}/v{contractVersion}/resolve.

    • Items. Up to 1,000 items (CONSUMER_RESOLVE_MAX_ITEMS, reported by GET /capabilities as consumerApi; more are 413 BATCH_TOO_LARGE), each a query or an id, with an optional ref and the definition's parameters over the request's defaults; JSON values are never split on commas (C-D47, C-D50).
    • Outcomes. In item order, each the single read's answer: found with the object, or not_found, ambiguous or rejected with that read's error, a rejection pointing into the item (QUERY_OR_ID_REQUIRED for both or neither of query and id); a body of the wrong shape or a wrong name in defaults refuses the request (C-D48, C-D49).
    • One scoring path. XREF is a batch of one: exact matches of every term are read at once, contains matches one select per term with its pattern bound; items group by their parameters, each group in a fixed number of statements (C-D51). A single best match is sought with the exact rules first, so an expect=one lookup its exact matches settle is no longer refused for what its contains terms match (C-D52, amends C-D37).
    • Index. Migration 20261005_02 adds a trigram index on names holding the workspace and type; it needs the btree_gin extension, which startup now requires beside pg_trgm and creates when it may.
    • Budgets. 1,000 lookups in 816 ms at full volume (451 ms over three parameter sets); XREF's lookups 13–65 ms. test_consumer_xref_volume checks equal statements for 10 and 100 items.
  • Base 0.9 C5: the consumer API's parameters on XREF.

    • Parameters. Every definition parameter (objectType, ancestor, related, projection) narrows XREF with its list meaning: an object is a result when it scored and every parameter keeps it, with its unchanged score; totalCount, the pages and expect=one follow the narrowed set, and the cursor binds the parameter values (C-D42). A definition parameter on XREF is no longer 400 PARAMETER_NOT_SUPPORTED. The 10,000-match limit still counts what the terms match before parameters (C-D44).
    • scoresFrom. A projection parameter declaring it limits which related records pass weights through its projection when the request gives it; own scores are untouched (C-D43). Refused at /consumerApis/<key>/parameters/<name>/scoresFrom without a match.promoted entry over the projection (C-D45); turning it on or off is version_required (C-D46).
    • Budgets. test_consumer_xref_volume adds a lookup narrowed by an ancestor and one with scoresFrom, with equal statements at two sizes.
  • Base 0.9 C4: consumer API XREF lookup: GET /api/consumer/{apiKey}/v{contractVersion}/xref?query=…&expect=any|one.

    • Scoring. The live records of the exposed types, scored by the definition's match at read time over the existing indexes (no consumer index, C-D34): own exact weights add up, contains weights count only without an exact one (C-D35); a related record's first matching firstMatch weight passes to the objects it relates to through the projection, only to the preferred types where it relates to one (C-D36). One statement scores; ordered by score, public type name, name and object ID. Each result is the shared object with matchScore (C-D41).
    • Migration 20261005_01: ix_objects_workspace_lower_name on objects (workspace_key, lower(name)) and ix_attribute_values_key_lower_text on attribute_values (attribute_type_key, lower(text_value)), so an exact match ignoring case is an index seek rather than a recheck of every record the trigram index finds.
    • Terms and limits. Comma-separated (a comma inside a term as %2C) or repeated query, at most 100 (400 TOO_MANY_TERMS), none 400 QUERY_REQUIRED (C-D38). Contains needs 3 characters; a query matching more than 10,000 records is 400 QUERY_TOO_BROAD (C-D37).
    • Paging and expect=one. Cursors both ways, position, from=end, totalCount always (C-D39); expect=one answers the bare object, 404 NO_MATCH or 409 AMBIGUOUS_MATCH naming the tied objects (C-D40). 400 INVALID_EXPECT for another expect. query and expect off XREF are 400 PARAMETER_NOT_SUPPORTED. Definition parameters apply to XREF from C5.
    • Model. A contains weight on id or a typed attribute is refused at its /match/…/contains pointer (schema 0.8).
    • Budgets. test_consumer_xref_volume holds the statement counts at two sizes. At full volume lookups take 34–58 ms; refusing a term found in every item takes 116 ms, recorded in TODO.
  • Base 0.9 C3b: consumer API parameters list several values as OpenAPI 3 serialises a list in a query (RFC 3986): repeated, or separated by literal commas, with a comma inside a value sent as %2C. C3's \, escape is withdrawn; the raw query is split before it is decoded.

  • Base 0.9 C3a: lists sorted by an attribute read in value order. Migration 20261004_04 adds an index per typed value column on (attribute_type_key, value, object_id), and a live attribute sort pages its records with a value through it, then those without one. Nothing changes for callers. At full volume a consumer list by rank pages in 10–12 ms (was 0.5–2.6 s) and the per-type list's rank sort under the 20,000-child parent in 9.9 ms (was 104 ms, POSTGRESQL_BUDGETS E3 live). A consumer list below a parent or an ancestor reads its containers first. Decisions D46–D48; change sets and publications follow in C6.

  • Base 0.9 C3: consumer API lists and parameters: GET /api/consumer/{apiKey}/v{contractVersion}/lists/{listKey}.

    • Lists. Every live record of the list's type, or those anywhere below the parent records named by object ID (C-D27, C-D28), as the shared object; ordered by the list's new optional sortBy (records without a value last), then name, then creation order (C-D29). Paged as the management lists are: cursors both ways with anchor, position, from=end, seek and includeTotal, pageSize 1–100, default 20 (C-D30). The envelope names workspace.
    • Parameters. Every definition parameter applies to lists and to get by ID (C-D31): objectType, ancestor (any depth), related (through a projection, self or a shared projection) and projection (limiting the projection's items; requireMatch drops records left with none). Several values, repeated or comma-separated (a comma inside a value as %2C), are alternatives; different parameters combine (C-D32 as amended by C3b, C-D33). Each is a SQL predicate, so totals and position jumps stay in the database.
    • Model. sortBy on a consumer list (schema 0.8), validated at /lists/<list>/sortBy and part of the contract; from, seek and includeTotal are reserved parameter names.
    • Refusals. 404 LIST_NOT_FOUND, 404 PARENT_NOT_FOUND, 400 UNKNOWN_OBJECT_TYPE, 400 INVALID_PAGE_SIZE, 400 INVALID_PAGE_PARAMETER; the shared paging codes answer 400 on the consumer tier. A declared parameter on get by ID is no longer refused; a paging name or a list's parent parameter there is 400 PARAMETER_NOT_SUPPORTED.
    • Budgets. test_consumer_list_volume holds the statement counts at two sizes. At full volume a list by parent reads in 7 ms; reads ordered by sortBy take 0.15–2.6 s (the attribute sort's known cost, POSTGRESQL_BUDGETS E3; C3a reads it in value order).
  • Base 0.9 C2: the consumer API tier at /api/consumer, with get by ID: GET /api/consumer/{apiKey}/v{contractVersion}/objects/{id}.

    • The shared object. Exactly the fields the definition declares, built from the definition alone: the roles and attributes (first source with a value, then default, then null; empty text is no value), parent (or null, with objectType null for a type not exposed), path and hierarchy (the ancestors, top down, without the object) and each projection as an object or null, or an array ordered by name, by the relationship's cardinality. Values keep the management API's encoding. Decisions C-D21–C-D26.
    • Identity. {id} is the object ID (C-D24); another identifier is found with XREF (C4).
    • Live, by workspace. Reads live data from the active release's definition, never a change set; workspace selects the workspace (default: the deployment's), named in the RDM-Workspace header.
    • Refusals. 404 CONSUMER_API_NOT_FOUND, 404 CONTRACT_VERSION_NOT_FOUND (naming the served version), 404 WORKSPACE_NOT_FOUND (unknown or retired), 404 OBJECT_NOT_FOUND, 400 UNKNOWN_PARAMETER, and 400 PARAMETER_NOT_SUPPORTED for a declared parameter until C3.
    • Cost. One statement for the records and their values, one per projection and one recursive CTE for the ancestors, whatever the number of objects. The routes stay out of the management OpenAPI document.
  • Base 0.9 F2: change feed retention and recovery.

    • Retention. FEED_RETENTION_DAYS (default 30, reported as changeFeed.retentionDays by GET /capabilities). earliestPosition is the first entry within the period. A position below it is 410 feed_position_expired, naming earliestPosition, headPosition and the active publication with its feedPosition, the baseline to re-read before following on.
    • Always a way back. The earliest position never passes the active publication's, and a workspace without one keeps every entry (D40). The audit log is not pruned.
    • Publications carry feedPosition.
    • Cost. A 500-entry page reads in 25–35 ms with 4 statements, at the head and in the middle of a 1,000,000-entry feed.
    • Migration 20261003_03: the index the retention floor is found by. Decisions D40–D41.
  • Base 0.9 F1: the change feed GET /api/v1/workspaces/{key}/feed.

    • Positions. Every change to a workspace's live data has a position: per workspace, gapless and in commit order. A live write takes positions under the workspace lock it already holds (one statement per write), so a refused or rolled-back write leaves no gap. Change-set work takes none.
    • Reading. A reader passes the last position it processed (after) and pages with pageSize (1–1,000, default 500) until nextAfter equals headPosition. kind and objectTypeKey (repeatable) filter within a scan of at most ten pages, never an unbounded one.
    • Entries. Each entry names the record or relationship, its type, version, the changed fields and attribute types (never values), the cause (a user, a job or a publication) and the model release.
    • Publishing. A publish writes one entry per record and relationship it changed in live data (reclassified with the previous type for a type change), then its published entry. Withdrawing adds withdrawn.
    • Refusal. A position past the head is 409 feed_position_ahead.
    • Migration 20261003_02; the feed starts empty, with no backfill. Decisions D31–D34.
  • Base 0.9 B3: relationship items in POST /api/v1/batches.

    • Items. createRelation, ensureRelation (unchanged when the relationship already exists with the same values), updateRelation and deleteRelation, with the ends named by ID or by a unique value, and set claims as on the single routes.
    • Set replacement. replaceRelationSet makes a record's set of one relationship the listed records. Kept relationships keep their identity and values, the set's version is claimed (or the request declares an overwrite), and an equal set answers unchanged.
    • Bulk. Relationship items are decided in memory and written in bulk with the object items around them: 40–43 statements per chunk of 500 at two data sizes (D35).
    • Set versions on the anchored read. GET /relations?objectId=… names every set the record anchors with its version as relationSets, an empty set included (D38).
    • Decisions D35–D39. No migration.

Changed

  • Base 0.9 K2b: a change set's deletions are audited per record (change-set-object, deleted), as its creates and updates are; the single delete's preview token is computed differently (it stays opaque), so a token issued before this release is refused DELETION_PREVIEW_CHANGED and previewed again. The bulk change dialog's flow is shared by every bulk action (BulkJobDialog).

  • Base 0.9 K2 (D119): IMPORT_PREVIEW_MAX_ITEMS is renamed JOB_PREVIEW_MAX_ITEMS and integrationJobs.limits.importPreviewMaxItems previewMaxItems, without an alias, since the limit now bounds a batch's preview too. The OpenAPI schema ImportPreview is JobPreview, its rows an import's only; a batch's parameters name its stage.

  • Base 0.9 C17: webhook delivery choices. A subscription chooses how requests are made, three independent choices: maxEventsPerBatch (1–1,000, default 1: one event per request; batched, a request holds what is waiting, up to that many and 1 MiB), payload (full, "with the object", or identity, "notification only") and format (rdm, default, or cloudevents: CloudEvents 1.0 in structured mode, one event per request as application/cloudevents+json, batched as an application/cloudevents-batch+json array, each with the RDM event as data). The coalesced mode, mode and debounceSeconds are removed (C-D105 retired); sending them is 422. Capabilities' delivery.maxEvents is now maxEventsPerBatch (1,000). The delivery log has no notice rows and its detail no kind; a resend needs eventId and repeats the event in the format it was sent in. Revision 20261007_03: existing subscriptions take the defaults and logged notices are deleted. The webhook dialog's Delivery section has the three choices, and a webhook's page shows them.

  • Webhook URLs may name any host (C-D101 revised): HTTPS on any host and port, plain HTTP only to localhost, 127.0.0.1 or ::1. CONSUMER_WEBHOOK_ALLOWED_HOSTS is removed, with consumerSubscriptions.allowedHosts in capabilities, WEBHOOK_HOST_NOT_ALLOWED and the HOST_NOT_ALLOWED suspension; adding a receiver no longer needs a restart. The dispatcher still refuses link-local addresses (cloud metadata) when it connects. The Webhooks dialog and the Deployment page drop the allowed hosts.

  • Base 0.9 UA1: every application and every kind of record has a colour (ADR-040). Each takes one of eight hues by its position in the model, so any model is coloured and no screen knows a key; the application tabs and the rail's Collections show it as a tile with the model's icon. The hues, their dark values and the context strips are tokens held to the contrast minimums in both themes.

  • Base 0.9 UA2: the record browser shows what contains what. Every record carries its type's tile, an open branch hangs from a guide line, a type's heading is a quiet label with its count and never looks like a record, and the selected record is filled with its type's colour.

  • Base 0.9 UA3: the collection page in the Atlas look. The page header carries the type's tile, Live is a soft green strip with dots of the change-set and publication colours on their switches, a column that shows another record (an ancestor:<type> column) shows it as a chip in that type's colour, the selected row takes its type's colour, booleans read Yes and No in plain text everywhere, the first page loads as rows in the table's shape, and an empty collection shows its tile and the Add action.

  • Base 0.9 UA4: the record in the Atlas look. The record's header is a band tinted in its type's colour with the type's tile, the type named in its colour, and the ancestors in the breadcrumb as chips in their types' colours that open them; history markers take the type's colour. In the inspector beside a tree lens the sections are ruled, fields sit beside their labels, and the relationship panels become one Connected list of chips in the related types' colours (with Show more and a way to manage them on the full page); the full page keeps the tables.

  • Base 0.9 UA4 revision: one table for every list of records in rows (ui/DataTable): collections, a record's children and its relationships. Relationship tables gain the Display menu (columns, density, page size, widths), table-shaped loading rows, the empty state, row colours and the phone layout's three columns with Show all columns. Columns resize by dragging the edge of their header (it was reachable by keyboard only): a drag starts from the column's drawn width, keeps the other columns as they were, and may make a column narrower than its content, which then ends in an ellipsis. The look of a table that has not been resized is unchanged.

  • Base 0.9 UA4 revision: a record's fields are edited by clicking their value, not by Edit, Remove and Add value buttons. The value is a button that reads as text, with a soft fill and a pencil on hover and focus (a plus on an empty field, which adds a value); an optional value's trash icon shows with the row's hover or focus and always on touch, and removing still asks first. Saving stays explicit, a selected text is left for copying, and focus returns to the value after editing. Read-only fields and publications show plain values.

  • Base 0.9 UA4 revision: Settings › Appearance has Type colours: palette B (magenta and olive, the default) or A (plum and gold) for the record type colours, saved in this browser.

  • Base 0.9 UA4 revision: Settings › Appearance has Edit pencil: a record's editable fields show their pencil on hover and focus (the default) or faintly all the time, saved in this browser.

  • Base 0.9 UA4 revision: the top bar takes a way back (TopBar's back), shown after the product mark. Settings uses it: Back to {application} returns to the exact page Settings was opened from, or to the home page when Settings was the first page opened.

  • Base 0.9 UA4 revision: the record's header shows its ID (the id the management and consumer APIs return) under the title, in monospace with a copy button (ui/CopyValue), in the inspector and on the full page.

  • Base 0.9 UA5: change sets and publications in the Atlas look. A change set is the soft amber strip and a publication the soft violet one, as Live is green, each with a line saying what an edit does there; the pill names the context and the change set's bar gives its name, version, source and model with its actions at the end: Review changes, Validate change set, Publish change set (the indigo primary action) and a More change set actions menu (ui/ActionMenu, in place of a hand-made one) for Change model version and Discard change set. The change set and publication lists are ruled rows on one card (ui/RuledList), grey under the pointer as table rows are; a publication's Start a change set from this publication and Withdraw publication moved into its row's menu, beside Open publication. An empty change set list offers Start from current live data as the header does. In Review changes the kind of change is a pill (Added green, Changed grey, Removed red), a record shows its type's tile, and a relationship's two ends are chips in their types' colours.

  • Base 0.9 UA2 revision: the record browser's group frames show only while a group scrolls, in the sidebar and the hierarchy lens. At rest they are invisible, and the glow on the group under the pointer is removed.

  • Base 0.9 UA2 revision: a type group's heading in the record browser no longer shows its count; screen readers still hear it with the heading's name.

  • Base 0.9 UA7: dark mode and the gates. Every surface was checked in dark beside its light version. Checkboxes and radios are drawn from the design tokens instead of the browser's own controls, which in dark were grey discs where a disabled choice looked like an enabled one. A table's column resize handles stand outside the header cells, so a column header is named by its label alone (it read "Path Resize Path column"). The visual gate holds 64 baselines, every screen in light and dark, adding the mapping grid and its dialog and a collection and a record at 390px; the accessibility gate checks the same pages (73 checks, no violations).

  • Base 0.9 UA6 revision: the settings navigation's group headings are dark and bold, and their pages hang one step in from a guide line, so headings and pages no longer look alike. In the 390px drawer the navigation keeps the rail's inset and the headings their size (they took the dialog's heading size and sat against the screen edge).

  • Base 0.9 UA6: the remaining surfaces in the Atlas look. The relation matrix, mapping grid and tree lens carry their row type's tile in the page header, as a collection does; a matrix cell's record and a mapping's targets (in the grid and in its dialog's Currently) are chips in their type's colour, and mapping states are pills (Mapped green, Unmapped grey, Ambiguous amber). The dialogs that list records to choose (move, mapping target, matrix link and place, restructuring's siblings) show each record's type tile, through one ui/Candidate row; the matrix cell dialog and the restructuring plan show it before each record. Every state tag is a StatusPill (release and workspace states, capabilities, diagnostics, model changes, refusals), every panel a ui/Card, every notice ui/Notice. Removed: the 51 older token aliases in base.css (the stylesheets read the roles), the .surface, .pill, .compatibility and mapping badge rules, and six rules no screen used.

  • Base 0.9 UA2 revision: the record browser's groups sit in frames of their type's colour, nested as boxes inside boxes; a group glows in the accent while it scrolls and in teal while the pointer is in it; with a pointer, a scrolling group stops at its first and last row; records sit one step in from their group's heading. Folding headings is built and switched off (FOLDING_GROUPS).

  • Record browser: the group under the pointer owns the mouse wheel, its heading included; at its end the wheel moves the window around it only until the whole group is in view, so a group partly scrolled out of sight can always be brought back from inside it.

  • Record browser: a new scroll gesture on a group that is at its end moves the area around it on, so the sidebar never gets stuck; scrolling up into a group also shows the record it belongs to; the groups' resting frames are fainter.

  • Base 0.9 C12: POST /consumer-subscriptions/{id}/resume takes reset instead of acknowledgeLoss (C-D93 revised, C-D110). reset: true starts delivery after the workspace's feed head, whatever happened before; without it a resume after expired events is still 409 EVENTS_EXPIRED, its message naming reset. A resume and an edit now also clear a failing batch's retries, so the next attempt is at once. A filter value that stops matching suspends the subscription without delivering the deletions first (C-D94 revised).

  • Base 0.9 C12: the job worker, the event deriver and the new dispatcher log a database they cannot reach in one plain ERROR line, with the traceback at DEBUG, then a WARNING a minute; other failures keep their traceback. J12's outage case no longer fails when the deriver polls during the outage.

  • Base 0.9 C8: a consumer object's parent keys its fields by the roles' output names, as the object's own fields (masterId, not id, where the id role's output is masterId; C-D75). The reference deployment's parent has these names, and a flow reading a field from an object and then from its parent uses one name.

  • The example model 0.6.0 declares one consumer API, rdm, over every object type of the model, so one XREF looks up any ID, code or name across every type, systems and translations; type narrows it, and system and language resolve and translate codes (crosswalk). Lists per type. It replaces 0.5.0's four APIs split by audience, whose XREFs each searched only their own types.

  • Base 0.9 CN1: the consumer API's name is written consumerApi in public contracts, not apiKey, which reads as a credential: the route /api/consumer/{consumerApi}/v{contractVersion}/… and the planned API index, subscriptions, webhook bodies and events (U-D1). No response changes: none carried the name, and the not-found errors gain no field (C-D59). The model keeps key.

  • Breaking — Base 0.9 C2: a consumer API's identity is the object ID. The id role's source is ["id"]; a unique attribute as its source, which C1 accepted, is refused at /consumerApis/<key>/object/id/source. Publish a former system's ID as an attribute field.

  • Breaking — Base 0.9 T1b: error codes are upper case on every API: object_not_found is now OBJECT_NOT_FOUND. It covers error bodies, the errors and unprocessed reason of batch and job item outcomes, and validation diagnostics (D42). Status, action, kind and classification values keep their case; a consumer contract's version_required classification is unchanged. Revision 20261004_03 upper-cases the codes already stored in job results (D43). A check in the contract group refuses a new lower-case code (D45). Clients comparing codes must compare the new spelling.

  • Base 0.9 T1a: current-live change-set bases and publications copy records, relationships, typed values and matrix states inside PostgreSQL. Publications copy live data after applying the change; merge and graph validation remain in Python. Revisions 20261004_01 and 20261004_02 add the rdm_value_json and rdm_decimal_text encoding functions; a decimal at any stored scale (1e-16383 answered 500) encodes as Python does. API behavior is unchanged.

  • Base 0.9 B3: the single relationship writes and the mapping save are batches of one through the same decide steps as batch items, so they answer with the same codes.

    • A relationship update checks its version before its values, live as in change sets.
    • A change-set relationship delete records an audit entry.
    • A mapping transition's audit payload lists newTargets.
  • Base 0.9 B3 (D36): replacing a required relationship's only target, through the mapping save or a set replacement, is 422 required_relation when the record losing it has no other.

  • Base 0.9 F1 (D15): a publish's changes to live data now show in record history, one entry per record with cause: {"kind": "publication", "id"}; AuditCause.kind gains publication.

Fixed

  • Base 0.9 UA0: with a popover open (Account, Display, Filter), the first click on the page only closed it, so a tab or link seemed not to respond. ui/ControlPopover is now non-modal: a press outside, or the user's own wheel or touch scrolling, closes it and the press acts; a scroll the layout causes, such as an applied filter shortening the list, keeps it open. Escape and focus leaving it still close it. test:browser:settings clicks an application tab, a settings link and the page heading with Account open.

  • Base 0.9 UA0: the workspace switcher, opened before the current workspace had loaded, showed only that workspace: its name arriving as the field's value filtered the open list. The switcher is disabled until the current workspace is known. This was test:browser:w4's intermittent miss.

  • Base 0.9 UA0: the application tabs stood 20px after the product name in the top bar; they start 36px after it, as ADR-040 says.

  • Base 0.9 UA0: Settings had a top bar of its own, whose product mark could not be clicked and which had no applications. Every page now has the same top bar (U-D14): on Settings no application is current and Settings shows as the current tab (gear, name, underline), an application's tab goes back to it (the one last looked at, to the same view and query), and the mark goes home. "Back to applications" is gone. One frame (app/ShellFrame) and one top bar (app/TopBar) serve every area, and copied markup is replaced by shared components: ui/SearchField, ScopeBanner, a release picker and a change list.

  • Base 0.9 C8: the consumer test group now selects the J29 journey tests; since C4 it had left them out (JOURNEY_GROUPS had no J29).

  • Base 0.9 C6b: a consumer API document was not valid OpenAPI when a list's parent parameter shared a definition parameter's name (Duplicate parameter); the list's operation now lists the name once, as the parent, as the route reads it.

  • Base 0.9 C15: a model shape error inside a typed union (a view's or panel's config, a consumer API parameter) was reported at a path holding Pydantic's type tag, such as /applications/1/views/1/tree/config/pageSize, which names no node of the document, so the model draft editor could not go to it. The tag is left out: /applications/1/views/1/config/pageSize.

  • Base 0.9 T1: creating a change set from current live data and publishing a change set failed with 500 in a workspace holding more than about 65,000 records. Both read every record's values and relationships with a list of all the record IDs, one statement parameter each, and PostgreSQL refuses more than 65,535. They now select by workspace, and the publish checks each relationship end with a primary-key probe.

  • Base 0.9 T1: capturing current live data and publishing slowed quadratically with the number of records they wrote. The typed value index's flush hook rebuilt SQLAlchemy's set of pending rows once per row. At about 10,000 records (4 % of the volume fixture), capture now takes 4.6 s instead of 28.1 s, and publish 6.0 s instead of 29.9 s.

  • Base 0.9 T1: applying a publish to live data flushed the session once per changed record, with the whole workspace loaded. It now writes the changes with explicit flushes, and copies the publication and a change set's base in multi-row statements. Publishing 1,000 changes in a 26,000-record workspace takes 11.3 s instead of 25.0 s.

  • Base 0.9 B3: relating the same two records twice with one relationship type answers 409 duplicate_relation. Live, it answered 500; in a change set, the duplicate was staged.

Let us run it for you.

Backups, upgrades, TLS and monitoring are ours. Your data stays exportable any time.