Skip to content

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.

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.

When a client sends a Y.js update, the server:

  1. Extracts operation IDs from the update — which operations were inserted and which were deleted
  2. Tags each operation range with the authenticated userId and current timestamp
  3. Encodes the result as a binary ContentMap
  4. 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
},
);

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 ID
const 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.

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.

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.

getActivity is the single entrypoint for “who did what, when?” — all filters compose with AND:

const { attribution } = provider.rpc;
// All activity
await attribution.getActivity();
// Filter by user
await attribution.getActivity({ userId: "alice" });
// Time range
await attribution.getActivity({ from: hourAgo, to: now });
// Scoped to a milestone
await attribution.getActivity({ milestone: milestoneId });
// Changes between two milestones
await attribution.getActivity({ changeset: [fromId, toId] });
// Custom attribute filter
await attribution.getActivity({ attributes: { source: "ai" } });
// Combine any filters
await 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.

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.

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 ID
const 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]

Attribution requires a storage implementation that supports it. Your storage must:

  1. Accept the optional attribution parameter in handleUpdate
  2. Implement the optional retrieveAttribution method
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);
}
}

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(),
},
});

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.

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)
  • getActivity works — it is derived purely from authorship metadata
  • getForRange works — 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.

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 ContentMaps
const merged = mergeContentMaps([mapA, mapB, mapC]);
// Filter by attribute predicate
const byAlice = filterContentMap(contentMap, (attrs) => {
const user = attrs.find((a) => a.name === "insert");
return user?.val === "alice";
});
// Intersect: keep only ranges present in both
const scoped = intersectContentMap(fullMap, milestoneIds);
// Exclude: remove already-attributed ranges
const newOnly = excludeContentMap(fullMap, alreadyAttributedIds);
  • Milestones – Scope attribution to document snapshots
  • Provider – Client-side API reference
  • Server – Server configuration and events
  • Authentication – How userId flows into attribution