Skip to content

Room Metadata Contract — Shape, Limits, and PII Rules

Each participant in a resource room carries an optional metadata string — the slot that lets a face-pile show not just who is here but where they are inside the resource: which tab, which section, how far they have scrolled. This page is the contract for what goes in that slot.

public sealed record ResourcePresenceEntry(
Guid UserId,
DateTimeOffset LastSeenUtc,
string? Metadata = null); // ← this page

Metadata is a JSON object of string keys to string values — the same shape the framework uses everywhere for ad-hoc extensibility: the IHasMetadata convention, a JSON dictionary stored in a single string.

The framework treats the string as opaque: it never parses it, never indexes it, never reasons about its keys. It is stored verbatim and returned verbatim. All meaning is a contract between your server-side producer and your client-side renderer.

Because the shape matches the IHasMetadata convention, the same extension helpers from MetadataExtensions give you typed read/write without hand-rolling JSON:

// Writing (producer side) — build the bag, serialize once, pass to JoinAsync.
// The helpers operate on any IHasMetadata carrier; here a lightweight DTO.
entry.SetMetadataValue("tab", "content");
entry.SetMetadataValue("scrollPos", "0.42");
await rooms.JoinAsync(resource, userId, entry.MetadataJson, ct);
// Reading (consumer side) — typed getters parse on the way out.
string? tab = participant.GetMetadataValue("tab");
double scrollPos = participant.GetMetadataValue<double>("scrollPos");

The metadata string may not exceed 512 bytes when UTF-8 encoded. This is a hard limit, enforced twice (defense in depth):

  1. At the HTTP edgeHeartbeatRoomRequestValidator rejects an oversize body with 400 Bad Request and the error code Granit:Validation:PresenceRoomMetadataTooLarge.
  2. At the trackerJoinAsync re-checks and throws ArgumentException before anything is cached, so the limit holds even for in-process callers that bypass the HTTP layer.

512 bytes is deliberately small. Room metadata is a cursor hint, not a payload — it should comfortably fit a handful of short keys. If you find yourself fighting the cap, you are putting the wrong thing in the blob (see below).

Room metadata is broadcast to every other participant who can read the room, and it transits an ephemeral cache that is never audited or access-logged the way an EF aggregate is. It is therefore contractually off-limits to personal data.

The framework cannot enforce this — the blob is opaque to it by design. The cap bounds size, not sensitivity. Keeping PII out is the consumer’s responsibility, backed by documentation (this page) and the architecture test recommended below.

Because nothing at runtime inspects the blob, guard it at build time. Add an architecture test in the consuming codebase that scans the call-sites that populate room metadata (your SetMetadataValue(...) keys, or the literal keys you serialize) and fails the build if a forbidden key name appears.

// Consumer-side architecture test (illustrative).
[Fact]
public void Room_metadata_keys_contain_no_PII()
{
string[] forbidden = ["email", "name", "displayName", "phone", "url", "href"];
IReadOnlyList<string> usedKeys = ScanRoomMetadataSetPropertyCallSites();
usedKeys.ShouldNotContain(
k => forbidden.Contains(k, StringComparer.OrdinalIgnoreCase),
"Room metadata must not carry PII — resolve identity client-side from UserId.");
}

This mirrors the framework’s own convention test for the rooms surface — a static guard is the only reliable defense against an opaque field.

Metadata is opaque, so you are free to choose keys — but a shared vocabulary keeps clients interoperable and reviews easy. Prefer these:

| Key | Type | Meaning | Typical scenario | |-----|------|---------|------------------| | tab | string | Which tab/pane the user is on | Multi-tab editors (CMS, settings) | | section | string | Sub-region within the surface | Long forms, structured documents | | scrollPos | number (0–1) | Fractional scroll position | Long pages, articles | | viewMode | string | edit / view / comment … | Surfaces with explicit modes | | page | number | Current page number | PDF / document readers | | step | string | number | Current step in a flow | Wizards, workflows, approvals |