Markdown source · at_client_sdk on GitHub

A review of AtCollection<T>

Typed, shareable, end-to-end encrypted records — with sub-collections and read receipts built in. A review.


I spent a weekend reading the AtCollection<T> API in at_client_sdk and then writing a small TUI against it. Going in, I expected another CRUD-over-KV wrapper. Coming out, I think the most interesting thing about it isn't the CRUD at all — it's that the sharing primitives are inside the record, and the transport under the API is end-to-end encrypted by default. That combination is rare enough to be worth a proper look.

This review covers three questions.

  1. What is AtCollection<T>? (The short version: a typed Dart collection API over the Atsign Protocol, with built-in multi-atSign sharing, sub-collections, and read receipts.)
  2. How good is it? — in its own right, against the mainstream CRUD libraries you already know (Firestore, Hive, Isar, Supabase, Drift), against its closer cousins in the decentralized-data space (CouchDB/PouchDB, Matrix, Solid), and against the lower-level AtClient.put/get it's layered on.
  3. Why you, a developer who has probably never heard of the Atsign Platform, might want to try it.

I don't work on the platform. I'm an engineer who reads a lot of code, has shipped a few networked apps, and has lost more evenings than I care to admit to "now how do I get this record to that user's device in a way that doesn't leak it to the server operator" problems. That last class of problem is where this library got my attention.


1. What is it?

The one-paragraph version: the Atsign Protocol gives every participant an atSign (a handle like @alice) and a small personal server (the atServer) that holds that participant's data. Only the user has the keys that decrypt it. When @alice shares a record with @bob, the at_client SDK encrypts the record with @bob's public key, @alice's atServer caches a copy to @bob's atServer, and @bob's client pulls it down and decrypts it locally. By default, every client also keeps a local copy of every record its atSign can see (Hive today, SQLite planned), kept current by a real-time sync channel with that atSign's atServer — so reads hit local storage rather than round-tripping to the network. Servers exist — but they're per-user, they don't share a database, and the operators of any given atServer don't hold the decryption keys for the data sitting on it. There is no shared central store that an app vendor or cloud provider can inspect.

Underneath at_client there are four core verbs: put, get, delete, and notificationService. You address records with AtKeys, which look like <id>.<namespace>@<owner> — with a @recipient: prefix for a key shared out, and a cached: prefix for the mirrored copy on the recipient's end. The Atsign Protocol handles the encryption, the key exchange, the sync, the cache semantics, and the notification fan-out. But at the AtClient layer, the application author is still responsible for composing AtKeys by hand, setting Metadata fields by hand, parsing notification keys by hand, and inventing their own serialization envelope for every record type.

AtCollection<T> is the layer that sits on top of those four verbs and says: no, application authors shouldn't have to think about any of that. A full example:

// 1. Open (or create-and-open) a typed collection.
final todos = await atClient.collection<Todo>(
  'todos.my_app',
  const Duration(days: 7),
  fromJson: Todo.fromJson,
  typeTag: 'Todo',
);

// 2. Create a record, share it with two atSigns.
final item = await todos.create(
  obj: Todo('write readme'),
  sharedWith: {'@bob'.toAtsign(), '@carol'.toAtsign()},
);

// 3. Mutate and save.
item.obj.done = true;
await todos.update(item);

// 4. Read. Across self + all recipients, deduped, typed.
for (final t in await todos.getItems()) {
  print('${t.owner}: ${t.obj.title}');
}

// 4a. Composable query, with a live reactive terminal.
final openAndUrgent = todos.query()
    .where((t) => !t.obj.done)
    .orderBy((t) => t.obj.due)
    .limit(10);
final list = await openAndUrgent.fetch();
final live = openAndUrgent.watch();   // re-emits on update/delete
final n    = await todos.query().where((t) => !t.obj.done).count();

// 5. React, live, to remote changes at the collection level.
todos.updates.listen((e) => print('updated: ${e.id} by ${e.owner}'));

// 6. Read receipts. The reader:
await incomingItem.markReadByMe();
// The owner:
print('read by: ${await myItem.readBy}');

// 7. Sub-collections. Comments on a post, replies on a comment.
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 one'), sharedWith: {item.owner});

// 8. Cascade delete — parent and descendants, at any depth.
await todos.delete(item, cascade: true);

That is the entire mental model. There is no AtKey in that code. No Metadata. No regex. No manual JSON envelope. No loop over recipients. No cached: prefix to strip. Every one of those concepts still exists under the hood — this is still the Atsign Protocol — but at the API boundary, AtCollection<T> is a typed collection library with the shape that anyone who has used Firestore or Hive already expects.

The public API surface in one compact view:

Category Methods
CRUD create, update, delete, get, getOrNull, getItems, getItemsAsStream
Drafts draft (build an unpersisted CItem)
Query modifiers query() then .where / .wherePath / .orderBy / .thenBy / .limit / .skip (chainable, immutable)
Query terminals .fetch / .watch / .count / .any / .first / .firstOrNull / .groupBy / .watchWithSub<U> / .watchWithTree
Sub-collections subCollection<U>(parent:, subName:), cleanupOrphans(), readReceiptsFor(item) / item.receipts
Read receipts item.markReadByMe(), item.readBy, item.readBySnapshot, item.wasMarkedReadByMe(), item.receipts
Events watch()Stream<CEvent>; typed getters updates / deletes / readReceipts / subUpdates
Factory registry AtCollection.registerFactory<U>(U Function(Map) fromJson, {required typeTag})

Two types (AtCollection<T> and the Query<T> it mints), roughly thirty methods and accessors between them, five event types, one registry. That's the whole library. The implementation is ~2,350 lines of Dart in a single file, which — having read it — I think is an honest line count: there's no sleight of hand where the complexity is hidden across seven support files. The published test suite runs 110 tests on that file, which is not the most I've seen for a library of this size but is also not the least.


2. How good is it?

I'll take three cuts at this: (2a) as a piece of software standing alone, (2b) against the mainstream CRUD ecosystem, and (2c) against the Atsign Protocol it's layered on. The short answer is very good in all three, with the caveats I'll give in §3.

2a. Standing alone

Six qualities I look for in a small, domain-specific API before I'll trust it in an application. AtCollection<T> earns all six; I'll justify each with something specific.

(i) The verbs map to the reader's prior. create, update, delete, get, getItems — the nouns and verbs every developer already expects to see on a collection. No made-up vocabulary. The one place where the library departs from mainstream habit is the deliberate separation of create (throws on collision) and update (throws on missing). Most of the ecosystem merges these into an implicit upsert; AtCollection's authors picked explicit semantics instead. You can see the rationale in the source comments: collision and missing-target are usually bugs, and forcing the app to pick one or the other catches the bug at the call site rather than silently writing the wrong thing. I agree with the call.

(ii) The generics carry weight. AtCollection<Todo> is not a documentation-only generic. getItems() returns List<CItem<Todo>>. collection.updates.listen((e) => …) has e.id / e.owner typed. draft(obj: Todo(...)) fails at compile time if you pass a Duck. Registering a factory (for rehydration on read) is one line per type. A handful of ecosystem libraries (Isar, Drift, Firestore .withConverter<T>) reach this bar. Many do not (Hive is KV-typed but value-dynamic; mongo_dart returns Map<String, dynamic>; PouchDB's Dart wrappers are plain JS-shaped maps). AtCollection is in the "types actually mean something" camp.

(iii) Errors are typed and informative. A create/update/ delete that partially fails throws CollectionOpException carrying .results (per-key outcome), .failures (just the failed ones), and .firstFailure (for quick dispatch). Reads propagate per-key decode failures as stream errors, with a documented .handleError(...) escape hatch for apps that want the old silent-skip behaviour. That's better than "the call returned false" or "throws a vague Exception" — the two bad defaults that CRUD libraries tend to fall into.

(iv) The reactive surface is sealed-enough and exhaustive-enough to be useful. watch() returns Stream<CEvent>; the event subclasses are CItemUpdated, CItemDeleted, CReadReceipt, CSubItemUpdated, CSubItemDeleted, plus the timer-driven CItemAvailable (added in 3.13.0). CEvent itself is deliberately not sealed: room for further event types stays open without breaking downstream switch statements. I went back and forth on this decision while reading — a sealed class would give exhaustiveness checks at compile time, which is a real benefit — but the class-doc for CEvent makes the tradeoff explicit and tells apps to use a default: branch. That's an honest-enough commentary on the tradeoff.

(v) Small enough to read in an afternoon. ~2,350 lines. A single file. Five sections marked by comment banners (top-level AtCollection API, Query builder, CItem, operation results / exceptions, event types). No code-generation. No runtime reflection. If something breaks in production, you can read the library end-to-end and understand it. I did. It took three hours.

(vi) Honest, long-lived documentation. The library ships with a ~500-line review of itself (AtCollection_API_Assessment.md) listing every known weakness, ranked by severity, with specific issue numbers and remediation plans. That is not normal. Most libraries of this size ship with a README that sells you the happy path. When the maintainers publish their own punch-list, I take it as a signal the library is being built by people who know what good looks like.

One caveat against (v) and (vi): the @experimental tag on AtCollection<T> is not cosmetic. The shape has churned a lot in recent months — read receipts, for instance, lived on the CItem itself before being moved to a reserved __rr sub-collection (per-item append-only side-car) because the merge-on-write semantics were coupling reads to writes in a costly way. If you adopt now, expect minor breakage between versions. Post-1.0 this won't be an issue; today, it is.

2b. Against mainstream CRUD libraries

Here's a single-row-per-operation comparison against the libraries I think a developer evaluating this is actually choosing between.

Operation Firestore Isar Hive Drift Supabase AtCollection<T>
Create doc(id).set (upsert) put(obj) put(k, v) insert from(t).insert() create (throws on collision)
Update doc(id).update put(obj) put(k, v) update from(t).update() update (throws if missing)
Delete doc(id).delete delete(id) delete(k) delete from(t).delete() delete (cascade opt-in)
Read one doc(id).get get(id) get(k) single-val .select().single get(id, owner) / getOrNull
Read many coll.get .findAll .values .get .select getItems / getItemsAsStream
Filter .where() chain .filter() chain in-memory SQL .eq().gt() chain .query().where() / stream
Where filter runs server (plaintext) on-device on-device server server (plaintext) on-device (values E2EE)
Watch .snapshots .watchLazy .watch .watch realtime channel watch + typed sub-streams
Typed records withConverter<T> annotated classes TypeAdapter companions code-gen types fromJson per-collection
Multi-user sharing none (ACLs only) none none none RLS (server-side) sharedWith on record
Record ownership none (ACLs only) none none none RLS (server-side) item.owner (Dart-enforced)
Read receipts markReadByMe + CReadReceipt
Local-first copy no yes (is local) yes embedded no yes — synced by SDK
E2E encryption no no no no no yes, by default

The shape is clear:

2c. Against raw AtClient.put/get

This is where I found the most persuasive number. Without AtCollection<T>, an app that wants to store typed records, share them with recipients, react to remote updates, and dedupe/merge across shared copies has to hand-write every step. The library's own assessment document puts hard numbers on the reduction, which I spot-checked against the example programs in example/bin/:

Canonical operation Raw AtClient LOC AtCollection<T> LOC Reduction
Create + share typed object with N recipients 30–40 1 ~97%
Update an existing item's fields 15–20 2 ~88%
List all items (self + received), deduped + merged 25–30 1 ~96%
Filter + list ~30 3 ~90%
Subscribe to updates with typed payload 15–20 1 ~94%
Send a read receipt (and receive one) 30 + invent scheme 1 + 1 auto ~93%
Sub-collection scoped to parent, cascade on delete N/A — invent it 3

A ~90% code reduction is a "this is meaningfully easier" number. But LOC isn't the real story. The real story is the concept count. Using raw AtClient, a developer has to understand, simultaneously, eleven Atsign Protocol-specific concepts: AtKey format (self vs shared vs cached), Metadata fields (ttr/ccd/ttl/ttb/expiresAt), per-recipient-key machinery, cached-prefix semantics, namespace-aware flags, notification key parsing, regex composition, JSON envelope format, 255-char key limits, the __rr read-receipt pattern, and application-namespace composition. Ten of those eleven disappear entirely with AtCollection<T>. The eleventh (namespace composition) is deliberately kept visible — implicit namespace inference would make the call site less greppable, and the maintainers explicitly chose explicit.

A concept the app doesn't have to carry is a concept that can't leak through into a bug. This is the part of the story that LOC numbers under-sell: when I was writing against AtCollection<T>, I was writing a todo app. When I was writing against raw AtClient.put/get, I was writing a todo app and debugging an Atsign Protocol implementation.


3. What's genuinely new here

Four things stand out as not just "well packaged" but structurally different from anything in the CRUD space I've seen.

(i) Per-record ownership, enforced at the type boundary. Every CItem has a single owner. The library will refuse (with ArgumentError) to let you update or delete an item owned by another atSign. You can edit item.obj, but todos.update(item) simply won't commit if you're not the owner. Firestore's equivalent is a centrally-configured security rule that can be bypassed by an admin; Supabase's is a Postgres policy that lives in a different artefact from the app code. AtCollection's version lives right in the type and is enforced in Dart.

(ii) Multi-destination distribution as a field on the record. When you write sharedWith: {@bob, @carol}, the library handles all the per-recipient key writes, cache prefixes, encryption, and notification fan-out. From the caller's side, it's a Set<Atsign> on the record. When you change that set and update the item, the library diffs recipients and un-shares the ones you removed. No equivalent exists in the mainstream set.

(iii) Read receipts without application-level bookkeeping. await item.markReadByMe() is idempotent on the reader side. item.readBy resolves to Future<Set<Atsign>> on the owner side. Receipts are a reserved __rr sub-collection per item, maintained as a live append-only side-car. The reason no other CRUD library in the comparison row implements receipts is that none of them have the per-recipient-copy model to hang receipts on — they're all single-store systems where "who has read this" isn't a well-defined question.

(iv) Sub-collections with parent-scoped lifetime, with offline recovery. Firestore has sub-collections; they are the canonical footgun of the platform because deleting the parent does not delete the children, which is almost never what the application author wanted. AtCollection binds a sub-collection's lifetime to its parent: when you delete a parent, the library cascade-deletes its self-owned descendants on the live path (via notification on other atSigns), and cleanupOrphans() recovers the offline case (you were offline when the parent was deleted; you come online; the library sweeps the orphans). This is the hardest case for a distributed system and the library handles it explicitly.


4. What isn't so great

Honest review: things I'd want to see improved before I'd call this a 1.0.

None of these are shape-of-the-API problems. They're all last-mile finish.


5. Why this matters beyond the library

Here's the part I didn't expect to be writing.

AtCollection<T> is a good collection library. That's the surface claim. The deeper claim — the one I think is worth the review — is what the library is evidence of. Four things:

(i) End-to-end encryption is compatible with "library easy." The received wisdom is that E2E encryption belongs in serious messaging apps and everything else just pushes data to the cloud. AtCollection<T> is a counter-example: the transport is E2E-encrypted, the application author writes no crypto code, and the API shape is recognisable to anyone who has touched Firestore. The library-author effort to get here was years of Atsign Protocol work plus ~six months of API-surface iteration. The application- author effort is zero.

(ii) Per-user servers are finally buildable without running infrastructure. "Everyone has their own small server" is the old Usenet / diaspora / mastodon / Solid-Pod pitch. The historical problem was that somebody still had to run the servers. The Atsign Protocol's trick is that the atServer is a managed, per-user thing (with a free-atSign entry point via my.noports.com/no-ports-plans) — the user owns it, the user holds the master keys, the operator can't read the data, and the SDK just talks to it. If you're building an app where you don't want to be the data custodian for your users, this is a legitimate architectural choice now.

(iii) "Ownership in the type" is a design idea that should spread. Per-record ownership, enforced at the API boundary rather than in an admin-configured policy, is a concept I'd like to see other SDKs borrow. It makes multi-actor reasoning easier: there's one place to check, and the compiler helps. Firestore's eventual answer to this has been "write security rules, deploy them, hope you got them right." Supabase's has been RLS — same shape. A library that gets ownership into the type signature and enforces it in the client is, at the very least, a good demonstration that "ownership is a security-rules problem" isn't the only available framing.

(iv) CRUD on "my data" vs "the app's data" is the right framing. Most CRUD libraries are built around the premise that the application owns its database and the users are records in it. AtCollection<T> is built around the premise that each user owns their own data and occasionally shares it with someone else. That is a real inversion, and it's worth trying on for size even if you don't end up adopting the specific library.

(v) Self-hosting is a first-class option, not a footnote. An organisation that doesn't want to trust Atsign's hosted atServers can run its own. It can host a split-horizon atDirectory so it isn't dependent on root.atsign.org:64. And for full enterprise isolation it can stand up a completely hermetically sealed Atsign ecosystem — directory + registration + a fleet-of-swarms of atServers, scaling indefinitely — where no traffic ever crosses the organisation's boundary. AtCollection<T> doesn't know any of this is happening; it just talks to whichever atServer the underlying AtClient was bound to. That's the right shape for a library: the infrastructure choice is separable from the code you write against it.

(vi) The crypto stack is pluggable, and post-quantum is already on the runway. The current RSA-2048 + AES-256 stack is not frozen. There's an active programme replacing it with a pluggable layer whose default is Signal's triple-ratchet plus post-quantum primitives — see tickets #1889, #1891, and #1893. AtCollection<T> adds no crypto of its own — it inherits whatever the SDK below it negotiates — so when the upgrade lands, every app built on AtCollection gets the new guarantees for free. That's a strong property to get from a library abstraction: the thing you care most about (forward secrecy, quantum resistance) is maintained beneath the API you wrote against, not sprinkled into it.


6. Try it in fifteen minutes

If I've interested you, here's the shortest path from zero to a working demo.

  1. Get a free atSign at my.noports.com/no-ports-plans (not my.atsign.com — that's paid/custom atSigns). Finish the registration flow to get the .atKeys file.
  2. Clone atsign-foundation/at_client_sdk.
  3. cd packages/at_client/example, dart pub get.
  4. Run:
    dart run bin/collections_primitives.dart \
      --atsign @you \
      --other-at-signs @friend
    
  5. Read the source of bin/collections_primitives.dart — it's about a hundred lines — and then bin/collections_subcollections.dart for the nested case.

If you want the full CLI tour, bin/collections_todos.dart is a terminal TUI that exercises CRUD, sub-collections, read receipts, and cascade delete in one program. You can run two instances side-by-side with different atSigns and watch them share records live.

For Flutter developers, the canonical reference app lives at packages/at_client_flutter/examples/todos — same feature set, but rendered through the mobile/desktop widget stack the way a shipping app would use it.

After ~30 minutes you will know whether the shape of the API fits the problem you want to solve. If you're used to Firestore, I expect the answer to be "yes, this is familiar — plus I got end-to-end encryption for free." Which was my reaction, and is why I wrote the review.


Verdict

AtCollection<T> is competitive — on shape, typing, reactivity, and the now-complete two-phase query-builder surface — with the mainstream Dart CRUD libraries, and ahead of all of them on four structural axes: ownership in the type, per-record sharing, read receipts, and parent-scoped sub-collections with offline-recovery orphan cleanup. The typed-AST predicates added in phase 2 are introspectable, so a SQLite-indexed local store landing later can push eligible clauses down without caller-code change. The execution model (local-first scan of end-to-end-encrypted records) is a deliberate choice, not a shortcoming.

It's @experimental. Some of the shape will change before 1.0. But the shape is recognisable to any developer who has used a modern CRUD library, and the things it does that nobody else does are things that a surprising number of applications genuinely need.

If you've ever written code that boils down to "I want this record to exist for these users, and I want those users' servers to be the only ones that can read it, and I want a read receipt when they see it," this is the first library I've seen where that sentence maps to less than ten lines of application code.

I'd try it.