Introducing AtCollection<T>: typed, shared, end-to-end encrypted records in a single Dart API
A developer note from the team at Atsign.
For the last several months a small group of us at Atsign have been
quietly building a new API for the at_client SDK called
AtCollection<T>. It's just landed in the Dart SDK on the
gkc-enhance-api branch, and we think it's ready for a wider look.
This post is an introduction from the person who led the work —
what it is, what it's for, how it compares to the libraries most
Dart developers already know, and what the process of building it
taught us about software development in 2026.
The problem
For years, writing an app on the Atsign Platform looked broadly
like this. You needed to store a typed record — say, a Todo. You
wanted to share it with a couple of other atSigns, so they each got
their own end-to-end-encrypted copy on their own atServer. You
wanted your app to react in real time when one of those recipients
made a change, or when a new item arrived from somewhere else. And
when you were done, you wanted to delete cleanly across every
copy.
All of that is supported by the AtClient class that ships with
the SDK — put, get, delete, and notificationService are
powerful, general primitives. But as an application author, you
were the one composing AtKey strings by hand, setting
Metadata fields by hand, parsing notification keys by hand,
and inventing your own JSON envelope for every record type. You
had to know about cached key prefixes, namespace-aware flags,
255-character key limits, and a dozen other things that belong in
a protocol specification, not in the implementation of a todo
app.
The app authors I talked to were consistently spending more time debugging an Atsign Protocol implementation than building their product. That's a signal — not that the protocol is wrong, but that the layer on top of it was missing.
What AtCollection<T> is
A small set of verbs you would already expect on a collection:
create, update, delete, get, getItems,
getItemsAsStream. A draft helper for building a record without
writing it. A reactive surface — watch() returning a
Stream<CEvent>, plus typed sub-streams for updates, deletes, and
read receipts. Sub-collections to any depth, with a cascade-delete
opt-in and an offline orphan-sweep. And a little set of unique
things the shape of the Atsign Protocol lets us do that most CRUD
libraries can't.
A full example, end to end:
final todos = await atClient.collection<Todo>(
'todos.my_app',
const Duration(days: 7),
fromJson: Todo.fromJson,
typeTag: 'Todo',
);
final item = await todos.create(
obj: Todo('write readme'),
sharedWith: {'@bob'.toAtsign(), '@carol'.toAtsign()},
);
item.obj.done = true;
await todos.update(item);
for (final t in await todos.getItems()) {
print('${t.owner}: ${t.obj.title}');
}
// Composable query: build once, fetch or watch.
final openByDue = todos.query()
.where((t) => !t.obj.done)
.orderBy((t) => t.obj.due)
.limit(10);
final list = await openByDue.fetch();
final live = openByDue.watch(); // re-emits on update/delete
todos.updates.listen((e) => refresh(e.id));
await incomingItem.markReadByMe(); // reader side
print(await myItem.readBy); // owner side
final comments = todos.subCollection<Comment>(
parent: item, subName: 'comments',
defaultExpiration: const Duration(days: 30),
fromJson: Comment.fromJson,
typeTag: 'Comment',
);
await comments.create(obj: Comment('nice'), sharedWith: {item.owner});
await todos.delete(item, cascade: true);
Three things that are worth calling out on that snippet, because they don't show up in the API shape of most libraries we respect:
-
sharedWith: {@bob, @carol}. Sharing is a field on the record. You don't write the per-recipient copy loop, you don't compose the@recipient:key prefixes, you don't orchestrate the notification fan-out. When you laterupdate(item)with a differentsharedWithset, the library diffs recipients and un-shares the ones you removed. -
await myItem.readBy. Read receipts, for free. The reader callsmarkReadByMe()once; the owner gets aFuture<Set<Atsign>>that resolves to the set of readers, kept current by a live event subscription. The implementation lives behind a reserved__rrsub-collection so nothing is bolted on to the parent record. Nobody in our reference set of CRUD libraries implements receipts — because none of them have the per-recipient-copy model receipts need to hang on. -
todos.delete(item, cascade: true)deletes descendants at any depth. Sub-collections in AtCollection are scoped to a parent item. Delete the parent and the library removes descendants on the live path via notification. Come back online after a period of disconnection and acleanupOrphans()sweep catches anything the live path missed. That's the class of problem distributed systems textbooks write whole chapters about, and we think we've got it handled inside the library surface.
How it compares
A compact comparison against the libraries most Dart developers are actually choosing between. Every one of these is a serious piece of software — we're not trying to score points against them; we're trying to be honest about what each shape is good for.
| Feature | Firestore | Isar | Hive | Drift | Supabase | AtCollection<T> |
|---|---|---|---|---|---|---|
| Typed records | yes (withConverter) |
yes | TypeAdapter | companions | code-gen | yes (fromJson) |
| Reactive streams | .snapshots |
.watchLazy |
.watch |
.watch |
channel | watch + typed sub-streams |
| Query ergonomics | server-indexed | typed .filter |
in-memory | SQL | Postgres | composable Query<T> (on-device) |
| Local-first copy | no | yes | yes | embedded | no | yes — synced by SDK |
| Multi-user sharing | ACL rules | — | — | — | RLS policy | sharedWith on record |
| Record ownership | ACL rules | — | — | — | RLS policy | item.owner (Dart-enforced) |
| Read receipts | — | — | — | — | — | built in |
| Sub-collection cascade | no | — | — | FK | FK | automatic + orphan sweep |
| End-to-end encryption | no | no | no | no | no | yes, by default |
Four quick observations, in no particular order.
Firestore is a beautiful piece of software. Its query model is genuinely excellent, its realtime channel is rock-solid, and its offline cache is ergonomic. It occupies a different point on the design space than AtCollection — Google reads your plaintext, and in exchange you get server-side indexes and aggregates that we can't touch. If plaintext-to-operator is in your threat model, that trade is available. If it isn't, it isn't. Both are legitimate choices.
Isar and Drift are the right neighbours for thinking about
on-device performance. Both are excellent; both have earned every
bit of their reputations. Phase 1 of our Query<T> builder
narrowed the DSL gap considerably, but typed field-accessor values
and code-generated indexes — Isar and Drift territory — remain
phase 2 for us. Where AtCollection already lands alongside them is
on the execution model: by default, local-first, no server
round-trip on the read path, suitable for hundred-thousand-record
collections. The difference isn't in whether the filter runs
on-device — all three do — it's in what the whole stack is
shaped to do: theirs is single-user local data; ours is multi-user
shared data that also lives locally on every participant's device.
Supabase's RLS is a real achievement — row-level security in Postgres is a serious piece of engineering and a lot of teams should be using it. What AtCollection gives you is a different axis of the same idea: ownership lives on the record, enforced in the client library, across decentralised per-user atServers rather than one central Postgres. Different shape, same family.
Hive is where we store local data today, and it's fantastic.
The at_client SDK keeps AtCollection's local cache in Hive
under the hood. We're planning a move to SQLite for end-user apps
(with a pluggable RDBMS for backend services) because that unlocks
richer on-device indexing, but that's about our ambitions, not
Hive's fit for its original mission.
The honest headline: AtCollection is what a typed shared-records library looks like when end-to-end encryption and local-first storage are non-negotiable and everything else has to fit around them. For a lot of apps, that's the combination that matters.
A note on filtering
The "Query ergonomics" row in the table above got a substantial
upgrade a few days before this post went out: phase 1 of a
composable Query<T> builder landed in the SDK. It's worth saying
clearly what that does, and what the execution model is, because
both are easy to misread.
The Atsign Protocol is end-to-end encrypted between atSigns. When
@alice stores a record to share with @bob, the atServer
holding that record doesn't have the keys to decrypt it. That's
not a bug, it's the whole point. So the atServer can't do what
Firestore's or Supabase's server does with record values — it
can't index your field values, it can't filter on them, it can't
run aggregate queries over them. (The atServer can filter by the
plaintext-exposed key structure — that's how sync and notifications
work — it just can't reach into a record's encrypted contents.)
Instead, every at_client keeps a local copy (by default) of
every record its atSign can see, kept current by a real-time sync
channel. The current sync latency is ~1–3 seconds end-to-end; a
replacement in development (internally called fsync) drops that
into the tens-of-milliseconds range. Your .where() runs over that
local store, which is already decrypted, already in memory or on
disk, and already indexed by primary key.
The new Query<T> surface gives you:
final openByDue = todos.query()
.where((t) => !t.obj.done)
.orderBy((t) => t.obj.due)
.limit(20);
final page = await openByDue.fetch(); // Future<List<CItem<Todo>>>
final live = openByDue.watch(); // live Stream<List<CItem<Todo>>>
await todos.query().where((t) => t.obj.flagged).any(); // short-circuits
await todos.query().count(); // full spec
await todos.query().groupBy<Atsign>((t) => t.owner);
await todos.query().orderBy((t) => t.obj.created).first();
// Live parent-plus-children join. Each emission pairs every matching
// parent with the current contents of a named sub-collection.
final withNotes = todos.query().watchWithSub<TodoNote>(
subName: 'notes',
subDefaultExpiration: const Duration(days: 365),
subFromJson: TodoNote.fromJson,
);
// Live per-item read-receipt count from the reserved `__rr` sub-collection.
final readers = todo.receipts.query().watch().map((l) => l.length);
Queries are immutable values: build once, store, pass around, fetch or watch. The spec is kept as a data object rather than an opaque closure, so when the local store migrates from Hive to SQLite (planned) and JSON-field indexes become available, the library can push eligible predicates to those indexes without any changes at your call site. Phase 2 — typed field-accessor values and deeper sub-collection joining — is already queued.
And for the moments when the builder's vocabulary doesn't fit,
the raw getItemsAsStream().where(...) stream-transformer path
stays as an escape hatch. Two styles, same execution model.
Self-hosting and the crypto trajectory
Two more things worth mentioning because they matter for architecture decisions a team makes early.
Self-hosting is first-class. If you don't want Atsign's
hosted atServers in your stack, don't use them. You can run your
own. If you don't want to depend on the default atDirectory at
root.atsign.org:64, run a split-horizon one. If you're in an
enterprise where nothing is allowed to leave the organisational
boundary, you can stand up a complete Atsign ecosystem — directory,
atSign registration and provisioning, and a fleet-of-swarms of
atServers that scales indefinitely. AtCollection doesn't know any
of this; it talks to whatever atServer AtClient is bound to.
That separation is, in my view, one of the cleanest things about
the design.
Post-quantum crypto is in active development. The current
stack (RSA-2048 + AES-256) is good, but the world is moving and
so are we. An in-flight programme is replacing it with a pluggable
layer whose default will be Signal triple-ratchet with
post-quantum primitives (see
#1889,
#1891,
#1893).
Because AtCollection adds no crypto of its own — it inherits
whatever the SDK layer beneath it provides — every app built on
AtCollection picks up the upgrade without code changes. That's
one of the quieter reasons to adopt a typed library surface over
raw put/get: crypto is a moving target, and you want it
maintained below the code you wrote, not baked into it.
How we actually built this
I want to close with a reflection on the process of building AtCollection, because it taught me something I didn't fully appreciate even six months ago.
The design is non-trivial. Sub-collections in AtCollection can nest
to a theoretical maximum depth of about 21 levels (bounded by the
Atsign Protocol's 255-character key limit), and every record in
every sub-collection has its own owner. That means an item tree
can have arbitrary ownership — I write a blog post, you comment
on it, I comment on your comment, and each of those records is
owned by a different atSign. Ids are only unique per-atSign (we
use an 8-character [a-z0-9] random id — about 2.8 trillion
combinations per owner, but collisions across atSigns are
possible and have to be handled). So every filter, every cache
lookup, every event predicate has to operate on the
(owner, id) pair, never on id alone.
The combinatorics of getting this right are brutal. Sub-collection depth, owner diversity, live delete cascades, offline orphan recovery, read-receipts-as-a-sub-collection, id-collision safety. I had spent about twelve days on the work to get to a first milestone — CRUD and sharing and events all in place — but the last mile (sub-collections and the rigorous proof-of-correctness-through-testing) was a 10-day task I hadn't yet touched.
Then I started pairing with Claude.
I presented the task of re-implementing read receipts as a sub-collection. We spent a couple of hours going back and forth on the approach. Claude wrote a 300-line plan; I reviewed it; Claude executed. I spent a couple of hours on code review, manual testing, and scribbling test matrices on physical paper, then came back with a list of the bugs I'd found and bugs I hypothesised should also exist. Another hour of back-and-forth, a 950-line implementation plan this time, and an hour for me to review it — during which, to my genuine surprise, I could not find a single problem with the plan. Claude executed. The tests pass.
Paired with a skilled human engineer I'd estimate the same work at about 8 hours of planning and 20–30 hours of implementation. With Claude it took about one hour of implementation time on top of the thinking. The implementation — the part of the job that used to feel like most of the job — is now, honestly, the smaller part.
What changed wasn't the quality of my thinking. What changed was that I stopped being the bottleneck between thought and running code. The thinking and planning still need every ounce of attention I have. But once the plan is right, I can hand implementation off and trust that the plan will come back executed faithfully, with tests, in an hour rather than a week.
That reframes what we should be ambitious about as a team. We should design bigger. We should insist on more invariants. We should write longer, more careful plans, because plans are the artefact that matters now.
This is also why AtCollection<T> exists the way it does.
The explicit create / update / delete verbs (rather than a
clever implicit upsert); the typed generics that mean
getItems() returns List<CItem<Todo>> at compile time; the
sealed-but-not-sealed event hierarchy that has a default:
branch as a forward-compat hatch; the exceptions with named
failure reasons; the (owner, id) identity pair carried
through every predicate — all of these are small surface
choices, but they add up to an API that an AI coding assistant
can pick up without hidden invariants, without having to
re-derive the Atsign Protocol from first principles. We want
millions of developers, human and AI alike, to be able to build
on the Atsign Platform. AtCollection is our best guess at what
a library surface has to look like for that to be possible.
Try it
If this is at all interesting, the shortest path from zero to a working demo is about fifteen minutes.
- Register a free atSign at my.noports.com/no-ports-plans.
- Clone
atsign-foundation/at_client_sdk. cd packages/at_client/example,dart pub get.- Run
bin/collections_primitives.dartwith your atSign and a friend's. - Read the source — it's about a hundred lines.
If Flutter is your stack, the canonical reference app lives at
packages/at_client_flutter/examples/todos.
Same feature set as the CLI tour, rendered through the mobile /
desktop widget stack the way a shipping app would use it.
bin/collections_todos.dart is the full interactive CLI tour:
two-atSign shared todos in a terminal UI, exercising CRUD,
sub-collections, read receipts, and cascade delete in one
program. Run two instances side-by-side with different atSigns
and watch them share records live.
The API is marked @experimental in this release because we
expect to iterate a little more before a 1.0 cut. If you try it
and find something sharp, we'd love to hear about it — open an
issue on the repo, and tell us what you were trying to build.
That's the fastest way for us to get the last mile right.
Thank you for reading. We're genuinely excited about what people build with this.
— gkc, for the Atsign team