Repository navigation
v0.41.0-beta.8
Pre-release-
Discovery (#2198): a drive's pkarr record also lists the public https origins of the servers hosting it (
_atomic_http), published at boot and every hour, so the app can find a drive's server from the secret alone. New anonymousGET /genesis?subject=<did>returns only a drive's genesis certificate, which the app verifies against the drive DID before connecting. Drives minted before genesis certificates existed answer 404 and are not found this way. -
Schemas (#2151): properties get content-addressed subjects,
atomic:prop:{blake3}of their ontology, shortname and datatype (docs/src/schema/property-identity.md). The server verifies the hash on genesis, accepts the same property from two devices, and refuses changes to itsparent,shortnameordatatype. Existing properties keep their subjects and stay editable. -
Class constraints (#2151): a Class's
constraintsmap holds JSON Schema keywords per property (enum,minimum,maximum,exclusiveMinimum,exclusiveMaximum,minLength,maxLength,minItems,maxItems,pattern,class), enforced on commit; peer commits and sync skip it. An emptyallowsOnlyon a Property now restricts nothing. -
Lenses (#2151):
atomic:lens:resources map one property onto another (rename,wrap,head,map,convert); the derived values appear in reads and indexes without changing signed data (docs/src/schema/lenses.md). -
atomic-cli schema push | lock | check: turn a JSON Schema or ontology file into an ontology and a lockfile that pins its property IDs (#2151).atomic_lib::schema::json_schemaimports JSON Schema andensure_ontologycreates the result. -
Forms: field options use JSON Schema keyword names (
minItems,maxItems,minimum,maximum; the old names are still read) and may only tighten the column's class constraints;describe_formreturns the form's JSON Schema (#2151). -
--served-domain-suffix/ATOMIC_SERVED_DOMAIN_SUFFIXtakes a comma-separated list (atomicserver.eu,atomic.place), so a node can answer under two domains while it moves from one to the other. -
MCP endpoint:
POST /mcp(Streamable HTTP) is the one implementation of the
MCP tools, for clients that cannot run a local process, such as claude.ai, and
for the local@tomic/mcp, which now only bridges to it. It is an OAuth 2.1
resource server (RFC 9728 metadata at/.well-known/oauth-protected-resource)
with the node as authorization server (/oauth/register,/oauth/authorize,
/oauth/token, PKCE S256, dynamic client registration,resourcechecked).
The person approves in the app (/app/authorize-mcp): which drives, and read
only or read and edit. Reading is as an issued agent under the normal rights
checks. If editing was allowed,edit_resource,create_resource(documents
take Markdown as_documentText) anddelete_resourceexist, and the node
signs those commits as that issued agent, never as the person. Revoking is
removing it under Connected apps.list_drives,get_resource,search,
query,get_user_classesandget_schemaare the read tools.
--app-url/ATOMIC_APP_URLsays where the approval page lives when the app
is not served by this node. -
The approval page only works with a node the person is already using: a link
naming another server is refused, and the redirect back to the client must be
exactly the one registered, so a crafted link cannot grant access to someone
else's key or run script in the app. -
atomic-cli connect: connect the CLI with a key made on this machine,
approved in the app (/app/connect-agent), instead of pasting your agent
secret. Without a config file, the CLI now points you to it rather than
asking for the secret. -
Forms (beta): guests can fill in a form without an account, and each answer
becomes a row in a Table. New default ontologyforms
(lib/defaults/forms.json:Form,FormPage,FormField,FormHeading,
FormParagraph,FormInfoBox,FormCondition,FormInviteCodeand their
properties). New public endpoints:GET /form/{id}(the form page),
GET /form/{id}/definition,GET /form/{id}/challenge(a self-hosted
proof-of-work captcha, ALTCHA),GET /form/{id}/imageand
POST /form/{id}/submit.{id}is a short publish slug or the form's
identifier. Only published forms inside their open/close window answer
(others get410); invite-only forms need a single-use code. Submissions
are validated against the form's questions and branching conditions
(answers to hidden questions are dropped), rate-limited per client address,
and written by the server's agent; a form can only write into, and show
visitors resources from, its own drive. A class extender adds
form-submission-summaryto a Form for readers who may see it.
server/build.rsembeds thebrowser/form-appbundle.
#875 -
Collection
/query: the rights walk no longer full-decodes ancestors (memoize by subject +get_resource_shallow). Denied members still do not consumepage_size, so a readable row after a private streak is returned. -
Commits whose Loro update depends on ops the server does not have are now
refused ("parked as pending") instead of being accepted without effect; the
browser resends a self-contained update. -
/download/files/{hash}and blob URLs serve the File's stored mimetype. -
A plain
GET /wsthat is not a WebSocket upgrade (a crawler, a pasted URL) now answers 400 instead of 500, so it no longer shows up as an incident in error reporting. -
A commit is also sent to WebSocket subscribers of the resource's parent. A
resource shared out of a drive the recipient can't read (a chatroom) can only
be subscribed to on its own, and a new child is a new subject, so its new
messages reached neither that subscription nor a drive subscription the
recipient could hold. Read rights are checked per subscriber against the
resource when it is sent, and a connection already reached through the
resource or its drive doesn't get it twice. -
End-to-end encrypted conversations.
atomic_lib::conversationderives an
agent's X25519encryptionKey, keeps a conversation's keys in a keyring
wrapped to each member, and seals and opens messages; the WASM build exports
it. New default ontologyconversations(lib/defaults/conversations.json)
with theConversationandSealedMessageclasses. New endpoint
/conversationslists the conversations on the server the requesting agent
is a member of. Seeplanning/encrypted-conversations.md. -
New default ontology
notifications(lib/defaults/notifications.json):
theInboxandNotificationclasses, and aninboxproperty the private
drive points to its Inbox with. -
Removed range-based set reconciliation (RBSR,
lib/src/sync/rbsr.rs). The
server rebuilt a drive's whole inventory for everyRBSR_FPround trip, so
the descent cost more than the one full version-vectorSYNCit replaced.
RBSR_FPis now answered with{"drive","unsupported":true}and no
fingerprints, which makes an older browser fall back to the fullSYNC
immediately.RBSR_ITEMSstays as the drive inventory, and aSYNCwith
subjectsis still honoured. Therbsrcapability is no longer advertised. -
The server raises its own file-descriptor soft limit to its hard limit at
startup. It already budgeted HTTP connections against the soft limit and kept
a reserve, but HTTP is not the only tenant of that pool: the database, Iroh's
QUIC sockets and every open websocket draw on it too, so on a stock soft limit
of 1024 the process can run out while the HTTP budget still looks healthy.
Staging did, for twenty-three minutes, with 2,655error accepting connection: No file descriptors available, Iroh unable to bind its hairpin probe, and a
panic at the tail. A process may raise its own soft limit as far as the hard
limit without privileges, and the connection budget is computed from whatever
is in force afterwards, so a refused raise is logged and not fatal. -
Publishing a plugin release that is already recorded on the server (the
same zip, uploaded by someone else) now grants the new publisher read on
theReleaseand its package File. Before, their Installation could not
resolve the release and activation failed with a 401. -
Version 2 request signatures (ontola/atomic-plugins#54). A request sent
withx-atomic-signature-version: 2is checked against
atomic-request-v2\n{METHOD}\n{full URL}\n{timestamp ms}\n{sha-256 hex of the body}instead of v1's"{url} {timestamp}", so a captured proof can
no longer be replayed with a different method or body within its five
minutes. v2 is opt-in: a request without the header is checked as v1
exactly as before, and a v2 request that fails is refused, never retried as
v1./app-agentand/plugin-view-tokenaccept v2 (they do not require it
yet); every other endpoint refuses a v2 signature with a 401 that says to
sign with v1, as do an unknown version and a v2 header without the
x-atomic-*headers. Cookies and WebSocketAUTHstay v1.atomic_lib
exportsrequest_signature_message_v2,RequestBindingand
client::get_authentication_headers_v2; shared test vectors live in
lib/src/authentication_v2_vectors.json. -
Identifiers are now emitted as
atomic:(atomic:{genesis},
atomic:agent:,atomic:commit:,atomic:blob:,atomic:node:). The
previousdid:ad:spelling is accepted forever and names the same
resource. New genesis certificates still encode the v1 header byte
(0x01) soGenesisCertliterals in downstream crates keep compiling;
GenesisCert::new_v2canonicalizes parent/drive strings toatomic:.
Decode accepts a0x02header. Existing v1 certificates and the
personal-drive singleton stay v1. Pairing is
atomic:node:{id}?v=1&drives=*;/resource?subject=is the HTTP
endpoint (/atomicand/didremain aliases). The store canonicalizes
subjects and identifier-shaped values on write and on query filters.
Opening a pre-rename database rewrites leftoverdid:ad:keys in every
subject-keyed tree (resources, snapshots, DID mapping keys and hint
values, envelopes, tombstones, the outbox), streaming each tree, and
rebuilds indexes. A v2 certificate that carries adid:ad:string is
refused on decode; a v1 certificate's parent and drive are compared with
the resource in one spelling. Sync advertisescanonical-schemeand
emitsdid:ad:to peers that do not list it on every frame that names a
subject, over WebSocket and Iroh alike; a client canonicalizes what an
old server echoes back. The in-memory store keys resources canonically
(#1584). -
The causality guard no longer refuses a commit whose writes lost an honest
race. It asked whether the merge kept what the commit sent, which an unseeded
client and a client that simply lost to a newer peer both answer no, so a
client with a perfectly good doc was told to refetch and retry, and could only
resend the same bytes. It now asks what it means to ask: whether the incoming
update's version vector carries any peer the stored state also has. A doc
seeded from the server does, however far behind it has fallen, and losing
last-writer-wins from there is accepted. A doc built from scratch does not,
and its vanished writes are still refused, as before. The version comes from
the blob header viaupdate_range, so the check costs a parse and not a
second document build. -
A causality rejection now names the writes it dropped. The error a client
gets when its Loro update lost every write to LWW reports each mismatching
property assent <x>, stored <y>, in place of the full list of values sent
and the bare list of stored keys. Which write lost, and to what, previously
lived only in the server's own[causality-guard] rejectinglog line. The
Commit's Loro update produced no state changesprefix that
classify_commit_errorand the client outbox match on is unchanged, and so
is the condition for accepting or rejecting a commit. -
Fix: a stale authentication proof no longer fails a request that needed no
authentication. A browser keeps its proof in theatomic_sessioncookie, and
untilAUTH_MAX_AGE_MSarrived in 0.41 a proof never expired, so a stale one
is the ordinary state of any tab left open. Every request such a tab made was
answered 401, public ones included: on staging one tab polling the public
GET /serverendpoint produced 4,215 rejections, all carrying the same
signed attimestamp. An HTTP request, and the headers a socket is opened
with, now treat a proof that has aged out as no proof at all and continue as
the public agent, leaving the rights check to decide whether that matters for
what was asked. A signature that does not verify is still refused outright,
and so is a stale proof in anAUTHframe or a peer handshake, where the
caller asked to be authenticated and is owed the answer
(get_agent_from_auth_values_or_publicbeside the existing
get_agent_from_auth_values_and_check). -
Sentry no longer records every server error twice.
sentry_actixcaptures a
handler's 5xx with the request attached, andtracing_actix_webseparately
logs "Error encountered while processing the incoming HTTP request" at
error!, which the Sentry tracing layer turned into a second event; the two
staging floods of September arrived as paired issue groups of 6313 and 6312
events for the same incidents. The tracing layer now ignores the
tracing_actix_webtarget and delegates every other target to
default_event_filter, so background work reports as before and the stdout
log line is unchanged (server/src/trace.rs). -
Plugin install path, three fixes after the runtime convergence (#1571):
- A publish refused for claiming the wrong
worldused to have already
stored the package bytes and cached the release record. The claim is now
checked before the first write, so a refusal leaves nothing behind
(POST /plugin-release-package). - A JS plugin whose Installation could not be read, or whose
grantscould
not be, ran with everything its own manifest declared. Only a legacy draft
with no Installation still reads the manifest; anything else grants nothing
and logs why. Reading which classes a resource has also went through
Value::to_subjects, which errors on the scalarisAencodings, so an
encoding alone could widen a plugin's grants.Resource::class_subjects
andResource::has_classare the one encoding-tolerant reader, shared
withClassExtender. - An Installation's
releaseis declared anatomicURLand now holds one.
The browser's two zip paths dropped theReleasesubject the publish
response carries and stored the bareblake3:id, which meant writing the
property with datatype validation switched off.release::resolvestill
accepts a bare id, for Installations written before this.
- A publish refused for claiming the wrong
-
atomic_lib: a signed destroy commit now removes the resource (and its
cascade-deleted children, Loro snapshot, index and search rows) in the same
redb transaction that stores its envelope and commit row.Db::apply_commit
used to callremove_resource, which applied a transaction of its own, so a
crash between the two left a deleted resource with no signed destroy for
SYNC_DIFF.removeCommitsto carry; tombstones are now recorded once that
single transaction has landed. The destroy branch also returns an error on a
malformed commit instead of panicking the request. -
Security hygiene, from the September 2026 audit (
planning/security-audit-2026-09.md
D, C16, F):- CORS: any origin may still read (Atomic is a headless CMS), but
Access-Control-Allow-Credentialsis now sent only to origins this
server answers for — the configured domain, a subdomain of the base
domain, loopback, and the desktop webview origins (tauri://localhost,
http://tauri.localhost) — by the same ruleRequestContextuses for
trustingHost. Before, every origin was told it could attach the
session cookie;SameSite=Laxwas the only thing stopping it
(server/src/cors.rs). - Client errors are answered as client errors instead of 500: a request
body that does not parse is 400 (AtomicErrorType::ParseErrormaps to
BadRequest; malformed commits are parse errors), a signature that does
not verify, an unknown signer, or authentication headers that do not
parse are 401 (Commit::validate_signatureand
get_agent_from_auth_values_and_checktype every failure as
unauthorized, andget_client_agentno longer flattens the type into a
string). These no longer reach Sentry as crashes. /uploadreturns 400 when the multipart body cannot be read (no
boundary, truncated part) instead of ending its loop quietly and
answering 200 for the files that made it through.atomic_lib: the "importing from a peer" flag and the import source are
a tokio task-local scope (sync::ws_apply::import_scope) instead of
process-wide statics, so two peer connections importing at once no
longer clear each other's flag or stamp each other's peer id on their
writes (audit C16). The live push loop no longer consults a global mute;
it skips the one peer a change came from, via thesource_idthe scope
stamps on theDbEvent, which now also coversCOMMITframes applied
for a live peer.CommitIngestOpts::suppress_live_echois gone: a peer
commit is attributed to the connection's import scope instead.
sync::peer::is_importing()is now per task; a listener on another
task should read the event'ssource_id(the Flutter binding does).collections::sort_resourcesis a total order (missing values last in
both directions, numbers compared numerically withNaNin a fixed
place, numbers before strings), so a collection sorted through the
sort_byquery parameter can no longer panicsort_byon Rust 1.81+.sync::peer: the live peer map recovers from a poisoned lock instead of
unwrapping it, so one panic no longer stops live broadcast for the rest
of the process.
- CORS: any origin may still read (Atomic is a headless CMS), but
-
atomic_lib:DbEvent::Destroyedfor a cascade-deleted child is sent only
after the removal has been applied.Db::recursive_removeannounced each
child while it was still queueing the deletes into a transaction the caller
had yet to apply, so a listener (atomic-server'sCommitMonitor, which
fans removals out to WebSocket subscribers; a peer transport) heard of a
deletion that could still fail or roll back, and heard of the children
before their parent. The callers (remove_resource, the destroy branch of
apply_commit) now announce every removed subject once the transaction has
landed; the destroyed subject itself is still announced exactly once, wrapped
in its signed destroy commit.remove_resourcenow also announces the
subject it was called for, which it never did. Follow-up to #1544. -
classify_commit_errorclassifies "Commits cannot be edited." (a write whose
subject is itself a Commit) as the newIMMUTABLE_COMMIT(10) error code, on
the WSERRORframe and the HTTP/commiterror body'serrorCodealike.
It was going out asUNKNOWN, leaving clients to match the message text to
know the refusal is terminal. The Rust outbox drops such entries as terminal. -
Query index: a drive-scoped, sorted, class-filtered collection could list
and count fewer rows than the store held, with nothing to show for it
(planning/silent-failures.md). Rows whoseisAread back as a plain
Stringwere in the index and then hidden, because the built-in
collection class extender could not parse the value and
Db::resolve_query_memberdropped any row an extender check errored on;
an extender that cannot decide is now skipped with a warning and the row
is listed. Rows whoseisAwas aStringholding the JSON array were
never indexed under their class and never matched it; the index keys and
the matcher now read the array's elements from one helper
(Value::to_reference_index_strings,Value::contains_value). New
Db::check_query_index(&Query)compares a query's member index with a
scan of the store and reports the missing and stale subjects; the first
build of a filter cross-checks the equality constraints the planner did
not scan (bounded by its 512-entry scan cap), warns with the filter and
the subjects, and files them. DID resources stamped into another drive no
longer enter a drive's watched query on commit or build: theDbresolves
each watched filter'sdrivesubject to its drive root and compares
stamps (security audit C17; the filter's identity is unchanged). -
Startup store-size diagnostics and automatic redb compaction. Opening the
store now logs the file's size and open duration (a warning above 1 GiB,
namingatomic-server compact) and, from redb'sDatabaseStats, how much
of the file is live, fragmented and reclaimable (bytes on disk minus pages
in use; redb's sparse growth headroom does not count). When the file is at
least 256 MiB on disk and at least 30% of that is dead,Db::init_redb_file
compacts it before the server listens and logs the before/after sizes and
duration; the outcome is kept in the store and readable through
Db::last_compaction. A compaction failure is a warning, never a refused
start. New options--auto-compact(ATOMIC_AUTO_COMPACT, default
true),--auto-compact-min-mb(ATOMIC_AUTO_COMPACT_MIN_MB,256) and
--auto-compact-min-reclaimable-percent
(ATOMIC_AUTO_COMPACT_MIN_RECLAIMABLE_PERCENT,30);atomic_libcallers
pass adb::compaction::CompactionPolicyto
Db::init_redb_file_with_policy. Not applied to the OPFS (browser) or sled
backends. Seeplanning/disk-storage-and-persistence-optimization.md. -
CI: the
:developdocker image is published by its own job instead of a
step tacked onto the end of the CI job. As a step it inherited whatever the
CI step had already spent (a wedged Dagger engine on the runner burned the
ten minute connect timeout and the publish was skipped without trying), and
it could not be re-run on its own, so recovering a missed image meant
re-running a pipeline that takes hours.developwent 2026-09-15 to
2026-09-18 without an image that way. The gate is unchanged:needs: ci,
so a red pipeline still publishes nothing. A new manual
"Publish :develop image" workflow covers the case where the pipeline cannot
go green for reasons unrelated to whether the binary builds; it refuses
latestandv*tags, which release.yml owns. -
Error-handling hygiene on the commit and read paths. The legacy
set/push/removerejection insync::engine::ingest_commitnow checks
the parsed commit's properties instead of substring-matching the raw body,
so a commit whose subject is one of those Property resources (or whose
values quote their URLs) is no longer refused.Db::get_resourcewarns
(with the subject and error) when a stored Loro snapshot cannot be read or
applied instead of silently serving the stale propvals. The sled
Db::initwraps a migration failure in its error message (.map_err, not
.map).AppState::initdrops its own core-models bootstrap branch: its
"store did not exist" check ran after the store directory had been
created, andDb::init_redb_filealready seeds a fresh store on open. -
atomic_lib: host-to-Drive mappings are reconcilable, so a control plane can
install hosted vanity subdomains without an agent that can sign on the
Drive's behalf.Db::sync_drive_mappingsadds, repoints and removes a
desired set idempotently and scopes removal to the hosts it installed
itself (so/bind-drivebindings and thelocalhostentries from setup
survive);Db::list_drive_mappingsandDb::managed_alias_hostsread the
table, which had no read path beyond a single-host lookup. Mapping keys are
now normalized, so a mixed-caseHostheader resolves the Drive it was
bound to instead of falling through to the store root. -
A host bound to a Drive now serves that Drive or answers 404. It used to
fall through to the store root when the bound Drive was missing — not synced
yet, or moved to another node — which on a multi-tenant server answers one
tenant's hostname with another namespace's content. -
New
--served-domain-suffix/ATOMIC_SERVED_DOMAIN_SUFFIX: answer for
*.<suffix>so the request origin follows the hostname the visitor used,
without--base-domain's other effect of also becoming the store's base
domain (which changes how subjects are normalized and migrated, and is not
something to switch on for a server that already holds data). -
The
/versionand/all-versionsendpoints are removed. Resource history
is read from the Loro oplog on the client now (resource.getLoroHistory(),
with signers from/history-attribution), which is what the History route
has used for a while; these endpoints were the older server-side path to the
same data, reachable only from a single "All versions" link.atomic_lib
loseshistory::encode_version_idandhistory::decode_version_idwith
them -- they existed to carry an opaqueVersionIDthrough an HTTP query
parameter.history::versions,history::at_version,
Resource::get_historyandResource::view_atare unaffected. -
The outbox drains over a live Iroh link too (
sync::peer::LivePeerCommitTransport):
a device with no hub in reach delivers its queued writes to a paired peer as
signedCOMMITframes, which the peer validates and applies like a hub
would, and a freshly registered live link drains on connect. The Flutter
binding falls back to it when no WebSocket session is open. This is the
sending half of serverless sync (planning/serverless-p2p.mdP4). -
atomic_libhas a durable outbox (sync::outbox,Tree::Outbox): local
commits mark their subject dirty per signing agent, a drain exports the
Loro delta since the version the hub holds, signs one commit per subject
in parent-before-child order and sends it as aCOMMITframe over any
CommitTransport(the WebSocket client implements it). Refusals back off
exponentially, park the entry after repeated authorization failures, and
drop it only when the hub says it can never apply. The Flutter binding
records every local save in it and drains on save and on session open, so
an edit made offline on Android now survives an app kill and reaches the
hub on the next connection (planning/unified-sync.md, Outbox
modernization item 6). -
Desktop: the app window now runs under a Content Security Policy
(desktop/tauri.conf.json; security audit B7). Scripts only from the
bundle plus WebAssembly, connections to the embedded and remote servers
over HTTP and WebSocket, Google Fonts, no plugins or objects. Tauri hashes
the bundle's inline scripts itself, sodangerousDisableAssetCspModification
is off. A script injected through synced content can no longer reach the
IPC surface. -
CI: a
v*tag now publishes@tomic/*to npm through Trusted Publishing
(release.ymlnpmjob). Tags only published crates.io and GitHub assets
before, which is why npmlateststayed on 0.40.0 andbetaon
0.41.0-beta.0 through v0.41.0-beta.7. Pre-releases get thebeta(or
rc, …) dist-tag, notlatest;@tomic/pluginand@tomic/edit-mode
are marked public for their first publication. -
Signed history now replicates.
SYNC_PUSHcarries the retained envelopes
of the subjects it pushes (flag0x02 ENVELOPES, a trailer old decoders
never read) over WebSocket, Iroh and browser peer links, and Cloud Vault
packs carry them per entry. Receivers verify each envelope's signature and
subject before keeping it, honouring their own--envelope-retention. A
restored or newly invited device therefore shows who signed each change
instead of "Unattributed" (planning/auditability-loro-history.md). -
Security:
POST /commitcharged the per-agent write budget to the signer
the body named before verifying the signature, so anyone who knew an
agent's public DID could flood the endpoint with forged commits naming that
agent and lock them out of writing with429s. The signature is now
verified first and the budget is charged to the signer it proves; a body
that proves no signer spends the peer address's anonymous budget, like any
other unsigned write. GHSA-f9rw-g3gr-3jw5, reported by
hackchang. -
Rate-limit the write endpoints.
POST /commit(HTTP and the WebSocket
COMMITframe),/upload,PUT /blob,/iroh-sync,/forget-peerand
resource posts spend a per-agent token (--write-rate-limit, default 6000
per minute,ATOMIC_WRITE_RATE_LIMIT); requests without a signed agent
share a per-peer-address budget (--anonymous-write-rate-limit, default
60 per minute). Over budget answers429 Too Many Requestswith a
Retry-After;0disables either limit. -
Fix: writes made through a directly opened file store (the Flutter/Android
binding) were never made durable. Every redb commit skips the fsync and
relies on a periodic flush that only the server, desktop and WASM hosts
ran, so on Android an app kill rolled back every edit since the last drive
switch.Db::init_redb_filenow owns the 100ms durable-flush tick for every
binding, and an idle tick no longer writes anything. -
Fix remaining
clippywarnings inwasm/src/lib.rsblockingdevelop's pre-commit hook (#1508).