The plan
The plan is what a backend renders. It is computed once from a project (the spec tree
under spec/ plus truewire.toml) and holds every decision that used to be made inside
the Python generator while it was writing strings: which types a module defines and what
they are called, what the request looks like, what a method returns and whether that can
be null, and how a declared pagination is walked. It contains no rendered code and no
language name. The Python backend reads it; a TypeScript backend reads the same object.
truewire plan # one line per router and per endpoint
truewire plan --json # the whole plan, camelCase keys, `null` fields omittedfrom truewire.plan.build import build_plan
plan = build_plan(Path('examples/github')) # a frozen pydantic `PackagePlan`
plan.endpoint('issues.list').pagination.walker # 'paginated'The models live in truewire.plan.model, the type tree in truewire.plan.types, the
builder in truewire.plan.build. Every model is frozen; PackagePlan.to_json() is the
JSON --json prints.
The JSON shape
{
"name": "github", // [project].name
"rootClass": "GitHub", // [python].name, else PascalCase of name
"cores": { // one entry per symbolic core name
"default": { "meta": { /* JSON Schema */ } },
"streams": { "forward": ["market_client"], "params": { "network": { "type": "...", "required": true } },
"children": { "market_data": "market_client" } }
},
"schemas": { // shared types per scope
"": { "Label": { "type": "record", ... } }, // spec/schemas.json
"futures": { ... } // spec/endpoints/futures/schemas.json
},
"routers": [
{ "path": [], "core": "root", "doc": { "description": "...", "upstream": "https://..." },
"children": [ { "name": "repos", "kind": "router", "class": "Repos" } ] },
{ "path": ["repos"], "core": "default", "children": [ { "name": "get", "kind": "endpoint", "class": "Get" } ] }
],
"endpoints": [ /* EndpointPlan, sorted by path */ ]
}One endpoint:
{
"path": ["issues", "list"], // function path; the last segment is the method
"kind": "rpc", // or "stream"
"transports": ["http"],
"wire": { "path": "/repos/{owner}/{repo}/issues", "method": "GET", "placeholders": ["owner", "repo"] },
"core": "default", // nearest router.json's `core`
"meta": { "public": true },
"deprecated": false,
"request": {
"shape": "fields", // none | fields | union | array
"type": "Request", // the `types` entry holding the whole request
"fields": [ // for `fields`: one per property, wire names
{ "wire": "owner", "required": true, "type": { "type": "scalar", "base": "string" },
"description": "Account owner of the repository, case-insensitive." },
{ "wire": "per_page", "required": false, "type": { "type": "scalar", "base": "integer" }, "default": 30 }
],
"needsCast": false // a bare Literal/Any alias, not a class
},
"response": {
"wire": "Issues", // the wire body's type; a `wireTypes` entry when enveloped
"payload": "Issues", // the returned type: a `types` entry or a shared one
"selector": "", // envelope.payload
"optional": false, // `X | null`
"needsCast": false
},
"pagination": {
"strategy": "page", "driver": "page", "driverRequired": false,
"size": "per_page", "sizeDefault": 30, "start": 1,
"done": { "kind": "short_page" }, "rows": null, "cursorFrom": null,
"rowType": { "type": "ref", "id": "Issue" },
"stateType": { "type": "scalar", "base": "integer" },
"seedable": true,
"walker": "paginated" // paginated | generator | none
},
"stream": null, // for kind: stream, see below
"types": { "Request": { "type": "record", ... }, "Issue": { ... }, "Issues": { "type": "list", ... } },
"wireTypes": {}, // the frame's types, when `selector` is not empty
"docs": { "description": "...", "url": "https://...", "notes": [] }
}A stream endpoint's request is its parameters and response.payload its pushed
message; stream adds channelParams (parameters that are channel placeholders),
connectOnly (a connect-triggered push whose channel is its one parameter),
directChannel (the parameters are exactly the placeholders, so no parameters object is
built), push, verb and replyPayload as declared.
Types are the eight-node tree of truewire.plan.types: scalar{base, format?}, ref{id},
literal{values}, list{item}, tuple{items}, union{variants}, dict{key, value},
record{id, fields}. A scalar carries the wire base (string, integer, number,
boolean, null, any) and the spec format that narrows it (decimal-string,
epoch-millis, date-time, ...); which language type that becomes is the backend's
call. A ref names an entry of the same endpoint's types or of a shared scope.
Pagination
walker is the decision the whole block leads to:
paginated: the endpoint has rows (rowType) and a state the walker can seed (seedable): the backend exposes pages and a resumable state (PaginatedResponse[row, state]in Python).generator: a plain async iterator of responses, for a declaration the resumable shape does not cover (offset,window,seekwithoverlap, or rows the tree cannot name).none: the declaration cannot be walked at all (anoffsetwalk ending on an item count with no rows to count and no page size to step by).
stateType is the driver parameter's type without its null; seedable is true unless
the strategy is token/seek and the cursor has neither a zero value ('', 0) nor is
required on the single call. sizeDefault is the size property's own default.
How the Python backend uses it
truewire generate builds the plan once and attaches it to the generator.
rpc_endpoint and stream_endpoint read four decisions off it that they used to
re-derive by parsing their own rendered output: the cursor's type for the walker's seed
(stateType), whether the walker can be seeded (seedable), whether the returned type is
nullable so every read on it is guarded (response.optional), and whether a request or
response type needs cast(type, ...) (needsCast). A generator built outside the CLI
(a test) plans the one endpoint it is asked for on demand. The stream-shape predicates
(direct channel, connect-only) and the driver-parameter rule are the plan's functions,
called from both sides.
How a second backend consumes it
truewire.codegen.typescript is that backend (docs/typescript.md); it reads the plan as
described here and needed nothing added to it.
Walk endpoints. For each: define the entries of types (and wireTypes when present)
in its own language, rendering scalar by base and format; build the method's parameters
from request.fields (or one parameter of request.type for a union/array); return
response.payload; pass wire, meta and core to the hand-written core (ADR 0011);
render a walker per pagination.walker from driver, size, done, rows,
cursorFrom, rowType and stateType. Walk routers to compose classes: children
gives each child's attribute name, kind and class, core which declared base composes
it, and cores[core] how (forward, params, children). schemas gives every shared
scope's types, keyed by the directory that owns them. Identifiers Truewire invents
(class, rootClass) are PascalCase and language-neutral; identifiers the API invented
(fields[].wire, driver, size) are verbatim.
The tests in packages/truewire/test/test_plan.py pin the GitHub example's plan as
test/fixtures/plans/github.json, so a change to the shape is a visible diff.
Not yet on the plan
- The Python walkers' bodies are still string emitters (
paged_method,paged_response_methodand their dispatch targets); they read the plan's decisions but render from the header they are handed. The row type the Python backend puts in a signature is still resolved bypaged_response_rows_typeover rendered definitions; the plan'srowTypecomes from the type tree, and the tests check the two agree on both examples. - Python-side naming: which request fields are positional (
_flat_request_kwargs), identifier sanitising (safe_identifier), and an endpoint class renamed to dodge an imported name (Any,Literal, ...) rather than a type in its module. - gRPC endpoints and OpenAPI-shaped (not yet migrated) endpoints are not planned; the Python backend keeps its own path for both.
authis reserved (architecture review, item 4) and alwaysnull: no spec field declares it yet.