From e9f9e2d5e6ad1688cfc35b8bacfd2bf83169d297 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Wed, 21 Oct 2026 16:43:21 +0100 Subject: [PATCH] docs(reference): rewrite the changelog one change per bullet --- content/docs/(reference)/changelog.mdx | 591 ++++++++++++++++--------- 1 file changed, 374 insertions(+), 217 deletions(-) diff --git a/content/docs/(reference)/changelog.mdx b/content/docs/(reference)/changelog.mdx index cc6db207..17635da5 100644 --- a/content/docs/(reference)/changelog.mdx +++ b/content/docs/(reference)/changelog.mdx @@ -3,9 +3,15 @@ title: Changelog description: What each release of Marfa changed for someone running an instance or building on it. --- -Each release of Marfa ships the server and its container image, the CLI for macOS on Apple silicon, and the TypeScript SDK on npm. Below is what each one changed that you'd notice when you run an instance or build on it, newest first, with a link to the full list on GitHub. +Each release of Marfa ships the server and its container image, the CLI for macOS on Apple silicon, and the TypeScript SDK on npm. This page lists every release, newest first, with what changed and a link to the full list on GitHub. The Swift SDK's own releases are listed after Marfa's. -Marfa **doesn't upgrade a database in place**. A build refuses a database whose schema differs from its own or that holds retired tables or settings, and leaves the file unchanged. If the build refuses your database, [Upgrade](/guides/upgrade) covers exporting with the old build and restoring into fresh storage. A version change alone does not cause a refusal. +**Marfa doesn't upgrade a database in place.** A build refuses a database whose schema differs from its own, or that holds retired tables or settings, and leaves the file unchanged; a version change alone doesn't cause a refusal. [Upgrade](/guides/upgrade) shows how to move your data with an export and a restore. + +## v0.0.20 + +Released October 21, 2026. [v0.0.20 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.20) + +No change for people running an instance or building on it. ## v0.0.19 @@ -13,17 +19,25 @@ Released October 21, 2026. [v0.0.19 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **The owner has General and Health pages.** General, at `/auth/owner/general`, shows the instance's name and the five retentions that change while the server runs, and saves only the ones you changed. Health, at `/auth/owner/health`, shows whether the database, saving changes, disk and file storage are working, and the object store when one is attached. It lists every housekeeping job with when it last ran, its error if it failed, and Run now. [Change settings while it runs](/guides/settings#change-settings-while-it-runs) and [Housekeeping](/guides/housekeeping) cover them. -- **The owner has Connected apps and Browsers pages.** Connected apps, at `/auth/owner/apps`, lists the apps you approved, what each can reach, and the keys it made. Disconnecting an app ends its sign-in at once, and its keys keep working unless you also revoke them there. Browsers, at `/auth/owner/browsers`, lists where you are signed in, and signs out one other browser or all of them. Disconnecting an app and signing out another browser need a sign-in within the last five minutes, and the page asks you to confirm it's you if yours is older. [Revoke access](/guides/authenticate#revoke-access) covers apps, and [Manage passkeys and sign-ins](/guides/sign-in-as-the-owner#manage-passkeys-and-sign-ins) covers browsers. -- **The owner has Activity and All settings pages.** Activity, at `/auth/owner/activity`, lists the [audit log](/guides/audit-log) newest first and filters it by kind, time and text. All settings, at `/auth/owner/settings`, lists every environment setting by group with its variable, whether it can change while the server runs, and its value now. A setting that holds a secret shows only whether it is set. -- **Eight more settings count as secrets.** `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `ERROR_WEBHOOK_URL`, `MARFA_HEARTBEAT_URL`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` and `MARFA_POSTHOG_HOST` can carry a credential in an address, so no page shows their value and a startup message that names one no longer prints it. `MARFA_AUTH_BASE_URL` stays readable and the server now refuses one that has a user name, password or query string. [Authentication](https://github.com/withmarfa/marfa/blob/main/conformance/spec/authentication.md#authenticationconsole-settings-no-secret) holds the rule. -- **The owner has Export and restore and Connectors pages, and the Manage and Restore pages are gone.** Export and restore, at `/auth/owner/export`, downloads an archive of everything or of one type and the types under it, with or without file bytes, and restores one. Connectors, at `/auth/owner/connectors`, lists each connector as Failing, Problems, Not heard from for over a day, or Running, shows its recent runs, and clears its saved state or removes it after you confirm. `/auth/owner/manage` and `/auth/owner/restore` no longer exist, and nothing redirects from them to the new pages. [Export an archive](/guides/export-and-restore#export-an-archive) and [Restore an archive](/guides/export-and-restore#restore-an-archive) cover them. +- **The owner has a General page.** At `/auth/owner/general`, it shows the instance's name and the five retentions that can change while the server runs, and saves only the ones you change. [Change settings while it runs](/guides/settings#change-settings-while-it-runs) covers it. +- **The owner has a Health page.** At `/auth/owner/health`, it shows whether the database, saving changes, disk, file storage and any attached object store are working, and lists every housekeeping job with its last run, its error if it failed, and Run now. [Housekeeping](/guides/housekeeping) covers the jobs. +- **The owner has a Connected apps page.** At `/auth/owner/apps`, it lists the apps you approved, what each can reach and the keys it made. Disconnecting an app ends its sign-in at once, and its keys keep working unless you revoke them there too ([Revoke access](/guides/authenticate#revoke-access)). +- **The owner has a Browsers page.** At `/auth/owner/browsers`, it lists where you're signed in and signs out one other browser or all of them. [Manage passkeys and sign-ins](/guides/sign-in-as-the-owner#manage-passkeys-and-sign-ins) covers it. +- **Disconnecting an app or signing out another browser needs a recent sign-in.** If yours is more than five minutes old, the page asks you to confirm it's you. +- **The owner has an Activity page.** At `/auth/owner/activity`, it lists the [audit log](/guides/audit-log) newest first and filters it by kind, time and text. +- **The owner has an All settings page.** At `/auth/owner/settings`, it lists every environment setting by group with its variable, whether it can change while the server runs, and its value now; a secret shows only whether it's set. [Every environment setting](/guides/settings#every-environment-setting) lists them. +- **Eight more settings count as secrets.** `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `ERROR_WEBHOOK_URL`, `MARFA_HEARTBEAT_URL`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` and `MARFA_POSTHOG_HOST` can carry a credential in an address, so no page shows their values and no startup message prints them. +- **`MARFA_AUTH_BASE_URL` can't hold a user name, password or query string.** The server refuses one that does; the setting stays readable on the All settings page. +- **The owner has an Export and restore page.** At `/auth/owner/export`, it downloads an archive of everything, or of one type and the types under it, with or without file bytes, and restores one. [Export and restore an archive](/guides/export-and-restore#export-an-archive) covers it. +- **The owner has a Connectors page.** At `/auth/owner/connectors`, it lists each connector as Failing, Problems, Not heard from for over a day, or Running, shows its recent runs, and clears its saved state or removes it after you confirm. +- **The Manage and Restore pages are gone.** `/auth/owner/manage` and `/auth/owner/restore` no longer exist, and nothing redirects from them. For apps and devices: -- **`PATCH /config` changes only the settings it names.** A setting in the body is replaced, a setting set to `null` is removed so its default applies, and every other setting stays. `enforcement` is one setting, so a patch that names it replaces all three levers. It takes the same fields and refusals as `PUT /config`. `marfa config update` sends it, with `--body` or `--file`. [Update the configuration](/guides/settings#update-the-configuration) covers it. -- **`GET /audit` filters by `kind` and `q`.** `kind` is one of `access`, `data`, `settings` and `housekeeping`, and each entry is of exactly one kind. `q` keeps the entries that hold every word you give, in any order, ignoring ASCII case. Any other `kind` is refused `400 validation_error`. `marfa audit` doesn't take either yet. [Filter it](/guides/audit-log#filter-it) covers them. -- **`GET /export?format=archive&files=false` leaves out file bytes.** The archive carries no file, and its manifest names each file it would have carried under `missing_blobs`. A restore of it reports those files in `blobs_missing` when the instance doesn't hold them. `marfa export` has no option for it yet. [Leave out the files](/guides/export-and-restore#leave-out-the-files) covers it. +- **`PATCH /config` changes only the settings it names.** A setting set to `null` goes back to its default, and `enforcement` is one setting, so naming it replaces all three levers. [Update the configuration](/guides/settings#update-the-configuration) covers it. +- **`marfa config update` sends `PATCH /config`**, with `--body` or `--file`. +- **`GET /audit` filters by `kind` and `q`.** `kind` is `access`, `data`, `settings` or `housekeeping`, and `q` keeps the entries that hold every word you give. [Filter it](/guides/audit-log#filter-it) covers both. +- **`GET /export?format=archive&files=false` leaves out file bytes.** The manifest names each file it left out under `missing_blobs`, and a restore reports those the instance doesn't hold in `blobs_missing`. [Leave out the files](/guides/export-and-restore#leave-out-the-files) covers it. ## v0.0.18 @@ -31,31 +45,46 @@ Released October 20, 2026. [v0.0.18 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **The owner has a Home page at `/`.** A browser signed in as the owner sees the instance's name and address, what needs attention, a search for pages, and Sign out. A browser that isn't signed in gets a page that says the instance is running, with a Sign in link. An owner page under `/auth/owner/` sends a browser with no session to the sign-in page and returns it to the page it asked for, and an unclaimed instance sends it to `/setup`. A program that requests `/` still gets the description of the instance. [Start from Home](/guides/sign-in-as-the-owner#start-from-home) covers it. -- **The owner manages API keys in the browser.** The API keys pages list the keys, create one, and edit or revoke one. A key's access starts off, and the secret shows once and is removed when the owner leaves the page. A save is refused if the key changed after the page opened. [Use the API keys page](/guides/manage-keys#use-the-api-keys-page) covers it. -- **The owner has Profile and Password and passkeys pages.** Adding, renaming or removing a passkey asks the owner to confirm it's them when the sign-in is more than five minutes old. Changing the password asks for the current password instead. A passkey name that is empty or too long is refused `400 validation_error` when the passkey is verified. [Manage passkeys and sign-ins](/guides/sign-in-as-the-owner#manage-passkeys-and-sign-ins) covers it. -- **A folder that a different directory replaces stays listed.** A folder registry entry records the directory's identity. When another directory takes the folder's place, the entry stays, and the watch resumes when the directory the folder was admitted at returns. Marfa removes an entry when the directory at its path holds no folder state and isn't known to differ from the one admitted. [Folder safeguards](/guides/folder-safeguards#a-missing-or-unreadable-directory) covers it. -- **Sign out stays beside the title on a phone.** On Home, the Sign out button no longer drops under the title and address. -- **Destructive actions sit in their own card, and Save appears only after a change.** Revoking a key and removing a passkey are in a bordered card of their own. A key's page shows Save and Discard, with a count of the changes, only once something has changed. +- **The owner has a Home page at `/`.** A browser signed in as the owner sees the instance's name and address, what needs attention, a search for pages, and Sign out; a program that requests `/` still gets the description of the instance. [Start from Home](/guides/sign-in-as-the-owner#start-from-home) covers it. +- **Owner pages send a browser with no session to sign in**, and back, or to `/setup` on an unclaimed instance. At `/`, such a browser sees that the instance is running, with a Sign in link. +- **The owner manages API keys in the browser.** The API keys pages list, create, edit and revoke keys; a new key's access starts off, and its secret shows once, until you leave the page. [Use the API keys page](/guides/manage-keys#use-the-api-keys-page) covers it. +- **A key's page refuses a save if the key changed** after the page opened. +- **The owner has Profile and Password and passkeys pages.** Changing a passkey asks you to confirm it's you if your sign-in is more than five minutes old, and changing the password asks for the current one. [Manage passkeys and sign-ins](/guides/sign-in-as-the-owner#manage-passkeys-and-sign-ins) covers them. +- **A passkey name that is empty or too long is refused** `400 validation_error` when the passkey is verified. +- **A folder whose directory is replaced stays listed.** The folder registry records the directory's identity, so the watch resumes when the original directory returns; an entry goes only when its path holds no folder state and isn't known to be another directory. [Folder safeguards](/guides/folder-safeguards#a-missing-or-unreadable-directory) covers it. +- **Sign out stays beside the title on a phone.** On Home, it no longer drops below the title and address. +- **Destructive actions sit in their own card.** Revoking a key and removing a passkey are in a bordered card of their own. +- **Save appears only after a change.** A key's page shows Save and Discard, with a count of the changes, once something has changed. - **The server needs Node.js 22.14 or later.** The container image runs Node.js 24. [Deploy](/guides/deploy) covers it. For apps and devices: -- **The owner chooses what an app gets.** The approval screen lists each type with No access, Read only or Read and edit, then the owner's profile and the powers over Marfa that are off until the owner turns them on. Three powers that can't be undone are marked. Access the app already holds shows as Already allowed. A request for all data also shows Choose types, and if the owner picks types the token names them instead of all data. If the request asks for access that the app doesn't already hold and the owner turns all of it off, the app gets `access_denied` and keeps what it held. A request for nothing but `openid` and `offline_access` offers nothing to turn off, so the owner can approve it. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. -- **A device code is easier to type.** The device page shows a code as `XXXX-XXXX`, accepts it in any case with or without the hyphen, and a code ends after 10 minutes. -- **Approving or revoking asks to confirm in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser carries on at the same page. A password counts against the attempt windows: a wrong one answers `401 unauthorized`, and once the windows are spent the answer is `429 rate_limited` with `Retry-After`. A refused confirmation keeps the session. A program that calls a route that needs a recent sign-in gets `403 forbidden` with "Sign in again to perform this operation". `POST /auth/confirm` answers `401` to a request with no live owner session or with a key. A browser may end its own sign-in at any time. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. -- **`narrow_only` keeps a key from gaining access.** `PATCH /keys/{id}` with `narrow_only: true` refuses a change that adds access, and the API keys pages send it. `POST /keys` answers `Cache-Control: no-store`, so the secret isn't cached. [Narrow a key](/guides/manage-keys#narrow-a-key) covers it. -- **Marfa keeps a whole number exact, or refuses it.** A `number` or `integer` value that Marfa can't serve back with the same digits, such as `1500000000000000001`, is refused `400 invalid_properties`, and `details.errors` names the field. A `url` value is trimmed and judged as the WHATWG URL Standard does. For a special scheme such as `http`, a host with a non-ASCII character must be valid IDNA, and a host with a forbidden code point is refused. [Field kinds](/catalog/field-kinds) covers it. +- **The owner chooses what an app gets.** The approval screen sets each type to No access, Read only or Read and edit, then the profile, then the powers over Marfa, which start off. Access the app already holds shows as Already allowed. [What the owner chooses](/guides/authenticate#what-the-owner-chooses) covers it. +- **The owner can narrow a request for all data to types.** The approval screen shows Choose types, and if the owner picks types, the token names them instead of all data. +- **Turning off all the new access refuses the request.** The app gets `access_denied` and keeps what it held. A request for nothing but `openid` and `offline_access` offers nothing to turn off, so the owner can approve it. +- **A device code is easier to type.** It shows as `XXXX-XXXX`, is accepted in any case with or without the hyphen, and ends after 10 minutes. +- **Confirming it's you happens in a dialog, not a redirect.** `POST /auth/confirm` takes the owner's password or a passkey and replaces the session, so the browser stays on the same page. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. +- **A wrong password at `POST /auth/confirm` counts against the sign-in limits.** It answers `401 unauthorized`, then `429 rate_limited` with `Retry-After` once the attempts are spent, and a refused confirmation keeps the session. +- **`POST /auth/confirm` answers `401` without a live owner session**, and to a request that carries a key. +- **A program refused for want of a recent sign-in gets `403 forbidden`**, with "Sign in again to perform this operation". A browser can always end its own sign-in. +- **`narrow_only` keeps a key from gaining access.** `PATCH /keys/{id}` with `narrow_only: true` refuses a change that adds access, and the API keys pages send it. [Narrow a key](/guides/manage-keys#narrow-a-key) covers it. +- **`POST /keys` answers `Cache-Control: no-store`**, so the new secret isn't cached. +- **Marfa keeps a whole number exact, or refuses it.** A `number` or `integer` value that Marfa can't serve back with the same digits, such as `1500000000000000001`, is refused `400 invalid_properties`. [Field kinds](/catalog/field-kinds) covers it. +- **A `url` value is judged as the WHATWG URL Standard does.** It's trimmed first, and for a special scheme such as `http`, a non-ASCII host must be valid IDNA and a host with a forbidden code point is refused. - **A tag of `.` or `..` is refused** with `400 validation_error`, as an empty, blank or over-long tag is. A restore still accepts what an archive holds. -- **Narrowing a webhook cancels the deliveries it no longer selects.** Pending deliveries that the narrowed subscription doesn't select end `canceled`. Changing the `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. -- **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami`, and `keys list`, `current`, `create` and `update` print No access, Read only and Read and edit for each type, and name powers by their titles. `--json` is unchanged. [CLI](/cli#check-what-a-command-will-use) covers it. -- **A write that fails alone is set aside.** A device marks a queued write `blocked` with the reason `failing_alone` when it keeps failing in the words of an outage, over some minutes, while the server still answers other writes. It tests the write first by sending another write that doesn't depend on it, and sets the write aside only if it fails in the same way when sent once more. Its dependents wait as `awaiting_dependency`. `marfa device queue` prints the last answer the write met after `last answer:`. [Devices](/devices#verdicts) covers it, and [`queue-and-verdicts/stuck-write-suspect`](https://github.com/withmarfa/marfa/blob/main/conformance/spec/queue-and-verdicts.md#queue-and-verdictsstuck-write-suspect) has the exact rule. -- **A file item keeps its bytes at a document name.** A file already bound to a file item, at a name ending `.md`, `.markdown` or `.txt`, is sent as bytes whatever they hold, with no frontmatter read. If it is empty it waits, and the rest of the scan goes on. A file at such a name that isn't bound to a file item is still read as a document. A `marfa_id` over 256 bytes is no id. -- **`--no-keychain` keeps the CLI off the keychain.** `--no-keychain` or `MARFA_NO_KEYCHAIN=1` makes every command treat the keychain as absent, and `marfa login --print-token` no longer reads or writes it. A command that sends a key from `--key` or `MARFA_API_KEY` and has no server named is refused `no_server`. [CLI](/cli#run-without-the-keychain) covers it. -- **The approval rows of two types with the same label are told apart.** When two item types share a label, such as `core.task` and `aic.ticket.task`, each shows its identifier under the label, and the type search matches the identifier. -- **A lock file the CLI can't write is refused with a message.** When the lock file opens read-only, the CLI stops with ` cannot be opened for writing, so it cannot be locked: check that it is writable`. -- **A repeat of a write whose first answer was large is refused, not run again.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` with `details.original_status`, and writes nothing. A device counts a `2xx` status there as accepted, except for a create that names no `id`, which it refuses. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. -- **A waiting edge edit or delete stays in view through a catch-up.** When a catch-up brings an item into a device's copy along with its edges, the device lays its own waiting writes over those edges, as hydration does, instead of showing the server's version until the server answers. +- **Narrowing a webhook cancels the pending deliveries it no longer selects.** Changing its `url` or setting `active: false` cancels all of them, and widening revives none. [Change what a webhook sends](/guides/receive-webhooks#change-what-a-webhook-sends) covers it. +- **The CLI prints access in the words the approval page uses.** `marfa login`, `whoami` and `keys list`, `current`, `create` and `update` show No access, Read only or Read and edit for each type, and name powers by their titles; `--json` is unchanged. [CLI](/cli) covers it. +- **A queued write that fails alone is set aside.** When a write keeps failing like an outage while the server answers other writes, a device tests it and then blocks it as `failing_alone`; the writes that depend on it wait as `awaiting_dependency`. [Devices](/devices#verdicts) covers it. +- **`marfa device queue` prints the last answer a write met**, after `last answer:`. +- **A file item's file at a document name is sent as bytes.** A file bound to a file item, at a name ending `.md`, `.markdown` or `.txt`, is sent as bytes with no frontmatter read, and waits while it's empty. A file at such a name that isn't bound to a file item is still read as a document. +- **A `marfa_id` over 256 bytes isn't read as an item ID.** +- **`--no-keychain` keeps the CLI off the keychain.** It, or `MARFA_NO_KEYCHAIN=1`, makes every command treat the keychain as absent, and `marfa login --print-token` no longer reads or writes it. [CLI](/cli) covers it. +- **A key sent with no server named is refused.** A command that sends a key from `--key` or `MARFA_API_KEY` without naming a server is refused `no_server`. +- **Two types with the same label are told apart on the approval screen.** Each shows its identifier under the label, and the type search matches the identifier. +- **A lock file the CLI can't write is refused with a message** that says to check that the file is writable. +- **A repeat of a write whose first answer was large is refused.** Marfa keeps only the status of a first answer over 1,048,576 bytes, so a repeat under the same `Idempotency-Key` answers `422 idempotency_result_not_retained` and writes nothing. [Write safely](/guides/write-safely#retry-with-an-idempotency-key) covers it. +- **A device counts such a repeat's `2xx` status as accepted**, except for a create that names no `id`, which it refuses. +- **A waiting edge edit or delete stays in view through a catch-up.** When a catch-up brings in an item with its edges, the device lays its own waiting writes over them, as hydration does. ## v0.0.17 @@ -63,18 +92,27 @@ Released October 20, 2026. [v0.0.17 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **A blob folder that has gone is no longer read as an empty one.** When the disk store's folder or its `.marfa-store` marker is missing, as when its drive is unmounted while the server runs, `GET /health` answers `blob_storage` as `down` with status `503`. The `blob-integrity` job strikes none of that store's copies, counts the store in `unreachable` in its result, and still checks the stores it can reach. A blob read answers `503 blob_store_unavailable` rather than `404 blob_not_found` when a store that holds a copy can't answer and no other store returns the bytes. A restore that finds the disk store's marker missing, or naming another store, when it places a blob answers the same and writes nothing. Replication doesn't end its run on such a store, and the `enrichment-sweep` job doesn't count the attempt against the file. [Deploy](/guides/deploy#keep-it-running) covers the health check. -- **The bin purge keeps going through a backlog.** `trash-purge` removes a limited number of items in a run. While items past the retention remain after a run that removed some, the job runs again at once instead of waiting for its next interval, so an instance that bins more items in an interval than a run removes no longer falls behind. [Housekeeping](/guides/housekeeping#empty-the-bin) covers the job. -- **A restore keeps an item or an edge as the archive holds it.** An item whose type the instance no longer registers, such as after a forced delete, or that lacks a field its type later required, is restored with the properties and `schema_version` the archive holds. A restore also keeps an edge whose edge type was force-deleted, and no longer judges an end that is an item of a force-deleted type against the edge type's constraints. Such an edge is still skipped for the other reasons, such as a missing end. An export no longer refuses these with `409 export_rows_unrestorable`, so an instance that holds them can be exported again. An item of a `core` or `system` type that the build ships is still held to its shape. [Export and restore an archive](/guides/export-and-restore#restore-an-archive) covers it. -- **A restore refuses an archive that lacks one of its four files.** An archive without `manifest.json`, `items.ndjson`, `edges.ndjson` or `types.ndjson` is refused `400 validation_error`, naming each missing entry in the message and in `details.entries`. Before, it answered `200` with counts of zero. -- **A restore or an upload is no longer cut off while its bytes arrive.** An admitted upload has no total time limit. `MARFA_UPLOAD_IDLE_TIMEOUT_MS`, 60000 milliseconds unless set, ends one that receives no bytes for that long with `408 request_timeout`, and a restore then writes nothing and an upload stores no blob. The owner's recent sign-in is judged when a restore starts, so a restore that starts within five minutes of the sign-in isn't refused for finishing later. A session that signed in more than five minutes before the restore starts is refused `403 forbidden` without waiting for the body to finish arriving, and one that ends during the upload is answered `401 unauthorized`. [Settings](/guides/settings#every-environment-setting) lists the setting. -- **`marfa export --output` keeps the last whole archive.** When the new archive lacks blobs, the CLI doesn't replace the file at the path. It keeps the archive beside it as `.incomplete`, replacing only an earlier file of that name, and exits `1` with `blobs_missing`. The message says whether the path still holds an earlier file. If the CLI can't write that name, it keeps the archive in a hidden file beside the path and names the file. [Export and restore an archive](/guides/export-and-restore#when-an-archive-lacks-a-files-bytes) covers it. -- **Claiming in a browser signs the owner in, and setup is six steps.** A claim that carries the instance's own `Origin` leaves the browser signed in, as a sign-in does, and that counts as a sign-in within the last five minutes, so adding a passkey needs no second prompt. A claim that sends no `Origin`, such as a script's, and a claim that is refused set no session. If the sign-in that follows a claim fails, the owner stays claimed and signs in at the page that follows. Setup runs as six steps: the setup code, the owner's details, a passkey, a name for the instance, connecting, and backups. [Quickstart](/quickstart#claim-the-instance) covers claiming. -- **The sign-in page says how long a lockout lasts.** When password sign-in or a device code lookup is refused for too many attempts, the page states the wait in whole minutes, rounded up, and the sign-in page says that a passkey still signs in. The wait runs to the time another attempt is allowed, the latest end among the limits that are spent. The sign-in page also links to a Forgot password page at `/auth/forgot-password`, which gives the command that recovers the owner on the instance's machine. [Troubleshooting](/guides/troubleshooting) covers it. +- **A blob folder that has gone reads as down, not empty.** When the disk store's folder or its `.marfa-store` marker is missing, as on an unmounted drive, `GET /health` reports `blob_storage` as `down` with status `503`. [Deploy](/guides/deploy#keep-it-running) covers the health check. +- **Housekeeping passes over a store it can't reach.** `blob-integrity` strikes none of its copies and counts it in `unreachable`, replication carries on past it, and `enrichment-sweep` doesn't count the attempt against the file. +- **A blob read answers `503 blob_store_unavailable` when no store returns the bytes** and a store that holds a copy can't answer, rather than `404 blob_not_found`. A restore that finds the disk store's marker missing, or naming another store, answers the same and writes nothing. +- **The bin purge keeps going through a backlog.** While items past the retention remain, `trash-purge` runs again at once instead of waiting for its interval. [Empty the bin](/guides/housekeeping#empty-the-bin) covers it. +- **A restore keeps an item as the archive holds it.** An item whose type the instance no longer registers, or that lacks a field its type later required, keeps its archived properties and `schema_version`; an item of a `core` or `system` type the build ships is still held to its shape. [Restore an archive](/guides/export-and-restore#restore-an-archive) covers it. +- **A restore keeps an edge whose edge type was force-deleted**, and no longer judges an end of a force-deleted type against the edge type's constraints. Such an edge is still skipped for the other reasons, such as a missing end. +- **An instance that holds such rows can be exported again.** An export no longer refuses them with `409 export_rows_unrestorable`. +- **A restore refuses an archive that lacks one of its four files.** An archive without `manifest.json`, `items.ndjson`, `edges.ndjson` or `types.ndjson` is refused `400 validation_error`, with the missing entries in `details.entries`, where before it answered `200` with counts of zero. +- **An upload or a restore has no total time limit.** `MARFA_UPLOAD_IDLE_TIMEOUT_MS`, 60000 milliseconds unless set, ends one that receives no bytes for that long with `408 request_timeout`, and nothing is stored. [Every environment setting](/guides/settings#every-environment-setting) lists the setting. +- **A restore's recent sign-in is judged when it starts.** A restore that starts within five minutes of the owner's sign-in isn't refused for finishing later; an older session is refused `403 forbidden` at once, and one that ends during the upload gets `401 unauthorized`. +- **`marfa export --output` keeps the last whole archive.** When the new archive lacks blobs, the CLI leaves the file at the path, keeps the new archive beside it as `.incomplete`, and exits `1` with `blobs_missing`. [When an archive lacks a file's bytes](/guides/export-and-restore#when-an-archive-lacks-a-files-bytes) covers it. +- **Claiming in a browser signs the owner in.** A claim that carries the instance's own `Origin` leaves the browser signed in, which counts as a recent sign-in; a claim with no `Origin`, such as a script's, or a refused claim sets no session. [Claim the instance](/quickstart#claim-the-instance) covers it. +- **A failed sign-in after a claim leaves the owner claimed**, to sign in at the page that follows. +- **Setup runs as six steps:** the setup code, the owner's details, a passkey, a name for the instance, connecting, and backups. +- **The sign-in page says how long a lockout lasts.** When password sign-in or a device code lookup is refused for too many attempts, the page gives the wait in whole minutes and says that a passkey still signs in. [Troubleshooting](/guides/troubleshooting) covers it. +- **The sign-in page links to Forgot password,** at `/auth/forgot-password`, which gives the command that recovers the owner on the instance's machine. [Recover the owner's password](/guides/manage-keys#recover-the-owners-password) covers the command. For apps and devices: -- **An export ends with an error when its credential ends.** If the key or signed-in app that started `GET /export` is revoked, expires or is disconnected while the response is open, the response ends with an error instead of ending cleanly, in both formats and while an archive sends a blob. The same happens if the credential is narrowed so that it no longer reads something the export has still to send. A narrowing that takes away nothing the export has still to send changes nothing. An archive export whose key is revoked while it reads the selection, before the response begins, gets `401 unauthorized`. [Export and restore an archive](/guides/export-and-restore#what-an-export-checks) covers it. +- **An export ends with an error when its credential ends.** If the key or app that started `GET /export` is revoked, expires, is disconnected, or is narrowed so it can't read what's left to send, the response ends with an error instead of ending cleanly. [What an export checks](/guides/export-and-restore#what-an-export-checks) covers it. +- **An archive export whose key is revoked before the response begins** gets `401 unauthorized`. ## v0.0.16 @@ -82,14 +120,20 @@ Released October 19, 2026. [v0.0.16 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **An archive export is checked against a restore before it is sent.** Before it sends a byte, Marfa checks the whole archive the way a restore into an empty instance does, so an archive it sends restores into an empty instance of the same build without skipping an item or an edge, even when writes landed between its pages. If a row fails the check, Marfa reads the selection once more. A row that fails both times and didn't change answers `409 export_rows_unrestorable`, and `details.row` names it. A row or type that changed, or a second read that refused a different row, answers `503 export_unrestorable`, so export again. An export's temporary files count against `MARFA_DISK_RESERVE_BYTES`. [Export and restore an archive](/guides/export-and-restore#what-an-export-checks) covers it. -- **Marfa refuses text that is not well-formed UTF-16.** A request body that holds a string or a property name with a lone surrogate, such as an emoji cut in the middle, is refused `400 validation_error`, and `details.errors[].path` names the text by its JSON pointer. A restore refuses an archive that holds such text, and names the row. Text that the server extracts from a file is cut at `MARFA_ENRICHMENT_MAX_TEXT_CHARS` UTF-16 code units, never through a character. The body checks, this one and the limit of 64 nested levels, now apply whatever `Content-Type` a request states, except on `POST /blobs`, `POST /restore` and inbound webhooks. [Troubleshooting](/guides/troubleshooting) covers the refusal. +- **An archive export is checked against a restore before it's sent.** Marfa checks the whole archive as a restore into an empty instance does, so the archive restores into an empty instance of the same build without skipping an item or an edge. [What an export checks](/guides/export-and-restore#what-an-export-checks) covers it. +- **An export that fails the check is refused.** A row that fails twice without changing answers `409 export_rows_unrestorable`, named in `details.row`; a row or type that changed meanwhile answers `503 export_unrestorable`, so export again. +- **An export's temporary files count against `MARFA_DISK_RESERVE_BYTES`.** +- **Text that isn't well-formed UTF-16 is refused.** A string or property name with a lone surrogate, such as an emoji cut in the middle, is refused `400 validation_error`, and a restore refuses an archive that holds one. [Troubleshooting](/guides/troubleshooting) covers the refusal. +- **Text extracted from a file is never cut through a character.** It's cut at `MARFA_ENRICHMENT_MAX_TEXT_CHARS` UTF-16 code units. +- **The body checks apply whatever `Content-Type` a request states.** The UTF-16 check and the limit of 64 nested levels apply to every body except on `POST /blobs`, `POST /restore` and inbound webhooks. For apps and devices: -- **Approving an app for more than reading needs a recent sign-in.** On the approval screen and the device approval screen, a browser that signed in more than five minutes ago can approve reading only. To approve write access, or a permission the app wasn't approved for before, Marfa asks the owner to sign in again and returns them to the screen. Revoking a key or disconnecting an app from a browser needs a recent sign-in too. A key, an app token and the private local command are not affected. [Authenticate](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. -- **A write is judged by the key as it stands when it commits.** If a key is narrowed or revoked while one of its item writes waits its turn, the write is refused where before it went through with the old grants: `403 type_not_permitted`, `404 item_not_found` when the key can no longer read the type, or `401 unauthorized`. This covers creating, updating, deleting, restoring, transitioning and purging an item, and bulk writes, where an atomic page is refused and a best-effort entry is `errored`, naming the same code. -- **A device keeps its working copy when the server sends an answer it cannot read.** A hydration, catch-up or follow whose answer or event isn't JSON ends with `decoding`, and the copy and its place in the event stream are kept, where before the copy expired and asked for a hydration that failed the same way. A stream cut in the middle of an event is an interruption: a catch-up ends `stream_incomplete`, and a follow opens the stream again from its place. +- **Approving an app for more than reading needs a recent sign-in.** A browser signed in more than five minutes ago can approve reading only; for write access or a permission the app wasn't approved for before, Marfa asks the owner to sign in again. [When approval needs a recent sign-in](/guides/authenticate#when-approval-needs-a-recent-sign-in) covers it. +- **Revoking a key or disconnecting an app in a browser needs a recent sign-in** too. A key, an app token and the private socket aren't affected. +- **A write is judged by the key as it stands when it commits.** If a key is narrowed or revoked while one of its item writes waits, the write is refused with `403 type_not_permitted`, `404 item_not_found` or `401 unauthorized`. In a bulk write, an atomic page is refused and a best-effort entry is `errored`. +- **A device keeps its working copy when the server sends an answer it can't read.** A hydration, catch-up or follow whose answer isn't JSON ends with `decoding` and keeps the copy and its place in the event stream, where before the copy expired. +- **A stream cut in the middle of an event is an interruption.** A catch-up ends `stream_incomplete`, and a follow opens the stream again from its place. ## v0.0.15 @@ -97,62 +141,65 @@ Released October 19, 2026. [v0.0.15 on GitHub](https://github.com/withmarfa/marf For running an instance: -- **A restore keeps the edges of items in the bin.** An archive made with `--state any` carries them, and a restore used to skip them as `item_not_found`. `edges_skipped_reasons` now names only `already_present`, `endpoint_missing`, `malformed`, or the error code `POST /edges` would answer for that edge. [Export and restore an archive](/guides/export-and-restore#restore-an-archive) covers it. -- **A backup restores when its types refer to each other.** Types that name each other in `compatible_with`, and a type whose display hints, links or merge fields name fields it inherits, restore in any order. Marfa also refuses a change to a type that would break another type's `compatible_with` claim, so an archive Marfa wrote stays restorable. -- **A first request with a key no longer fails with 503 during a long restore or bulk job.** A read with a key that was new to the process, or the first after a restart, waited behind the job and could answer `503 write_contention`. Marfa now records a key's last use in the background, so `last_used_at` can lag behind, or stay `null` after a busy moment. -- **A bulk job ends when the key that started it is revoked.** It ends `failed` and writes nothing more, where it used to stay `in_progress`. A text-extraction run or a webhook scheduling run started by hand no longer fails for the same reason. +- **A restore keeps the edges of items in the bin.** An archive made with `--state any` carries them, and a restore no longer skips them. [Restore an archive](/guides/export-and-restore#restore-an-archive) covers it. +- **`edges_skipped_reasons` names only four kinds of reason:** `already_present`, `endpoint_missing`, `malformed`, or the error code `POST /edges` would answer for that edge. +- **A backup restores when its types refer to each other.** Types that name each other in `compatible_with`, or whose display hints, links or merge fields name fields they inherit, restore in any order. +- **A key's first request no longer waits behind a long job.** Marfa records a key's last use in the background, so a first request during a restore or bulk job no longer fails with `503 write_contention`, and `last_used_at` can lag or stay `null` after a busy moment. +- **A bulk job ends when the key that started it is revoked.** It ends `failed` and writes nothing more, where before it stayed `in_progress`. +- **A text-extraction or webhook scheduling run started by hand finishes** when the key that started it is revoked. For apps and devices: -- **A read answers from the data it checked.** When an item changed type during a read, the read could return metadata, such as a private tag, to a key that could no longer read the item. The same applied to the link between an occurrence and its series. A read now checks permission against the data it returns. -- **Replacing or deleting a type that another type depends on can be refused.** A replacement that breaks another type's `compatible_with` claim, and a deletion of a type that other types name in `compatible_with`, are refused with `422 compatible_with_violation`, and `details.dependent_type_ids` names the dependents. `force=true` does not bypass it. [Define a type](/guides/define-a-type#change-the-type) covers it. -- **Adding a parent or a compatibility target needs write permission on it.** Keeping one the type already has does not, so a connector can still update its type after its permissions narrow. [Define a type](/guides/define-a-type#what-your-key-needs) covers it. +- **A read checks permission against the data it returns.** An item that changed type during a read could return metadata, such as a private tag, to a key that could no longer read it, and so could the link between an occurrence and its series. +- **Breaking another type's `compatible_with` claim is refused.** A replacement that breaks a dependent type's claim, or a deletion of a type others name in `compatible_with`, is refused `422 compatible_with_violation`, naming the dependents in `details.dependent_type_ids`, even with `force=true`. [Change the type](/guides/define-a-type#change-the-type) covers it. +- **Adding a parent or a compatibility target needs write permission on it.** Keeping one the type already has doesn't, so a connector can still update its type after its permissions narrow. [What your key needs](/guides/define-a-type#what-your-key-needs) covers it. ## v0.0.14 Released October 19, 2026. [v0.0.14 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.14) -**Before you update, if your container keeps its database in a bucket:** the replica moves from `db` at the bucket's root to `/db`. On its next start, an existing deployment streams its database to the new path and takes a full snapshot there at once. The old `db/` objects are no longer read or pruned, and you can delete them once the new replica exists. **Every instance on a shared bucket now needs its own `S3_PREFIX`.** Before this release, instances on one bucket shared one replica, and a new volume of one could start on another's database. [Back up and restore](/guides/back-up-and-restore#with-a-bucket) and [Deploy](/guides/deploy#an-object-store) explain the prefix. +**Before you update, if your container keeps its database in a bucket:** the replica moves from `db` at the bucket's root to `/db`. On its next start, an existing deployment streams its database to the new path and takes a full snapshot there, and you can delete the old `db/` objects once it has. **Every instance on a shared bucket needs its own `S3_PREFIX`**: before this release, they shared one replica, and a new volume of one could start on another's database. [With a bucket](/guides/back-up-and-restore#with-a-bucket) and [An object store](/guides/deploy#an-object-store) explain the prefix. For running an instance: -- **Each release publishes the server image** to `ghcr.io/withmarfa/marfa-server`, for `linux/amd64` and `linux/arm64`, tagged with the release and `latest`. [Deploy](/guides/deploy#run-it-in-a-container) pulls it, and [Upgrade](/guides/upgrade) names the old build by its tag. `GET /` answers the `release` of such a build beside `version`, and `marfa status` and `marfa whoami` show both. A build made from a clone has no `release`. The container's port is published on the loopback address only, because Docker publishes a bare port on every interface. -- **A proxy on the same machine as the container needs `TRUSTED_PROXY_CIDRS`** set to the container network's range. Without it, every caller shares one address, and sign-in lockouts, which count attempts for each address, apply to everyone together. [Deploy](/guides/deploy#the-address) explains it. -- **A webhook to a private address is refused when you register it.** A `url` whose host is a private address, or a name that resolves to one, such as a Tailscale name, is refused `400 validation_error` that names `MARFA_WEBHOOK_ALLOW_PRIVATE_ADDRESSES`, and so is a change of `url` to one. A delivery refused for its address is `dead_letter` at once and not retried, so redeliver it after you allow private addresses. An outbound webhook reaches only global IPv6 addresses, and other IPv6 blocks are refused. [Receive webhooks](/guides/receive-webhooks#receive-deliveries-on-a-private-network) covers it. -- **Telemetry names the instance.** Every log record, span and reported exception carries `service.instance.id`, the `instance_id` that `GET /` answers. `OTEL_RESOURCE_ATTRIBUTES` marks an instance, such as `deployment.environment=staging` for a test one, for the server and the CLI, and `OTEL_SERVICE_NAME` wins over a `service.name` in it. The bare-metal start in [Deploy](/guides/deploy#run-it-without-a-container) loads the telemetry preload. [Send telemetry](/guides/telemetry#tell-instances-apart) covers it. -- **A backup names the files it lacks.** An archive leaves out a blob that no store holds whole and names it under `missing_blobs` in its manifest. `marfa export` then exits `1` with `blobs_missing`, a restore answers `blobs_missing`, and `marfa restore` exits `1` when it lists any. A cut `marfa export --output` leaves the path as it was and exits `3`. A blob store that fails answers `503 blob_store_unavailable`. [Export and restore an archive](/guides/export-and-restore#when-an-archive-lacks-a-files-bytes) covers it. -- **Headers a Tailscale proxy adds, those named `Tailscale-`, are not stored** with a connector's inbound delivery. [Receive vendor webhooks](/guides/connector-webhooks) covers it. +- **Each release publishes the server image** to `ghcr.io/withmarfa/marfa-server`, for `linux/amd64` and `linux/arm64`, tagged with the release and `latest`. [Run it in a container](/guides/deploy#run-it-in-a-container) pulls it. +- **An instance names its release.** `GET /` answers `release` beside `version`, and `marfa status` and `marfa whoami` show both; a build made from a clone has no `release`. +- **The container's port is published on the loopback address only**, because Docker publishes a bare port on every interface. +- **A proxy on the container's machine needs `TRUSTED_PROXY_CIDRS`.** Set it to the container network's range, or every caller shares one address and sign-in lockouts apply to everyone together. [The address](/guides/deploy#the-address) explains it. +- **A webhook to a private address is refused when you register it.** A `url` whose host is, or resolves to, a private address, such as a Tailscale name, is refused `400 validation_error` naming `MARFA_WEBHOOK_ALLOW_PRIVATE_ADDRESSES`. [Receive deliveries on a private network](/guides/receive-webhooks#receive-deliveries-on-a-private-network) covers it. +- **A delivery refused for its address is `dead_letter` at once** and not retried, so redeliver it after you allow private addresses. +- **An outbound webhook reaches only global IPv6 addresses.** +- **Telemetry names the instance.** Every log record, span and reported exception carries `service.instance.id`, the `instance_id` that `GET /` answers. [Tell instances apart](/guides/telemetry#tell-instances-apart) covers it. +- **`OTEL_RESOURCE_ATTRIBUTES` marks an instance** for the server and the CLI, such as `deployment.environment=staging` for a test one, and `OTEL_SERVICE_NAME` wins over a `service.name` in it. +- **The bare-metal start loads the telemetry preload.** [Run it without a container](/guides/deploy#run-it-without-a-container) shows it. +- **A backup names the files it lacks.** An archive leaves out a blob that no store holds whole and names it under `missing_blobs`. `marfa export` then exits `1` with `blobs_missing`, and a restore lists such files in `blobs_missing`, which makes `marfa restore` exit `1`. [When an archive lacks a file's bytes](/guides/export-and-restore#when-an-archive-lacks-a-files-bytes) covers it. +- **A cut `marfa export --output` leaves the path as it was** and exits `3`. +- **A blob store that fails answers `503 blob_store_unavailable`.** +- **Headers named `Tailscale-` aren't stored** with a connector's inbound delivery. [Receive vendor webhooks](/guides/connector-webhooks) covers deliveries. For apps and devices: -- **A file item names only bytes its writer uploaded or can already read.** Any other write of `blob_ref` is refused `404 blob_not_found`, where it was taken and left a file that nobody could download. Upload with the same credential first. The refusal isn't kept against an `Idempotency-Key`, and a working copy uploads again under its current key when the key changed. [Handle files](/guides/handle-files#point-an-item-at-the-bytes) covers it. -- **`marfa folders push` says whether it finished.** It exits `3` when a write is left unsent or the catch-up fails, and `5` when the credential is refused. Under a rate limit it doesn't wait: it reports the writes as not delivered, names the wait and exits `3`, and a watch waits on its own. The report counts files with an unanswered write as waiting. `marfa folders restore` lists the files it couldn't write back. A file moved to another folder and saved again as UTF-16 stays the same item, and a file that isn't text no longer holds up deletes in other folders. [Folder safeguards](/guides/folder-safeguards#know-whether-a-push-finished) covers it. -- **`marfa keys keep` keeps any valid key**, including one that holds only `keys.mint`, `instance.read` or `audit.read`. `marfa events` ends with exit `1` for `cursor_ahead` and `catchup_too_old`, `3` for a stream that stopped early, and `5` for `credential_ended`. `marfa owner passkeys add` opens the ordinary sign-in page. [The CLI](/cli#connect-to-a-server) and [Keep a copy in sync](/guides/keep-in-sync#when-the-stream-ends) cover them. - -## Swift SDK v0.0.21 - -Released October 18, 2026. [v0.0.21 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.21) - -- The package builds against Marfa v0.0.13, including the folder handover repair below. -- Generated API types cover owner and delegated profile updates, and accept the instance's configured display name as a string. -- Optional nullable fields distinguish omission from an explicit null, including profile avatars, key expiry, webhook filters and feed retention. +- **A file item names only bytes its writer uploaded or can already read.** Any other `blob_ref` is refused `404 blob_not_found`, where before it left a file nobody could download, so upload with the same credential first. [Point an item at the bytes](/guides/handle-files#point-an-item-at-the-bytes) covers it. +- **A working copy uploads again under its current key** when the key changed, and the refusal isn't kept against an `Idempotency-Key`. +- **`marfa folders push` says whether it finished.** It exits `3` when a write is left unsent or the catch-up fails, and `5` when the credential is refused. [Know whether a push finished](/guides/folder-safeguards#know-whether-a-push-finished) covers it. +- **`marfa folders push` doesn't wait out a rate limit.** It reports the writes as not delivered, names the wait and exits `3`; a watch waits on its own. +- **A push counts files with an unanswered write as waiting.** +- **`marfa folders restore` lists the files it couldn't write back.** +- **A file moved to another folder and saved as UTF-16 stays the same item**, and a file that isn't text no longer holds up deletes in other folders. +- **`marfa keys keep` keeps any valid key**, including one that holds only `keys.mint`, `instance.read` or `audit.read`. [Give a key to a script or agent](/guides/manage-keys#give-a-key-to-a-script-or-agent) covers it. +- **`marfa events` exits by why it ended:** `1` for `cursor_ahead` and `catchup_too_old`, `3` for a stream that stopped early, and `5` for `credential_ended`. [When the stream ends](/guides/keep-in-sync#when-the-stream-ends) covers it. +- **`marfa owner passkeys add` opens the ordinary sign-in page.** ## v0.0.13 Released October 18, 2026. [v0.0.13 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.13) -- An instance has a display name in its live configuration. It defaults to Marfa, is returned in the instance description, and appears on existing pages. Changing it takes effect without restarting and keeps the instance ID. -- The owner can change the sign-in email and display name through `PATCH /owner`, using a browser session authenticated within the last five minutes or the private local command `marfa owner update`. Changing email needs no confirmation; the owner's password, passkeys, existing sessions, keys and app grants keep working. -- A key or app can change name and avatar through `PATCH /profile` or `marfa profile update`, with profile write access for each submitted field. Email remains owner-only, and explicit email-write grants are refused. The approval screen identifies profile write access. -- Folder handover observers no longer mistake one another for commands requesting the folder. A watcher yields to an actual command without competing watchers taking the folder from one another. - -## Swift SDK v0.0.20 - -Released October 18, 2026. [v0.0.20 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.20) - -- An app using `MemberImportVisibility` can use the SDK's aliased core types with only `import Marfa`. The package exports those declarations individually, so names belonging to the SDK remain unambiguous. -- The package builds against Marfa v0.0.12, including the working-copy repair and owner passkey operations described below. -- Generated wire types preserve fields whose only JSON value is `null`. Optional null-only fields distinguish an absent key from an explicit null, and decoding a non-null value is refused. +- **An instance has a name.** Its live configuration holds a display name, Marfa by default, which the instance description and the owner's pages show; changing it needs no restart and keeps the instance ID. [Change settings while it runs](/guides/settings#change-settings-while-it-runs) covers it. +- **The owner can change their sign-in email and name.** `PATCH /owner` does it from a browser session signed in within the last five minutes, and `marfa owner update` through the private socket. [Change the owner's email or name](/guides/sign-in-as-the-owner#change-the-owners-email-or-name) covers it. +- **An email change needs no confirmation**, and the owner's password, passkeys, sessions, keys and app grants keep working. +- **A key or an app can change the profile's name and avatar.** `PATCH /profile` and `marfa profile update` need profile write access for each field sent, and email stays the owner's alone. [Change profile fields with a credential](/guides/authenticate#change-profile-fields-with-a-credential) covers it. +- **A grant to write the email is refused**, and the approval screen names profile write access. +- **A folder watch yields only to a command that asks for the folder.** Watches no longer take each other for such a command, so they don't take a folder from each other. ## v0.0.12 @@ -160,42 +207,49 @@ Released October 18, 2026. [v0.0.12 on GitHub](https://github.com/withmarfa/marf For apps and devices: -- A catalog-changing catch-up continues when an item in the working copy has unreadable stored properties. It preserves those bytes and the reported error, indexes valid tags, and removes stale property search text. Healthy items still update, and a later repair from the server replaces the damaged properties. -- `marfa device --reader watch` no longer prints a hint that it is waiting for hydration. A reader watches saves by another process without contacting the server. +- **A catch-up that changes the catalog survives an item with unreadable properties.** It keeps those bytes and the error, indexes the valid tags and removes stale search text, and healthy items still update. A later repair from the server replaces the damaged properties. +- **`marfa device --reader watch` doesn't wait for hydration.** A reader watches another process's saves without contacting the server, and no longer prints a hint that it's waiting. For running an instance: -- The owner can read item counts and export NDJSON or a restorable archive through a live browser session, without making a key or signing in again within the last five minutes. The instance's source filtering still applies. A request that also sends a credential uses that credential's access alone. -- Owners can list, rename and remove passkeys through owner operations or the local CLI. Browser changes require authentication within the last five minutes. `marfa owner passkeys add`, with the private socket, opens a fresh browser sign-in followed by passkey registration; password recovery is unchanged. -- `PORT=0` lets the operating system assign the public listener's port. `MARFA_LISTEN_HOST` selects its bind address; when unset, the runtime uses an unspecified address. Outside production, an implicit authentication origin uses the port the listener actually bound. -- Health-probe reuse, API-key and OAuth usage-stamp windows, and error-notification throttling measure elapsed time independently of changes to the wall clock. Stored dates and reported event times still use the wall clock. -- Extension permissions recognize only `read` and `write` as grants. An explicit invalid entry denies access even when a wildcard grants it. -- The [Housekeeping](/guides/housekeeping) job table is generated from the server's registered job definitions, including optional jobs. [Settings](/guides/settings) describes configurable intervals and retention windows. +- **The owner can count and export items from a browser session**, as NDJSON or a restorable archive, with no key or recent sign-in. The source filter still applies, and a request that also sends a credential uses that credential's access alone. +- **The owner can list, rename and remove passkeys**, through the owner operations or the CLI over the private socket; a change in a browser needs a sign-in within the last five minutes. [Manage passkeys and sign-ins](/guides/sign-in-as-the-owner#manage-passkeys-and-sign-ins) covers it. +- **`marfa owner passkeys add` registers a passkey** over the private socket, after a fresh browser sign-in. +- **`PORT=0` lets the operating system choose the port.** `MARFA_LISTEN_HOST` sets the bind address, an unspecified address when unset, and outside production an implicit authentication origin uses the port actually bound. [Every environment setting](/guides/settings#every-environment-setting) lists both. +- **Time windows don't move with the wall clock.** Health-probe reuse, key and OAuth usage stamps and error-notification throttling measure elapsed time; stored dates and event times still use the wall clock. +- **Extension permissions recognize only `read` and `write`.** An explicit invalid entry denies access even when a wildcard grants it. [Access](/access) covers extension maps. ## v0.0.11 Released October 18, 2026. [v0.0.11 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.11) -**Before you update:** v0.0.11 adds a column to the items table, so **it refuses a database that v0.0.10 wrote** and leaves the file as it was. Move your data by exporting it with v0.0.10 and restoring the archive into v0.0.11 on empty storage, as [Upgrade](/guides/upgrade) shows. **A restore is refused with `400 invalid_properties` if the archive holds an item with an array element of the wrong kind**, because v0.0.11 now checks array elements (see below), so correct such items in v0.0.10 before you export. +**Before you update:** v0.0.11 adds a column to the items table, so **it refuses a database that v0.0.10 wrote** and leaves the file as it was; move your data with an export and a restore, as [Upgrade](/guides/upgrade) shows. Because v0.0.11 checks array elements, **a restore is refused `400 invalid_properties` if the archive holds an array element of the wrong kind**, so correct such items in v0.0.10 before you export. For running an instance: -- A type can say how long its feed items stay, and a new `feed-expiry` housekeeping job moves the ones that have outlived it to the trash. It runs every hour by default; `FEED_EXPIRY_INTERVAL_MS` changes that. The job counts from when an item arrived, so refreshing an item by its natural key doesn't restart the count. It leaves library items, archived items and items you brought back from the trash alone, and it holds back an item whose delete would also delete an item that hasn't expired or that an edge with `cascade_on_delete` set to `block` protects. A moved item can be restored until the trash retention ends. Each move is announced as `item.deleted` and audited as `items.feed_expired`. [Housekeeping](/guides/housekeeping) covers the job and [Type rules](/catalog/your-own-types#retention-policy) the policy. -- The source filter now narrows more than listings. A stream of events, live and on a replay from a cursor, and the deliveries of a webhook subscription leave out an item whose source the filter hides, and an edge whose source item it hides. A delivery already waiting is settled unsent once the filter comes to hide its item. A copy stream, which a device keeps its working copy from, still sends such an item, marked `listed: false`. [Settings](/guides/settings#what-the-enforcement-levers-do) covers the filter. -- An item with no source is judged by the source filter as an item whose source is `unknown`, as every read already shows it. -- `MARFA_TEST_HOOKS` and `MARFA_TEST_CLOCK_OFFSET_MS` are new settings for testing the server, which let a test harness move the server's clock and set faults through the private socket. **Leave them off on any instance that holds data.** The server refuses to start when `NODE_ENV` is `production`, which the container image sets, and `MARFA_TEST_HOOKS` is on, and when `MARFA_TEST_CLOCK_OFFSET_MS` is above 0 while hooks are off. +- **A type can say how long its feed items stay.** The `feed-expiry` housekeeping job, hourly unless `FEED_EXPIRY_INTERVAL_MS` says otherwise, moves feed items past that window to the trash, counting from when each arrived, which a refresh by natural key doesn't reset. [Housekeeping](/guides/housekeeping) covers the job and [Type rules](/catalog/your-own-types#retention-policy) the policy. +- **Feed expiry leaves some items alone.** It skips library and archived items and items brought back from the trash, and holds back an item whose delete would delete one that hasn't expired, or one an edge with `cascade_on_delete: block` protects. +- **An expired item can be restored until the trash retention ends.** Each move is announced as `item.deleted` and audited as `items.feed_expired`. +- **The source filter narrows event streams and webhook deliveries.** A live or replayed stream and a webhook's deliveries leave out an item whose source the filter hides, and an edge whose source item it hides; a waiting delivery is settled unsent once its item is hidden. [What the enforcement levers do](/guides/settings#what-the-enforcement-levers-do) covers it. +- **A copy stream still sends a hidden item**, marked `listed: false`, so a device's working copy keeps it. +- **The source filter judges an item with no source as `unknown`**, as every read already shows it. +- **Two settings let a test harness control the server.** `MARFA_TEST_HOOKS` and `MARFA_TEST_CLOCK_OFFSET_MS` move the server's clock and set faults through the private socket. **Leave them off on any instance that holds data.** +- **The server refuses to start with test hooks in production.** It refuses when `NODE_ENV` is `production`, as the container image sets, and `MARFA_TEST_HOOKS` is on, and when `MARFA_TEST_CLOCK_OFFSET_MS` is above 0 while hooks are off. For the API: -- A type definition can carry `retention_policy`, an object with one key, `feed_days`: a whole number from 1 to 36500, or `null` to keep feed items although a parent type sets a window. A type that names none takes its parent's. `GET /types/{id}` shows the window after inheritance, and `GET /types` shows the one the type declares. -- **An array property now has each element checked against its `items_type`**, for items and for edges. A write that leaves an element of the wrong kind is refused `400 invalid_properties`, and `details.errors` names the property and the element's position, as `names.1`. Some writes that were accepted before are now refused. The check also covers `links` (strings) and `attachments` (objects), the two arrays every type has. It judges the properties the item would hold after the write, so an item that already stores a wrong element refuses a write that carries `properties` or changes its type and leaves that array in place, until the write corrects it. The refusal names the element, and the item stays as it was. An `items_type` that isn't a field kind leaves the elements unchecked. -- `in-folder` now shows `written_at: "none"` in `GET /edge-types`, because no file writes a folder placement. `POST /edge-types` accepts `none` as well as `source` and `target`, and refuses another value with `400 validation_error`. -- A `backref` term of a filter counts an edge only if the key's source filter shows the edge's source item, as a listing of edges does. -- The description of `core.entity` now says it covers any named entity: a person, a company, a band, a team, a charity, a brand, a school or a place. It said non-person entities before. +- **A type definition can carry `retention_policy`.** Its one key, `feed_days`, is a whole number from 1 to 36500, or `null` to keep feed items although a parent sets a window; a type that names none takes its parent's. [Retention policy](/catalog/your-own-types#retention-policy) covers it. +- **`GET /types/{id}` shows the retention window after inheritance**, and `GET /types` the one the type declares. +- **Each element of an array property is checked against its `items_type`.** For items and edges, including the `links` and `attachments` arrays every type has, a wrong element is refused `400 invalid_properties` with its position, such as `names.1`, so some writes accepted before are refused. [Fields](/catalog/your-own-types#fields) covers it. +- **An item that already holds a wrong element must correct it.** A write that carries `properties` or changes the type, and leaves that array in place, is refused until it does. An `items_type` that isn't a field kind leaves the elements unchecked. +- **`in-folder` is written at `none`.** `GET /edge-types` shows `written_at: "none"` for it, and `POST /edge-types` accepts `none`, `source` or `target` and refuses anything else with `400 validation_error`. [Edges](/catalog/edges) lists the edge types. +- **A filter's `backref` term counts an edge only if the key's source filter shows its source item**, as a listing of edges does. +- **`core.entity` covers any named entity:** a person, a company, a band, a team, a charity, a brand, a school or a place, where before it said non-person entities. [Entity](/catalog/entity) describes it. For the CLI: -- `marfa` checks array elements in the writes it queues, and flags a folder file that names an edge no file writes, such as a line for an `in-folder` edge. `marfa` lists an edge type that no file writes as written at no end. +- **The CLI checks array elements in the writes it queues.** +- **The CLI flags a folder file that names an edge no file writes**, such as a line for an `in-folder` edge, and lists such an edge type as written at no end. ## v0.0.10 @@ -203,22 +257,27 @@ Released October 18, 2026. [v0.0.10 on GitHub](https://github.com/withmarfa/marf For the CLI: -- `marfa device watch` holds a working copy's watch open and prints a line for each change, as an app's listener is told them. With `--reader`, it holds no event stream and prints a line for each save another process makes instead. It ends with the error if the watch fails, and `--for ` stops it after that long. Without `--reader`, it drains the queue once each time the server is reachable again after it was lost, and once at its first stream after a drain left a write undelivered. -- `marfa device changes` checks the store for a save by another process every 250 milliseconds, not every 100. +- **`marfa device watch` holds a working copy's watch open** and prints a line for each change, as an app's listener is told them. It ends with the error if the watch fails, and `--for ` stops it after that long. +- **`marfa device watch --reader` follows another process's saves**, holding no event stream. +- **`marfa device watch` drains the queue when the server comes back.** Without `--reader`, it drains once each time the server is reachable again after it was lost, and once at its first stream after a drain left a write undelivered. +- **`marfa device changes` checks for another process's saves every 250 milliseconds**, not every 100. For apps and devices: -- An app can watch a working copy once and share the watch among any number of listeners. `Core::watch` in the core, `core.watch(onChange, onEnd)` in the Node module and `Core.watch` in the Swift binding hold one event stream for all of them, and tell each listener the changes made after it joined, in order. One listener that is slow to return delays the others but makes them lose no change. A watch of a store opened to read tells `store.saved`, with the data version, after another process saves, and a watch that fails tells each listener `watch.failed` once. A working copy with no server that isn't opened to read is refused `no_server`. -- A hydration or catch-up takes the stream from a running watch and gives it back, so an app no longer stops its listeners around either. One called from inside a listener's change handler is refused `invalid`, because it would wait on its own thread. -- A listener is told `server.unreachable` once for each loss of the server and `server.reachable` once for each return, and a listener that joins during an outage is told about it first. Any request that reaches the server ends an outage: the stream ending on the server's own answer, a hydration or catch-up that succeeded, or a drain that answered a write. -- A drain that left a write undelivered is finished by the first stream a watch has afterward, even after the app restarts. Before, only an outage that the same stream saw earned a drain. A working copy where an app only holds writes, with the server never lost, still drains nothing by itself. -- A link in a body written on another device keeps naming its item after that item is renamed, and after another item takes the old title. Before, a working copy recorded which item each link named only for bodies it wrote itself, so after a rename such a link read as `pending`, and a later write could answer it with the item that took the title. The copy now records it just before a name changes, and before a hydration replaces what it holds. A body and edge that reach the copy only after the rename, and a rename made before this release, leave no record, so the link is read by the names the copy holds. -- A link that another device takes out of a body and puts back is read afresh, by the titles the copy holds then, not by the item the link named before it was taken out. -- A write whose JSON body nests more than 64 levels, counting the outermost object as the first, is refused `invalid` when it is queued, as the server would refuse it, and nothing is queued. This holds in the CLI, the Node module and the Swift binding. Before, a body only a little too deep was queued and left for the server to refuse. -- A list or search filtered on properties no longer fails as a whole because one row's stored text isn't JSON. That row matches no property condition, `not_exists` included, so a filtered list leaves it out. A folder placement whose stored text isn't JSON no longer fails the lookup of an item that a body link names by its path. -- A read that refuses a row whose stored properties can't be read, as the CLI and the Node module do, also refuses a row whose stored properties aren't an object, instead of showing them as empty. An edit to an edge with unreadable properties is refused, and a hydration replaces the edge from the server. -- The Swift binding hands properties, queued bodies and answers to an app as JSON values (`JsonValue`, and `JsonObject` for a set of properties) in place of JSON text, so the app doesn't parse text again. A queued write's `answer` arrives parsed. -- The Swift binding keeps a row whose stored properties can't be read in a list, a search or an edge read, with empty properties and `unreadableProperties` saying why, in place of failing the whole read. A write to that item still fails, and a hydration replaces it from the server. The Swift SDK adopts the Swift binding's changes in its own release. +- **Any number of listeners can share one watch of a working copy.** `Core::watch` in the core, `core.watch(onChange, onEnd)` in the Node module and `Core.watch` in the Swift binding hold one event stream, and tell each listener, in order, the changes made after it joined. +- **A slow listener delays the others but makes them lose no change.** +- **A watch of a store opened to read tells `store.saved`**, with the data version, after another process saves, and a watch that fails tells each listener `watch.failed` once. +- **A watch of a working copy with no server is refused `no_server`**, unless the copy is opened to read. +- **A hydration or catch-up borrows a running watch's stream**, so an app no longer stops its listeners around either. One called from inside a listener's change handler is refused `invalid`. +- **A listener hears of each outage once.** It's told `server.unreachable` once for each loss and `server.reachable` once for each return, and a listener that joins during an outage is told first. Any request that reaches the server ends an outage. +- **A watch finishes an undelivered drain on its first stream**, even after the app restarts. A copy whose server was never lost still drains nothing by itself. +- **A body link written on another device keeps naming its item after a rename.** It no longer reads as `pending` or lands on the item that took the old title. A link that reaches the copy after the rename, or a rename made before this release, is read by the titles the copy holds. +- **A link taken out of a body and put back is read afresh**, by the titles the copy holds then. +- **A write nested more than 64 levels deep is refused when it's queued.** Counting the outermost object as the first level, it's refused `invalid` and nothing is queued, in the CLI, the Node module and the Swift binding. +- **A list or search filtered on properties survives a row whose stored text isn't JSON.** That row matches no property condition, `not_exists` included, and a folder placement that isn't JSON no longer fails a body link's lookup by path. +- **A row whose stored properties aren't an object is refused** by a read that refuses unreadable rows, as the CLI and the Node module do. An edit to an edge with unreadable properties is refused, and a hydration replaces the edge. +- **The Swift binding hands over JSON values, not text.** Properties, queued bodies and answers arrive as `JsonValue`, or `JsonObject` for a set of properties. +- **The Swift binding keeps an unreadable row in a read.** A list, search or edge read returns it with empty properties and `unreadableProperties` saying why; a write to it still fails, and a hydration replaces it. The Swift SDK took these changes in [Swift SDK v0.0.18](#swift-sdk-v0018). ## v0.0.9 @@ -226,42 +285,49 @@ Released October 16, 2026. [v0.0.9 on GitHub](https://github.com/withmarfa/marfa For running an instance: -- A web page can connect with one approval and no pasted key. The discovery documents and `POST /auth/oauth2/register` answer a request from any origin, and once the owner approves the page, the server lets the origin of its redirect URIs call the API from a browser, with no `CORS_ORIGINS` entry. [Connect a web page](/guides/connect-a-web-page) covers it. -- Because any page can now register, a registration that never receives a grant is removed 1 day after its registration or the last time its approval page was offered, rather than 30 days (`MARFA_DCR_CLIENT_RETENTION_DAYS`). A registration takes a body of at most 32,768 bytes and at most 10 redirect URIs. -- Registering a client that authenticates with `private_key_jwt` is refused with `400 invalid_client_metadata`, and the discovery documents list the client authentication methods the instance accepts. -- A connector operation that only the connector's own key can make answers an owner session or the private socket with `403 forbidden`, not `401`. -- Bulk imports no longer delay unrelated outbound webhooks, and listing the owner's connected apps no longer reads every active item, so it stays quick on a large store. +- **A web page can connect with one approval and no pasted key.** The discovery documents and `POST /auth/oauth2/register` answer any origin, and once the owner approves the page, the origins of its redirect URIs may call the API from a browser with no `CORS_ORIGINS` entry. [Connect a web page](/guides/connect-a-web-page) covers it. +- **A registration that never receives a grant is removed after 1 day,** not 30 (`MARFA_DCR_CLIENT_RETENTION_DAYS`). +- **A registration takes a body of at most 32,768 bytes** and at most 10 redirect URIs. +- **Registering a client that uses `private_key_jwt` is refused** with `400 invalid_client_metadata`, and the discovery documents list the client authentication methods the instance accepts. +- **A connector-only operation answers an owner session `403 forbidden`**, not `401`, and so does the private socket. +- **Bulk imports no longer delay unrelated outbound webhooks.** +- **Listing the owner's connected apps stays quick on a large store.** For the API: -- An operation that takes a JSON body refuses a top-level key it doesn't declare with `400 validation_error`, and `details.unknown_body_fields` lists the keys. Before, `POST /items`, `POST /edges` and several others dropped the key and answered success. `PUT /items/{id}/extensions/{namespace}` and `POST /auth/oauth2/register` still accept any key, and the operations that define a type or an edge type refuse one as `invalid_schema`. -- A write can require the exact version you read. `PATCH /items/{id}` takes `version_match: "exact"`, `POST /items/{id}/transition` takes a `version` query parameter, and `POST /edges` and `DELETE /edges/{id}` take a `source_version` query parameter. A write whose version no longer matches is refused with `409 version_conflict` and changes nothing. -- A write can name the instance it was made for in `X-Marfa-Instance-Id`. An instance with a different `instance_id` refuses it with `409 instance_changed` and writes nothing, so a write meant for one instance can't land on another that took over the address. -- `POST /items/{id}/tags`, `DELETE /items/{id}/tags/{tag}`, `PUT` and `PATCH /items/{id}/metadata`, and `PUT` and `DELETE /items/{id}/extensions/{namespace}` honor `Idempotency-Key`: a repeat is answered from the record of the first and changes nothing. The matching CLI commands take `--idempotency-key`. -- `GET /edges`, `GET /items/{id}/edges`, `GET /items/{id}/backrefs` and the `edges` and `backrefs` blocks of an item leave out an edge whose source item a key's source filter hides, whatever the source of its target. -- `POST /edge-types` with `evolve_constraints: true` widens the endpoint constraints of a registered edge type in place, keeping its definition and its edges. It answers `200`, or `404` if the type isn't registered. -- The API description types the `version` of an item, an edge, a type and a version snapshot as an integer, so a generated client reads a whole number. +- **An undeclared top-level key in a JSON body is refused.** The operation answers `400 validation_error` and lists the keys in `details.unknown_body_fields`, where `POST /items`, `POST /edges` and others dropped the key and answered success. +- **Three kinds of operation treat unknown keys differently.** `PUT /items/{id}/extensions/{namespace}` and `POST /auth/oauth2/register` accept any key, and the operations that define a type or an edge type refuse one as `invalid_schema`. +- **A write can require the exact version you read.** `PATCH /items/{id}` takes `version_match: "exact"`, `POST /items/{id}/transition` a `version` query parameter, and `POST /edges` and `DELETE /edges/{id}` a `source_version` query parameter; a mismatch is refused `409 version_conflict`. [Send the version you read](/guides/write-safely#send-the-version-you-read) covers it. +- **A write can name the instance it was made for** in `X-Marfa-Instance-Id`. Another instance refuses it with `409 instance_changed` and writes nothing, so a write can't land on an instance that took over the address. +- **Tag, metadata and extension writes honor `Idempotency-Key`.** `POST /items/{id}/tags`, `DELETE /items/{id}/tags/{tag}`, `PUT` and `PATCH /items/{id}/metadata`, and `PUT` and `DELETE /items/{id}/extensions/{namespace}` answer a repeat from the first, and the matching CLI commands take `--idempotency-key`. [Retry with an Idempotency-Key](/guides/write-safely#retry-with-an-idempotency-key) covers it. +- **Edge reads follow the source filter.** `GET /edges`, `GET /items/{id}/edges`, `GET /items/{id}/backrefs` and an item's `edges` and `backrefs` leave out an edge whose source item the key's source filter hides, whatever the source of its target. +- **`POST /edge-types` with `evolve_constraints: true` widens an edge type in place,** keeping its definition and its edges. It answers `200`, or `404` if the type isn't registered. +- **The API description types every `version` as an integer**, for an item, an edge, a type and a version snapshot, so a generated client reads a whole number. For the CLI: -- `marfa types scaffold ` prints a starting type definition from an id and its fields (`--field`, `--required`, `--parent`, `--description`), without a server or a key. -- `marfa folders watch` with no directory keeps every folder on the machine in step, in one process, and `marfa folders autostart on` runs it from login as a launchd agent on macOS or a systemd user unit on Linux. `off` removes it and `status` reports it. [Keep folders running](/guides/keep-folders-running) covers it. -- A command that works a folder, such as `marfa folders confirm`, asks a running watch for the folder instead of being refused. The watch lets it go and takes it back after the command ends. A watch that doesn't let go within 10 seconds leaves the command refused with `reading_handle`. -- A watch keeps a record of its folders in `folder-watch.json` for other programs on the machine to read. [Folder watch record](/folder-watch-record) covers it. -- `marfa folders add` and `marfa folders watch` warn once on standard error when a folder is in iCloud Drive, a cloud service's synced location or a network file system, and go on. A refused `marfa folders add` leaves behind no directory it made. -- `marfa folders push --send-only` sends what a folder holds and writes or removes no file in it. `marfa folders unmatched ` lists the files a folder keeps whose items its search no longer matches, and `--remove` takes them away after a confirmation or with `--yes`. -- A Markdown document's file name and its item's title stay in step in a folder: renaming the file sets the title, and a new title renames a file whose name still matches the previous title. A file you named differently, or an empty title, leaves the file name as it is. -- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the CLI exports one OpenTelemetry trace for each command. With `MARFA_POSTHOG_HOST` and `MARFA_POSTHOG_PROJECT_TOKEN` set as well, a failed command is also sent to PostHog error tracking. With none set, the CLI sends nothing. +- **`marfa types scaffold ` prints a starting type definition** from an ID and its fields (`--field`, `--required`, `--parent`, `--description`), with no server or key. [Define a type](/guides/define-a-type) uses it. +- **`marfa folders watch` with no directory keeps every folder on the machine in step**, in one process. [Watch every folder](/guides/keep-folders-running#watch-every-folder) covers it. +- **`marfa folders autostart on` runs the watch from login**, as a launchd agent on macOS or a systemd user unit on Linux; `off` removes it and `status` reports it. [Sync from login](/guides/keep-folders-running#sync-from-login) covers it. +- **A folder command asks a running watch for the folder.** A command such as `marfa folders confirm` takes the folder and gives it back when it ends, and is refused `reading_handle` if the watch doesn't let go within 10 seconds. [Work on a folder while the watch runs](/guides/keep-folders-running#work-on-a-folder-while-the-watch-runs) covers it. +- **A watch records its folders in `folder-watch.json`** for other programs on the machine to read. [Folder watch record](/folder-watch-record) describes it. +- **`marfa folders add` and `marfa folders watch` warn about synced storage.** They warn once on standard error for a folder in iCloud Drive, a cloud service's synced location or a network file system, and go on. +- **A refused `marfa folders add` leaves behind no directory it made.** +- **`marfa folders push --send-only` sends without touching files.** It sends what a folder holds and writes or removes no file in it. [Send without pulling](/guides/keep-folders-running#send-without-pulling) covers it. +- **`marfa folders unmatched ` lists files the folder's search no longer matches,** and `--remove` takes them away after a confirmation or with `--yes`. [Remove files a folder keeps but no longer holds](/guides/keep-folders-running#remove-files-a-folder-keeps-but-no-longer-holds) covers it. +- **A Markdown document's file name and title stay in step.** Renaming the file sets the title, and a new title renames a file whose name still matches the old title; a name you chose, or an empty title, leaves the file name as it is. [Edits on either side](/guides/sync-a-folder#edits-on-either-side) covers it. +- **The CLI can send telemetry.** With `OTEL_EXPORTER_OTLP_ENDPOINT` set, it exports one trace for each command, and with `MARFA_POSTHOG_HOST` and `MARFA_POSTHOG_PROJECT_TOKEN` too, a failed command goes to PostHog error tracking; with none set, it sends nothing. [What the CLI sends](/guides/telemetry#what-the-cli-sends) covers it. For apps and devices: -- A body link keeps naming the item its edge was made to when other links are added to the body or taken out of it, and a link by an item's id always names that item, whatever other items are titled. Before, a new link could move an existing link to another item, and a link by id could land on an item titled with that id. -- An app can forget only the answered writes it names, so an answer recorded between its read and its clear isn't lost unread. Use `forgetAnswered(ids:)` in the Swift SDK from v0.0.17, `forgetAnsweredIds` in the Node module, or `marfa device forget --id `, which you can repeat. Without ids, `marfa device forget` still clears every answered write it can, as before. -- A working copy that holds the event stream, such as `marfa device follow`, drains its queue once when the server is reachable again, and reports the result as a `queue.drained` or `queue.not_drained` change, so an app no longer has to drain itself. -- A working copy refuses a write to a `system.*` item before it saves or queues anything, with `403 type_not_permitted`. -- A row that a slice holds in the trash stays in the working copy after a drain reads it back, and a person can discard a create that no read can land, so the queue can empty. -- Moving the source of several edges costs one hydration for the whole batch rather than one for each. -- A catch-up report no longer has a `reached_head` field, in the core, the Node module or the Swift binding. +- **A body link keeps naming the item its edge was made to** when other links are added to the body or taken out. A link by an item's ID always names that item, whatever other items are titled. +- **An app can forget only the answered writes it names**, so an answer recorded between its read and its clear isn't lost unread. Use `forgetAnswered(ids:)` in the Swift SDK from v0.0.17, `forgetAnsweredIds` in the Node module, or `marfa device forget --id `, which you can repeat. +- **A working copy that holds the event stream drains when the server comes back.** A copy such as `marfa device follow` drains once and reports `queue.drained` or `queue.not_drained`, so an app no longer has to drain itself. +- **A working copy refuses a write to a `system.*` item** with `403 type_not_permitted`, before it saves or queues anything. +- **A row the slice holds in the trash stays in the copy after a drain reads it back.** +- **A person can discard a create that no read can land**, so the queue can empty. +- **Moving the source of several edges costs one hydration** for the whole batch. +- **A catch-up report has no `reached_head` field**, in the core, the Node module or the Swift binding. ## v0.0.8 @@ -269,23 +335,23 @@ Released October 14, 2026. [v0.0.8 on GitHub](https://github.com/withmarfa/marfa For running an instance: -- The CLI for macOS is signed and notarized by Apple, so a copy downloaded in a browser runs, as one downloaded in a terminal does. -- One setting, `OTEL_EXPORTER_OTLP_ENDPOINT`, turns on the server's telemetry: with it set, the server sends its traces and logs to that endpoint. `MARFA_OTEL_ENABLED` is gone. -- Replacing a connector's revoked or expired key keeps its inbound webhook addresses and the deliveries waiting for it. +- **The CLI for macOS is signed and notarized by Apple**, so a copy downloaded in a browser runs, as one downloaded in a terminal does. +- **One setting turns on the server's telemetry.** With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the server sends its traces and logs to that endpoint, and `MARFA_OTEL_ENABLED` is gone. [Send traces and logs](/guides/telemetry#send-traces-and-logs) covers it. +- **Replacing a connector's revoked or expired key keeps its inbound webhook addresses** and the deliveries waiting for it. For the API and the CLI: -- An edge write checks the edge's properties against its edge type's `property_schema`, as an item write checks an item's properties against its type, and refuses a wrong value with `400 invalid_properties`. -- `POST /items/bulk-get` takes `X-Marfa-Read-View`, as reading one item does: a view that changed is refused with `409 read_view_changed`, and the answer's `listed` says, for each item, whether listings show it. -- `marfa folders health ` reads a folder's sync health from the instance, as each computer that follows the folder reports it. -- An event stream keeps a held change when its key gains read access while the stream opens. +- **An edge write is checked against its edge type's `property_schema`**, as an item write is against its type, and a wrong value is refused `400 invalid_properties`. +- **`POST /items/bulk-get` takes `X-Marfa-Read-View`.** A view that changed is refused `409 read_view_changed`, and the answer's `listed` says whether listings show each item. +- **`marfa folders health ` reads a folder's sync health** from the instance, as each computer that follows the folder reports it. +- **An event stream keeps a held change** when its key gains read access while the stream opens. For apps and devices: -- A folder can be added when a directory above it is a symbolic link, as `/tmp` and `/var` are on macOS. -- An edge edit that changes nothing queues no write, so the edge's version stays and no `edge.updated` is announced. -- A working copy reads the items it keeps outside its slice in bulk, so opening one that keeps many sends far fewer requests. -- A catch-up says whether it changed the catalog of types the copy holds, also when it fails. +- **A folder can be added below a symbolic link**, as `/tmp` and `/var` are on macOS. +- **An edge edit that changes nothing queues no write**, so the edge's version stays and no `edge.updated` is announced. +- **A working copy reads the items it keeps outside its slice in bulk**, so opening one that keeps many sends far fewer requests. +- **A catch-up says whether it changed the copy's catalog of types**, also when it fails. ## v0.0.7 @@ -293,126 +359,217 @@ Released October 11, 2026. [v0.0.7 on GitHub](https://github.com/withmarfa/marfa For claiming and access: -- A new instance is claimed once, by its owner, with a setup code from the server's log, a single-use link opened in a browser, or `marfa --socket setup claim` on the instance's own machine. The owner and explicit management permissions replace the operator key. -- `--socket` gives the CLI the machine's own authority through a private socket, for `setup`, making keys, restoring an archive, and `owner recover`, which resets a lost owner password. -- Managing an instance takes a named permission, such as `keys.mint`, `audit.read` or `config.manage`, that a key or an app is given on purpose. -- A key can be made with an expiry, and given, changed or cleared one later with `marfa keys create` and `marfa keys update`. An expired key is refused with `401`, as a revoked one is. -- An app the owner connected stays signed in when the owner's browser session ends, and every app is shown as unverified on the consent and sign-in pages. -- An extension namespace is reached only through a key's extension map, never through its label. -- The owner can add passkeys, and list, rename and end apps and keys alongside browser sessions. A browser session ends after a week without use; ending one leaves apps connected. -- A device authorization request must name its scopes. Signed-in owner approval has lookup limits independent of anonymous requests. +- **A new instance is claimed once, by its owner.** Claim it with a setup code from the server's log, a single-use link opened in a browser, or `marfa --socket setup claim` on the instance's own machine. [Claim the instance](/quickstart#claim-the-instance) covers it. +- **The owner and named permissions replace the operator key.** Managing an instance takes a permission given to a key or an app on purpose, such as `keys.mint`, `audit.read` or `config.manage`. [Permissions](/catalog/permissions) lists them. +- **`--socket` gives the CLI the machine's own authority** through a private socket, for `setup`, making keys, restoring an archive and `owner recover`, which resets a lost owner password. [CLI](/cli) covers the socket. +- **A key can expire.** `marfa keys create` and `marfa keys update` set, change or clear an expiry, and an expired key is refused `401`, as a revoked one is. [Make a key expire](/guides/manage-keys#make-a-key-expire) covers it. +- **An app the owner connected stays signed in when the owner's browser session ends.** +- **Every app is shown as unverified** on the consent and sign-in pages. +- **An extension namespace is reached only through a key's extension map**, never through its label. +- **The owner can add passkeys**, and list, rename and end apps and keys beside browser sessions. +- **A browser session ends after a week without use**, and ending one leaves apps connected. +- **A device authorization request must name its scopes.** +- **A signed-in owner's approval has its own lookup limits**, apart from anonymous requests. For running an instance: -- A Railway template creates a personal instance with persistent storage, HTTPS and daily backups. [Deploy](/guides/deploy) covers it. -- An upload or a restore that would fill the disk is refused with `507 insufficient_storage`, rather than failing later writes. -- Text extraction from office documents is bounded, so a large document can't exhaust the server's memory. -- The server's diagnostics never carry values from the database. -- Only the sign-in routes Marfa uses answer under `/auth/`, and password changes count toward the sign-in limits. -- A restart recovers a stale private socket left by a crash and refuses to disrupt a live listener. -- An archive restores app-written records under `restored:` sources and marks restored app connections revoked, preserving their history without reconnecting them. -- A working copy verifies the same instance at a changed address before sending credentials. Folder sync preserves empty frontmatter properties and defers deletion after an incomplete scan. +- **A Railway template creates a personal instance** with persistent storage, HTTPS and daily backups. [Deploy](/guides/deploy) covers it. +- **An upload or a restore that would fill the disk is refused** with `507 insufficient_storage`, rather than failing later writes. +- **Text extraction from office documents is bounded**, so a large document can't exhaust the server's memory. +- **The server's diagnostics never carry values from the database.** +- **Only the sign-in routes Marfa uses answer under `/auth/`.** +- **Password changes count toward the sign-in limits.** +- **A restart recovers a stale private socket** left by a crash, and refuses to disrupt a live listener. +- **A restore keeps app-written records under `restored:` sources** and marks restored app connections revoked, keeping their history without reconnecting them. +- **A working copy checks that a changed address is the same instance** before it sends credentials. +- **Folder sync keeps empty frontmatter properties**, and defers deletion after an incomplete scan. For the API and the CLI: -- Moving an item into or out of the trash with `POST /items/{id}/transition` announces `item.deleted` or `item.restored`, as a delete or a restore does. -- The CLI warns once before it sends a credential over plain `http` to a host other than this machine. `--allow-http` says that's intended. -- A device client that can't refresh its token is refused `offline_access`, rather than approved without a refresh token. -- Version history identifies the sign-in that wrote each version and carries a cursor to continue its pages. -- `POST /types/normalize` and `marfa types normalize` compare a candidate with its registered declared and resolved definitions without writing it. Unknown type and edge type schema keys are refused. -- Connector registrations expose persistent problems separately from private state and run results. A connector replaces or clears problems under its hold. +- **A transition into or out of the trash announces `item.deleted` or `item.restored`**, as a delete or a restore does. +- **The CLI warns once before it sends a credential over plain `http`** to a host other than this machine, and `--allow-http` says that's intended. +- **A device client that can't refresh its token is refused `offline_access`**, rather than approved without a refresh token. +- **Version history names the sign-in that wrote each version**, and carries a cursor to continue its pages. [History](/versions#history) covers it. +- **`POST /types/normalize` and `marfa types normalize` compare a candidate with the registered type** without writing it. [Compare a definition without registering it](/guides/define-a-type#compare-a-definition-without-registering-it) covers it. +- **Unknown type and edge type schema keys are refused.** +- **A connector registration shows persistent problems apart from its state and runs.** A connector replaces or clears its problems under its hold. ## v0.0.6 Released October 7, 2026. [v0.0.6 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.6) -- `marfa docs` reads this site from a terminal: it searches the pages, lists them, and prints one as Markdown. [Docs as Markdown](/docs-as-markdown) covers it. -- `marfa login` keeps waiting when the server asks it to slow down, and a new instance tells its operator how to create the owner. -- A conflicted copy is linked to its original by a `derived-from` edge, so an app can show the two together on any device. -- A file item carries the size of its bytes in `size_bytes`, set by the server from the blob. -- When the trash or a revoked app's grant is purged by its retention sweep, the purge is announced as an event, so a device learns the item is gone. -- `GET /occurrences` places a whole-day event from local midnight in its time zone, whether it repeats or not. -- A housekeeping run that outlives its deadline is ended and recorded as an error, so the job can run again. -- A working copy, in the Swift SDK or the CLI's device commands, can read the trash and restore or purge from it, hold both tiers in one slice, read and change a folder's settings, and move one end of an edge in a single write. Links and embeds in an item's body become edges when it's written through a copy, as they do in a folder. +- **`marfa docs` reads this site from a terminal.** It searches the pages, lists them, and prints one as Markdown. [Docs as Markdown](/docs-as-markdown) covers it. +- **`marfa login` keeps waiting when the server asks it to slow down.** +- **A new instance tells its operator how to create the owner.** +- **A conflicted copy is linked to its original by a `derived-from` edge**, so an app can show the two together on any device. +- **A file item carries the size of its bytes in `size_bytes`**, set by the server from the blob. +- **A retention purge is announced as an event**, for the trash or a revoked app's grant, so a device learns the item is gone. +- **`GET /occurrences` places a whole-day event from local midnight** in its time zone, whether it repeats or not. +- **A housekeeping run that outlives its deadline is ended** and recorded as an error, so the job can run again. +- **A working copy can use the trash.** In the Swift SDK or the CLI's device commands, it reads the trash and restores or purges from it. +- **A working copy can hold both tiers in one slice**, read and change a folder's settings, and move one end of an edge in a single write. +- **Links and embeds in a body become edges** when the item is written through a working copy, as they do in a folder. ## v0.0.5 Released October 6, 2026. [v0.0.5 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.5) -The Swift SDK's binding builds again. v0.0.4 had updated a library the binding is made with to a version its build tool can't read. Nothing else changed. +- **The Swift SDK's binding builds again.** v0.0.4 had updated a library the binding is made with to a version its build tool can't read. Nothing else changed. ## v0.0.4 Released October 5, 2026. [v0.0.4 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.4) -Some of these changes need a change in your code: +Changes that need a change in your code: -- Several operations are renamed so that each verb means one thing, such as `updateType` to `replaceType`, `mergeItemMetadata` to `updateItemMetadata` and `createEdgeType` to `registerEdgeType`. Canceling a bulk action job is `POST /items/bulk-actions/jobs/{id}/cancel`. -- Every frame on the event stream names its event in `event_type`, the same field a webhook delivery uses. -- A request body sent without `Content-Type: application/json` is refused with `400 validation_error`. -- Every operation refuses a query parameter it doesn't recognize, and input the server can't read answers `400` rather than `500`. +- **Operations are renamed so each verb means one thing**, such as `updateType` to `replaceType`, `mergeItemMetadata` to `updateItemMetadata` and `createEdgeType` to `registerEdgeType`. +- **Canceling a bulk action job is `POST /items/bulk-actions/jobs/{id}/cancel`.** +- **Every frame on the event stream names its event in `event_type`**, the field a webhook delivery uses. +- **A request body sent without `Content-Type: application/json` is refused** with `400 validation_error`. +- **Every operation refuses a query parameter it doesn't recognize**, and input the server can't read answers `400` rather than `500`. For access and files: -- A key holds exactly the permissions it names, and an item of a type the key can't read answers the same on every operation. -- An expired key is no longer found by the key operations. -- An edge or an extension that names a blob lets a credential that reads it fetch the blob. +- **A key holds exactly the permissions it names**, and an item of a type the key can't read answers the same on every operation. +- **The key operations no longer find an expired key.** +- **A credential that reads an edge or an extension that names a blob can fetch the blob.** For running an instance: -- The archive export streams, and a restore brings back items with their dates and history, all or nothing. -- `/health` reports a database, write path or disk that is down as a failure. -- In the container image, a database from another build leaves the container running and unhealthy, with the reason in its log, rather than restarting it. -- The sign-in, consent and device approval pages work as they say, and every HTML page carries a content security policy. +- **The archive export streams.** +- **A restore brings back items with their dates and history**, all or nothing. +- **`/health` reports a database, write path or disk that is down as a failure.** +- **A database from another build leaves the container running and unhealthy**, with the reason in its log, rather than restarting it. +- **The sign-in, consent and device approval pages work as they say.** +- **Every HTML page carries a content security policy.** For devices and folders: -- A search matches the same rows on the server and in a working copy. -- An app can read and save to its working copy before its first sync, and declares the types it uses. -- The Swift SDK can add, sync and watch folders as the CLI does. -- A folder's first sync says what it will write and send, and waits for `marfa folders confirm`, or `--yes` on `marfa folders add`. +- **A search matches the same rows on the server and in a working copy.** +- **An app can read and save to its working copy before its first sync**, and declares the types it uses. +- **The Swift SDK adds, syncs and watches folders** as the CLI does. +- **A folder's first sync waits for you.** It says what it will write and send, and waits for `marfa folders confirm`, or `--yes` on `marfa folders add`. [Sync a folder](/guides/sync-a-folder) covers it. ## v0.0.3 Released October 3, 2026. [v0.0.3 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.3) -Folders arrive: a directory on your machine kept in step with your instance, as Markdown and other files. A folder's settings are a `system.folder` item, shared by every machine bound to it, and a file's frontmatter carries its item's fields and edges. Moving a file from one folder to another on the same machine is a move, not a delete, and a large removal waits for you to confirm it. +For folders, a directory on your machine kept in step with your instance as Markdown and other files ([Sync a folder](/guides/sync-a-folder)): + +- **A folder's settings are a `system.folder` item**, shared by every machine bound to it. +- **A file's frontmatter carries its item's fields and edges.** +- **Moving a file from one folder to another on the same machine is a move**, not a delete. +- **A large removal waits for you to confirm it.** [A large removal waits for you](/guides/folder-safeguards#a-large-removal-waits-for-you) covers it. For connectors: -- Inbound webhooks: a connector can have an address on the instance where a service such as GitHub posts its changes. -- A connector holds its registration while it runs, and keeps its state on the instance. -- A type can name a link field, unique across its items, and a purge leaves a tombstone, so a connector doesn't bring a purged record back. `POST /items/lookup` finds items and tombstones by link, natural key or ID. +- **A connector can have an inbound webhook address** on the instance, where a service such as GitHub posts its changes. [Receive vendor webhooks](/guides/connector-webhooks) covers it. +- **A connector holds its registration while it runs**, and keeps its state on the instance. [Holds and retries](/guides/connector-holds-and-retries) covers it. +- **A type can name a link field**, unique across its items. +- **A purge leaves a tombstone**, so a connector doesn't bring a purged record back. +- **`POST /items/lookup` finds items and tombstones** by link, natural key or ID. For the API: -- A refused write names the grant the key lacks in `details.grant`, or says the item is in the trash. -- `DELETE /items/{id}` takes the `version` you read. -- An item of a type the key can't read answers `404 item_not_found`, as a missing one does. -- An edge type can declare a reverse name. -- `GET /items/stats` counts the items a listing's filters match. -- An `Idempotency-Key` belongs to the credential that sent it. -- Every blob downloads as a sandboxed file. +- **A refused write names the grant the key lacks** in `details.grant`, or says the item is in the trash. +- **`DELETE /items/{id}` takes the `version` you read.** +- **An item of a type the key can't read answers `404 item_not_found`**, as a missing one does. +- **An edge type can declare a reverse name.** +- **`GET /items/stats` counts the items a listing's filters match.** [Count items](/guides/list-filter-search#count-items) covers it. +- **An `Idempotency-Key` belongs to the credential that sent it.** +- **Every blob downloads as a sandboxed file.** For running an instance: -- Every setting is checked when the server starts, and a bad value stops it with a message that names the setting. -- The server refuses a database written by another build, naming what differs. -- With telemetry on, unhandled errors can go to PostHog's error tracking. +- **Every setting is checked when the server starts**, and a bad value stops it with a message that names the setting. +- **The server refuses a database written by another build**, naming what differs. +- **With telemetry on, unhandled errors can go to PostHog's error tracking.** [Report errors to PostHog](/guides/telemetry#report-errors-to-posthog) covers it. + +For the CLI: -For the CLI, `marfa items add` adds a file as an item of its own, and the first run of a new instance is a series of `marfa` commands. Under `--json`, a usage error is a JSON refusal like any other. +- **`marfa items add` adds a file as an item of its own.** +- **The first run of a new instance is a series of `marfa` commands.** +- **Under `--json`, a usage error is a JSON refusal** like any other. ## v0.0.2 Released September 27, 2026. [v0.0.2 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.2) -- A key can read its own grants, with `GET /keys/current` or `marfa keys current`. -- The event stream sends `stream_live` once it has replayed what you missed, so a reader can tell it has caught up. -- `marfa keys update --no-permissions` takes every permission and map from a key, so a key made too wide can be emptied without making a new one. -- The TypeScript SDK refuses a credential with characters a bearer token can't hold, without repeating the credential in the error, and sends a body of bytes as the bytes. +- **A key can read its own grants**, with `GET /keys/current` or `marfa keys current`. +- **The event stream sends `stream_live` once it has replayed what you missed**, so a reader can tell it has caught up. +- **`marfa keys update --no-permissions` takes every permission and map from a key**, so a key made too wide can be emptied without making a new one. +- **The TypeScript SDK refuses a credential with characters a bearer token can't hold**, without repeating the credential in the error. +- **The TypeScript SDK sends a body of bytes as the bytes.** ## v0.0.1 Released September 27, 2026. [v0.0.1 on GitHub](https://github.com/withmarfa/marfa/releases/tag/v0.0.1) The first release: the server, the CLI for macOS on Apple silicon, and the TypeScript SDK. + +## Swift SDK releases + +The [Swift SDK](/swift-sdk) is released from its own repository, and each release carries a build of Marfa's core. + +### Swift SDK v0.0.24 + +Released October 21, 2026. [v0.0.24 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.24) + +- **Builds against Marfa's core from after v0.0.20**, with `MarfaTypes` generated again from its API description. The `Marfa` API is unchanged. + +### Swift SDK v0.0.23 + +Released October 20, 2026. [v0.0.23 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.23) + +- **Builds against Marfa v0.0.18.** +- **`BlockedReason.failingAlone` is new.** A write set aside for failing alone is blocked with it, so an exhaustive `switch` over `BlockedReason` needs the case. [Devices](/devices#verdicts) explains the reason. +- **`QueuedWrite.lastFailure` and `environmentalStreak` are new.** `lastFailure` holds the status and the start of the text body of the last failure that wasn't an answer to the write, and `environmentalStreak` counts the drains in a row that met such a failure. +- **`Queue.withdraw(_:)` also takes a write blocked `failingAlone`**, and returns `false` instead of throwing `invalid` when the copy moved while it read the row from the server. +- **`WorkingCopy.open(store:server:)` throws `MarfaError.store` when it can't write the store's lock file.** + +### Swift SDK v0.0.22 + +Released October 20, 2026. [v0.0.22 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.22) + +- **Builds against Marfa's core from after v0.0.17.** +- **`MarfaTesting` is new.** `WorkingCopy.fake(items:itemTypes:)` gives tests and previews a working copy with no store, server or core. + +### Swift SDK v0.0.21 + +Released October 18, 2026. [v0.0.21 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.21) + +- **Builds against Marfa v0.0.13**, including its folder handover repair. +- **The generated types cover owner and delegated profile updates**, and accept the instance's display name as a string. +- **Optional nullable fields tell omission from an explicit `null`**, including profile avatars, key expiry, webhook filters and feed retention. + +### Swift SDK v0.0.20 + +Released October 18, 2026. [v0.0.20 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.20) + +- **Builds against Marfa v0.0.12**, including its working copy repair and owner passkey operations. +- **An app that uses `MemberImportVisibility` can use the SDK's aliased core types with only `import Marfa`.** The package exports those declarations one by one, so the SDK's own names stay unambiguous. +- **The generated wire types keep fields whose only JSON value is `null`.** An optional null-only field tells an absent key from an explicit `null`, and a non-null value is refused when decoded. + +### Swift SDK v0.0.19 + +Released October 18, 2026. [v0.0.19 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.19) + +- **Builds against Marfa v0.0.11**, so an app can read an edge type that no file writes from a server at v0.0.11 or later. +- **`EdgeType.writtenAt` is an `EdgeWrittenAt`**, with the cases `source`, `target` and `none`, in place of `EdgeEnd`. A `switch` over it needs a `.none` case. +- **`MarfaTypes` carries `RetentionPolicy`**, and its `written_at` takes `none`. + +### Swift SDK v0.0.18 + +Released October 18, 2026. [v0.0.18 on GitHub](https://github.com/withmarfa/marfa-swift/releases/tag/v0.0.18) + +- **Builds against Marfa v0.0.10.** +- **Properties, bodies and answers are JSON values, not text.** `Edit(properties:replaceProperties:baseVersion:)` replaces `Edit(.merge(_:))` and `Edit(.replace(_:))`, `declareTypes(_:)` takes `[JSONObject]`, and `QueuedWrite.answer` is a `JSONValue?`; the release on GitHub lists every change. +- **`Item.unreadableProperties` and `Edge.unreadableProperties` are new.** A row whose stored properties can't be read comes back with empty `properties` instead of failing the whole read. +- **A value the core can't hold is refused `invalid` before it's queued:** a number that isn't finite, a key that repeats in one object, or nesting deeper than the server reads. +- **`LiveQuery` is new.** It reads from the working copy and reads again when the copy changes, so a SwiftUI view stays current with no refresh code. [Swift SDK](/swift-sdk) covers it. +- **A held `changes()` stream drains after a drain left a write undelivered**, the next time the server is reached, and tells `.queueDrained`. +- **A follow that ends on the server's own answer after an outage tells `.serverReachable` before `.stopped`.** + +### Earlier Swift SDK releases + +Swift SDK v0.0.1 to v0.0.17 have no release notes of their own. The [marfa-swift releases](https://github.com/withmarfa/marfa-swift/releases) list them with the core each one carries.