Source on GitHub

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 omitted
from 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, seek with overlap, or rows the tree cannot name).
  • none: the declaration cannot be walked at all (an offset walk 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_method and 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 by paged_response_rows_type over rendered definitions; the plan's rowType comes 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.
  • auth is reserved (architecture review, item 4) and always null: no spec field declares it yet.