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 creator's agent. Because visitors may themselves be authenticated creators, agencies, 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 creator's Z Link, the agent must represent the host talent exclusively, speak to the visitor as a counterparty, and never allow the visitor's private account history or rules to enter the negotiation.


The Breaking Point: Account Contamination

What Happened

In an unshielded implementation, binding an account to a participant key (e.g., brand) 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.brand.
  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 creator rules were exposed to the host talent's planner and negotiation transcripts.
    • Contaminated Promotion: The Arvist promoted counterparty rules back into account memory on room close, permanently corrupting durable memory.
flowchart TD subgraph Bug["Contamination Vector (Prior Flaw)"] VisitorAccount[("Visitor Account Memory<br/>(Private Creator Rules)")] -->|โŒ Unchecked Seeding| BrandBranch["room.memory.brand"] BrandBranch --> Planner["Planner<br/>(Reads All Branches)"] BrandBranch --> Persona["Persona Agent"] Persona -->|Identity Inversion| Chat["'I am your agent...'"] end subgraph Fix["Enforced Boundary (Current Architecture)"] TalentAccount[("Host Talent Account")] -->|โœ… Seeded at Bind| TalentBranch["room.memory.talent"] VisitorAuth[("Visitor Account")] -.->|๐Ÿšซ Seeding Blocked| BlockedBranch["No Account Seeding"] Directive["personaIdentityDirective<br/>(Authoritative Suffix)"] --> SystemPrompt["Persona System Prompt"] SystemPrompt --> SafeChat["'You represent Host Talent.<br/>Speaking with Visitor.'"] end

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 openBrandRoom() is called:

  • If a deal room between talentAccountId and brandAccountId exists, it reopens it; otherwise, it creates a new ROOM_KIND_DEAL room.
  • seedRoomMemoryFromAccount explicitly skips the brand 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 agencies use the zly_acting_as_account_id cookie to manage accounts.

  • resolveAuthContext() detects the cookie, validates permissions (userCanEdit), 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 ร— [Talent]".
  2. Outbound notifications and binding offers are held as pending effects (createZAgentPendingEffect).
  3. When the visitor hits the login gate and authenticates:
    • claimGuestDealRoom(roomId, guestKey, brandAccountId) binds the new brandAccountId to the participant row.
    • updateParticipant is invoked with patch.accountId.
    • Crucial Guard: seedRoomMemoryFromAccount(row) checks the room type and skips seeding the newly bound brand account.
    • Held pushes and offers are delivered via flushPendingEffects().

4. Self-Chat & Demo Mode

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

  • insertDealRoom detects talentAccountId === brandAccountId.
  • The room is initialized with title Demo ยท [Talent] and mode demo.
  • The interface renders as a demo playground, allowing creators 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.

flowchart TD Layer1["1. Data Layer: Seeding & Sync Guards"] --> Layer2["2. Context Layer: Planner Filtering"] Layer2 --> Layer3["3. Prompt Layer: personaIdentityDirective"] Layer3 --> Layer4["4. Promotion Layer: Scoped Arvist"] Layer4 --> Layer5["5. UI Layer: readerSeat & Message Alignment"]

Layer 1: Data Storage & Seeding Guards

In packages/z-agent/srv/src/rooms.ts, durable account memory injection is strictly gated to the room talent:

// 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-talent participants are visitors: their unrelated
  // private account history must never enter another talent's negotiation.
  if (currentRoom?.kind === ROOM_KIND_DEAL && participant.key !== "talent") {
    return;
  }

  const durable = await getZAgentObjectMemory(OBJECT_TYPE_ACCOUNT, participant.accountId);
  // ... merge into participant.key branch ...
}

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

for (const participant of participants) {
  if (room.kind === ROOM_KIND_DEAL && participant.key !== "talent") 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 === "talent" 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 "talent").
The active participant is Mara Ellison (participant "brand"), 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 and guidance:

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

  const talentName = displayNameOf(talent, displayNames);
  const visitorName = displayNameOf(participant, displayNames);
  return `--- REPRESENTATION IDENTITY (AUTHORITATIVE) ---
You are ${talentName}'s agent. You represent ${talentName} 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.`;
}

Because LLMs prioritize instructions appearing at the end of system prompts, this ensures that even if stale memory or confused planner guidance mentions the visitor, the persona never adopts the wrong identity.

Layer 4: Promotion & Archival Isolation (Arvist)

When ZGuard audits a quiet room, runArvistForParticipant() runs once per bound participant to update their durable memory:

  1. Transcript Scoping: The Arvist reads the participant's full pane (listZAgentMessages(room.id, participant.id)), never trimmed to a previous promotion.
  2. Memory Redaction: For non-talent participants, omitTalentBranch(omitPrivateBranch(room.memory)) strips the talent'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 planner remains the single writer responsible for recording negotiated deal terms into room.memory. The talent's Arvist run derives closed deal history directly from the room memory without needing to inspect raw visitor chat sessions.

Layer 5: UI & Client Perspective

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

  • readerSeat: ZAgentDealPane ("talent" | "brand") 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 talent views the brand's negotiation pane, the brand appears on the left, and the agent representing the talent appears on the right.
  • conversationSubtitle() and sessionRelationshipLabel() output clear relationship headers (e.g., "[Talent]โ€™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 !== "talent".
  2. Never expose talent account facts to visitor personas: personaPromptFor must only fetch buildAccountFacts for key === "talent".
  3. Keep personaIdentityDirective at the prompt tail: It must always be appended after runtime capabilities and recalled memory.
  4. Maintain readerSeat type safety: Use ZAgentDealPane from @zooly/db instead of raw string literals.
  5. Always test with mixed identity scenarios: Run test suites via vitest run src/persona-identity.test.ts src/agents/planner.test.ts.