Attribution
Attribution tracks who inserted or deleted each piece of content in a Y.js document, and when. It answers questions like “who wrote this paragraph?” and “what changed between yesterday and today?” — the foundation for features like author highlighting, activity feeds, and audit trails.
Overview
Section titled “Overview”Every Y.js CRDT operation has a unique ID: (clientID, clock). The attribution system maps these operation IDs to authorship metadata — userId, timestamp, and optional custom attributes — in a compact binary structure called a ContentMap.
flowchart LR
Client[Client sends update] --> Server[Server extracts operation IDs]
Server --> Tag[Tag with userId + timestamp]
Tag --> Store[Store ContentMap alongside update]
Store --> Query[Clients query attribution on demand]
The server computes and stores attribution automatically. Clients query it on demand via RPC — attribution data is not synced continuously, only fetched when requested.
How It Works
Section titled “How It Works”Server-Side: Computing Attribution
Section titled “Server-Side: Computing Attribution”When a client sends a Y.js update, the server:
- Extracts operation IDs from the update — which operations were inserted and which were deleted
- Tags each operation range with the authenticated userId and current timestamp
- Encodes the result as a binary ContentMap
- Stores it alongside the update in storage
// This happens automatically inside the server — no configuration needed// beyond enabling attribution in your storage implementation.The server emits a document-attribution event on every attributed update, which you can use for real-time integrations:
server.on( "document-attribution", ({ documentId, namespacedDocumentId, sessionId, userId, timestamp, contentMap }) => { // React to attribution changes in real-time },);Client-Side: Querying Attribution
Section titled “Client-Side: Querying Attribution”Attribution is accessed via a client RPC extension registered on the Provider. The extension queries the server and resolves content ranges locally:
import { Provider } from "teleportal/providers";import { createAttributionRpc } from "teleportal/protocols/attribution";import { createEncryptionKey } from "teleportal/encryption-key";
const provider = await Provider.create({ url: "wss://example.com", document: "my-document", encryptionKey: createEncryptionKey(), // encrypted by default rpc: { attribution: createAttributionRpc, },});
// Activity timeline — who edited, and when?const activity = await provider.rpc.attribution.getActivity();
// Who wrote characters 0..100 of a Y.Text?const text = provider.doc.getText("body");const segments = await provider.rpc.attribution.getForRange(text, 0, 100);// → [{ from: 0, to: 45, userId: "alice", timestamp: 1700000000,// attributes: { insert: "alice", insertAt: 1700000000 } },// { from: 45, to: 100, userId: "bob", timestamp: 1700000500,// attributes: { insert: "bob", insertAt: 1700000500 } }]
// Point lookup by CRDT IDconst author = await provider.rpc.attribution.resolveItem(clientID, clock);// → { userId: "alice", timestamp: 1700000000, attributes: { ... } }getForRange works with any Y.js sequence type — Y.Text, Y.Array, Y.XmlText, Y.XmlFragment, etc. For key-value types like Y.Map, use resolveItem with the CRDT ID of the item you’re interested in.
Custom Attributes
Section titled “Custom Attributes”You can attach arbitrary metadata by providing an attributionConfig when creating the server. The returned attributes are stored as-is on both the insert and delete sides of the ContentMap:
import { Server } from "teleportal/server";
const server = new Server({ storage: async (ctx) => storage, attributionConfig: { getAttributes: ({ context }) => ({ source: context.source ?? "human", model: context.model ?? "unknown", }), },});Custom attributes are encoded, stored, and transmitted alongside standard attributes. Use cases include AI agent tagging, change source tracking, or any domain-specific metadata.
Client API
Section titled “Client API”Each Provider instance represents a single document. Attribution methods are available on provider.rpc.attribution when the createAttributionRpc extension is registered — subdocuments have their own Provider instances with independent attribution.
Activity Timeline
Section titled “Activity Timeline”getActivity is the single entrypoint for “who did what, when?” — all filters compose with AND:
const { attribution } = provider.rpc;
// All activityawait attribution.getActivity();
// Filter by userawait attribution.getActivity({ userId: "alice" });
// Time rangeawait attribution.getActivity({ from: hourAgo, to: now });
// Scoped to a milestoneawait attribution.getActivity({ milestone: milestoneId });
// Changes between two milestonesawait attribution.getActivity({ changeset: [fromId, toId] });
// Custom attribute filterawait attribution.getActivity({ attributes: { source: "ai" } });
// Combine any filtersawait attribution.getActivity({ milestone: milestoneId, userId: "alice" });Each entry includes an attributes record containing all attributes (standard and custom):
// → [{ from: 1700000000, to: 1700000500, userId: "alice",// attributes: { insert: "alice", insertAt: 1700000000, source: "human" } }, ...]Adjacent entries from the same user within 1 second are grouped, but only when their attributes match — entries from the same user but different custom attributes (e.g. human vs AI) stay separate.
Range Attribution
Section titled “Range Attribution”Resolve who authored a range of content in a Y type:
const text = provider.doc.getText("body");const segments = await provider.rpc.attribution.getForRange(text, 0, 100);// → [{ from: 0, to: 45, userId: "alice", timestamp: 1700000000,// attributes: { insert: "alice", insertAt: 1700000000, source: "human" } },// { from: 45, to: 100, userId: "bob", timestamp: 1700000500,// attributes: { insert: "bob", insertAt: 1700000500, source: "ai" } }]Segments with different custom attributes are not merged, even if they have the same userId and timestamp. Runs entirely client-side, so it works identically for encrypted and unencrypted documents.
Lower-Level Methods
Section titled “Lower-Level Methods”For advanced use cases, the raw ContentMap and point-lookup APIs are available:
const { attribution } = provider.rpc;
// Raw ContentMap (fetched once, cached for subsequent calls)const map = await attribution.getMap();const filtered = await attribution.getMap({ userId: "alice" });attribution.invalidateCache(); // force re-fetch on next use
// Point lookup by CRDT IDconst author = await attribution.resolveItem(clientID, clock);// → { userId: "alice", timestamp: 1700000000, attributes: { ... } } | null
// Milestone-scoped ContentMaps (for direct set operations)const milestoneMap = await attribution.getMilestoneContentMap(milestoneId);const changesetMap = await attribution.getChangesetContentMap(fromId, toId);flowchart LR
Full[Full ContentMap] --> Intersect["Intersect with milestone IDs"]
Milestone[Milestone Snapshot] --> Extract["Extract operation IDs"]
Extract --> Intersect
Intersect --> Scoped[Scoped ContentMap]
Server Configuration
Section titled “Server Configuration”Enabling Attribution
Section titled “Enabling Attribution”Attribution requires a storage implementation that supports it. Your storage must:
- Accept the optional
attributionparameter inhandleUpdate - Implement the optional
retrieveAttributionmethod
import { type DocumentStorage, type EncodedContentMap } from "teleportal/storage";
class MyStorage implements DocumentStorage { async handleUpdate(documentId: string, update: VersionedUpdate, attribution?: EncodedContentMap) { // Persist the update await this.saveUpdate(documentId, update);
// Persist attribution alongside the update (merge with existing) if (attribution) { await this.mergeAttribution(documentId, attribution); } }
async retrieveAttribution(documentId: string): Promise<EncodedContentMap | null> { // Return the merged ContentMap for the document return this.loadAttribution(documentId); }}Attribution RPC Handlers
Section titled “Attribution RPC Handlers”Register the attribution RPC handlers on the server to expose the query API to clients:
import { Server } from "teleportal/server";import { getAttributionRpcHandlers } from "teleportal/protocols/attribution";
const server = new Server({ storage: async (ctx) => storage, rpcHandlers: { ...getAttributionRpcHandlers(), },});Permission Control
Section titled “Permission Control”Attribution RPC methods (attributionActivity, attributionGet) are covered by the server’s global checkPermission hook. Use the rpcMethod field to apply method-level authorization:
const server = new Server({ storage: async (ctx) => storage, checkPermission: async ({ context, documentId, rpcMethod }) => { if (rpcMethod === "attributionActivity" || rpcMethod === "attributionGet") { return canReadAttribution(context.userId, documentId); } // ... other permission logic }, rpcHandlers: { ...getAttributionRpcHandlers(), },});When checkPermission returns false (or throws), the RPC call fails with a 403.
Encryption Boundary
Section titled “Encryption Boundary”Documents are end-to-end-encrypted by default, and attribution works on them without the server ever seeing content.
Every Y.js operation has a structural ID — (clientID, clock) — that is separate from the content it represents. With content-level encryption the CRDT structure stays in plaintext and only the content is encrypted into sidecars, so the server reads these IDs directly from the plaintext structure update — the same code path as unencrypted documents — and tags them with the authenticated userId and timestamp. The client does not extract or ship IDs separately. The structure reveals the shape of the CRDT (which client wrote how many operations), but never the text or data itself.
- The server holds only the encrypted content sidecars plus the plaintext structure and ContentMap (operation IDs + userId/timestamp — no document content)
getActivityworks — it is derived purely from authorship metadatagetForRangeworks — it resolves content positions against the local decrypted document, entirely client-side- Milestone attribution works — the client decrypts the milestone snapshot locally before extracting operation IDs
The server never sees document content in any of these flows.
Set Operations
Section titled “Set Operations”The attribution library provides a full set algebra over ContentMaps and ContentIds, useful for advanced use cases:
import { mergeContentMaps, filterContentMap, intersectContentMap, excludeContentMap,} from "teleportal/attribution";
// Merge multiple ContentMapsconst merged = mergeContentMaps([mapA, mapB, mapC]);
// Filter by attribute predicateconst byAlice = filterContentMap(contentMap, (attrs) => { const user = attrs.find((a) => a.name === "insert"); return user?.val === "alice";});
// Intersect: keep only ranges present in bothconst scoped = intersectContentMap(fullMap, milestoneIds);
// Exclude: remove already-attributed rangesconst newOnly = excludeContentMap(fullMap, alreadyAttributedIds);Next Steps
Section titled “Next Steps”- Milestones – Scope attribution to document snapshots
- Provider – Client-side API reference
- Server – Server configuration and events
- Authentication – How userId flows into attribution