Changelog

0.12.0, 2026-10-28

Sign-in and roles for everyone, a record of who changed what, approval before publishing, and a promise that the APIs change only by adding.

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-28. 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.

Security, authentication and authorization: every caller signs in, every operation is authorized against a role, and every change records who made it; change-set publication approvals and an inbox; mail; licensing and the compatibility commitment (COMPATIBILITY). Upgrading from 0.11: create the first administrator with python -m app.cli.create_admin (OPERATIONS); the schema-language window is 0.10 only, and a workspace on an older release is brought up to date from its own screen or with python -m app.cli.model_release_raise.

Added (0.12 NT1, notifications: a short list and a history page)

  • The bell lists only unread notices, at most 20 with how many more, and a notice opened from it leaves the list; "See all notifications" opens the new Notifications page (/notifications, also in the account menu with the unread count): search, Kind, Workspace, Show (all, unread, read) and Sort, rows loaded as the list scrolls, notices selected as records are (Cmd/Ctrl-click, Shift for a range, Select all) and marked read or unread or deleted from one steady bar, and Delete all read…. Settings › Notifications is renamed Notification email.
  • A notice opens in its own workspace's application from the bell, also on Home and the Notifications page.
  • GET /me/notifications gains q, kind, workspace and sort; POST /me/notifications/read gains subjectId and read: false; new POST /me/notifications/removal ({ids} or {read: true}). Additive.
  • The engine closes what is done: a job's "waiting for your confirmation" notice is read once the job is confirmed or cancelled, and opening a job's page or dialog, a migration, a move or a change set's review marks the person's notices about it read, including one that arrives while it is shown.
  • The 90-day delete runs in the job worker's hourly sweep instead of inside every insert (migration 20261116_01).

Fixed (0.12 NT1)

  • A second click on the bell, the account button or any other popover button closed and reopened it. Focus leaving the popover closed it and the same press opened it again; that press now only closes it.

Added (0.12 MR1, retiring and deleting model releases)

  • A model release can be retired (PATCH /api/v1/model-releases/{releaseId}, Settings › Model › the release › Retire…): it is no longer offered or accepted where a release is chosen (409 MODEL_RELEASE_RETIRED for a new workspace, an activation, a migration or a change set elsewhere), and the workspaces and change sets on it keep working. It can be brought back; installing its exact model again also does. The default workspace's release is not retired (409 MODEL_RELEASE_IS_DEFAULT).
  • A release nothing refers to can be deleted (DELETE /api/v1/model-releases/{releaseId}, Delete… on its page). Every release says deletable and, if not, deleteBlockedBy (running, history, importProfile, mapping); a refusal is 409 MODEL_RELEASE_IN_USE. python -m app.cli.model_release_prune deletes the retired ones at once (--unused also the active ones, --dry-run lists).
  • Model pickers offer only what can be chosen. GET /model-releases gains status and usable=true (active, of a schema this version reads); every picker uses it. The new-workspace picker lists each model's newest release, older ones behind "Show older versions"; a change set's pickers list every usable version, and its workspace's own release even when retired. Settings › Model lists fully retired models last, under Retired.
  • Release answers gain status, statusVersion, retiredAt, deletable and deleteBlockedBy (additive). The database gains model_releases.status, status_version and retired_at (migration 20261115_01).

Fixed (0.12 R, the release review)

  • Reloading and moving on at once no longer signs you out. A reload renews the session; when the browser navigated before it kept the new refresh cookie, the next page presented a used token and the session ended as reuse. A used refresh token presented again within 30 seconds of its use, while the token that use issued is unused, now renews from that token; an older token, a later one, or a successor already used still ends the session.
  • A DELETE could change live data under a change-set permission. The record, relationship and attribute-value deletes took changeSetId from the body for the permission check but from the query for the write, so a Contributor could delete live. The check now reads it where the write does, and the write unit itself refuses a live write by a caller allowed only change-set work (and the reverse), 403 FORBIDDEN.
  • A record-type narrowing was skipped for attribute writes in a change set that already held the record.
  • A migration that publishes a change set now needs what publishing it directly needs: Publish in the workspace and the workspace's approval policy (409 APPROVAL_REQUIRED); an open request is closed as a direct publication closes it. Confirming such a migration is no longer allowed to someone with only Work in change sets.
  • A submitted change set can no longer change underneath its request. Moving it to another release freezes or withdraws the request (409 CHANGE_SET_SUBMITTED, or withdrawSubmission=true), and an approval is bound to the version that was submitted (409 VERSION_CONFLICT).
  • Withdrawing a request follows one rule: its submitter or an approver. DELETE …/submission is declared by its own action, change-sets.withdraw (Work in change sets or Approve publications).
  • Adding a passkey asks for your password or a code first, as removing one does (POST /oauth/passkeys/registration/options takes {"proof"}; 422 PROOF_REQUIRED, 422 CODE_INCORRECT, 429 SIGN_IN_SLOWED). A malformed passkey answer is refused instead of answering 500.
  • More guessing is slowed: a wrong current password on POST /me/password, wrong client secrets on /oauth/token (per client and address; an unknown client costs the same), and open provider sign-ins are capped at 2,000 per provider.
  • An engine that started with nothing to serve (every workspace on a schema outside the window) starts its background work as soon as a release is raised, without a restart.
  • A one-time link is never mailed twice when a mail server is slow; a provider sign-in with unchanged groups no longer empties every process's permission cache.
  • The application: actions stay hidden until your permissions are known; the draft's consumer API documents download with your sign-in; a server error while renewing the session no longer signs you out; live updates back off when the engine is unreachable; a change set's page follows its request after it is sent back; reload after a version conflict on the approval policy; no Error: prefix on the crash screen.
  • The development proxy answers 502 ENGINE_UNREACHABLE with the reason (nothing at the engine's address, or another program holds its port) instead of printing a TLS stack trace.

Changed (0.12 R, Settings)

  • Settings › Account › What you can do is a table of your workspaces: your roles, and what you may read, propose, change live, publish and approve. A limited permission is a count ("2 applications, 1 record type") that opens what it covers, application by application, with the role it comes from. Each role appears once per workspace; roles across the instance are listed under it. The Account and Notifications pages are wider.
  • People, groups and clients scroll page by page; the Groups page names each group's source on the group.

Added (0.12 UW1, a workspace that cannot be served)

  • A workspace says why it cannot be opened. GET /api/v1/workspaces and GET /api/v1/workspaces/{key} carry availability: available, or unavailable with the reason (the schema left the window, is a later engine's, or the release is invalid), the release, its schema, the schemas this version reads and whether it can be raised.
  • POST /api/v1/model-releases/{releaseId}/raising brings a release of a schema that left the window up to the schema this version reads, in place, for someone who may install models. {confirm: false} shows the schemas, the consumer API definitions rewritten and the workspaces that return, and changes nothing; {confirm: true, expectedModelHash} rewrites the release, records it in the audit trail, and every process serves the workspaces again from its next request. Refusals: RELEASE_NOT_RAISABLE, RELEASE_RAISE_REFUSED, MODEL_HASH_CHANGED. The code is shared with python -m app.cli.model_release_raise, which behaves as before. A maintenance step before 1.0, outside the compatibility commitment.
  • The interface shows it in three places with one component. A workspace that cannot be served opens a screen saying why in plain words, with "Bring it up to date" (for a schema that left the window: "Review the change…" for someone who holds Model release at the instance, one sentence for everyone else, nothing while the permissions load) beside "Or continue elsewhere" (the workspaces that open and All workspaces). The review dialog previews, then "Raise and reopen" confirms against the preview's hash (a changed release is read again); afterwards "{workspace} is back." offers "Choose another model…" and "Open {workspace}". Settings › Workspaces marks the workspace "Cannot be opened" in its list and shows the same notice at the top of its Overview.

Changed (0.12, the Workspaces list)

  • Settings › Workspaces lists each workspace's model and release in aligned columns, labelled once per group; the second line is the workspace's key. At a phone's width they stack under the name.
  • Settings › Model lists each model the same way: its key on the second line, then Releases, Latest, Draft (the model's newest open draft) and Schema (the newest release's schema language) as aligned columns; a draft not yet installed shows "—" for the release columns. Every group has the same columns and the status column fits its pill, so they line up from group to group. An opened draft or release no longer repeats the page's title as its heading.

Added (0.12 L2, the compatibility commitment)

  • docs/COMPATIBILITY.md (D436–D440): from 0.12.0 the management API /api/v1, each consumer API contract version, webhooks and events with the RDM-* headers, sign-in and tokens, the setting names and the documented commands change only by addition; a breaking change comes beside the old version and the old one is kept at least 12 months and two minor releases. Clients ignore unknown fields and values; four extensions are reserved (a retired record's survivor, a value's source, computed values, new value types). Before 1.0 the schema language is the exception: each release reads the version it writes.
  • Consumer API documents describe answers as open objects (no additionalProperties: false), so a client that validates against them accepts a field added later; requests stay closed.

Removed (0.12 L2a, the schema-language window cleaned)

  • The window is schema 0.10 only (D439, D441). Before 1.0 the window holds the one version a release writes; 0.5 to 0.9 left it. GET /system answers schemaLanguages: ["0.10"], every refusal names supportedSchemaVersions: ["0.10"], and setRelease accepts only 0.10. Gone with them: the per-version feature checks, the reader of 0.8-shaped consumer API definitions, and the offline schema 0.4 upgrade command (python -m app.cli.model_schema_upgrade).
  • The example model is released as 0.10.0 in schema 0.10 (model/rdm-starter.json); the 0.8 copy of the example model is removed. A stored release of an older schema is raised with python -m app.cli.model_release_raise or from the interface (UW1).

Changed (0.12 L2a follow-up, a bootstrap release conflict no longer stops the start)

  • Startup serves when the bootstrap model's release is stored with other content. It logs one error (no traceback) and continues: for a stored release of a schema outside the window the error gives the exact command, otherwise it asks to raise release.version in the model file. Workspaces on a release outside the window answer 503 MODEL_REGISTRY_UNAVAILABLE; the others are served.
  • python -m app.cli.model_release_raise [--dry-run] raises stored releases of a schema older than the window to 0.10 in place (schema 0.8 and earlier also get consumerApis in the present syntax), under the installation lock and in one transaction. A release whose raised model is invalid, or whose hash another release has, is skipped and reported (exit status 1). It replaces the "start a new database" advice for releases of a schema that left the window.

Added (0.12 L1, licensing and policies)

  • The licence and the source are offered by a running instance (D219, AGPL section 13). GET /system answers engine.license (AGPL-3.0-or-later) and engine.sourceCode; the OpenAPI document has info.license; Settings › About shows both and the account menu ends with "Source code · licence". The new setting SOURCE_CODE_URL defaults to the project's tag of the version (https://github.com/refiroo/refiroo/tree/v<version>); an operator running a modified version sets their own.
  • Policies: README licence section, CONTRIBUTING.md (no outside code until a contributor agreement exists), SECURITY.md, TRADEMARKS.md, COMMERCIAL.md, and THIRD_PARTY_NOTICES.md, generated by scripts/third_party_notices.py (it refuses a licence outside its allow-list). The project is named refiroo and its repository is refiroo/refiroo.

Added (0.12 M2 follow-up, notification types)

  • Choose which notices are mailed (J46.10, D434, D435). A new Settings › Notifications page holds the e-mail switch (moved from Account) and one setting for each kind of notice (a change set waiting for your approval, yours approved, yours sent back, a job of yours finished, failed or waiting for confirmation). Everything is on until switched off; a kind switched off stops the e-mail only, the inbox, notices and workspace events are unchanged. GET/PUT /me/notification-mail answer kinds: [{kind, label, enabled}] and PUT takes {enabled?, kinds?} (at least one; an unknown kind is 422 UNKNOWN_NOTICE_KIND). The mail's last line now points to Settings › Notifications. Migration 20261113_01 (users.notice_mail_kinds_off); the one list of kinds is engine/notifications/kinds.py.

Changed (0.12 A3a, the workspace page in tabs)

  • A workspace's page has tabs. Settings › Workspaces › a workspace: the actions sit under the header, then Overview (the facts and the workspace's applications as clickable cards), Access, Approval and Activity, each shown only to someone who may use it (Activity needs Browse; it lists the workspace's changes, newest first, by day, from GET /changes). The tab is in the address: /settings/workspaces/{key}/{tab}. Visited tabs stay loaded, so switching keeps the page where it is; a workspace opens at once on Back (its last known state, read again meanwhile); reading a workspace no longer counts its records to say whether it can be deleted.
  • Workspaces and Model open on a list. Settings › Workspaces lists the workspaces (active first, the default on top, then retired; search by label and key) and Settings › Model lists the models; opening one shows its page under a bar "← All workspaces" / "← All models" with a trail. The switcher boxes and their dialogs are gone.
  • The access list groups a principal's grants. GET /workspaces/{workspaceKey}/access is ordered by principal (people, groups, clients; then name and id), then by grant, so one principal's grants are adjacent.

Changed (0.12 A3c, Settings › Model in tabs)

  • The model page has tabs: Overview, Releases, Drafts and Structure; the "Deployment capabilities" panel is gone (the Deployment page has it). A release or draft opens as before, under a bar "← Back to Releases" and a trail that returns to the tab it was opened from. The new Structure tab is a read-only list of a release's applications, object types, attributes and relations, with a release picker.

Changed (0.12 A3b, Access and Activity of a workspace)

  • A consumer API is granted to a client only: a person or a group is refused 422 CONSUMER_API_CLIENTS_ONLY; people read through the management API. Migration 20261114_01 removes such grants made earlier.
  • Access is one line per person, group or client, with its roles as coloured chips, a trash on hover (asking first), a + to add a role, search and Role/Kind filters; at 390px each is a card. The people's and clients' own pages keep their tables.
  • Switching workspace is smoother: the previous workspace stays on screen, dimmed, until the next one is read (a row prefetches on hover); the dialog shows its last lists at once and refreshes them in place; a short tab no longer sends the page back to the top; dialogs fade in.
  • Activity has coloured marks: the record type's tile, a Created/Changed/Removed pill, and one expandable line for the records of one change set by one person on one day.

Added (0.12 A3, approvals in the interface)

  • Submit, decide and set the policy in the interface (J42). The change-set bar offers "Submit for publication" where direct publishing is not allowed, shows "Waiting for approval" with the request and "Withdraw request"; the review has a Decision card ("Approve and publish", "Send back" with a required comment, the decisions so far); Settings › Workspaces has an Approval section for the policy; Home lists "Waiting for your approval".
  • GET|PUT …/approval-policy also return approverCount, how many can approve in the workspace.

Added (0.12 M2, inbox notices by mail)

  • E-mail for inbox notices (J46.10, D432, D433). A person with an address switches on "Email me when something arrives in my inbox" on Settings › Account (GET/PUT /me/notification-mail; off by default; the switch is not shown where mail is not set up, the person has no address or the caller is a client). The mail sender then mails one plain-text message per notice that is unread, made after switching on and under 24 hours old. A message names the kind of event and links to the application; it holds no record, change-set, workspace or person content. Enabling is refused 409 MAIL_NOT_SET_UP, 409 NO_EMAIL and 403 NOT_A_PERSON; switching off always works. Migration 20261112_01 (users.notice_mail_since, notifications.mailed_at).

Added (0.12 A2, the inbox and workspace events)

  • An inbox for people and clients (J42.5). GET /me/notifications (unread, cursor-paged, with unreadCount), POST /me/notifications/read (ids or all) and a live stream, GET /me/notifications/events (unread and notification server-sent events, woken by PostgreSQL NOTIFY on the job stream's listener). A submitted request reaches every active person or client holding Approve publications in the workspace except the submitter; an approval or send-back reaches the submitter; a background batch, import, export, migration or move a person started reaches them when it completes, fails or waits for confirmation (a trigger on jobs, so every path is covered). Ending a request marks its "waiting" notices read. Notices older than 90 days are deleted. Migration 20261111_01.
  • Workspace events for webhooks (D428–D431). A subscription with source: "workspace" delivers change-set.submitted, change-set.approved and change-set.sent-back with the same signing, retry, log, resend, rotation and formats as record events (CloudEvents type rdm.change-set.approved). Events name the people (kind, id, name; no e-mail) and are numbered per workspace without gaps. Subscriptions read source; consumerApi is null for this source.
  • The interface: a bell with the unread count in the top bar opens the inbox panel; Webhooks lists a workspace-events subscription as "Workspace events".

Added (0.12 A1, a publication request and its approval)

  • Submit, approve or send back (J42). POST /change-sets/{id}/submission submits a validated change set for publication (DELETE withdraws it); POST /change-sets/{id}/decisions approves it, which publishes it in the same transaction bound to the reviewed version and the active publication, or sends it back with a required comment; GET /workspaces/{key}/publication-requests lists the requests with their decisions, cursor-paged. A change set's read gains submission. Each submission and each decision is a row of its own (change_set_submissions, change_set_decisions, migration 20261110_01); audit events change-set.submitted, .withdrawn, .approved and .sent-back name the actor.
  • Approve publications (D424). A new action group, held by the Data owner role, not narrowable.
  • A workspace's approval policy (D425). GET|PUT /workspaces/{key}/approval-policy: requireApproval makes a direct publication need an approval (a holder of Publish and Approve publishes in one step and the approval is recorded; anyone else gets 409 APPROVAL_REQUIRED), secondPerson keeps the submitter from deciding their own request (403 FORBIDDEN, details.reason: "ownApproval"). A change is a security event (approval-policy).
  • A submitted change set is frozen (D426). Any write to it is refused 409 CHANGE_SET_SUBMITTED unless the request carries withdrawSubmission=true, which withdraws the submission in the same transaction. The inbox and webhook events are A2, the interface A3.

Fixed (0.12 E1, security review)

  • A profile's check is made in a workspace (F5, low). GET and POST /import-profiles/{key}/check, and the check's new-values, drift, missing-records and rows reads, took no workspace: they picked the profile's newest file or check across workspaces, so a caller holding Bulk in one workspace could check, or read the check of, another workspace's file. They now take a required workspaceKey and need Bulk there (declared as "the query's workspace"; Bulk at the instance serves any); they read only the profile's current file and latest check in that workspace, "no file" or "no check" when it has none, and 422 without the parameter. The action's groups are unchanged; the OpenAPI lock is re-baselined for these six routes only. Settings › Import profiles reads the check in the workspace of the profile's latest file.

  • A profile's file is uploaded and current per workspace (C2, medium). A Data steward holding Bulk in one workspace could upload a file for an import profile naming another workspace, and the upload superseded (and the retention sweep aged) the file that workspace's owner had made. POST /source-files for a profile now needs Bulk and import in the request's workspaceKey before anything is stored (403 FORBIDDEN naming the action, bulk and the workspace), and a profile's files are superseded and expired per profile and workspace. A model draft's files keep the documented rule (Bulk at any scope). The action's groups and the OpenAPI document are unchanged.

  • A deletion honours a grant narrowed to record types (C1, high). DELETE /objects/{type}/{id} (with a cascade too), a delete item in a batch or import, the rows an import deletes for being missing from a file, and a delete in a change set skipped the narrowing check: a Data steward narrowed to one type could delete records of another. The one deletion path (records/deletion.py) now checks the distinct record types of everything it removes, cascade victims included, and the ends of the relationships it removes, before anything is written; a refusal is 403 FORBIDDEN with details.recordTypes as for every narrowed write, ahead of the impact or the preview, and a batch item is refused on its own (an atomic batch applies nothing). Journey cases in J40 for a live, cascading, batch and import, and change set delete; the narrowing table of J47 maps the delete route to them.

  • Unauthenticated and unauthorized calls are refused before an unknown parameter (F1, low). On a route that needs a token the order is now 401, 403, then 422 UNKNOWN_PARAMETER; the open routes still refuse it first.

  • A request body that is not UTF-8 is 422, not 500 (F3). The rejected bytes are left out of the error's details.

  • A NUL character in a name, a key or an address is 422 REQUEST_VALIDATION_ERROR, not 503 DATABASE_UNAVAILABLE (F4). The driver refuses such a text before the database sees it; that is no database condition and no longer counts as an outage in the process report. A database that cannot be reached still answers 503.

  • Behind a load balancer (WEB_TLS=proxy) a caller cannot choose the address the engine sees (C3, medium). web believes X-Forwarded-For only from the addresses in the new WEB_TRUSTED_PROXIES (default empty: nobody), and sends the engine the one address it settled on. List the balancer's range, or every caller counts as the balancer. compose.acme.yml lists the private ranges for Caddy. A malformed entry stops web with a message. The deployment runner proves a forged address is ignored, and honoured from a listed balancer.

  • The application page cannot be framed (C5). Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY on every response of web.

  • A secret is not carried to another server or provider (C6). Saving mail with a new host or port, or a sign-in provider with a new issuer, needs the password or client secret in the same request (422 MAIL_PASSWORD_REQUIRED; new 422 SIGN_IN_PROVIDER_SECRET_REQUIRED). The issuer of a provider anyone has signed in with cannot change (409 SIGN_IN_PROVIDER_IN_USE: add a new provider).

  • Changing SECRETS_ENCRYPTION_KEY says what it voids (C7). Recovery codes are keyed with the key itself; reseal_secrets (and --check) now print how many people hold unused ones, who must make new ones.

  • The API reference page has a content security policy (C10). Only the engine's scripts and the page's own two run, nothing is evaluated, requests go only to the engine, the page cannot be framed.

  • Documentation corrected (C4, C9). The refresh cookie's path is /oauth; each consumer API's OpenAPI document needs no token; the sign-in slowing is stated as the code does it (5 free failures per username, 20 per address, 3 and 20 for reset asks), and that anyone knowing a username can keep it slowed.

Added (0.12 E1, the security sweep)

  • The security model is checked against its catalogue. Journey J47: every operation of the OpenAPI document that declares an action is called as a caller holding each of the nine predefined roles, as one holding nothing and with no token, and must be allowed or refused as catalogue.py says (403 FORBIDDEN naming the action, 401 without a token); the probes cannot change data. Composed cases: a Reader on every write path with the state read before and after, a table naming the narrowing test of every route a grant can narrow, expired, altered, alg: none and HS256 tokens that run nothing, a rotated client secret, and the reset-by-mail ask (a provider-only person, slowing per address, no password, code or record in any message). A unit probe shows authorization runs before body validation for one operation of each scope kind. No feature changed.
  • The sweep reaches what the first pass left out. J47.6 calls every consumer route each API's own OpenAPI document declares, and the index, as a client granted that API, one granted another, one granted it in another workspace, one holding only Reader (with a consumer and a management token), one holding nothing and with no token, and reads the answers against API.md and J41 (403 naming the API and workspace, 401 audience, the index filtered, the documents open). J47.7 classifies every route outside the OpenAPI document (open by design, needs a session, needs the operations role) and sends each nothing, a well-formed body with no credentials, a foreign origin and junk (not JSON, wrong types, 70,000-character values, a cut-off deep document, forms, meaningless cookies, redirect and traversal tries): never 5xx, never a traceback, no person, session, grant, link, code or message changed. J47.8 makes a person (a real sign-in) holding each of five roles and requires every answer to equal a client's with the same role. J47.9: a reset by mail keeps the second factor; the new password alone gets the code step and no session. Two defects found are recorded as strict expected failures (F3: a body that is not UTF-8 is 500; F4: a NUL byte in an address is 503 DATABASE_UNAVAILABLE), fixed below; the sweep itself changed no engine code.

Added (0.12 E1 prep, a real sign-in provider try)

  • A runner that signs in against a real provider. npm run test:browser:real-provider (heavy, Docker, run before E1 and at R) starts a pinned Keycloak with the realm in backend/tests/keycloak/refiroo-realm.json and walks ten sign-in cases on two models: register, first sign-in, groups, a wrong password, groups changed at the provider, signing out of the provider, local sign-in off and a rotated key.

Fixed (0.12 E1 prep, provider sign-in callback)

  • Signing in through a real provider works. Providers such as Keycloak return the browser with parameters of their own (session_state, iss); the callback refused them as unknown (422 UNKNOWN_PARAMETER) and no session started. GET /oauth/providers/{key}/callback now ignores extra parameters, and an iss it is given (RFC 9207) must equal the provider's configured issuer, else the sign-in is refused like any other unconfirmed answer (a sign-in failed event with reason issuer). Other routes still refuse unknown parameters.

Added (0.12 M1)

  • A waiting message can be stopped. Settings › Mail › Messages shows Stop sending on a message still being retried; after a confirmation it fails at once ("Stopped by an administrator") and is not tried again. POST /mail-outbox/{id}/stop; 409 MESSAGE_NOT_WAITING, 409 MESSAGE_BEING_SENT. The list also loads and empties as the Jobs and deliveries lists do.

Fixed (0.12 M1, I6)

  • Every message names the product the same way. A reset asked for on the sign-in screen said "RDM Engine" (the model's product name) while one mailed from People said "refiroo" (a constant), and the test message used the constant. templates.product is now the one place: the model's own name, or refiroo where the model keeps a default name; callers no longer pass a name. The list of default names moved to common/brand.py, used by the sign-in pages too.

Added (0.12 I6)

  • A forgotten password is asked for on the sign-in screen. Where mail is set up and local sign-in is on, the screen links to /reset-password: the person types a username or e-mail address and is told the same whoever they name. A link to the application's reset page is mailed only to an active person with a local password and an address; asking is slowed per name and per address (3 free asks, then doubling to 15 minutes) whatever the name matches. The link names the person, works once, is replaced by a newer one, and sets a password under the engine's rules; the person's sessions end. New JSON routes beside I5's (POST /oauth/sign-in/reset-request, GET/POST /oauth/sign-in/reset), not in the OpenAPI document; options gains reset.
  • Links an administrator makes and links in mail now open the application's reset page (the Mail page's application address, else the first CORS_ORIGINS entry); with neither known they open the engine's /oauth/password, which still works for links made before.

Added (0.12 SP3)

  • A new person's link is mailed. POST /users/{id}/password-reset takes send: true: with mail set up and an address on the person, the one-time link is queued as a message in the transaction that makes it (a welcome for a person with no password, a new-password message for one with a password) and the answer names sentTo, still holding the link once. 409 MAIL_NOT_SET_UP and 409 NO_EMAIL are answered before the earlier link is replaced. The password-link dialog on People (one button, as before) offers "Mail it to
    " beside "I have copied it" when mail is set up and the person has an address (a newer link is made and mailed, and shown in place of the first); otherwise it shows the link to pass on as before.

Added (0.12 M1)

  • The engine can send mail. Settings › Integration › Mail (GET/PUT/DELETE /mail-settings) holds the organisation's SMTP server: host, port, TLS, STARTTLS or none, an optional login whose password is sealed with SECRETS_ENCRYPTION_KEY and never answered, the from address and the address links point to. A login is refused over an unencrypted connection, and the server's certificate is verified. POST /mail-settings/test sends a test message at once and, when it cannot, answers 422 MAIL_SEND_FAILED naming what failed (connection, tls, login, recipient, sender, rejected). Every change to the settings is a security event (mail-settings).
  • Mail is sent in the background. Messages wait in an outbox (mail_outbox, migration 20261109_01) and a thread in each process sends them, trying again after 30 seconds and then twice as long up to an hour, failing an address the server refuses for good at once and anything else after 24 hours. GET /mail-outbox lists them with the reason of a failure; a message's text, which holds a one-time link, is sealed while it waits and erased when it is finished. mailSender is among a process's threads. Plain-text templates exist for the activation link, the reset link and the test. Nothing queues a link yet: SP3, I6 and M2 do. reseal_secrets covers the password and queued text.

Added (0.12 I5)

  • The application signs people in on a screen of its own. The sign-in screen is part of the application, so it follows the saved light or dark theme, carries the refiroo mark and a line about what refiroo is for, and covers every way in: a passkey, a password, a sign-in provider's button, the authenticator app's code or a recovery code, the set-up when a second factor is required (with the recovery codes shown once), and the administrators' link when local sign-in is off. It asks the engine through new JSON routes, GET /oauth/sign-in/options, POST /oauth/sign-in/password and POST /oauth/sign-in/second-factor; the passkey ceremonies are the existing ones. The token flow is unchanged (code flow with PKCE, /oauth/token, the refresh cookie).
  • Every sign-in rule is in one place. The checks of an authorization request, of a password and of a second step moved out of the routes into engine/identity/browser_sign_in.py; the engine's own HTML pages (/oauth/authorize, kept for outside web clients, now with the welcome words too) and the new JSON routes only read their input and present the outcome. A post from one of the application's own origins (CORS_ORIGINS) is accepted by both kinds of route and the passkey ceremonies; one from another site is refused. A failed provider sign-in's Try again returns to the application.

Added (0.12 C1)

  • The API reference's "try it" console is signed in. /docs asks the engine, with the session's cookie, for the token of the person signed in at the browser (POST /oauth/console-token, answered {access_token, expires_in}, 401 not_signed_in without a session, 403 from another site) and gives it to the console for the management API and the consumer APIs, so a request is answered or refused as the application would answer it. The cookie is read without renewing it, so the application's own tabs keep their session; the console renews its token in the token's last minute. A client's token pasted over it is sent as it is. The page itself stays open and holds no token.

Added (0.12 K1)

  • A webhook's signing key can be rotated. POST /api/v1/consumer-subscriptions/{id}/rotate-secret {expectedVersion, overlapHours} (24 by default, 0 to 168) answers a new key once, as the create does. For the overlap every delivery is signed with both keys, RDM-Signature: sha256=<new>,sha256=<previous>, and a receiver accepts it when either verifies; afterwards only the new key signs, and with 0 the old key stops with the next delivery, a retry of a waiting batch included. A subscription names previousSigningKeyExpiresAt while an overlap runs. Receivers must accept a comma-separated list. Settings › Integration › Webhooks has Rotate key…, with the overlap choices of a client's secret (one shared dialog now), and shows the new key once.
  • python -m app.cli.reseal_secrets re-seals every sealed secret (token signing keys, webhook keys, a provider's client secret, authenticator secrets and the provider ID tokens kept with a session) under a new SECRETS_ENCRYPTION_KEY, all or none; --check proves every one opens. Migration 20261108_01.

Changed (grant dialog)

  • Grants are made in bulk, from a grid. The grant dialog (a client's, person's or group's page, and a workspace's Access) now takes several things at once. Consumer APIs are ticked in a grid of APIs by workspaces (a row or column heading ticks the line, Shift extends a range; a workspace whose model does not declare an API has no tick for it); roles are ticked as cards, in several workspaces or the whole instance. What the holder already has is shown ticked and locked and is not sent again; a sentence says how many grants will be made. Each tick is one POST /api/v1/grants, so a refusal leaves the others made and its tick in place with the reason. Narrowing is offered when one role is granted in one workspace. A client's page opens on Consumer APIs.

Fixed (development)

  • The API reference can call the engine in development. scripts/dev.sh engine allows the engine's own addresses (localhost:8000, 127.0.0.1:8000; with --network also its https address) in CORS_ORIGINS, so Scalar's Try it and token request are not refused as 400 Disallowed CORS origin. A CORS_ORIGINS already in the environment is kept.

Fixed (0.12 D412)

  • The API reference shows the consumer APIs. A consumer API's openapi.json needs no token, like the management API's, so /docs and the link on Settings › API reference load it instead of answering 401. The consumer index and the data routes still need a token.

Changed (branding)

  • The application is named refiroo. The interface shows the refiroo mark in the top bar, the loading and empty screens, Home and Settings › About, with the title and favicon in the browser; the sign-in pages and the API reference at /docs (and the OpenAPI title) carry the same name and mark, served from /brand/<file>. The logos are in logos/, renamed by what they are; the interface and the engine keep resized copies. Naming rules and files: docs/BRANDING.md.

Fixed (0.12 I4 review)

  • Removing a passkey asks for the password again (or a code from the app or a recovery code): DELETE /me/passkeys/{id} takes proof, so a stolen session cannot strip a person's passkeys.
  • Recovery codes are stored keyed (HMAC-SHA-256 under SECRETS_ENCRYPTION_KEY), not as plain SHA-256 of 60-bit codes. Recovery codes made before this stop working: make new ones.
  • A second factor and passkeys need a password: 409 PASSWORD_REQUIRED for a person who signs in with a provider (a passkey would have been a way in past the provider).
  • Linking locks both people in one order (no deadlock between opposite links) and refuses a deactivated person as the target (409 LINK_TARGET_DEACTIVATED).
  • Open passkey sign-ins are capped (2000 waiting at once, then 429 with Retry-After), so the open options endpoint cannot fill the database.

Added (0.12 DP3, the deployment guide and its check)

  • OPERATIONS § Deploying: the topology, the steps in the order an operator needs them (address, .env, HTTPS in three ways, start, first administrator, provider, mail), what is public and what is kept inside, upgrading and backups; "From a phone on the same network" for development.
  • WEB_CERTS_DIR names the folder of the certificate and key (default ./certs).
  • npm run test:deployment builds the images and starts the installation from empty at the computer's network address, signs in from a desktop and a phone-sized browser, restarts, checks that web refuses to start without a certificate and that WEB_TLS=proxy serves plain HTTP, then removes everything it made.

Added (0.12 DP4, automatic HTTPS for a public installation)

  • compose.acme.yml and caddy/Caddyfile: Caddy in front of web obtains and renews a Let's Encrypt or ZeroSSL certificate for PUBLIC_URL with no certificate files; web runs as WEB_TLS=proxy and is no longer published. Started with docker compose -f compose.yml -f compose.acme.yml up -d.

Added (0.12 DP2, images and a reference installation)

  • compose.yml is the reference installation (database, engine, web), read from an .env filled from .env.example: PUBLIC_URL, the database password, SECRETS_ENCRYPTION_KEY and RDM_CURSOR_SIGNING_KEY are required and never in an image. It replaces docker-compose.yml, which no longer started the engine.
  • The web image (frontend/Dockerfile): the application built once, served by unprivileged nginx with the engine's paths forwarded, HTTPS from the organisation's certificate files and plain HTTP redirected to PUBLIC_URL (or WEB_TLS=proxy behind a load balancer), HSTS, /metrics kept inside.
  • The engine image runs as an unprivileged user behind the proxy (--proxy-headers), with a health check and export files in a volume. A .dockerignore sends only what the images copy.

Changed (0.12 DP1, one address for the application and the engine)

  • The application calls the engine on the address the page was opened at (D404); VITE_API_URL still names an engine on another address. The Vite dev server forwards /api, /oauth, /.well-known, /docs, /openapi.json and /health to the engine (ENGINE_URL), so a phone on the network needs only the frontend's address. PUBLIC_URL is that address: scripts/dev.sh engine sets it, and trusts the forwarded caller address from this computer.
  • Browser runner test:browser:one-address: signing in, a reload and signing out through the page's address with no request to the engine's own port, on two models and at 390px.

Added (0.12 DP1a, a developer instance on the network)

  • scripts/dev.sh runs a developer instance in two terminals: settings (the database and a secrets key in a git-ignored .dev/settings.env), admin (the first administrator), engine, frontend; with --network both over https on this computer's network address, after cert finds the address and makes an mkcert certificate, so a phone or another computer on the network signs in.
  • README's setup uses it, and OPERATIONS § A developer instance explains each step and the settings.

Changed (0.12 SP1b, a group's members)

  • Add members on a group's page adds several people at once: searched and ticked (people already in the group or deactivated shown but not tickable), or pasted as a list of usernames and e-mail addresses that is checked first, each line saying whom it names or that nobody has it; the people to add gather as chips and are added together (D401).
  • Members are ticked and removed together; the last Security administrator is still kept (D402).
  • POST /groups/{id}/members/batch (with dryRun) and POST /groups/{id}/members/removal.

Changed (0.12 RA1, a person's recent activity)

  • Recent activity on a person's or client's page reads like a record's history: a heading for each day, one line each, filtered by kind (Signing in, Roles and groups, Account), the 10 newest first, Load more adds 20, Show fewer returns to 10. The kinds use the API's existing aggregateType filter.

Changed (0.12 RH1, a record's history)

  • A record's History lists its changes under a heading for each day, one line each (what, who, when); a line opens to show each value with the one before (D389, D16 revised for history only), or says the one before was not recorded after a publish or a type change (D390).
  • Filters by kind (values, relationships, moves) and by who made the changes; the 10 newest first, Load older history adds 20, Show fewer returns to 10 (D391, D393). A relationship's line names the record at its other end (D392).
  • GET /objects/{type}/{id}/history takes kind and actorId and answers kind, values and related on each entry. The change feed and consumer events still name changes only.

Added (0.12 I4c, passkeys)

  • Passkeys (D389–D394): Settings › Account › Passkeys adds one on the engine's own page, where the browser asks for a fingerprint, face, device PIN or security key; it signs the person in alone from the sign-in page's Sign in with a passkey, or after a password as the second step, and meets the requirement for a second factor. Rename and remove them; an administrator removes one from a person's page. A passkey belongs to the host of PUBLIC_URL: changing it invalidates them.
  • The sign-in page runs one script of the engine's own, /oauth/passkey.js, only where a passkey is offered, and none elsewhere (script-src 'self'; D275 revised, D390).
  • GET|PATCH|DELETE /me/passkeys, GET|DELETE /users/{id}/passkeys, passkeys on a person and on /me/second-factor. Migration 20261107_01; one new dependency, webauthn.

Fixed (0.12 I4c)

  • A sign-in provider's key cannot be password or passkey, nor local: a session's signedInWith is one of those or a provider's key.

Added (0.12 I4b, a second factor for local accounts)

  • An authenticator app (D383–D388, D225 revised): Settings › Account › Second factor sets one up with a QR code the engine draws, the key and a code, and shows ten recovery codes, once. After the password the sign-in page asks for the app's code, or a recovery code; each works once, and a wrong one is slowed like a wrong password. New recovery codes… and Remove… ask for a code.
  • Require a second factor with a password (Settings › Security › Sign-in › Local accounts): a person without an app sets one up on the sign-in page, and sees the recovery codes, before their session starts; nobody removes their own factor meanwhile. Provider sign-ins are never asked.
  • A Security administrator removes a person's second factor from their page (a security event).
  • GET|POST /me/second-factor, POST /me/second-factor/confirmation, POST /me/second-factor/recovery-codes, DELETE /me/second-factor, DELETE /users/{id}/second-factor, secondFactor on a person, secondFactorRequired on local sign-in. Migration 20261106_01; one new dependency, segno (the QR code).

Fixed (0.12 I4b)

  • The browser runner's provider helper passed its secret as a separate argument, which failed when the random secret began with -.

Added (0.12 I4a, a provider identity linked, signing out of the provider)

  • Link to an existing person… on the person a provider sign-in made (D377, D378): their identity and provider groups move onto the person who already had the account, with their roles and history; the new person is deactivated. Refused for a person with a password, roles of their own or local groups. Unlink… undoes it. POST /users/{id}/identities, DELETE /users/{id}/identities/{provider}.
  • Sign people out of name too (D379, D380): a provider's setting, off by default, with the second address to register (signedOutUri). The app signs out by opening GET /oauth/sign-out; the browser goes through the provider's sign-out with the ID token the session kept, sealed, and back to the sign-in page. Migration 20261105_01.
  • Keeps local sign-in is a switch on a person's page (D381); while local sign-in is off, the last person who keeps it can neither lose it nor be deactivated (409 LAST_LOCAL_ADMINISTRATOR).

Added (0.12 I3, a sign-in provider)

  • Sign in with the organisation's provider (D361–D370): an OpenID Connect provider (Entra ID, Google, Keycloak and others) added on Settings › Security › Sign-in with its issuer, client ID, secret and groups claim, checked when saved, with the address to register shown to copy; the sign-in page offers Continue with name. A person signing in through it the first time is created from the provider's name and e-mail and found by the provider's subject afterwards, never by e-mail; sessions record the provider.
  • Provider groups: the provider's groups become groups listed under it, their members set at each sign-in ("Members seen"), renamed and granted like any group, and made ahead of anyone's sign-in with New group › From a sign-in provider.
  • Local sign-in can be turned off once a provider has signed someone in; the administrators made on the server with create_admin keep a password as the way back, through a link on the sign-in page. A person's page says how they sign in; Account names the provider.
  • API: GET|POST /sign-in-providers, GET|PATCH|DELETE /sign-in-providers/{key}, GET|PATCH /sign-in-providers/local; a group's source and POST /groups with one; a member's seenAt; a person's identities and localKept; signInProvider on /me; the security events' sign-in-provider aggregate. Migration 20261104_01_sign_in_providers; people made before 0.12 Z4 keep local sign-in.

Fixed (0.12 I3)

  • A group's page kept its old name after Rename until it was opened again.
  • Two fields side by side in a dialog no longer push one input down when only the other has help text.

Changed (0.12 HM1a, Home)

  • A workspace card's tile (its initials) opens the workspace, as its name does.
  • Continue where you left off stands on a shade of its own, in light and dark.

Changed (0.12 LS1, a collection's rows scroll on their own)

  • A collection's table page fits the window (D371): only its rows scroll; the top bar, the band, the header and the toolbar stay, and the table's footer and the rail end on the window's bottom line, also with few records and on a phone.
  • The wheel over a list that fills its page is the browser's alone (D372); a list nested in a page that scrolls (a record's panels, dialogs) keeps carrying the wheel on at its ends, finding the scroller around it once per gesture.
  • A record list's rows keep to one line once its columns are laid out (D375); a cut value is shown whole on hover, focus or a long press.

Fixed (0.12 LS1)

  • Scrolling a collection lagged even with a few hundred records. Rows passing under the pointer measured their cells and drew the whole table again, twice, for every cut-off value; every page arriving in the background and every draw of the page drew every row again; the wheel waited for a script that read the layout at each step; and every scroll frame laid the table out again. Now a row on the page is drawn once (D373), and the layout is read only when the list or its size changes (D374).

Added (0.12 SP2, clients and their access)

  • Settings › Security › Clients (D348–D357): the systems that call the API, each with its consumer APIs and roles and when it last obtained a token; a new client's ID and secret shown once; a client's page with who created it, Rename, Deactivate and Restore, Rotate secret keeping the current secret working for 1 hour, 24 hours or 7 days, or stopping it at once, secrets told apart by their last four characters and the previous one ended early, its access, the webhooks it created, its recent activity and its changes.
  • The grant dialog grants a consumer API as well as a role, from a client, a person, a group or a workspace's Access; its button reads Grant.
  • Settings › Security › Signing keys: the token signing keys and their state; start a new key, or retire the current one at once after a leak.
  • A webhook's page names who created it.
  • roles, consumerApis and createdBy on every read of a client, hint (the last four characters) on each of its secrets (migration 20261103_01), and GET /api/v1/workspaces/{workspaceKey}/grant-options.

Fixed (0.12 SP2)

  • A person holding only Security administrator could not narrow a grant: the grant dialog read the workspace's model, which needs Browse; it now reads grant-options.
  • A segmented choice in the grant dialog was drawn taller than its options (the dialog's label rule reached its labels).

Added (0.12 TV1, a record's parent in its list)

  • A table view's Columns… and Filters… in the model editor's layout editor offer the record's relationships, the ones to its parent first (D358–D360): a list of records under a parent can show which parent each belongs to and be filtered by it. The engine has listed and filtered by relationships since 0.9; only the model file could choose them until now.

Added (0.12 Z6, one person's changes across workspaces)

  • Changes (D340–D347): everything a person or client changed, across the workspaces the reader may browse, newest first and grouped by day: records and relationships, live or in a change set, change sets, publications and model drafts and releases, each naming its workspace, change set and what it changed with its type from that workspace's own model, narrowed by workspace, kind and period; each title opens what it changed while it exists. A name in a record's history opens it, as does "See changes" on a person's page; Home shows Your recent changes, the five latest.
  • GET /api/v1/changes (actorId, workspaceKey, kind, since, until, paged), filtered to what the caller may see; a Security administrator without data roles reads an empty list.
  • Every audit entry that belongs to a workspace now names it, also a change set's work, and a change set's records and relationships name what they changed; the upgrade places existing entries from their change sets (migration 20261102_01).

Added (0.12 HM1, a home page after sign-in)

  • Home (D331–D339): signing in, the bare address and the product name open a home page: a greeting with the person's roles and what is open; Continue where you left off, back to the last list, record or review opened in this browser; the workspaces they may browse as cards, the default first, each with its model, their role, its applications and buttons counting their open change sets and, for a publisher, every open change set; their open change sets across workspaces, each opening its review. A workspace's name opens the workspace. An address naming a workspace, application or record opens it as before.
  • GET /api/v1/me/workspaces and GET /api/v1/me/change-sets (D332, D333); product on GET /api/v1/system (D338).

Fixed (0.12 HM1)

  • A person with no workspace saw the model release's name in the top bar; it is the instance's product name, as everyone else sees.
  • A browser runner's seed built on an older schema seed was reused after the schema seed was rebuilt, so its backend refused the harness client (401 invalid_client); such a seed is rebuilt now.

Added (0.12 Z5, the interface follows permissions)

  • The interface offers only what a person may do (D321–D330): a Reader sees no way to change anything and the data-context band says they can read; a Contributor proposes a change from a record into a change set of their own (named the first time, continued afterwards, counted in the band) and is not offered Publish; a narrowed writer edits only the permitted record types, named in the band; imports, bulk changes, moves, publishing, withdrawals, migrations and Settings' pages appear only to whoever holds their groups, and a flow appears only when every step can finish.
  • The first workspace is the default one the person may read, otherwise the first they may read; with none, a page says so with their roles and leads to Settings (People for a Security administrator). The workspace selector lists only workspaces the person may read.
  • GET /api/v1/me/action-groups, holding= on GET /api/v1/workspaces and createdBy= on a workspace's change sets (D325, D327, D328).

Fixed (0.12 Z5)

  • The change-sets and test:browser (navigation) browser runners start again: they ran their own servers without the secrets key and token every engine needs since I1, and now use the shared harness. test:browser:wp12 accepts the activation's sentence naming who made it (0.12 Z4).

Added (0.12 Z4, audit identity)

  • Every change names who made it (D313): actor beside cause on a record's history, a feed entry and a release's activation: the person or client by their current name, {"kind": "engine"} for the engine's own changes, null for changes from before 0.12 ("Before sign-in"). A job's changes name its requester, a publish's the publisher. Consumer events name no one (D314).
  • createdBy on a change set, publishedBy and withdrawnBy on a publication, requestedBy on a job (D315).
  • Security events (D317, D318): GET /api/v1/security-events (principalId=, aggregateType=) lists changes to people, groups, roles, grants, clients and signing keys, and sign-ins, succeeded or failed, with who made them and never a secret.
  • In the interface: "by name" in a record's history and the model editor's history, "Created by" on a change set, "Published by" and "Withdrawn by" on a publication, "Requested by" on a job and in the Jobs list, and a person's Recent activity in Settings › Security › People (D319, D320).

Changed (0.12 Z4)

  • author on a model draft's and a model mapping's operations is now {kind, id, name} (or null) instead of a string, which was never filled.
  • A subscription's owner uses the shared PrincipalResponse schema (same fields).

Added (0.12 Z3, consumer APIs per client)

  • Consumer API grants (D306): POST /api/v1/grants with consumerApi instead of role lets a client, person or group read that consumer API in one workspace; a grant reads role: null and consumerApi (stale: true once the model drops the API); GET /api/v1/grants takes consumerApi=. A workspace's Access and the Account page show it as "Consumer API: name".
  • Each consumer API's OpenAPI document declares the OAuth 2.0 client credentials scheme with the token URL and scope consumer, and 401 and 403 on every operation (D311).
  • A subscription reads owner, the person or client that created it, and the list takes ownerId= (D310).

Changed (0.12 Z3)

  • The consumer data routes need a grant of the API in the workspace read (D307): 403 FORBIDDEN with details: {consumerApi, workspaceKey}, checked after the workspace and before the definition, so an unknown API key now answers 403 to a caller without a grant (D309). No role opens them. After upgrading, consumer clients read nothing until granted.
  • The consumer index lists only the APIs granted to a consumer token; with a management token the index and the OpenAPI documents need Browse in the workspace (D308).

Added (0.12 SP1, the Security pages)

  • Settings › Security › People, Groups and Roles, shown to people holding Security administration (D303): people listed with the roles they hold, searched, active or deactivated; a person's page to edit them, make a one-time password link shown once (D304), sign them out everywhere, deactivate or restore them, and see their roles, directly or through a group, and their groups; groups with their roles and members; the matrix of roles by action group, a custom role made empty or copied and edited in place (D305 at phone width).
  • A workspace's Access on its page in Settings › Workspaces: every grant that applies there, made in it or for the whole instance (D301). One grant dialog for Access, a person and a group, with the narrowing to applications and record types and a sentence of what the grant allows.
  • GET /api/v1/users and GET /api/v1/users/{id} answer the person's roles and where (D298); GET /api/v1/users/{id}/grants lists a person's grants including their groups' (D299); q and memberId on GET /api/v1/groups, q on GET /api/v1/clients (D300).

Changed (0.12 SP1)

  • POST /api/v1/grants refuses a workspace grant of a role whose action groups are all held only at the instance, such as Security administrator, with 422 GRANT_SCOPE_INVALID (D302): such a grant was stored and allowed nothing.
  • Webhooks' "keep the signing key" dialog is the shared "shown once" dialog the password link uses.

Fixed (0.12 SP1, after delivery)

  • New person said only "Request validation failed" for a username with a space: it now says a username has no spaces as it is typed, and the engine's refusal of a field is shown under that field (New person, Edit) or by the field's name (groups, roles).

Added (0.12 Z2, record types at the write unit, jobs as their requester)

  • A grant narrowed to applications and record types now limits every write its holder makes in the workspace (D289–D291): a record may be written when its application or its type is named, grants holding the group there add up, and a relationship may be written when either end may. Live writes, change sets, restructurings (both the old and the new type), batches, imports, publishing a change set and moves into a workspace are checked at the write unit; a refusal is 403 FORBIDDEN with details.recordTypes. A batch, an import or a move refuses only the items of other types; a single write and a publication are refused whole (D293).
  • Jobs run with their requester's permissions (D236, D294): checked at confirmation and before every worker step; a requester deactivated or without the job's group fails it with 403 FORBIDDEN and details.requestedBy. The requester reads, follows, cancels and confirms their own job and sees it in GET /api/v1/jobs.
  • An import profile's source files are read, changed and listed for their uploader and for holders of Bulk and import in their workspace, 403 for anyone else (D295).
  • A grant naming what the workspace's model no longer has reads stale: true with scope.missingApplications and scope.missingRecordTypes, and /me/permissions marks the group stale; such names allow nothing (D296). Migration 20261030_01 (jobs.requested_by_*, source_files.uploaded_by_*).

Changed (0.12 Z2)

  • Publish and Copy and move are narrowable action groups (D290).
  • POST /api/v1/moves with mode: move also needs Work in change sets in the source, which it writes a change set of; mode: copy needs Browse there as before (D297).
  • A write the engine cannot attribute to a request or a job is refused with 500 WRITER_UNKNOWN (D292).

Fixed (0.12 Z2, found in Z1)

  • An import naming a stored source file, sent as a URL-encoded form without a file part, failed with an internal error: the route check read its body as JSON after the form was read.

Added (0.12 Z1, roles, groups and grants at the route)

  • Authorization (ADR-044): every management operation declares its action, published in the OpenAPI document (x-action, x-action-groups, x-action-scope), and a request its caller's roles do not allow answers 403 FORBIDDEN naming the action, the action groups that would allow it and the workspace. Record writes check Edit live, or Work in change sets with changeSetId.
  • Roles made of 13 action groups: nine predefined, read-only roles (Reader, Contributor, Data steward, Data owner, Model, Workspace and Integration administrator, Operator, Security administrator) and custom roles (/api/v1/roles, GET /api/v1/action-groups); local groups of people (/api/v1/groups); grants of a role to a person, client or group at the instance or in a workspace, optionally narrowing writes to applications and record types of its model (/api/v1/grants); who can do what in a workspace (GET /api/v1/workspaces/{key}/access); the caller's own (GET /api/v1/me/permissions, GET /api/v1/me/grants). Removing the last active Security administrator is refused. Migration 20261029_01.
  • Settings › Account lists the person's roles, where each applies, what it narrows and the group it comes through; the account menu names the roles held in the workspace open, and its Account and All settings are buttons like Sign out.
  • create_client --role <key> grants roles to a new client at the instance.

Changed (0.12 Z1)

  • A principal may do only what its roles allow. create_admin gives the first administrator Security administrator, Data owner, Model, Workspace and Integration administrator and Operator at the instance; the upgrade gives the same to every person and client that existed before it; new people and clients hold nothing until granted. /metrics and /api/v1/system/* but GET /system need the Operator role.
  • The workspace list, the workspace models, the jobs and the consumer subscriptions list only what the caller holds a role for; a named workspace the caller holds nothing in answers 403.
  • Permissions follow a generation number PostgreSQL raises on every change to roles, groups, memberships, grants or a principal's active, so a change applies on the next request in every process.

Added (0.12 I2, signing in from the browser)

  • The engine's sign-in page (/oauth/authorize): the browser signs a person in with the authorization code flow and PKCE and returns to the page asked for; the access token stays in memory and the session in an HttpOnly, SameSite=Strict cookie that renews it unseen, each refresh token used once (a reused one ends the session). Sessions end after SESSION_IDLE_HOURS (12) idle or SESSION_DAYS (14). POST /oauth/revoke signs out. Failed sign-ins are slowed per account and address, never locked. Migration 20261028_01.
  • The account menu names the person and offers Account, All settings, the theme and Sign out; Settings › Account shows the person, changes the password and lists where they are signed in, with sign out per browser or everywhere (/api/v1/me/sessions, /api/v1/me/password).
  • POST /api/v1/users/{id}/password-reset: a one-time link (valid PASSWORD_RESET_HOURS, 24) to the engine's page that sets the person's password; DELETE /api/v1/users/{id}/sessions signs a person out everywhere. Deactivating a person ends their sessions.
  • PASSWORD_MIN_LENGTH (15): a development machine may lower it for a short password (D276).
  • Settings › Account reworked: a profile card, the password changed in place, sessions with device icons and relative times, and what you can do in each workspace (everything, until roles).

Changed (0.12 I2)

  • GET /api/v1/me adds signedInWith and sessionId. The metadata lists the authorization and revocation endpoints and the authorization_code and refresh_token grants.
  • The browser reads a job's progress and downloads exports and consumer OpenAPI documents with fetch and the access token; the consumer openapi.json link on Settings › API reference, refused since I1, works again. The browser runners sign in as a test person through the real form.

Added (0.12 I1, the engine issues tokens; every route authenticated)

  • The engine is its own OAuth 2.0 authorization server: GET /.well-known/oauth-authorization-server, GET /oauth/jwks and POST /oauth/token with the client credentials grant (scope management or consumer), errors in the RFC's shape. Access tokens are ES256 JWTs lasting an hour (ACCESS_TOKEN_MINUTES); their signing keys live in the database, sealed, replaced after TOKEN_SIGNING_KEY_DAYS (30) when the next token is issued, and rotated on request, at once after a leak (/api/v1/token-signing-keys).
  • People (/api/v1/users: create, change, deactivate, never delete) and OAuth clients (/api/v1/clients, the secret shown once, a second secret with an overlap); GET /api/v1/me.
  • python -m app.cli.create_admin (the first administrator, with the password rules: 15–128 characters, not the username, not one of the most common) and python -m app.cli.create_client (a client's credentials, shown once). Migration 20261027_01.
  • GET /api/v1/system/configuration: the database revision and the settings, moved out of GET /api/v1/system, which keeps what the deployment runs. Settings › Deployment shows the migration revision; About no longer does.

Changed (0.12 I1, breaking)

  • Every route needs an access token except /health, /health/live, /openapi.json, /docs and the OAuth endpoints: 401 AUTHENTICATION_REQUIRED without one, 401 TOKEN_INVALID with the reason otherwise. This includes /metrics and the consumer API (a consumer token; its index and OpenAPI documents take a management token too, D266). Until roles (Z1) every active client or person may use every route. The browser shows the refusals until signing in arrives (I2).
  • SECRETS_ENCRYPTION_KEY is required on every installation and seals token and webhook signing keys; it replaces CONSUMER_WEBHOOK_KEY_ENCRYPTION_KEY, which startup refuses by name. PUBLIC_URL names the engine's address, the token issuer. Upgrading: OPERATIONS § Authentication.

Added (0.12 P0, design only)

  • The security design: the engine as its own OAuth 2.0 authorization server (ADR-043), roles made of journey-named action groups granted to people, groups and clients per workspace (ADR-044), and change-set publication approvals with an inbox (ADR-045); every management operation mapped to its action group in API.md § Security; journeys J39–J43; the Security pages (U8) from approved mock-ups. Decisions D243–D257. No code changes yet.

Run it today.

One Compose file, your PostgreSQL, every feature.