Account Pairing¶
A document created over the unauthenticated MCP endpoint has no owner. Ownership is established afterward by a claim handshake that binds the draft to a user account. This is account pairing — structurally similar to device pairing (a short code shown on a surface the requester can't read authorizes an untrusted client), except the durable principal is the user account, not a device.
In brief. An anonymous create_document writes an unclaimed draft and hands the model a claim token (S) and a short claim link (R). The user opens /claim/<R>, which resolves R to the document GUID H, signs in, and relays the six-digit device code (C) the server minted for that browser. The model replays claim_document(S, C) and the server binds the draft to the account. The handshake ties forward access to the account and resists an attacker who never saw the chat — chiefly prompt injection — but only under out-of-band delivery (see Delivery mode and trust). S survives the claim as the key for update_document edits.
The elements¶
S, R, and C are stored only as hashes, never in plaintext; H is the raw row id.
S— claim token. Model-carried; no human transcribes it. Resolves the row atclaim_documentand again atupdate_document, so it lives for the draft's lifetime — ending only when the draft expires and the row is swept. (Save Copy promotes a copy into the user's library; it does not delete the draft or retireS.)C— device code. Six digits, single-use, rate-limited — the only human-transcribed element. Salted byHand verifiable only against that row. Bound to the authenticated account that minted it (pending_owner_id); a different account viewing it trips tamper.H— document GUID. The 128-bit row id. Pre-claim it serves no content (minting lives on/claim/<R>); post-claim it is the stable, owner-gated document address (/ephemeral/<H>).R— claim link. A short code that travels in clear chat text — a chat-safe handle for the draft, not a separate authority. Its page,/claim/<R>, is where claiming happens: it gates sign-in and mints the device codeC, and serves no document content pre-claim. Only once the model completesclaim_documentdoes the page redirect the now-owner to the viewer at/ephemeral/<H>. Durable: it keeps resolving until expiry, so a re-click always lands.
Flow¶
create_document(content)→ row writtenowner_id = NULLwithS,R, and a 5-minute deadline. ReturnsSand the/claim/<R>link to the model; records the delivery mode.- The model asks the user to open the link, sign in, and paste the code into chat.
/claim/<R>resolvesR→H, gates sign-in, mintsC, binds it to the authenticated account (pending_owner_id), and extends the row into the claim window.- The user pastes
Cinto chat; the model callsclaim_document(S, C). - The server verifies
SandC, promotespending_owner_id → owner_id, and bumpsexpires_atto +24 h.Cis consumed;SandRstay live.
After the claim, /claim/<R> redirects the owner to /ephemeral/<H>, which renders the draft with a Save Copy action that promotes it into the user's documents.
sequenceDiagram
participant Mdl as Model
participant Server
participant Usr as User (signed in)
Mdl->>Server: create_document(content)
Server->>Server: write draft, owner_id = NULL (5-min deadline)
Server-->>Mdl: claim token S, claim link R
Mdl->>Usr: "open /claim/R, sign in, send me C"
Usr->>Server: open /claim/R (authenticated)
Server-->>Usr: device code C
Usr->>Mdl: device code C
Mdl->>Server: claim_document(S, C)
Server->>Server: owner_id = user, expires_at +24h
Note over Usr,Server: Save Copy promotes draft to documents
Post-claim updates (update_document)¶
update_document(S, content) resolves the claimed row by S and overwrites its content in place — no second handshake, link, or device code — and bumps expires_at back to the full claimed window. An owner_id IS NOT NULL guard keeps it strictly post-claim; the row (and S) live until the draft's expires_at, when a sweep removes them — Save Copy promotes a copy into the user's library but leaves the draft live, so update_document keeps editing the draft (not the saved copy) until it expires. Possession of S is the write capability: the call does not re-check the session. It is write-only — it cannot read the document or re-own it — and does not widen the injection surface, since an attacker able to read S from model context can already call it.
Principal model¶
Sign-in is the claim gate; the user account (owner_id) is the durable principal. During the mint→claim window, C binds to the authenticated account (pending_owner_id): only the account that minted C is shown it (others get a tamper page), so only that user completes the claim — from any browser. Post-claim, forward access is gated by owner_id, consistent with the rest of the app's auth.
Delivery mode and trust¶
How the claim link reaches the user sets the injection boundary, and the server decides it (never the model):
- Out-of-band — a host UI surface the model cannot read: injection captures
Sbut neverR/H, so it cannot mintCor claim. Full trust. - In-band — the link rides in model text: injection sees both
SandR, equivalent to handing over the claim — it can open/claim/<R>, authenticate as an attacker account, mint its ownC, and claim. Reduced trust.
Today the MCP endpoint is stateless Streamable HTTP with no host surface that can show the claim link out of band, so every MCP-created draft is recorded in_band — the reduced-trust path. Out-of-band (full trust) requires a host UI that displays the claim link where the model can't read it.
Trust is derived from the recorded delivery_mode at decision time, not stored as a separate flag; delivery_mode lives on the ephemeral row only and is not carried onto the document on promotion.
Known limitation: mint-race¶
Minting is first-mint-wins: before anyone mints, the first authenticated visitor to /claim/<R> — any account — binds it and is shown the code. An attacker who holds R and wins the race makes the legitimate user trip the tamper page, and the draft expires unclaimed. This is an availability grief, not disclosure: staging a claim is worthless without S, which is model-carried and never enters the URL, logs, or sign-in callback. (Under in-band delivery an injection holds S too — that is the reduced-trust case above.)