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.ownedColumnslays 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 gainrelatedRecord.versionColumn. The Export dialog's Advanced offers Rows or Columns per parent, the parents to add and the fields, and the lists. Browser runnertest:browser:j6a. - Base 0.9 J6: a workbook for people (D179–D190; J27.15–J27.17).
POST /exportstakeslayout: "people"withpeople(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_LAYOUTotherwise. An XLSX imported without a profile is read through the profile it carries (parameters.profile.embedded). Import profiles gainkey.versionColumn,key.nameColumnand a relation binding'sidColumn,versionColumnandattributes. The Export dialog offers For people with an Advanced section; the import page names the workbook; the job reads "Workbook for people". Browser runnertest: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 itsfromend, itstoend 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 takescandidateForwithanchorTypeKey; the relationship view's and matrix's candidates apply the criteria. A new relationship outside them is refused with422 RELATION_CANDIDATE_REFUSEDon every write path (batches check a chunk's targets in one statement per restricted end); relationships made before are kept. The model-aware query takesancestor:<type>.<field>criteria. Browser runnertest: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
filterFieldsmay namerelated:<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 takesfilter.related:<key>.eq=<record ID>(repeatable) and.isNull=true|falsefor any relationship the type has, built by Q3's relationship hop in every context;INVALID_FILTERotherwise. 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 runnertest: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
uniquethe release changes. Activation now locks the active subscriptions, and whenuniquechanges 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: truewhose 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,updatedAtor 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
stateAtand 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
ETagchanges 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, notIN (run) OR IN (run)(R-F23); a change set's matrix walk leaves out the rows the change set replaces withNOT EXISTS, notNOT 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_filterandinvalid_sortin lower case; they areINVALID_FILTERandINVALID_SORT(found in 0.9 K6). - An export with a
relatedcriterion 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-deletiontimes 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
expandtakes 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, orancestor:<type>.<field>, written as in a consumer API definition ("Has translation · Language code":arcsen+4 more). A record without a value shows "—"; such a column never sorts.expandtakes the same text; the entry names itsfieldand each record carries the value beside its name (INVALID_EXPANDfor 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 insortFields, and it is never a filter.GET /objects/{objectTypeKey}takesexpand(repeatable, at most 5) andexpandLimit(1–20, default 3), answering each item'sexpandedin Q3's shape,422 INVALID_EXPANDotherwise, andsort=related:<key>; the list's response is nowTypedObjectPage(QueryObjectitems). The starter is content release 0.9.4 with relation columns on system values and master values. Q3's expansions read a page in oneLATERALstatement per expansion (a page of 100 with two keys: 45 ms live, was 611 ms). Browser runnertest: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 (
updateitems withparentand 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 runnertest: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 runnertest: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: eachfromrecord holds at most one relationship of it, and atorecord serves any number. A second one is409 CARDINALITY_VIOLATIONon every write path; a release tightening a binding to it needs a migration while afromrecord holds more than one (RELATION_CARDINALITY_VIOLATEDat the binding'scardinality, with the count). It cannot berequired(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 fieldviaa many-to-one binding answers one object ornull. 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 (itsrdmconsumer 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, optionalexpectedVersion) deletes the relationship of that type joining the two records asDELETE /relations/{id}does, with its refusals, or answersunchangedwhen there is none, so a removal sent again changes nothing (D129). AreplaceRelationSetin a preview needs noexpectedRelationSetVersion: its outcome names the version it found (previousRelationSetVersion, also in its refusals' details), and confirming carries it into the stored item, so execution refusesRELATION_SET_VERSION_CONFLICTonly 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 overBACKGROUND_JOB_MAX_ITEMS, or a replacement overJOB_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
deleteitem takescascadeanddeletionPreviewTokenas the singleDELETEdoes: withoutcascadea record with dependants is refusedOBJECT_HAS_DEPENDANTSwith itsimpact; with it the item deletes the record with its dependants under the impact its token confirms. A batch preview gives each delete item'simpact(records by type, relationships, those reaching records that stay, and the token) and the preview'sdeletiontotals; 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 answersunchangedwithdeletedWithItem(D122). Deletion is one path for the singleDELETEand 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 /batchestakesstage: "preview"for a background batch (inline is refused): its items run without writing anything, as an import's preview runs, and the job waits inawaiting_confirmationwithpreview(norows);POST /jobs/{id}/confirmruns 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 toatomicJobMaxItemsor 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
backgroundJobMaxItemsis refused with an explanation naming the count, the limit,BACKGROUND_JOB_MAX_ITEMSand 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"onPOST /importsandPOST /batchesmakes 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'smatchBy: {"field": "id"}too; relationship IDs are not. The file's versions are ignored on counterparts (onExistingdecides); outcomes namesourceId; the notice readsRECORD_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
noticesonce bound (RECORD_IDS_OF_ANOTHER_WORKSPACE, with the workspace and the rows),OBJECT_ALREADY_EXISTSnames the workspace holding the ID (details.workspaceKey, live and in a change set), andREFERENCE_NOT_FOUNDnames 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/FileFieldis the one file input (the model draft dialog uses it). No backend or contract change. New runnertest: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), theoperationsit offers (objects,xref,resolve,lists,events: listed means offered),object.fieldswith roles,relatedblocks by a relation'skeyorreverseKey(role fields may stand anywhere and are output first),operations.xref.matchOnandalsoMatchThrough(field: "code"for each code source),filterswith one verb each (byType,under,linkedTo,onRelated), listswithina filter, eventsperTypeorcustom(watching outputs andrelatedblocks), 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 answers404 OPERATION_NOT_OFFEREDand leaves the OpenAPI document; dropping one needs a newcontractVersion. 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 andPOST /model-validationsrefuse a new schema 0.8 document that declaresconsumerApis(UNSUPPORTED_SCHEMA_VERSIONat/schemaVersion), and startup refuses such a file named byRDM_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/importstakesstage: "preview": the file is bound, its firstIMPORT_PREVIEW_MAX_ITEMS(10,000) items run independently against the data in a transaction rolled back whole, and the job waits inawaiting_confirmationwithpreview(coveragecompleteorsample, items and rows covered, counts by action, the result under the request's policy) (D80, D82, D83).POST /api/v1/jobs/{jobId}/confirmqueues it to bind the stored file again and execute (D81); it is idempotent, checks the context again and refuses with409 JOB_NOT_AWAITING_CONFIRMATIONor410 JOB_EXPIRED; cancel ends a waiting preview at once and retention expires it (D87). Every import carriesbindingReportper source and column (D86);GET /api/v1/jobs/{jobId}/rowsreads its outcomes by row and/itemstakesref=(D84); a row refusal lists every failing cell indetails.cells(D85). Revision20261007_08addsjobs.report,jobs.confirmed_atand the source and row of inputs and outcomes;integrationJobs.limits.importPreviewMaxItemsreports the setting. -
Base 0.9 J2a: the database holds unique attribute values.
unique_attribute_valueskeeps each live value of an attribute the active release declaresuniqueonce per workspace, keyed by the SHA-256 of its canonical JSON under a deferred unique constraint (D75, D77); PostgreSQL triggers onattribute_valuesandmodel_runtime_statekeep 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's409 DUPLICATE_ATTRIBUTE_VALUE, and at commit by the database; values handed between records within one batch or one publish pass. Live data only (D78). Revision20261007_07adds 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/importstakes a file and its request asmultipart/form-dataand queues a job of kindimport: XLSX or CSV through a profile, or JSON Lines batch items, or an export's CSV or XLSX tables without one (the records table gainsapplicationKey, 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. Revision20261007_06addsimport_profiles. openpyxl becomes a runtime dependency andpython-multipartis 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}/eventsstreams a job (server-sent events) at once and after each committed change until it ends; a trigger onjobs(revision20261007_05) notifies the engine at commit, so nothing is polled (D57–D63). -
Base 0.9 J1: export jobs.
POST /api/v1/exportsexports a stated scope of one workspace (live, a change set or a publication; object types each with their own criteria, a subtree,asOf, relationshipsnone,withinScopeorall) as JSON Lines, CSV or XLSX, in the background from one database snapshot. The job's newoutputfield states what the file holds (counts per type, context, the feed position it equals, model release, format, size, SHA-256), andGET /api/v1/jobs/{jobId}/outputserves the file only once the job is completed. JSON Lines holdsupsertandensureRelationbatch 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 settingJOB_FILE_DIR(defaultvar/job-files); revision20261007_04addsjobs.output. The feed's410 FEED_POSITION_EXPIREDnames 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}/resendsends one event of a request again exactly as it was sent (a notice as it was), alone in a new request with the sameeventIdand the headerRDM-Redelivery: manual; once, during the call, on any subscription; the new request names the one it repeats (resendOf). Revision20261007_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 forCONSUMER_WEBHOOK_LOG_RETENTION_DAYS(7, in capabilities); a pending batch longer; deleted with its subscription. Revision20261007_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.
- A subscription has a
-
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/CatalogueLayoutandCatalogueEntryreplace 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
/docssources 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 eventvalues-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;CopyValuecan link its value;ConfirmDialogtakes a label for its dismiss button.
- The starter model is content release 0.9.2: it declares a consumer API
-
Base 0.9 U3: the Settings area.
/settingshas 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 newui/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).
/modelsand/workspacesaddresses 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/systemanswers the same facts. Settings come from a catalogue inconfig.py(group, description, secret) held againstSettingsand 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_ENABLEDis 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 isdebounceSecondsold, 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'ssigningSecret;RDM-Delivery-Idstays the same on every retry of a batch (C-D12). - Any
2xxacknowledges. 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 suspendedDELIVERY_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, andpendingwhile a batch fails, with its attempts, first failure, last error and next attempt; delivery never changesversion(C-D108).GET /capabilitiesreports the limits inconsumerSubscriptions.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_03adds the delivery state toconsumer_subscriptions.
- 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 (
-
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/PageHeadernow draws all ten page headers; there are alsoui/StatusPillandui/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-darkwas added.
- Design tokens are in three layers in
-
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 withexpectedVersion, 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 inCONSUMER_WEBHOOK_ALLOWED_HOSTS,perChangeorcoalesceddelivery and thefulloridentitypayload. Each filter value must match a record (C-D100). It starts after the feed's head, or after an earlierstartAfter(C-D97). Its signing key is shown once and stored sealed (AES-256-GCM) underCONSUMER_WEBHOOK_KEY_ENCRYPTION_KEY, which startup requires with the capability on;python -m app.cli.generate_keymakes one (C-D96). A subscription isactiveorsuspendedwith a reason (C-D98). Activating a release that drops what it names suspends itMODEL_CHANGED, one that raises the contract versionCONTRACT_VERSION_CHANGED, and retiring its workspaceWORKSPACE_RETIRED(C-D99). A resume checks it again and, after unacknowledged events expired, needsacknowledgeLoss(C-D93). A workspace with subscriptions cannot be deleted (deleteBlockedBy: subscribed).GET /capabilitiesgainsconsumerSubscriptions. 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. Migration20261006_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,updatednaming 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) anddeleted; a type change as seen from each event (C-D82, C-D83, C-D87).GET /api/consumer/{consumerApi}/v{contractVersion}/eventsreads them in position order fromafteror a lastingcursor, withcaughtUp, narrowed byeventand the definition's parameters (C-D90, C-D91);payload=full(default) carries each object as get by ID answers it, labelledstateAt,payload=identityleaves 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_EXPIREDnaming 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.payloadbecomes a reserved parameter name. A record's feed entry now also keeps its parent and previous parent, not shown in the feed (C-D95). Migration20261006_01addsconsumer_eventsandconsumer_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 optionalwatchof 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 highercontractVersion, one that tells them more is compatible (C-D85). Whatupdatedmeans and how a type change is reported are settled for C10 (C-D82, C-D83).eventandafterbecome 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-DDon 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 as400 AS_OF_NOT_SUPPORTEDon a list or record; a malformed date is400 INVALID_AS_OF. Only the returned records are dated, not projection items or ancestors (C-D76). Lists, XREF pages and batch resolve carryasOfApplied, and every dated answer theRDM-As-Of-Appliedheader (C-D78). Each API's OpenAPI document declaresasOfwhere 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
/docslists 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'sconsumerApi,name,contractVersionandopenapiURL, 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.versionthe contract version, schemas from the contract, the limits stated. Both answer anETag. Dev dependencyopenapi-spec-validatorchecks the document and real answers against it (C-D70). -
Base 0.9 C6a: consumer API reads pinned to a publication.
publication={id}orlatest(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_MISMATCHwhen that release declares another contract version, C-D60);404 PUBLICATION_NOT_FOUNDfor an unknown publication, another workspace's, orlatestwith none current (C-D61, C-D62).RDM-Publicationand an envelopepublicationfield name it (C-D63); a cursor keeps its publication, also withlatest(C-D64).GET /api/consumer/{consumerApi}/v{contractVersion}/publicationslists them newest first witheventSequenceandstatus(C-D66). Every consumerGETanswers anETagand304to a matchingIf-None-Match(C-D65). Publication lists sort by an attribute through value-order indexes, on the management API too (D47, C-D67). Migration20261005_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 aprojectionparameter declaredtargetableand a value; the projection shows the items it matches, and each object carries them again astarget, beforematchScore. 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_FOUNDfor none,409 AMBIGUOUS_TARGETfor several, each naming the object; a page refuses nothing (C-D55). Oneto.name per read (400 MULTIPLE_TARGETS); ato.name of anything but a targetable parameter is400 UNKNOWN_PARAMETER(C-D56, C-D57). Turningtargetableoff isversion_requiredat/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 byGET /capabilitiesasconsumerApi; more are413 BATCH_TOO_LARGE), each aqueryor anid, with an optionalrefand the definition's parameters over the request'sdefaults; JSON values are never split on commas (C-D47, C-D50). - Outcomes. In item order, each the single read's answer:
foundwith the object, ornot_found,ambiguousorrejectedwith that read's error, a rejection pointing into the item (QUERY_OR_ID_REQUIREDfor both or neither ofqueryandid); a body of the wrong shape or a wrong name indefaultsrefuses 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=onelookup its exact matches settle is no longer refused for what its contains terms match (C-D52, amends C-D37). - Index. Migration
20261005_02adds a trigram index on names holding the workspace and type; it needs thebtree_ginextension, which startup now requires besidepg_trgmand 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_volumechecks equal statements for 10 and 100 items.
- Items. Up to 1,000 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 andexpect=onefollow the narrowed set, and the cursor binds the parameter values (C-D42). A definition parameter on XREF is no longer400 PARAMETER_NOT_SUPPORTED. The 10,000-match limit still counts what the terms match before parameters (C-D44). scoresFrom. Aprojectionparameter 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>/scoresFromwithout amatch.promotedentry over the projection (C-D45); turning it on or off isversion_required(C-D46).- Budgets.
test_consumer_xref_volumeadds a lookup narrowed by an ancestor and one withscoresFrom, with equal statements at two sizes.
- Parameters. Every definition parameter (
-
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
matchat 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 matchingfirstMatchweight 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 withmatchScore(C-D41). - Migration
20261005_01:ix_objects_workspace_lower_nameonobjects (workspace_key, lower(name))andix_attribute_values_key_lower_textonattribute_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 repeatedquery, at most 100 (400 TOO_MANY_TERMS), none400 QUERY_REQUIRED(C-D38). Contains needs 3 characters; a query matching more than 10,000 records is400 QUERY_TOO_BROAD(C-D37). - Paging and
expect=one. Cursors both ways,position,from=end,totalCountalways (C-D39);expect=oneanswers the bare object,404 NO_MATCHor409 AMBIGUOUS_MATCHnaming the tied objects (C-D40).400 INVALID_EXPECTfor anotherexpect.queryandexpectoff XREF are400 PARAMETER_NOT_SUPPORTED. Definition parameters apply to XREF from C5. - Model. A
containsweight onidor a typed attribute is refused at its/match/…/containspointer (schema 0.8). - Budgets.
test_consumer_xref_volumeholds 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.
- Scoring. The live records of the exposed types, scored by the definition's
-
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_04adds 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 withanchor,position,from=end,seekandincludeTotal,pageSize1–100, default 20 (C-D30). The envelope namesworkspace. - Parameters. Every definition parameter applies to lists and to get by ID (C-D31):
objectType,ancestor(any depth),related(through a projection,selfor a shared projection) andprojection(limiting the projection's items;requireMatchdrops 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.
sortByon a consumer list (schema 0.8), validated at/lists/<list>/sortByand part of the contract;from,seekandincludeTotalare 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 answer400on the consumer tier. A declared parameter on get by ID is no longer refused; a paging name or a list's parent parameter there is400 PARAMETER_NOT_SUPPORTED. - Budgets.
test_consumer_list_volumeholds the statement counts at two sizes. At full volume a list by parent reads in 7 ms; reads ordered bysortBytake 0.15–2.6 s (the attribute sort's known cost, POSTGRESQL_BUDGETS E3; C3a reads it in value order).
- 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
-
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, thennull; empty text is no value),parent(ornull, withobjectTypenullfor a type not exposed),pathandhierarchy(the ancestors, top down, without the object) and each projection as an object ornull, 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;
workspaceselects the workspace (default: the deployment's), named in theRDM-Workspaceheader. - 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, and400 PARAMETER_NOT_SUPPORTEDfor 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.
- The shared object. Exactly the fields the definition declares, built from the definition
alone: the roles and attributes (first source with a value, then
-
Base 0.9 F2: change feed retention and recovery.
- Retention.
FEED_RETENTION_DAYS(default 30, reported aschangeFeed.retentionDaysbyGET /capabilities).earliestPositionis the first entry within the period. A position below it is410 feed_position_expired, namingearliestPosition,headPositionand the active publication with itsfeedPosition, 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.
- Retention.
-
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 withpageSize(1–1,000, default 500) untilnextAfterequalsheadPosition.kindandobjectTypeKey(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
(
reclassifiedwith the previous type for a type change), then itspublishedentry. Withdrawing addswithdrawn. - 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),updateRelationanddeleteRelation, with the ends named by ID or by a unique value, and set claims as on the single routes. - Set replacement.
replaceRelationSetmakes 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 answersunchanged. - 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 asrelationSets, an empty set included (D38). - Decisions D35–D39. No migration.
- Items.
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 refusedDELETION_PREVIEW_CHANGEDand previewed again. The bulk change dialog's flow is shared by every bulk action (BulkJobDialog). -
Base 0.9 K2 (D119):
IMPORT_PREVIEW_MAX_ITEMSis renamedJOB_PREVIEW_MAX_ITEMSandintegrationJobs.limits.importPreviewMaxItemspreviewMaxItems, without an alias, since the limit now bounds a batch's preview too. The OpenAPI schemaImportPreviewisJobPreview, itsrowsan import's only; a batch'sparametersname itsstage. -
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", oridentity, "notification only") andformat(rdm, default, orcloudevents: CloudEvents 1.0 in structured mode, one event per request asapplication/cloudevents+json, batched as anapplication/cloudevents-batch+jsonarray, each with the RDM event asdata). The coalesced mode,modeanddebounceSecondsare removed (C-D105 retired); sending them is422. Capabilities'delivery.maxEventsis nowmaxEventsPerBatch(1,000). The delivery log has no notice rows and its detail nokind; a resend needseventIdand repeats the event in the format it was sent in. Revision20261007_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.1or::1.CONSUMER_WEBHOOK_ALLOWED_HOSTSis removed, withconsumerSubscriptions.allowedHostsin capabilities,WEBHOOK_HOST_NOT_ALLOWEDand theHOST_NOT_ALLOWEDsuspension; 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'sback), 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
idthe 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/Candidaterow; the matrix cell dialog and the restructuring plan show it before each record. Every state tag is aStatusPill(release and workspace states, capabilities, diagnostics, model changes, refusals), every panel aui/Card, every noticeui/Notice. Removed: the 51 older token aliases inbase.css(the stylesheets read the roles), the.surface,.pill,.compatibilityand 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}/resumetakesresetinstead ofacknowledgeLoss(C-D93 revised, C-D110).reset: truestarts delivery after the workspace's feed head, whatever happened before; without it a resume after expired events is still409 EVENTS_EXPIRED, its message namingreset. 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
parentkeys its fields by the roles' output names, as the object's own fields (masterId, notid, where theidrole's output ismasterId; 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;typenarrows it, andsystemandlanguageresolve 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
consumerApiin public contracts, notapiKey, 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 keepskey. -
Breaking — Base 0.9 C2: a consumer API's identity is the object ID. The
idrole's source is["id"]; auniqueattribute 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_foundis nowOBJECT_NOT_FOUND. It covers error bodies, the errors and unprocessedreasonof batch and job item outcomes, and validation diagnostics (D42). Status, action, kind and classification values keep their case; a consumer contract'sversion_requiredclassification is unchanged. Revision20261004_03upper-cases the codes already stored in job results (D43). A check in thecontractgroup 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_01and20261004_02add therdm_value_jsonandrdm_decimal_textencoding functions; a decimal at any stored scale (1e-16383 answered500) 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_relationwhen 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.kindgainspublication.
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/ControlPopoveris 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:settingsclicks 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
consumertest group now selects the J29 journey tests; since C4 it had left them out (JOURNEY_GROUPShad noJ29). -
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
500in 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 answered500; in a change set, the duplicate was staged.