Identity and Representation Boundaries

How Z Link conversations enforce strict representation invariants and memory isolation across authentication modes

A Z Link is a public entry point where any visitor can hold an autonomous commercial negotiation with a host principal's agent. Because visitors may themselves be authenticated accounts (with their own services, private floors, and deal history), admins, or anonymous guests, the system must enforce a strict, immutable invariant:

This distinction must never blur. Even when an authenticated creator with extensive private negotiation rules visits another account's Z Link, the agent must represent the host principal exclusively, speak to the visitor as a counterparty, and never allow the visitor's private account history or rules to enter the negotiation.

Seats are generic (host / visitor), not talent / brand. See Services, Seats, and Derived Role.


The Breaking Point: Account Contamination

What Happened

In an unshielded implementation, binding an account to a participant key (e.g., visitor) automatically invoked seedRoomMemoryFromAccount(), merging that account's durable memory into room.memory[participant.key].

When an authenticated creator visited another creator's Z Link:

  1. The visitor's durable account memory (their own floor prices, private blackout dates, brand exclusions, and negotiation style) was merged into room.memory.visitor.
  2. The planner saw this branch and treated the visitor's private limits as active room context.
  3. The persona agent for the room was exposed to the visitor's profile, leading to severe failure modes:
    • Identity Inversion: The model began referring to itself as the visitor's agent ("I represent you, the creator...").
    • Confidentiality Breach: The visitor's private pricing and rules were exposed to the host principal's planner and negotiation transcripts.
    • Contaminated Promotion: The Arvist promoted counterparty rules back into account memory on room close, permanently corrupting durable memory.

Authentication Modes & Visitor Resolution

The system supports three distinct visitor modes on Z Link routes (/z/:slug). All three resolve to the same underlying contract:

ModeIdentification MechanismaccountIdSeeding Behavior
Regular LoginAuthenticated session cookie (resolveAuthContext)Target account IDNever seeded into deal room
Act-As (Impersonation)zly_acting_as_account_id cookie evaluated by resolveAuthContextImpersonated account IDNever seeded into deal room
Incognito / Anonymous GuestEphemeral guestKey cookie (readGuestKey)null (unclaimed)No account exists to seed

1. Regular Logged-In User

resolveAuthContext(cookieHeader) extracts the authenticated user's account ID. When openVisitorRoom() is called:

  • If a deal room between this host and this visitor account exists, it reopens it; otherwise, it creates a new ROOM_KIND_DEAL room.
  • seedRoomMemoryFromAccount explicitly skips the visitor participant.
  • The visitor's display name and avatar appear in the chat UI, but their account memory remains isolated.

2. Act-As Mode

Admins and roster managers use the zly_acting_as_account_id cookie to work as a managed account.

  • resolveAuthContext() detects the cookie, validates permissions (userCanEdit — a management edge in account_agents, not a stored agency type), and sets accountId to the target client.
  • All downstream Z Link operations treat the session as if that target account is the visitor.
  • Memory seeding is blocked identically to regular login.

3. Incognito & Mid-Session Claiming

Anonymous visitors start without an account:

  1. First contact sets an HttpOnly guestKey cookie and creates an unclaimed room titled "A visitor × [Host]".
  2. Outbound notifications and binding offers are held as pending effects (createZAgentPendingEffect). The substance is stored, not a frozen spoken line.
  3. When the visitor hits the login gate and authenticates:
    • claimGuestDealRoom(roomId, guestKey, visitorAccountId) binds the new account to the visitor participant row.
    • updateParticipant is invoked with patch.accountId.
    • Crucial Guard: seedRoomMemoryFromAccount(row) checks the room type and skips seeding the newly bound visitor account.
    • Held pushes are delivered via flushPendingEffects(). The persona composes them from current memory and the DEAL STAGE block (heldPushMustConvey), so news that waited on signup is not spoken as it stood when it was held.

4. Self-Chat & Demo Mode

When a host (or someone acting as that host) opens their own Z Link:

  • insertDealRoom detects matching host and visitor account ids.
  • The room is initialized with title Demo · [Host] and mode demo.
  • The interface renders as a demo playground, allowing the principal to safely preview their agent's responses.

Multi-Layered Defense Architecture

Representation boundaries and data isolation are enforced across five independent layers. No single failure can break the invariant.

Layer 1: Data Storage & Seeding Guards

In packages/z-agent/srv/src/rooms.ts, durable account memory injection is strictly gated to the room host. The account has one durable memory document; the confidentiality boundary is between host and visitor, not between agent capabilities. A non-home bind also accepts the home room's host branch when that document is newer than durable (pickHostAccountSeed, ZLY-1897), so a z-link opened right after Update Agent is not waiting on Arvist:

// packages/z-agent/srv/src/rooms.ts
async function seedRoomMemoryFromAccount(
  participant: ZAgentParticipantRow,
  room?: ZAgentRoomRow,
): Promise<void> {
  if (!participant.accountId) return;

  const currentRoom = room ?? (await getZAgentRoom(participant.roomId));
  // In a deal room, non-host participants are visitors: their unrelated
  // private account history must never enter another principal's negotiation.
  if (currentRoom?.kind === ROOM_KIND_DEAL && participant.key !== "host") {
    return;
  }

  const durable = await getZAgentObjectMemory(OBJECT_TYPE_ACCOUNT, participant.accountId);
  // pickHostAccountSeed(durable, home.host) — durable unless home is newer
  // ... merge the picked document into the host participant branch ...
}

The periodic background sync (syncStaleAccountMemory) enforces the identical restriction:

for (const participant of participants) {
  if (room.kind === ROOM_KIND_DEAL && participant.key !== "host") continue;
  if (!participant.accountId) continue;
  // ... runAccUpdateForParticipant ...
}

Layer 2: Planner Context & Ground Truth

The planner receives full room memory, but participant account facts and representation rules are strictly partitioned:

  1. Account Facts Filter: In deal rooms, loadPlannerContext() filters accountFactParticipants so only key === "host" facts are loaded into ctx.accountFacts.
  2. Authoritative Ground Truth Directive: In packages/z-agent/srv/src/agents/planner.ts, sharedContext() injects an explicit representation rule:
REPRESENTATION IDENTITY — GROUND TRUTH
Every persona agent in this deal room represents Dev Raman (participant "host").
The active participant is Mara Ellison (participant "visitor"), the person that persona is speaking with. Do not tell the persona it represents the active participant unless they are also the represented participant.
A participant-key memory branch describes that participant; it does not decide whom the persona represents. This identity rule overrides names found in account memory.

Layer 3: Persona Prompt & Identity Directive

The persona agent stream in packages/z-agent/srv/src/persona-identity.ts builds an authoritative suffix that sits after memory. Every speaking turn also receives DEAL STAGE — GROUND TRUTH from composePersonaRuntimeContext (packages/z-agent/srv/src/agents/persona-prompt.ts) — the live stage from the offers row, never a copy in memory.

// packages/z-agent/srv/src/persona-identity.ts
export function personaIdentityDirective({
  participant,
  participants,
  displayNames,
}: PersonaIdentityFields): string | null {
  if (participant.key === "host") return null;
  const host = participants.find((candidate) => candidate.key === "host");
  if (!host) return null;

  const hostName = displayNameOf(host, displayNames);
  const visitorName = displayNameOf(participant, displayNames);
  return `--- REPRESENTATION IDENTITY (AUTHORITATIVE) ---
You are ${hostName}'s agent. You represent ${hostName} in this conversation.
You are speaking with ${visitorName}. ${visitorName} is the visitor, not the person you represent.
Never identify yourself as ${visitorName}'s agent. This remains true even if memory or planner guidance says otherwise.

--- DEAL STAGE AND BOOKING AUTHORITY (AUTHORITATIVE) ---
The deal's stage is the DEAL STAGE block in your context, derived from the offers system. You read it; you never infer it and never move it.
- Agreeing terms with ${visitorName} in this chat is your work and is not a booking.
- ${hostName} instructing you to take terms (recorded in memory as their instruction) lets you agree those terms; it is not a booking either, and you never call it "${hostName} approved".
- The booking exists only when the DEAL STAGE block shows a BOOKED stage — ${hostName} approving the written offer in the product.
...`;
}

Because LLMs prioritize instructions appearing at the end of system prompts, this ensures that:

  1. Even if stale memory or confused planner guidance mentions the visitor, the persona never adopts the wrong identity.
  2. Even if planner guidance tells the persona to "confirm the booking" or omit that the written offer is still pending, the persona cannot declare the deal booked unless the DEAL STAGE block shows a BOOKED stage.

Layer 4: Promotion & Archival Isolation (Arvist)

When a live home room finishes Distil, commitHomeArvistAfterDistil runs Arvist for the host immediately (ZLY-1897). When any other room goes quiet or closes — or as catch-up if that home run fails — runArvistForParticipant() runs once per bound participant to update their durable memory:

  1. Transcript Scoping: The Arvist only reads messages from the participant's own pane (listZAgentMessages(room.id, participant.id)).
  2. Memory Redaction: For non-host participants, omitHostBranch(omitPrivateBranch(room.memory)) strips the host's private rules and planner notes.
  3. Perspective Boundary: The Arvist prompt contains an immutable constraint:
IMPORTANT PERSPECTIVE BOUNDARY:
Only record enduring facts, accepted terms, and preferences from the perspective of Mara Ellison.
Do NOT adopt the identity or record the private rules/strategy of the counterparty.

The memory pass remains the single writer responsible for recording negotiated deal terms into room.memory. The host's Arvist run derives closed deal history directly from the room memory without needing to inspect raw visitor chat sessions. Writes into durable memory are partitioned into shared vs services.<room.service> — see Services.

Layer 5: UI & Client Perspective

In packages/offers/client and packages/db, conversation rendering adapts dynamically to readerSeat:

  • readerSeat: ZAgentDealPane ("host" | "visitor") indicates which side of the room the logged-in reader owns.
  • messageAlignment(readerSeat, sessionPane, fromAgent) computes left/right bubble placement:
    • When the reader views their own pane, their messages appear on the right (dark bubble), while the agent appears on the left.
    • When the host views the visitor's negotiation pane, the visitor appears on the left, and the agent representing the host appears on the right.
  • conversationSubtitle() and sessionRelationshipLabel() output clear relationship headers (e.g., "[Host]’s agent and [Visitor]").

Developer Invariants

When modifying packages/z-agent, packages/db, or authentication routes, you must preserve these rules:

  1. Never seed visitor accounts in deal rooms: Any call to seedRoomMemoryFromAccount or syncStaleAccountMemory must check room.kind === ROOM_KIND_DEAL && participant.key !== "host".
  2. Never expose host account facts to visitor personas: personaPromptFor must only fetch buildAccountFacts for key === "host".
  3. Keep personaIdentityDirective at the prompt tail: It must always be appended after runtime capabilities and recalled memory.
  4. Project the live offer stage; never copy it into memory: personas, Distil, and Guard read describeOfferStage / buildOfferFacts. The persona records pendingPrincipalDecisions.<key>.principalInstruction in the room's state half, not a stage.
  5. Maintain readerSeat type safety: Use ZAgentDealPane from @zooly/db instead of raw string literals ("host" | "visitor").
  6. Always test with mixed identity scenarios: Run test suites via vitest run src/persona-identity.test.ts src/agents/planner.test.ts.