···11+---
22+"@atmo-dev/contrail": minor
33+"@atmo-dev/contrail-sync": minor
44+---
55+66+unify the per-space marker field on records as `space` everywhere. previously `listRecords` / `getRecord` HTTP responses used `space: <spaceUri>` while watch events and `WatchRecord` exposed it as `_space`. the underscored form was inconsistent with the surrounding fields (`uri`, `cid`, `did`, etc.) and forced consumers to remember which path produced which name.
77+88+**breaking.** anywhere you read `r._space` on a `WatchRecord` (or a watch event payload's `record._space` / `child._space`), rename to `r.space`. drop-in.
99+1010+```ts
1111+// before
1212+if (record._space) ...
1313+1414+// after
1515+if (record.space) ...
1616+```
1717+1818+no migration needed for `listRecords` / `getRecord` consumers — that path was already `space`.
+20
.changeset/space-uri-scheme.md
···11+---
22+"@atmo-dev/contrail": minor
33+"@atmo-dev/contrail-sync": minor
44+"@atmo-dev/contrail-lexicons": minor
55+---
66+77+permissioned spaces now use the `ats://` scheme instead of `at://`. tracks the [permissioned data spec](https://dholms.leaflet.pub/3mhj6bcqats2o), which floats `ats://` as a distinct scheme so spaces can't be confused with atproto record URIs at any layer (logs, query params, dispatch, error messages).
88+99+```
1010+- at://did:plc:alice/com.example.event.space/birthday
1111++ ats://did:plc:alice/com.example.event.space/birthday
1212+```
1313+1414+what changed:
1515+- `buildSpaceUri` / `parseSpaceUri` (`@atmo-dev/contrail`) emit / accept `ats://`. anything else returns `null` from `parseSpaceUri`.
1616+- generated lexicons no longer claim `format: "at-uri"` on `spaceUri` params, on the `space` record-output field, or on `spaceView.uri` — they're plain `string`. (atproto's `at-uri` format would reject `ats://`.) regenerate committed `lexicons/generated/*` with `contrail-lex generate`; downstream `lex-cli generate` then emits `v.string()` instead of `v.resourceUriString()` for those fields.
1717+- realtime topics are unchanged in shape (`space:<uri>`), but `<uri>` is now an `ats://` URI.
1818+- record URIs (the `uri` on a record, the `appPolicyRef` field, `notifyOfUpdate` payloads) keep `at://` — those are still atproto record URIs.
1919+2020+**breaking.** anywhere you build a space URI by string concatenation (`` `at://${did}/${type}/${key}` ``), switch to `ats://` or call `buildSpaceUri()`. anywhere you persist space URIs in your own DB, migrate (`UPDATE … SET space_uri = REPLACE(space_uri, 'at://', 'ats://') WHERE space_uri LIKE 'at://%'`).
···79798080Server-side minting comes in two flavours:
81818282-- `com.example.realtime.ticket` — POST `{ topic }` (e.g. `"space:at://..."`) → `{ ticket, topics, expiresAt }`. Bare topic-list ticket, used with the generic `<ns>.realtime.subscribe` endpoint.
8282+- `com.example.realtime.ticket` — POST `{ topic }` (e.g. `"space:ats://..."`) → `{ ticket, topics, expiresAt }`. Bare topic-list ticket, used with the generic `<ns>.realtime.subscribe` endpoint.
8383- `<collection>.watchRecords?mode=ws&spaceUri=…` (or `&actor=…`) handshake — returns `{ snapshot, ticket, wsUrl, sinceTs, ticketTtlMs, querySpec }`. The ticket is bound to `(did, topics, querySpec)` and is the one to use for the per-collection `watchRecords` stream — both for SSE (`?ticket=…`) and the subsequent WS upgrade.
84848585Both flavours are signed by `realtime.ticketSecret` (a 32-byte random, configured once). Clients hand the ticket off via `?ticket=...` on connect.
···88888989```ts
9090createWatchStore({
9191- url: "/xrpc/com.example.message.watchRecords?spaceUri=at://...",
9191+ url: "/xrpc/com.example.message.watchRecords?spaceUri=ats://...",
9292 mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
9393});
9494```
+2-2
docs/05-spaces.md
···6677> A **space** is a bag of records with one lock. The **member list** says who has the key.
8899-- One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`.
99+- One owner (DID), one type (NSID), one key. Identified by `ats://<owner>/<type>/<key>` — distinct scheme from atproto record URIs (`at://`) so the two can't be confused at any layer.
1010- Every member (including owner) has read + write inside the space. Delete is scoped to your own records — no one can remove records they didn't author, owner included. To wipe everything, delete the space.
1111- Optional **app policy** gates which OAuth clients can act in the space.
1212···5151| `?spaceUri=…` + JWT | one space (ACL-gated) |
5252| JWT, no `spaceUri` | public **unioned** with every space the caller is a member of |
53535454-Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field on `listRecords`/`getRecord` responses (the watchRecords stream uses `_space` instead — see [Sync](./07-sync.md)).
5454+Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field — same on `listRecords`/`getRecord` responses and `watchRecords` stream events.
55555656## Invites
5757
···278278 expect(createRes.status).toBe(200);
279279 const bridged = ((await createRes.json()) as any).space.uri as string;
280280281281- const firstAdmin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
281281+ const firstAdmin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
282282 expect((await grant(app, DIANA, bridged, { spaceUri: firstAdmin }, "member")).status).toBe(200);
283283284284 // Alice has no direct grant in the second community, but is reachable via
+8-8
packages/contrail/tests/community-e2e.test.ts
···131131 it("adopts a community and creates reserved spaces with creator as owner", async () => {
132132 const did = await adopt(app, ALICE);
133133134134- const adminUri = `at://${did}/tools.atmo.event.space/$admin`;
135135- const publishersUri = `at://${did}/tools.atmo.event.space/$publishers`;
134134+ const adminUri = `ats://${did}/tools.atmo.event.space/$admin`;
135135+ const publishersUri = `ats://${did}/tools.atmo.event.space/$publishers`;
136136137137 // whoami in both reserved spaces → owner
138138 for (const uri of [adminUri, publishersUri]) {
···171171 });
172172 expect(res.status).toBe(200);
173173 const body = (await res.json()) as any;
174174- expect(body.space.uri).toBe(`at://${COMMUNITY_DID}/tools.atmo.event.space/general`);
174174+ expect(body.space.uri).toBe(`ats://${COMMUNITY_DID}/tools.atmo.event.space/general`);
175175 expect(body.space.ownerDid).toBe(COMMUNITY_DID);
176176 });
177177···193193 });
194194195195 it("owner grants Bob member access to #general; reconciler populates spaces_members", async () => {
196196- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
196196+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
197197 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, {
198198 spaceUri,
199199 subject: { did: BOB },
···214214 });
215215216216 it("manager cannot grant higher than own level", async () => {
217217- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
217217+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
218218 // Promote Bob to manager
219219 await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, {
220220 spaceUri,
···232232 });
233233234234 it("grant cannot downgrade a subject who outranks the caller", async () => {
235235- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
235235+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
236236 // Bob is currently manager (promoted earlier in this describe block).
237237 // Alice is owner of the space. Bob tries to downgrade Alice to member via grant.
238238 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, {
···245245 });
246246247247 it("revokes and reconciler removes from spaces_members", async () => {
248248- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
248248+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
249249 const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, {
250250 spaceUri,
251251 subject: { did: BOB },
···262262 });
263263264264 it("cannot delete a reserved space", async () => {
265265- const adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
265265+ const adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
266266 const res = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, {
267267 spaceUri: adminUri,
268268 });
+1-1
packages/contrail/tests/community-mint.test.ts
···149149 expect(ourCalls[0]!.body.rotationKeys).toHaveLength(2);
150150151151 // Reserved spaces exist with the caller as owner.
152152- const adminUri = `at://${body.communityDid}/tools.atmo.event.space/$admin`;
152152+ const adminUri = `ats://${body.communityDid}/tools.atmo.event.space/$admin`;
153153 const whoami = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE);
154154 expect(((await whoami.json()) as any).accessLevel).toBe("owner");
155155 });
···220220 ? {
221221 space: {
222222 type: "string",
223223- format: "at-uri",
224224- description: "Present when the record was read from a permissioned space; its value is the space URI.",
223223+ description: "Present when the record was read from a permissioned space; its value is the `ats://` space URI.",
225224 },
226225 }
227226 : {}),
···279278 ? {
280279 space: {
281280 type: "string",
282282- format: "at-uri",
283283- description: "Present when the record was read from a permissioned space.",
281281+ description: "Present when the record was read from a permissioned space; `ats://` URI.",
284282 },
285283 }
286284 : {}),
···320318 ? {
321319 space: {
322320 type: "string",
323323- format: "at-uri",
324324- description: "Present when the record was read from a permissioned space.",
321321+ description: "Present when the record was read from a permissioned space; `ats://` URI.",
325322 },
326323 }
327324 : {}),
···591588 ? {
592589 spaceUri: {
593590 type: "string",
594594- format: "at-uri",
595595- description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token).",
591591+ description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.",
596592 },
597593 byUser: {
598594 type: "string",
···725721 ? {
726722 spaceUri: {
727723 type: "string",
728728- format: "at-uri",
729729- description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token).",
724724+ description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.",
730725 },
731726 inviteToken: {
732727 type: "string",
+1-1
packages/sync/src/index.ts
···2222 time_us?: number;
2323 indexed_at?: number;
2424 /** Set when the record originates from a per-space table. */
2525- _space?: string;
2525+ space?: string;
2626 /** Present on optimistic entries added via `addOptimistic` — not set by
2727 * records arriving from the stream. Auto-dropped when a real record
2828 * with the same rkey arrives via `record.created`. */
+1-1
packages/contrail/src/core/types.ts
···292292 /** Set when the row originates from a per-space table. Used by the
293293 * pipeline/hydration/response layers to route child queries to the same
294294 * space and tag the output. */
295295- _space?: string;
295295+ space?: string;
296296}
297297298298export interface IngestEvent {
···22 *
33 * The URIs are returned as `ResourceUri` (branded) so they flow into
44 * lexicon-typed XRPC params without per-call casts. We apply the brand
55- * once here — structurally these are just `at://…` strings. */
55+ * once here — structurally these are just `ats://…` strings. */
6677import type { ResourceUri } from '@atcute/lexicons';
8899const SPACE_TYPE = 'tools.atmo.chat.space';
10101111export function buildSpaceUri(communityDid: string, key: string): ResourceUri {
1212- return `at://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri;
1212+ return `ats://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri;
1313}
14141515export function buildMembersUri(communityDid: string): ResourceUri {
···2020 return buildSpaceUri(communityDid, '$admin');
2121}
22222323-const SPACE_URI_RE = new RegExp(`^at://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`);
2323+const SPACE_URI_RE = new RegExp(`^ats://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`);
24242525export function parseSpaceUri(uri: string): { communityDid: string; key: string } | null {
2626 const m = SPACE_URI_RE.exec(uri);
···1010import { batchedInQuery, formatRecord } from "./helpers";
11111212/** Group rows by their origin: public (undefined key) or a specific spaceUri. */
1313-function groupBySource<T extends { _space?: string }>(rows: T[]): Map<string | undefined, T[]> {
1313+function groupBySource<T extends { space?: string }>(rows: T[]): Map<string | undefined, T[]> {
1414 const groups = new Map<string | undefined, T[]>();
1515 for (const r of rows) {
1616- const key = r._space;
1616+ const key = r.space;
1717 const g = groups.get(key);
1818 if (g) g.push(r);
1919 else groups.set(key, [r]);
···130130 formatRecord({
131131 ...(row as any),
132132 collection: childNsid,
133133- ...(sourceSpace ? { _space: sourceSpace } : {}),
133133+ ...(sourceSpace ? { space: sourceSpace } : {}),
134134 } as RecordRow)
135135 );
136136 }
···212212 result[parentUri][refName] = formatRecord({
213213 ...(row as any),
214214 collection: refNsid,
215215- ...(sourceSpace ? { _space: sourceSpace } : {}),
215215+ ...(sourceSpace ? { space: sourceSpace } : {}),
216216 } as RecordRow);
217217 }
218218 }
+7-7
packages/contrail/src/core/spaces/uri.ts
···11/** Centralized space URI construction / parsing.
22 *
33- * Permissioned spaces are addressed by (ownerDid, type, key). The rough spec
44- * (https://dholms.leaflet.pub/3mhj6bcqats2o) floats `ats://` as a likely
55- * distinct scheme for permissioned data, but that's unresolved — we keep
66- * `at://` today and isolate the format here so swapping is a one-liner.
33+ * Permissioned spaces are addressed by (ownerDid, type, key) and use the
44+ * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two
55+ * can't be confused at any layer (logs, params, dispatch). Tracks the rough
66+ * spec at https://dholms.leaflet.pub/3mhj6bcqats2o.
77 *
88 * Record URIs inside a space are minted by authorDid for index purposes
99 * (`at://<authorDid>/<collection>/<rkey>`); the spec is explicitly undecided
···18181919/** Build a space URI from its three addressing components. */
2020export function buildSpaceUri(parts: SpaceUriParts): string {
2121- return `at://${parts.ownerDid}/${parts.type}/${parts.key}`;
2121+ return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`;
2222}
23232424/** Parse a space URI into its components, or null if malformed. */
2525export function parseSpaceUri(uri: string): SpaceUriParts | null {
2626- if (!uri.startsWith("at://")) return null;
2727- const rest = uri.slice("at://".length);
2626+ if (!uri.startsWith("ats://")) return null;
2727+ const rest = uri.slice("ats://".length);
2828 const [ownerDid, type, key, ...extra] = rest.split("/");
2929 if (!ownerDid || !type || !key || extra.length > 0) return null;
3030 return { ownerDid, type, key };
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···135134 },
136135 "space": {
137136 "type": "string",
138138- "format": "at-uri",
139137 "description": "Present when the record was read from a permissioned space; its value is the space URI."
140138 }
141139 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···1515 "properties": {
1616 "spaceUri": {
1717 "type": "string",
1818- "format": "at-uri",
1919- "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check."
1818+ "description": "Optional \u2014 ownership is inferred from the invite row; required for user-owned spaces for a sanity check."
2019 },
2120 "tokenHash": {
2221 "type": "string"
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···134133 },
135134 "space": {
136135 "type": "string",
137137- "format": "at-uri",
138136 "description": "Present when the record was read from a permissioned space; its value is the space URI."
139137 }
140138 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···44 "defs": {
55 "main": {
66 "type": "procedure",
77- "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and — for `community:<did>` topics — expanded to the concrete list of `space:<uri>` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.",
77+ "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and \u2014 for `community:<did>` topics \u2014 expanded to the concrete list of `space:<uri>` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.",
88 "input": {
99 "encoding": "application/json",
1010 "schema": {
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···130129 },
131130 "space": {
132131 "type": "string",
133133- "format": "at-uri",
134132 "description": "Present when the record was read from a permissioned space; its value is the space URI."
135133 }
136134 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···44 "defs": {
55 "main": {
66 "type": "procedure",
77- "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.",
77+ "description": "Remove the caller from a space's member list. The owner cannot leave \u2014 they must delete the space instead.",
88 "input": {
99 "encoding": "application/json",
1010 "schema": {
···1414 ],
1515 "properties": {
1616 "spaceUri": {
1717- "type": "string",
1818- "format": "at-uri"
1717+ "type": "string"
1918 }
2019 }
2120 }
···44 "defs": {
55 "main": {
66 "type": "query",
77- "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID — useful for listing all channels in a specific community the caller can access.",
77+ "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID \u2014 useful for listing all channels in a specific community the caller can access.",
88 "parameters": {
99 "type": "params",
1010 "properties": {