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.
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:
room.memory.visitor.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 openVisitorRoom() is called:
ROOM_KIND_DEAL room.seedRoomMemoryFromAccount explicitly skips the visitor participant.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.Anonymous visitors start without an account:
guestKey cookie and creates an unclaimed room titled "A visitor × [Host]".createZAgentPendingEffect). The substance is stored, not a frozen spoken line.claimGuestDealRoom(roomId, guestKey, visitorAccountId) binds the new account to the visitor participant row.updateParticipant is invoked with patch.accountId.seedRoomMemoryFromAccount(row) checks the room type and skips seeding the newly bound visitor account.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.When a host (or someone acting as that host) opens their own Z Link:
insertDealRoom detects matching host and visitor account ids.Demo · [Host] 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 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 ...
}
The planner receives full room memory, but participant account facts and representation rules are strictly partitioned:
loadPlannerContext() filters accountFactParticipants so only key === "host" 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 "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.
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:
BOOKED stage.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:
listZAgentMessages(room.id, participant.id)).omitHostBranch(omitPrivateBranch(room.memory)) strips the host'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 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.
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:
conversationSubtitle() and sessionRelationshipLabel() output clear relationship headers (e.g., "[Host]’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 !== "host".personaPromptFor must only fetch buildAccountFacts for key === "host".personaIdentityDirective at the prompt tail: It must always be appended after runtime capabilities and recalled memory.describeOfferStage / buildOfferFacts. The persona records pendingPrincipalDecisions.<key>.principalInstruction in the room's state half, not a stage.readerSeat type safety: Use ZAgentDealPane from @zooly/db instead of raw string literals ("host" | "visitor").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