@volter/twin-plain
v0.1.35
Published
Local Plain twin — the real published GraphQL SDL over the @volter/world-core kernel; your real `@team-plain/typescript-sdk` talks to it unmodified. Mirror, simulate, and fork.
Readme
@volter/twin-plain
The Plain twin — a local, stateful, vendor-faithful replica of Plain's
Core GraphQL API on the shared @volter/world-core kernel. An unmodified
@team-plain/typescript-sdk client pointed at it (via the SDK's own apiUrl option) gets
vendor-correct responses; it is the QA stack's authoritative local Plain.
State lives in the kernel's append-only action/event log — a write appends an action that projects
into visible state; reads use the kernel’s tree. No real Plain is ever called (the only network path is
the connector, over an injected client, behind a fail-closed rate budget). Auth is a
faked-locally Bearer API key — Plain's API auth is a plain bearer plainApiKey_…, so there is
no JWT, no JWKS, no signing.
The twin executes Plain's OWN published schema
Plain publishes its full GraphQL SDL at https://core-api.uk.plain.com/graphql/v1/schema.graphql —
literally the schema: field of the SDK's own codegen.ts. That document is vendored at
test-fixtures/plain-schema.graphql (provenance in the sibling .SOURCE.md), committed as the
text module src/plain-schema-sdl.gen.ts (writer scripts/text-modules.ts, drift gate
scripts/text-modules.test.ts — the serve path imports the constant and reads no filesystem), and
the twin buildSchema()s it, attaching behaviour resolvers for the operations it backs with
state. GET <graphql>/schema.graphql serves those same bytes.
That is not a detail; it is the design:
- The SDK's printed documents validate here because they validate there — including the deep
fragment spreads on
Customer/Thread/Actor/ThreadStatusDetail. - Variable coercion is the vendor's. A caller sending a field Plain does not define gets
Plain's own
Field "x" is not defined by type "…"400, not a twin-flavoured approximation. - The twin cannot invent surface. A hand-written subset schema drifts toward whatever its
author modeled; this one cannot. (
GET /graphql/v1/schema.graphqlserves the same document.)
Errors are DATA, not status codes
This is Plain's defining idiom and the twin reproduces it exactly. A failed mutation is
HTTP 200 with data.<operation>.error set to a typed MutationError
({ message, type, code, fields[] }) — which is what the SDK's getMutationErrorFromResponse
reads, and what a real consumer branches on:
// dub, apps/web/lib/plain/upsert-plain-customer.ts
if (result.error?.type === 'mutation_error' &&
result.error.errorDetails.code === 'customer_already_exists_with_external_id') { /* retry */ }The transport envelope is modeled too: a missing/malformed Authorization: Bearer is 401
(the SDK's forbidden), an unparseable body or a document the schema rejects is 400
(bad_request, carrying the real GraphQL errors — each with extensions.code, without which the
SDK's own zod schema drops them), and a rate-limit refusal is 429 with
extensions.code = "RATE_LIMITED".
Usage
bun run src/cli.ts serve # the Plain GraphQL twin (writable) — POST /graphql/v1
# (+ POST /_twin/inbound — the twin-only mail seam)
bun run src/cli.ts serve --read-only # a pure mirror of pulled state; writes get a FORBIDDEN MutationError
bun run src/cli.ts mirror # the React support-inbox mirror
bun run src/cli.ts conformance # offline operation-probe check over projected stateimport { PlainClient } from '@team-plain/typescript-sdk';
const plain = new PlainClient({ apiKey: 'plainApiKey_local', apiUrl: 'http://127.0.0.1:PORT/graphql/v1' });The store doors: GET /twin/store/<name>
Plain is a GraphQL API, so every read is a query POSTed to /graphql/v1. The twin serves
exactly two GETs of its own on the vendor path — the service descriptor at /graphql/v1 and the
codegen SDL at /graphql/v1/schema.graphql — and both are static: nothing in the vendor surface
observes stored state with a GET.
Six names answer under /twin/store/: customers, threads, tenants, notes,
customerEvents and webhookTargets. They are the twin's own named, read-only, deterministic
projections over that state (the twin programming model's store door; GET /twin names them
under stores). They serve the same hydrated shapes the GraphQL resolvers return, folded from the
kernel log by plain-store.ts. They are twin doors, not vendor surface, so they need no API key
and are deliberately absent from the capability manifest — counting scaffolding Plain does not
publish would pad the coverage denominator.
customers and threads are what the support-inbox mirror renders. The other four exist for the
gate: the R9 replay's reads are GETs, so a type Plain only surrenders through a POSTed query has
nothing to byte-compare. label, labelType and threadField need no door — hydrateThread
already folds all three into every threads row. The doors are what makes this pack's determinism
checkable at the resource level: the replay writes one subject of every declared type it can reach
from an empty world and reads them all back on two fresh roots, which is how the pack's ids
stopped being entropic and its timestamps stopped falling through to wall time.
Coverage
The capability manifest (src/plain-capabilities.ts) is the real Plain surface as the
denominator, authored top-down from the vendored SDL — 314 Mutation root fields and 206 Query
root fields, counted from the built schema rather than by grep. All 520 are named: every
root field of Mutation and Query appears in some entry's title, so the enumeration is
mechanically checkable rather than a claim. Coverage is honest and partial, and reads LOW by
design. Run bun scripts/manifest-baseline-one.ts plain for the live count. As of writing:
done=149 / total=483 (regressions=0) — ~31%.
Entries are at ONE granularity: per vendor operation (or a tight family of them), except for the handful that name a behaviour no single operation covers (webhook delivery and signing, inbound email, filter algebra, nested label types) plus the connector and UI dimensions. Growing that denominator further (coverage % dropping) is success, not regression — it went 377 → 469 during review for exactly that reason.
Modeled (proven done, each with a failable offline verify through handlePlainTwinRequest):
- Customers —
upsertCustomercreate/update/NOOP keyed on email, externalId or customerId, thecustomer_already_exists_with_external_idconflict and its retry path,customer/customerByEmail/customerByExternalId/customers(real cursor paging),deleteCustomer(which cascades to the customer's threads, notes, events and memberships, as Plain's own SDL describes), and a delete → re-upsert that creates a genuinely NEW customer rather than resurrecting a tombstone. - Threads —
createThread(by customerId or emailAddress, with components, labels, priority, externalId, tenant),thread/threadByRef/threadByExternalId/threadswith the status, customer, label and priority filters;assignThread/unassignThread,changeThreadPriority,markThreadAsDone/markThreadAsTodo/snoozeThread(with theWaitingForDurationdeadline),deleteThread(cascading to its labels, fields and timeline), a ref ratchet that never re-issues a deleted thread's ref, and the negative path for each. - Labels —
createLabelType/archiveLabelType/labelTypes/labelType,addLabels(idempotent per thread+type) andremoveLabels. - Timeline — a thread's opening components land as a real timeline entry;
createThreadEvent,createCustomerEvent,replyToThread,Thread.timelineEntriesandtimelineEntries(customerId:).componentRow/componentDividermetadata is flattened into the preview rather than dropped. - The send surface —
sendNewEmail(opening a thread when given none,isHiddenFromUser→USER_HIDDEN, extra recipients, attachments),replyToEmail(which takes its thread AND its subject from the email it answers, and recordsinReplyToEmailId),sendChatandsendCustomerChat— the pair the SDL now prefers overCreateThreadInput.components, including the ISO-8601timestampbackdate that genuinely REORDERS the timeline (and, as the SDL requires, must be in the PAST — a future stamp is refused rather than parked at the end of every timeline forever). An inboundsendCustomerChatis attributed to aCustomerActorand reopens the thread withThreadStatusDetailNewReply, the same transition a continued inbound email applies. A send onto another customer's thread is refused, and a send with no knowable support address is refused rather than sent from an invented one — the twin models noWorkspaceEmailDomainSettings, so the caller passesfromAlternateSupportEmailor sends on a thread whose inbound mail recorded one. - Attachments —
createAttachmentUploadUrlreserves a realAttachmentsubject (file name, size in Plain's three units, extension and MIME type derived from the name, the kernel-reservedtypefield stored renamed and mapped back) and hands back a form URL that actually accepts the bytes: it points at the twin's ownPOST /_twin/attachments/<id>rather than at a presigned S3 URL that would 404.attachmentIdsoncreateThread,replyToThread,sendNewEmail,replyToEmail,createNoteand both chat sends attach to the message; an id naming no attachment is refused, never dropped. The published two-hour expiry is kept, not merely printed: a POST to the form outside the window is refused withattachment_upload_url_expired, and an attachment whose window closed with no upload can no longer be referenced by a message either. - Search —
searchThreadsover the vendor's own three targets — "thread titles, message contents, and customer names/emails" (pluspreviewText, which is the stored head of that message text) — case-insensitively, narrowed further by the sameThreadsFilterthe list takes. The thread REF is deliberately not a target: the same SDL sentence routes exact ref lookups tothreadByRef. A term below the SDL's own two-character minimum is refused rather than answered with everything. - Inbound email — a support email CREATES a thread on the matching customer (
channel: EMAIL,ThreadStatusDetailCreated, the recipient recorded inThread.supportEmailAddresses) or CONTINUES the thread it replies to (back toTODOwithThreadStatusDetailNewReply, preview refreshed, no second thread). The message lands as a realEmailEntrywith the vendor's received/sent split —receivedAtset,sentAt/sendStatusnull, the mirror image of thereplyToThreadentry —EmailAuthenticity,from/toasEmailParticipants resolving toCustomerEmailActorandSupportEmailAddressEmailActor, and aCustomerActoron the entry itself. The fourThread.{first,last}{Inbound,Outbound}MessageInfofields are folded from those entries. Delivery is through the twin-onlyPOST /_twin/inboundseam — see the deviations below. - Notes / thread fields / tenants / customer groups / webhook targets — the CRUD each exposes, with the vendor's typed error for the unknown-referent case.
- Workspace + users —
myWorkspace(answerable from an empty root, replaced by a pulled workspace),users/user/userByEmail. - Protocol + honesty — errors-as-data, 401/400/404/405 transport envelope,
readOnlyrefusing every write with aFORBIDDENMutationError, and unmodeled operations failing like the vendor: an unmodeled mutation OR root query raises a real GraphQL error (extensions.code = "UNSUPPORTED_OPERATION") intoerrors[]— never a fabricated success, and never a 404 (a GraphQL API has no such thing). The same rule is enforced one level down, on arguments: aThreadsFilter/CustomersFilter/ThreadTimelineEntriesFilterfield the twin does not actually apply is refused withUNSUPPORTED_FILTER, and asortByit does not order by withUNSUPPORTED_SORT— because an ignored filter returns the unfiltered list and an ignored sort returns a differently-ordered one, both fabricated successes wearing the argument's clothes. - Connector — pull workspace/users/label types/customer groups/customers/threads+labels and
each thread's MESSAGES over an injected client in one shadow-diffed
syncPlainFromReal(idempotent: a re-pull appends zero deltas). A pulled email keeps its direction from the vendor's ownreceivedAt/sentAtand itsCustomerActor; a pulled chat, which the SDL gives no direction field, takes it from the actor instead. A pull observes: an entry the snapshot does not carry is left alone, never tombstoned. Push sends local status/priority/assignment/label changes back and now also sends a customer edit through the identity-keyedupsertCustomer— but only for a customer the twin actually PULLED, whose id is the real account's own. It is LAST-WRITE-WINS, not a merge:onUpdatecarries the whole record from the pulled snapshot, so a field changed at Plain after that pull is overwritten. There is no vendor-drift check on the directpushPendingPlainActionshelper. World push checks the parent log position; that does not prove a vendor record has remained unchanged. A locally minted customer is refused loudly, because pushing it would mint a second identity at the real account that this twin could never address and the next pull would fold back as a duplicate row (plain.connector.push_id_remappingis the missing seam, andplain.connector.push_thread_createwaits on it for the same reason). Every push still refuses to confirm a payload carrying a MutationError; the fail-closed rate budget is proven by counting calls on an injected fake. - UI mirror — the inbox, the thread detail, the timeline, the status/priority badges, the
"Mark as done" write and the reply composer are all data-coupled: seeded through the
twin's write path, fetched through the SAME GraphQL documents the browser sends, and rendered
through the mirror's own formatter functions. The composer sends Plain's own
replyToThreadand then re-reads, never patching itself optimistically; the timeline marks each email inbound or outbound off the vendor's ownreceivedAt/sentAtfields, so a support agent can tell the customer's mail from the workspace's. Typing and clicking it is proven in a real headless chromium (plain.journey.thread_reply), not only by driving the mutation the client would have sent. The inbox also carries a filter bar (status, priority, assignee, label) and a search box, both of which change what is FETCHED — the filters through the sameinboxFiltersfunction the client calls, the search through Plain's ownsearchThreads— and the thread screen carries an assignee picker that sends the vendor'sassignThread/unassignThreadand re-reads. A filter that only hid already-loaded rows would lie the moment the inbox is longer than one page, so none of them does.
Out of scope (6, each with a reason — the only permitted non-coverage): real email delivery to
a customer's inbox; pushing chat to a live embedded widget session; posting into a real
Slack/Discord/MS-Teams workspace; real AI answer generation (Plain's own tuned model); hosting a
public help centre on a custom domain with TLS; taking a real payment. In every case the twin
models the record faithfully and only the vendor's physics is carved out; the surrounding APIs
are tracked as todos, not carved out with them.
The mirror decision
Plain gets a mirror, decided by the rule ("when someone does this vendor's core job, do they
open a browser or write code?") rather than by the current split. A support agent's core job is
triage — read the thread, see the customer, label it, prioritise it, reply, mark it done — and that
happens in Plain's inbox all day, the same shape as slack / jira / linear. The API is how the
product is fed (dub's support form and AI ticket tool both call createThread from code), not
where the work happens; reasoning "the API is rich, therefore API-first" would invert the rule here
exactly as it would for Notion.
The mirror is archetype A (API passthrough): the browser POSTs real Plain GraphQL documents to
the twin's own /graphql/v1, so reads and writes share one code path with the API and parity
cannot drift. ui-scope.json records needsUi: true with three registered Playwright journeys.
Rate budget
Plain documents 450 requests/minute on its lowest plan (Launch & Foundation), 600 on Grow &
Horizon and 1000 on Scale & Frontier
(help.plain.com/article/rate-limits, read 2026-08-20).
The pack declares 90 weighted units per 60s — one fifth of the lowest published tier, since the
operator's plan is not knowable from inside the pack. Calls are priced by GraphQL operation
name (Plain has one endpoint, so a method+path key would be inert): the five customer-visible
send mutations cost 5 and createThread costs 3, both judgement calls about blast radius (a
runaway loop there reaches real humans), not published costs. Everything else costs 1; nothing is
free.
Fidelity
src/plain-sdk.integration.test.ts drives the real, unmodified @team-plain/typescript-sdk
(pinned ^5.12.1) against the twin over HTTP — configured through the SDK's own public apiUrl
option, never patched. It covers the workspace, the customer lifecycle including dub's
externalId-conflict retry branch, threads, labels, the status lifecycle, notes, thread events,
webhook targets, the unmodeled-operation path, the schema-rejection path, the no-key forbidden
path, and dub's full support-form flow end to end.
Known deviations
Declared in test-fixtures/plain-schema.SOURCE.md rather than left to be discovered: inbound mail
arrives over a twin-only POST /_twin/inbound route because Plain publishes no ingestion mutation
(real mail is MX-forwarded to the workspace's inboundForwardingEmail), and an inbound sender that
matches no customer is REFUSED rather than auto-created; unmodeled
field selections synthesize a type-appropriate default (the truthful "not tracked" answer for a
schema that marks nearly everything non-null — while an unmodeled operation or filter field
errors); Actor unions resolve to the concrete member the twin serves; pagination is cursor-shaped
but offset-backed; ThreadStatusDetail is modeled only for the transitions the twin performs; and
MutationError.code is an untyped String! in the SDL, so every code but
customer_already_exists_with_external_id (attested by dub's retry branch) is the twin's own
choice rather than an observed vendor string.
Four more, added with the send/attachment/search surface:
- A send with no knowable support address is REFUSED (
workspace_support_email_address_not_set) where real Plain would use the workspace default. The twin models noWorkspaceEmailDomainSettings, so it has no default to use and will not print an invented address into a storedfrom. PassfromAlternateSupportEmail, or send on a thread whose inbound mail recorded a support address. createAttachmentUploadUrlreturns the twin's own upload route, not a presigned S3 form:uploadFormUrlis<origin>/_twin/attachments/<id>(origin from the request'sHost) anduploadFormDatacarries only the object key. The twin cannot compute an S3 policy or signature and does not print values that merely look like credentials. Two narrowings follow from that: the route takes the bytes as a raw body rather than themultipart/form-datathe SDL describes (it stores no file content — only that the upload happened, and only inside the vendor's published two-hour window), and the vendor's "unreferenced attachments are deleted after 24 hours" sweep is not modelled.Attachment.fileSizeuses SI divisors (1000 / 1e6); the SDL names the units but not the divisor.- A backdating
timestampis judged against the WORLD's instant, not the wall clock. Both chat mutations require it to be in the past, and "the past" is measured from the instant the request carries. In a world pinned behind real time, an app backfilling withnew Date().toISOString()is therefore refused — correct by this twin's own rule that the world's instant is the truth, and worth knowing before it surprises anybody. fromAlternateSupportEmailis USED but not VALIDATED. The SDL says it "must match one of the workspace support email addresses (default or alternate)"; the twin models noWorkspaceEmailDomainSettings(plain.workspace.email_domainsis the todo that would close it), so it holds no list to check against and accepts what the caller sends rather than inventing one to reject it with.searchThreadsrefuses a term under two characters with the twin's ownSEARCH_TERM_TOO_SHORT. The SDL states the constraint but not the error Plain answers it with.- Webhook delivery, webhook signing and customer cards are all still
todo, deliberately. The SDL describes only the CONFIGURATION side of each — a target's url andeventSubscriptions, a card config'sapiUrland headers — never the delivery body, the signature header, the signed canonical string or the card request/response envelope. Those live in a separately versioned webhook schema this pack has not dossiered as a first-party source, so guessing them would produce a twin that agrees only with itself.
Review record
This pack was built under ../../../docs/contributing/adding-a-twin.md and went through the mandatory §9 adversarial
review, twice, with a second round aimed squarely at the first round's fixes.
Round one refuted three dones and found a real fake-success bug (a sticky tombstone that
swallowed a re-added customer-group membership while reporting success), a mint that used half its
alphabet, deleteCustomer not cascading, silently-ignored filter arguments, eleven conformance
probes with no teeth, ~150 double-counted todos and a wrong SDL census.
Round two then broke several of those fixes: upsertTenant's new NOOP lied about an explicit
url clear, the filter refusal did not reach sortBy or Thread.timelineEntries, the two new
cascade pins asserted absence without ever establishing presence, the id-collision pin rested on
entropy rather than on the re-roll it claimed to test, a conformance probe still survived a
read-but-never-write binding, backward pagination reported the wrong hasPreviousPage, and the
denominator — freshly de-duplicated — turned out to enumerate the write surface while leaving 48%
of the query surface, including an entire analytics vertical, unnamed.
Everything above is fixed and pinned; the full findings are recorded in the commit message. The
denominator now names all 520 root operations, and the conformance probes are swept against three
saboteur shapes (null, inert success, and read-but-never-write) with zero survivors.
