Architecture Charter
This document states what corsa-bind is for, what it owns, and — more
importantly — what it must never own.
It sits above project_guide.md: the project guide explains how the crates fit together today, this charter explains which changes are allowed to happen to them tomorrow.
Position
corsa-bindis a stable systems interface for the native TypeScript compiler.
Not a compiler. Not a reimplementation of the Compiler API. Not a competitor to whatever upstream ships next.
The original argument for this repository included "typescript-go does not have
a usable programmatic API yet." That argument is expiring. Upstream is building
snapshots, build orchestration, batched RPC, workspace diagnostics, a native
LSP, and a virtual filesystem, and it will keep going. Anything corsa-bind
builds that competes with that will lose, because upstream owns the checker's
internal representation and its lifecycle rules and we do not.
This is not a threat to the project. It is the project getting smaller and more
important at the same time. As upstream's API matures, corsa-bind should get
thinner, not wider.
The Boundary
Own integration, not TypeScript semantics.
This sentence belongs directly after no forks, no patches in the list of things this repository will not trade away. Concretely:
| Concern | Owner |
|---|---|
| What a type means | upstream |
| What is assignable to what | upstream |
| How a project graph is built | upstream |
| How a snapshot is invalidated internally | upstream |
| Which process answers a query | corsa-bind |
| When that process starts, restarts, and dies | corsa-bind |
| How long a snapshot handle stays alive | corsa-bind |
| What a foreign host is allowed to ask | corsa-bind |
| What the API still looks like after an upgrade | corsa-bind |
Said the short way: checker semantics are never ours, checker lifecycle is aggressively ours.
Layers
flowchart TD
A["Tooling integration<br/>Oxlint, Vize, framework tooling"] --> B
B["Stable service API<br/>Rust, JS, C ABI"] --> C
C["Systems and lifecycle<br/>process, pool, I/O, timeout,<br/>cancellation, backpressure, cache"] --> D
D["Upstream TypeScript<br/>Corsa, TS7 API, LSP"]
Every layer above the bottom one is ours to design. The bottom one is ours to use, exactly as upstream intends it to be used.
Rules
1. The stable API layer is the product
Upstream's API will change shape, and repeatedly. Snapshot ownership alone is being reworked right now. If every consumer — Vize, Oxlint plugins, Rust tools, Node tools — has to follow each of those moves, the ecosystem pays the upgrade cost N times.
corsa-bind pays it once.
The endpoint-mirroring surface (ApiClient, ProjectSession) intentionally
tracks upstream naming, so new upstream capability is cheap to expose and easy
to audit. The stable surface is separate and ours:
SemanticQuery, reached
through ProjectSession::semantics(), and versioned by
SEMANTIC_QUERY_VERSION.
let facts = project.semantics();
let symbol = facts.symbol_at(file, position).await?;
let value_type = facts.type_of(&symbol.id).await?;
If upstream renames getTypeOfSymbol, type_of does not move. That is the
whole contract: corsa-bind owns how you ask, upstream owns what the answer
means.
Adding an endpoint mirror is routine. Adding a stable-surface method is a commitment, so keep that vocabulary small and answer-shaped.
2. Snapshots wrap upstream, they never reimplement it
ManagedSnapshot owns a handle: its cache identity, its lifetime, its
eviction, its release-on-drop, its pool affinity, its transport. It does not
own what a snapshot contains or when the checker considers it stale.
The moment upstream makes snapshots a first-class API, the correct move is to
keep ManagedSnapshot as the lifetime wrapper and let it reference the upstream
handle. Two parallel snapshot models is the failure state.
The same applies to path handling. If upstream introduces a canonical path
representation, corsa-bind converts to it at the boundary. It does not grow a
second normalization dialect.
3. The process boundary stays
Once upstream ships a comfortable API, "why not just call Go directly over FFI?" becomes an obvious question. The answer is no.
The subprocess buys, all at once:
- Go runtime and GC isolation
- crash isolation — a checker crash is not a Node crash
- ABI independence
- TypeScript-version isolation
- timeouts, cancellation, restart, memory kill, worker replacement
For an editor or a long-lived lint daemon, those are the properties that decide whether the thing is usable at 6pm on a Friday. Process isolation is a feature, and it should be advertised as one rather than apologized for.
4. Worker and session orchestration is the killer feature
The product is not "we can spawn Corsa." The product is:
acquire_project
query
query
refresh
query
release
Behind that: project affinity, worker reuse, snapshot reuse, query caching,
bounded queues, timeouts, restart. Upstream is not obliged to solve any of it,
because it is building a compiler service; corsa-bind is building the runtime
service on top.
This is why ApiOrchestrator::acquire_project pins a project to one warm worker
rather than round-robining it across the fleet: the program graph belongs to a
process, so spreading one project across three workers pays for the same graph
three times.
5. Virtual documents and virtual projects stay ours
Foo.vue cannot be handed to TypeScript. Something has to produce the virtual
TypeScript, keep the mapping, replace it incrementally, and own the plugin
lifecycle:
Foo.vue → virtual .ts → snapshot → mapping → type graph
A VFS in upstream does not remove that job — virtual file generation, source
mapping, incremental replacement, and language ownership are the framework
side's responsibility, and corsa-bind is where the framework side meets the
checker. VirtualDocument, LspOverlay, and the projection/mapping concepts
around them are permanent residents, not scaffolding.
Upstream's content mappers are the worked example of how this is supposed to
go. Upstream owns what a mapper is, when it runs, and what the virtual text
means; corsa-bind owns the integration around it — decoding the mapped source
file, carrying the span map, and translating positions so a lint diagnostic
lands where the user is looking rather than in text the user never wrote. The
SpanMap port is a port precisely so it stays faithful to spanmap.go, not so
we get to have opinions about it. See
content_mappers.md.
6. Foreign ASTs ask for facts; they do not import the checker's AST
typescript-eslint is slow in a structural way: ESTree → parser services →
Program → checker. The type-aware Oxlint path here is deliberately the other
shape:
OXC AST
├── syntactic rules
└── Corsa fact queries → semantic rules
The lint host walks its own AST and asks narrow questions. The checker's AST is
never adopted as the lint AST. corsa-bind's future is "query TypeScript
semantic facts from a foreign AST", not "export the TypeScript AST to every
language."
7. Export opaque handles, not a mirrored object graph
As upstream's API gets richer, the temptation is to bind Type, Symbol,
Node, Signature, and Program into every target language. That is a trap:
a Rust mirror of the checker's type representation breaks every time the
internal representation moves, and it makes Go GC and snapshot lifetimes
somebody else's problem in the worst possible way.
What crosses the boundary is identity only —
SnapshotHandle, ProjectHandle, TypeHandle, SymbolHandle,
SignatureHandle, NodeHandle — plus whatever the caller explicitly asked to
have rendered.
FFI object graph: no. Query service: yes.
SemanticQuery follows this literally: it answers with handles, plus at most
the display name or rendering Corsa already sent along. Consumers that want the
full upstream-shaped payload can still reach ApiClient, and in doing so they
knowingly accept upstream's churn.
8. Distributed orchestration is out of scope
Checker workloads have strong affinity to a repo, its project graph, and mutable snapshot state. That makes them a poor fit for a stateless "any node can serve any request" service, so replicating snapshots through Raft is not where the value is.
The far better investment is making a single machine excellent:
one machine
├ worker 1 — project A
├ worker 2 — project B
├ worker 3 — project C
└ worker 4 — spare
When scale genuinely demands more, the answer is repo-level sharding — repo A to machine A, repo B to machine B — not consensus over checker state.
This rule was applied to the repository itself in 2.0: the
experimental-distributed cargo feature, DistributedApiOrchestrator, the
in-process Raft implementation, the replicated-state model, and the
CorsaDistributedOrchestrator N-API class were all deleted — roughly 3,400
lines. Nothing replaced them, because nothing needed to. What remains is
ApiOrchestrator: pooling, project affinity, leases, and caches on one machine.
Keeping a consensus implementation alive "just in case" is exactly the sunk-cost pattern the convergence rule below exists to prevent. Should a real distributed requirement appear, it starts from repo-level sharding and gets designed against that requirement, not resurrected from git history.
9. The C ABI is the core; language bindings are tiered
Hand-maintaining first-class bindings for every language does not scale. One stable C ABI does.
corsa core
│
▼
stable C ABI
├ Rust (tier 1)
├ Node/napi (tier 1)
├ Go (tier 2)
├ Swift (tier 2)
├ Zig (tier 2)
├ C# (tier 2)
├ C++ (tier 2)
├ Elixir (tier 2)
└ MoonBit (tier 2)
Tier 1 is Rust, Node, and the C ABI itself: covered by the required CI matrix, and where API design decisions are made. Tier 2 wrappers are welcome and maintained on a best-effort basis on top of the C ABI; they do not get to shape the core API. See support_policy.md for the current commitment per tier.
The Convergence Rule
When upstream ships it, delete ours and wrap theirs.
This is the rule that keeps the charter honest over time. When upstream lands a real snapshot API, a real VFS, a real build orchestrator, or real workspace diagnostics, the correct response is not to keep a competing implementation alive out of sunk cost.
The checklist for that moment:
- Does upstream's version answer the same questions?
- Can our lifecycle wrapper sit on top of it without duplicating its state?
- Can our stable API keep its current signatures over it?
- If yes to all three: delete the implementation, keep the wrapper, keep the public surface, and note the swap in the release notes.
A corsa-bind release whose headline is "we deleted 2,000 lines because
upstream now does this properly" is a good release.
What This Does Not Mean
- It does not mean thin wrappers only. Pooling, caching, cancellation, backpressure, and observability are real engineering and they are ours.
- It does not mean upstream is always right. Where behavior is wrong, the answer is an upstream issue, not a local patch — see no forks, no patches.
- It does not mean the endpoint-mirroring API is deprecated. It stays as the fast path to new upstream capability, and as the escape hatch for callers who accept the churn.
- It does not mean shipping less. It means the surface grows sideways, into integration and lifecycle, instead of downwards into the checker.
Reading Order
- README.md — what the project is
- this charter — what the project refuses to become
- project_guide.md — how the crates implement it
- support_policy.md — what is actually supported