Problem
The gateway already serves different OpenAPI documents to different callers. One internal document is cut three ways: the public one (no token: the routes an API credential may call), the caller's own (with a token: the public routes plus whatever the account's plan adds), and the internal one (admins only). Only one plan exists today, so the cuts are equal in practice, but they will not stay equal: every product that opens to customers opens per plan.
A conventional SDK is generated once from one document and published. That SDK lies
in both directions for most callers. It offers operations their account cannot call,
which they discover as a 403 at run time, and it lacks the operations their plan
adds, which they then call by hand. A developer who builds for two accounts (a
personal sandbox and a team's production account, say) has it worse: one SDK, two
different sets of allowed calls, and nothing that tells them which is which.
"Based on the account type" can mean four things here, and they are not equal:
| Candidate | What differs | Today |
|---|---|---|
| The account's plan | which products and routes the account has | implemented: the document cut by the token's plan |
| The credential's scopes | which of those routes this token or key may call | enforced at the gateway, not reflected in the document |
| The account's kind (personal or team) | nothing yet: no route differs by kind | not used |
| The caller's kind (person, token, key) | people also reach the console's routes, which are not public | the public cut excludes them by design |
Proposal
What the document says
The document a credential sees is what that credential may call: the plan's routes,
narrowed to the credential's scopes. That is the plan cut that exists today, plus one
more cut by scope, so an SDK generated from it never contains an operation that answers
403 for that credential. Account kind is left out until a route differs by it; when one
does, it is one more input to the same cut, not a new mechanism.
Platform changes, small and in the gateway only:
GET /v1/openapi.jsonnarrows by the token's scopes as well as its plan.- A person signed in with the command line acts for several accounts. The same route
takes
?account=<id>: the accounts service confirms the caller is a member and says which plan that account has, and the gateway cuts by it (and by the scopes a person holds in that account). Membership is the accounts service's question, so the gateway asks it rather than deciding. - Each cut carries
info.x-iohr-cut: the plan, the scopes, and a content hash of the result. Two documents with the same hash are the same API.
What gets generated
The SDK repository already decided that models are generated and the client is written by hand (its ADR 0002). That stays, split along the same line:
- The runtime is published once per language, as the SDK repository decided: client, configuration, token handling, retries, errors, streaming, hooks. It is the same for everyone and is tested against the shared conformance cases.
- The surface is generated per developer by
iohr sdk generate(RFC 0019): the models and the thin operation methods for the operations their profiles may call. It depends on the runtime and contains no transport code of its own.
iohr sdk generate --lang rust --for personal --for acme-ci --out src/iohr
writes the surface into the developer's own repository, plus a lock file:
# iohr.lock: what this surface was generated from
generator = "0.1.0"
[profiles.personal]
cut = "sha256:…" # info.x-iohr-cut of the document it saw
[profiles.acme-ci]
cut = "sha256:…"
iohr sdk check in the developer's CI fetches the current cut per profile and fails
when a hash moved, so a plan change or a revoked scope is a failing check with a diff,
not a production surprise. The lock file holds profile names and hashes, never an
account id or a secret; the profile is resolved at run time from the environment.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
Several accounts at once
One generated surface serves several profiles, and each profile gets only its own operations. In Rust the compiler enforces it:
// generated: one zero-sized type per profile
pub struct Personal;
pub struct AcmeCi;
impl inorbithr::Profile for Personal { const NAME: &'static str = "personal"; }
impl inorbithr::Profile for AcmeCi { const NAME: &'static str = "acme-ci"; }
// one marker trait per operation, implemented for the profiles whose cut holds it
pub trait GetUnits: inorbithr::Profile {}
impl GetUnits for Personal {}
impl GetUnits for AcmeCi {}
pub trait ListDigests: inorbithr::Profile {}
impl ListDigests for AcmeCi {}
// the operations hang off handles, one per area, whose methods are bounded by the markers
pub struct Radar<'a, P: inorbithr::Profile>(&'a inorbithr::Client<P>);
impl<P: ListDigests> Radar<'_, P> {
pub async fn list_digests(&self, params: &RadarListDigestsParams)
-> Result<inorbithr::Response<ListDigestsResponse>, inorbithr::Error> { /* … */ }
}
Client::<Personal>::from_env()?.radar().list_digests(¶ms) does not compile, because
the personal account's cut does not hold that route. That is the generic that earns its
place: a whole class of 403 moves from run time to compile time, and the cost is one
zero-sized type per profile. The handles are types of the generated code rather than
methods added to the runtime's Client, because Rust lets a crate add methods only to
its own types; the guarantee is the same.
The other languages get the same guarantee as far as their type system reaches:
| Language | How a profile's client holds only its operations |
|---|---|
| TypeScript and JavaScript | a client type per profile, a mapped type over the operation map; plain JavaScript gets the same methods without the check |
| Python | one client class per profile, with type hints a checker (mypy, pyright) enforces |
| Go | one generated package per profile that wraps the shared runtime |
| Java | one client class per profile; the models and the runtime are shared |
| C# | a marker interface per operation and extension methods constrained to it (where P : IListDigests), so the compiler refuses the call, as in Rust |
| Rust | the marker traits above |
In every language the operations shared by all profiles are generated once, and models are shared.
The generator
Our document is OpenAPI 3.1 with proto-style schema names, few required lists and
oneOf without discriminators (the SDK repository's spec notes). Off-the-shelf
generators either do not read 3.1 (progenitor
reads 3.0 only) or produce code we would rewrite (the reason
Svix left openapi-generator). So:
- Normalise (Rust,
iohr-openapi): short names,requiredwhere the server always sends a field, discriminators, the SDK repository's rules, one place. Each rule is also raised upstream and removed when the platform fixes the cause. - Model: one intermediate representation of operations, models and which profiles hold each operation. Everything after this point is language-specific.
- Render: per language, a
Targetimplementation, six of them: TypeScript (which also serves JavaScript), Python, Go, Java, C# and Rust. Rust types come from typify (already chosen by the SDK repository); the operation layer and the other languages come from templates (minijinja) embedded in the binary. The SDK repository's existing picks (oapi-codegen, openapi-typescript, datamodel-code-generator) stay what generates the published runtimes' own models in that repository's CI; the command line does not shell out to them, so a developer needs one binary and no Java, Node or Python toolchain.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
Output is deterministic: the same document and generator version give byte-identical
files, so sdk check can also compare files and a reviewer sees a real diff.
One conformance suite holds every target to the same behaviour: the SDK repository's cases (token refresh, retries, error mapping, streaming) run against each language's runtime and each generated surface, and a compile test builds every target's output for a fixed set of documents. A target that does not pass the whole suite is not released.
Languages
All six languages the docs already show code samples in ship from the start: TypeScript and JavaScript, Python, Go, Java, C# and Rust. The SDK repository's design covers four of them today (TypeScript, Python, Go, Rust); Java and C# are new there and need their runtime design added to it.
Kotlin, Swift, PHP, Ruby and Dart come later, by demand. Kotlin calls the Java SDK
today; the others have no samples in the docs yet. Each new language is one Target
implementation plus a runtime that has to pass the conformance suite, and a runtime is
the larger part of that cost: it is maintained for as long as the language is offered.
Order of work
Six languages from the start is the expensive choice, and the order is set so the cost falls where it teaches the most first. The runtimes are the larger half of the work: six hand-written clients, each with token handling, retries, errors and streaming. The generator is the smaller half, because the model is shared and each target only renders.
- The platform: scope narrowing,
?account=,x-iohr-cut; tests that a token's cut holds exactly the routes admits for it. - The model and two lead targets, Rust and TypeScript, for one profile and then several. They settle the model; changing it later costs six targets instead of two.
- Python and Go, on runtimes the SDK repository already plans.
- Java and C#, with their runtimes designed and written.
sdk check, and a GitHub Action that runs it.
The first public release of iohr sdk generate offers all six, each passing the whole
conformance suite. Steps 2 to 4 are internal milestones, not separate releases.
Alternatives considered
- One published SDK with everything public, and a runtime 403. What most APIs ship. It is the lie this RFC exists to remove, and it gives a multi-account developer nothing.
- Generate from the plan only, ignore scopes. Less platform work, but a token with one scope would still get the whole plan's surface. The scope cut is a few lines in the gateway that already knows both.
- Cut by account kind (personal or team). Nothing differs by kind today; building the input before anything uses it is speculative. Kept as a future input to the same cut.
- Generating the surface at build time instead of committing it. No generated files in the developer's repository, but every build then needs the network, a credential and our generator, a plan change silently changes what compiles, and a reviewer never sees the API change as a diff.
- Runtime capability checks instead of generated types. A client that fetches the cut at start and refuses unknown calls. Useful as a second line, but it finds the mistake when the program runs, not when it builds.
- A hosted generator (Stainless, Speakeasy, Fern). Stainless announced in May 2026 that it is winding down its hosted generator; the others generate from one document per SDK, not per caller, and would put our customers' plan and scope data through a third party.
- The command line shelling out to openapi-generator. Needs Java on every machine, produces code we would not publish under our name, and still reads one document.
Decision
Decided 2026-10-02:
- The cut is the credential's plan narrowed by its scopes, with
?account=for a person in several accounts. It is the one cut that equals what the gateway admits, so it is the only one under which the generated SDK never offers a call that fails with403. The account's kind (personal or team) changes no route today, so it is not a dimension of the cut; it becomes one only when a route differs by kind, as one more input to the same cut. - All six languages from the start: TypeScript and JavaScript, Python, Go, Java, C# and Rust, in the order of work above, released together once each passes the conformance suite.
- The generated surface is committed in the developer's repository with
iohr.lock, andiohr sdk checkruns in their CI. A committed surface builds offline and without a credential, reproduces exactly from the lock, and turns any change in what the account may call into a reviewed diff and a failing check instead of a different build on a different day. That is the property this RFC exists for; generating at build time would give it up to save a directory of files.
Publication
When it ships: a docs guide "Generate an SDK for your account", the reference page of
GET /v1/openapi.json describes the cut and x-iohr-cut, the SDK repository's design
document gains the runtime and surface split, and this RFC's status log records each
step of the order of work.
Status log
- 2026-10-02: proposed with RFC 0019 (the command line) and RFC 0021 (packages).
- 2026-10-02: decided with the user's answers: the cut is the plan narrowed by scopes, all six documented languages from the start, the generated surface committed with a lock file.
- 2026-10-03: step 1, first half, built:
GET /v1/openapi.jsonnarrows by the credential's scopes and carriesinfo.x-iohr-cut(plan, account, scopes, a hash of the operations and schemas with prose left out, so documentation edits never move it). A test holds every scope's cut equal to the gateway's own admission rule for that scope.?account=for a person in several accounts follows. - 2026-10-03: step 1 built.
?account=<id>cuts a person's document to a team's plan; the accounts service decides membership, and every refusal is the same403, so the route never says whether an account exists. One deviation from the proposal, recorded here: a person is not narrowed by their role in the team. The gateway admits a signed-in person on every public route and the accounts service decides each write by role, so a role has no route catalogue to narrow by; narrowing by role is a follow-up if a route ever differs by it. - 2026-10-03: step 2 built, Rust end to end. The runtime crate
inorbithr(client, credentials, retries, errors), the generator with its Rust target, andiohr sdk generateandiohr sdk checkwith the lock file are in the SDK repository, and the docs have the guide. The crate's own public surface is generated by the same generator, so every spec sync exercises it. Streaming operations are left out of a generated surface until the runtime streams; TypeScript follows. - 2026-10-03: steps 2 to 4 complete, the other five languages end to end: TypeScript (and plain JavaScript), Go, Python, C# and Java each have a runtime, a generator target, and a compile test that proves a profile cannot call an operation outside its cut; all six pass the whole shared conformance suite. Every language has an iterator over a paged list in its own idiom. The repository's copy of the contract is synced with the platform (60 operations), and command line 0.1.0-alpha.4 generates all six languages. The libraries themselves are published to their registries together at the first library release.
- 2026-10-04: the first library release. The runtimes for Rust, TypeScript, Python and Go are published at 0.1.0 on crates.io, npm and JSR, PyPI and the Go module proxy, each with build provenance and a software bill of materials, and each installs in a fresh project. The C# and Java runtimes are built and pass the same suite; their NuGet and Maven Central releases come later, and until then they build from source.
- 2026-10-04: 0.2.0, the contract aligned. The public document now states what the SDK
repository's sync used to patch in (RFC 0033): an answer's always-sent fields as
required, the error detail's discriminator, the server, and each error code's HTTP status. The sync and the command line's normaliser dropped those four rules and now fail on a document that lacks one, so a regression here is caught there instead of patched over. Every generated request field is optional and left out when unset, an answer's field is present exactly when the document requires it, and an unset timestamp ("") is settled behaviour with one helper per runtime. Rust, TypeScript, Python and Go are released at 0.2.0; C# and Java carry the same version, ready for their registries; command line 0.1.0-alpha.5 generates the same types. - 2026-10-04: streams. A streaming operation is generated as a method of the same name
that yields one model per event, behind the same per-profile check, so a profile
without
events:readdoes not compile a call to the account's event stream. Every language opens it as server-sent events or over one multiplexed WebSocket, under the platform's bounds for keys (RFC 0048), and passes 13 new shared conformance cases. Released as 0.2.1. - 2026-10-05: 0.2.2: enterprise configuration and middleware in all six languages. One
loadwith one precedence (code, environment, theiohrconfig file, defaults) and adescribe()that names each value's source, the credential chain, proxy, CA bundle, mTLS and pinning, a named middleware pipeline (logging, OpenTelemetry, rate limits, a retry budget), retried writes that take anIdempotency-Key, and a 120 s total deadline by default. Rust, TypeScript, Python and Go are on their registries; C# and Java carry the same version. Command line 0.1.0-alpha.10 addsiohr sdk add. - 2026-10-07: Checked: the SDKs ship from inorbithr/sdk (#249, #342 record 0.2.1 and 0.2.2); nothing since the last line changes the decision. Status unchanged.