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.
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:
room.memory.brand.The system supports three distinct visitor modes on Z Link routes (/z/:slug). All three resolve to the same underlying contract:
| Mode | Identification Mechanism | accountId | Seeding Behavior |
|---|---|---|---|
| Regular Login | Authenticated session cookie (resolveAuthContext) | Target account ID | Never seeded into deal room |
| Act-As (Impersonation) | zly_acting_as_account_id cookie evaluated by resolveAuthContext | Impersonated account ID | Never seeded into deal room |
| Incognito / Anonymous Guest | Ephemeral guestKey cookie (readGuestKey) | null (unclaimed) | No account exists to seed |
resolveAuthContext(cookieHeader) extracts the authenticated user's account ID. When openBrandRoom() is called:
talentAccountId and brandAccountId exists, it reopens it; otherwise, it creates a new ROOM_KIND_DEAL room.seedRoomMemoryFromAccount explicitly skips the brand participant.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.Anonymous visitors start without an account:
guestKey cookie and creates an unclaimed room titled "A visitor ร [Talent]".createZAgentPendingEffect).claimGuestDealRoom(roomId, guestKey, brandAccountId) binds the new brandAccountId to the participant row.updateParticipant is invoked with patch.accountId.seedRoomMemoryFromAccount(row) checks the room type and skips seeding the newly bound brand account.flushPendingEffects().When a talent (or someone acting as that talent) opens their own Z Link:
insertDealRoom detects talentAccountId === brandAccountId.Demo ยท [Talent] and mode demo.Representation boundaries and data isolation are enforced across five independent layers. No single failure can break the invariant.
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 ...
}
The planner receives full room memory, but participant account facts and representation rules are strictly partitioned:
loadPlannerContext() filters accountFactParticipants so only key === "talent" facts are loaded into ctx.accountFacts.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.
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.
When ZGuard audits a quiet room, runArvistForParticipant() runs once per bound participant to update their durable memory:
listZAgentMessages(room.id, participant.id)), never trimmed to a previous promotion.omitTalentBranch(omitPrivateBranch(room.memory)) strips the talent's private rules and planner notes.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.
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:
conversationSubtitle() and sessionRelationshipLabel() output clear relationship headers (e.g., "[Talent]โs agent and [Visitor]").When modifying packages/z-agent, packages/db, or authentication routes, you must preserve these rules:
seedRoomMemoryFromAccount or syncStaleAccountMemory must check room.kind === ROOM_KIND_DEAL && participant.key !== "talent".personaPromptFor must only fetch buildAccountFacts for key === "talent".personaIdentityDirective at the prompt tail: It must always be appended after runtime capabilities and recalled memory.readerSeat type safety: Use ZAgentDealPane from @zooly/db instead of raw string literals.vitest run src/persona-identity.test.ts src/agents/planner.test.ts.On This Page
The Breaking Point: Account ContaminationWhat HappenedAuthentication Modes & Visitor Resolution1. Regular Logged-In User2. Act-As Mode3. Incognito & Mid-Session Claiming4. Self-Chat & Demo ModeMulti-Layered Defense ArchitectureLayer 1: Data Storage & Seeding GuardsLayer 2: Planner Context & Ground TruthLayer 3: Persona Prompt & Identity DirectiveLayer 4: Promotion & Archival Isolation (Arvist)Layer 5: UI & Client PerspectiveDeveloper Invariants