Markdown source · at_client_sdk on GitHub · third-party review

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:

  1. 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 later update(item) with a different sharedWith set, the library diffs recipients and un-shares the ones you removed.

  2. await myItem.readBy. Read receipts, for free. The reader calls markReadByMe() once; the owner gets a Future<Set<Atsign>> that resolves to the set of readers, kept current by a live event subscription. The implementation lives behind a reserved __rr sub-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.

  3. 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 a cleanupOrphans() 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.

  1. Register a free atSign at my.noports.com/no-ports-plans.
  2. Clone atsign-foundation/at_client_sdk.
  3. cd packages/at_client/example, dart pub get.
  4. Run bin/collections_primitives.dart with your atSign and a friend's.
  5. 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