@volter/twin-linear
v2.0.35
Published
Local Linear twin — GraphQL (SDL-derived); your real `@linear/sdk` talks to it unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-linear
Protocol 1: the pull observes through the kernel (
observeResources), and the pack still serves and pushes on the protocol-1 paths. Check the generated index for protocol standing and use the shared model for current state semantics.
The Linear twin — a local replica of Linear's GraphQL API on the shared
@volter/world-core kernel. The real @linear/sdk works against it
unmodified. This is the reference twin (every R1–R18 row passes).
Surface
- GraphQL API (
linear-twin.ts): the pack's ONE served schema, derived from Linear's real GraphQL SDL, so the strict SDK type-checks pass by construction. Issues + teams, projects, cycles, users, labels, workflowStates, comments, roadmaps, documents, initiatives, customers and the rest, with issue→team/project/cycle/comment links and project→roadmap grouping. - State + behaviour (
linear-state.ts): the vendor field mapping, identifier assignment, workflow-state resolution, filtering/search/pagination and everyresolve*write path — the ONE implementation the derived schema binds its root fields to. A second, hand-written SUBSET schema used to live beside it behindworld-linear serve --subset; it was deleted on 2026-09-03 (no consumer, no verify, no saboteur, and demonstrably divergent from the vendor — it typedNotificationas a concrete type where the SDL makes it an interface,metadataasStringwhere the vendor hasJSONObject!, and invented input fields real Linear rejects). - HTTP surface (
linear-server.ts): the schema's fetch closure and the pack's onlyBun.servecall —createLinearTwinFetch/createDerivedLinearTwinServer. The closure is on the kernel's one HTTP adaptation, so it serves the realGET /twinmanifest door and theGET /twin/store/<name>door; off the doors a GET answers a service blurb. The server factory is nothing butBun.servearound the closure, so standalone and hosted lanes serve the same bytes. - Connector (
linear-connector.ts): full-workspace pull from real Linear (issues + teams/projects/cycles/labels/workflowStates) and push of pending local actions, confirmed back into observed events (auth injected). - Webhooks (
linear-webhooks.ts): issueCreate/issueUpdate fire Linear-shaped webhooks on write. - Conformance (
linear-conformance.ts): operation-acceptance + vendor-SDL drift + recorded-diff vs a real captured Linear response (standing gate). - UI mirror (
linear-mirror-ui.ts): a Linear-style issue board (React), a pure frontend of the twin (R3) — the console reads its state through the store door (GET /twin/store/mirror, the ONE projectionlinear-mirror-state.tsdeclares on the pack's fetch adapter) and writes through the GraphQL API (issueCreatePOSTed to/).
CLI
world-linear serve [--read-only] [--port N] [--root DIR]
world-linear mirror [--port N] [--root DIR]
world-linear conformance [--root DIR]Point the real @linear/sdk LinearClient at it via apiUrl.
Interaction surfaces
- SDK/API — zero edits (preferred):
LINEAR_TWIN_URL=http://127.0.0.1:PORT node --require @volter/world-core/inject your-appredirects the real@linear/sdkfromapi.linear.appto the twin. Or override directly:new LinearClient({ apiKey: 'twin', apiUrl: 'http://127.0.0.1:PORT/' }). - API + CLI — run the twin in a World and inspect it with
volter world loganddiff. Use the deployment guide for changeset review and deployment. - Read-only —
world-linear serve --read-only: unlimited local reads, no rate limits; writes refuse like Linear (GraphQL error). - UI mirror —
world-linear mirrorrenders a Linear-style view of the twin's state.
(See Getting Started → "Twin interaction surfaces", and cookbook/zero-edit-inject.)
Stable on the twin rubric: fidelity, read/write/fork, sync, observability, event emission, and conformance are tracked with explicit coverage gaps.
Coverage
Goal: honest, explicitly tracked coverage of Linear's core feature surface. Every capability is
either done or todo; anything not done is a gap to close.
Done — issues (create/update/get/list) with rich IssueFilter (state/title/identifier/
priority/estimate/assignee/labels + and/or compounds) and opaque cursor pagination; workflow
states + transitions (server-assigned stateId, default per-team workflow, name/type stored on move);
comments; teams; projects + depth (state/progress/startDate/targetDate/lead); project milestones;
cycles; estimates; issue relations (blocks/duplicate/related/similar); labels + assignees;
attachments (title/subtitle/url/JSON metadata); notifications (generated from comment/assignment
activity, mark-read + archive); project updates (body/health); favorites (issue/project, create +
idempotent delete); server-side identifier assignment (TEAM-N); roadmaps (create + list/get,
projects linked to a roadmap — modeled on Linear's real Roadmap/RoadmapCreateInput SDL types
and exercised through the real @linear/sdk's createRoadmap/roadmaps/roadmap); documents
(rich-text docs with title/markdown content, create + update + list/get, linked to a project or
initiative — modeled on Linear's real Document/DocumentCreateInput/DocumentUpdateInput/
DocumentPayload SDL types, deterministic doc-N ids, exercised through the real @linear/sdk's
createDocument/updateDocument/documents/document plus Project.documents/Initiative.documents)
and initiatives (workspace themes grouping projects/documents, initiativeCreate/initiatives/
initiative). SDL-derived
schema built from Linear's real published GraphQL SDL so the real @linear/sdk type-checks and runs
unmodified — and, since 2026-09-03, the pack's ONLY schema; webhooks on issue create/update; UI
mirror (rung-5 ✅ — every board/detail surface rendered, plus a Documents view: docs list with
title/content-preview/linked-parent + initiatives); conformance (vendor-SDL drift + recorded-diff +
canonical ops validated against the real SDL); connector — full-workspace pull from real Linear
(issues AND teams/projects/cycles/labels/workflowStates/initiatives/documents, each via its real
collection query, mapped to twin subjects and observed through the kernel's observeResources,
which appends only what changed) + deep push:
issue fields with name→id resolution for state/assignee/labels, comments, relations, attachments,
project updates, favorites, notification update/archive, documents (create/update), initiatives —
each confirmed back into observed events). Niche admin/workflow surfaces:
organization settings (organizationUpdate + workspace policy fields: name/urlKey/
allowMembersToInvite/roadmapEnabled/gitBranchFormat/fiscalYearStartMonth); members + invites
(users/user reading the real User type with role/active/admin/guest, userUpdate/userSuspend/
userUnsuspend, organizationInvites/organizationInvite/organizationInviteCreate/organizationInviteDelete);
issue + project templates (templates/template/templateCreate/templateUpdate/templateDelete,
type-discriminated issue vs project); snooze (Issue.snoozedUntilAt/snoozedBy via issueUpdate);
triage (Team.triageIssueState, untriaged queue via Team.issues, server-stamped Issue.triagedAt
on triage exit); audit log (auditEntries AuditEntry feed, appended on admin mutations); email
intake (emailIntakeAddress/emailIntakeAddressCreate, minted per-team address); git automation
states (Team.gitAutomationStates/gitAutomationStateCreate, branch/PR → workflow-state rules). All
modeled on Linear's real published SDL and exercised through the twin's one GraphQL surface (which is
that SDL, by construction). UI mirror gained four screens: Timeline (project Gantt over a month
axis), Roadmap (initiative columns rolling up projects + progress bars), Settings (Workspace/
Teams/Members tabs), and a Command menu (Cmd-K palette with keyboard nav) — all rung-5 structural-DOM
verified.
Planned (known-missing, will do) — saved views / custom views / filters, SLAs / automations rules (beyond git-automation states), sub-team hierarchy, deeper webhooks (only issueCreate/issueUpdate today), OAuth scopes, third-party integrations, the issue import mutations with their async import-job record moving through its status states, and GraphQL subscriptions — serving the realtime websocket transport and pushing the same state changes the twin already emits to its webhook surface.
Rate budget — the fail-closed backstop on live calls
liveLinearExecute is the one place this pack issues a live request, so every call it makes is charged
against a persistent, fail-closed spend ledger before the request goes out. Past the ceiling, or
while a Retry-After/429 cooldown is armed, it throws instead of calling. The ledger is keyed by
vendor and a hash of the credential (limits are per credential, so it is deliberately not
cwd-scoped) and persists across processes, so a fresh process does not get a fresh allowance; a
corrupt ledger counts as a full window rather than zero spend. There is no option to disable it,
and no value you can pass for budget that yields an unguarded client — an injected budget is
validated by method identity, so a subclass or a Proxy that replaces checkBudget is refused.
The declared numbers: 600 requests / hour — 12% of Linear's documented 5,000/hour for API-key auth. Priced by GraphQL operation (query PullTeams, mutation PushComment), since Linear has one endpoint; an unreadable document is keyed mutation, because mispricing a write as a read is the direction that costs something. Mutations cost 2 (a judgement call: a runaway push creates issues and notifications a human must clean up). It does not model Linear's complexity axis — that is a function of document shape, not request count — and says so.
The mechanism is shared and vendor-agnostic — it lives in the kernel (@volter/world-core →
packages/world-core/src/rateBudget.ts); what lives here in src/linear-budget.ts is this vendor's
declaration (window, ceiling, per-endpoint weights, and a reason citing the limits above) plus
the vendor-bound LinearBudget. The rule is ratified as
../../../docs/contributing/architecture.md D8, and the kernel module's header documents what the
guard does not guarantee — read that before trusting it.
