Methods
defineMethod declares an API method with its IAM action, the resource it is decided on, validation, and optional admission or composite behaviour. The envelope does the rest.
A method is a typed function behind one route. You declare what it needs, and the operation envelope does the security work around it: authenticate the caller, check the tenant, make exactly one IAM decision, open the transaction, run your handler and write the audit row. Your handler never calls IAM for its own admission and never opens the outer transaction.
The definition
interface MethodDefinition<I, O> {
action: `compute:${string}` | null;
kind: 'read' | 'mutation' | 'public';
resource(input: I): MethodResource | undefined;
validate(input: unknown): I;
handler(context: MethodContext, input: I): Promise<O>;
admission?: MethodAdmission<I, O>;
transaction?: 'auto' | 'manual';
poll?: MethodPoll<I, O>;
}Prop
Type
defineMethod (from better-compute/core) freezes the definition and keeps its input and output types, which is
what types compute.api and the client. The registry checks the shape when the module registers: resource,
validate and handler must be functions, kind one of the three, a public method's action null, every other
action must match ^compute:[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$, and a plugin's must start with
compute:<id>:. Anything else is INVALID_CONFIG.
How a call runs
- Public methods (
kind: 'public',action: null) are validated and run; the credential is passed along only when one was presented. - Everything else needs a credential (
UNAUTHENTICATEDwithout one, before any IAM call). The input is validated, then the caller is authenticated through the , cached for 30 seconds by the credential's hash. - Tenant pre-check. A caller whose tenant is neither the input's
tenantIdnor the root tenant getsACCESS_DENIEDbefore compute's database is read or IAM is asked anything. - One decision. Mutations call
iam.require, never cached. Reads calliam.authorize, whose allows are cached for 10 seconds per session. A refusal isACCESS_DENIEDand no transaction is opened. - The handler runs in one transaction: read-only for reads, so a read that writes fails. An allowed mutation
also gets an
audit_eventsrow with its , in the same transaction, so a rolled-back mutation leaves no audit row either.
A thrown error rolls the transaction back and becomes the error response.
Actions and resources
resource(input) names what the action is decided on, without the IAM type prefix: { type: 'tenant', id },
{ type: 'pool', id: input.poolId }. The envelope adds the prefix (compute- by default) and decides in the tenant
the input names. So a method's input needs a tenantId, unless it is decided on the fleet ({ type: 'fleet', id: 'global' }), which is always decided in the root tenant. A method with neither is INVALID_CONFIG at call time.
Keep resource a pure projection of the validated input. For methods that have a row in the method table,
initialize() probes resource with a placeholder input and compares the kind it decides on with the row.
| Method kind | Decides on | Examples |
|---|---|---|
| Tenant-scoped | tenant | webhooks.* (compute:webhooks:manage), quotas.get, pools.create |
| Object-scoped | the object | reservations.get on reservation, pools.get on pool |
| Fleet-wide | fleet / global | fleet.apply, rollouts.create, quotas.set |
A plugin can decide on its own resource types, declared in its catalog.resourceTypes, as long as IAM can resolve
them: write their rows into the authz projection with ctx.authz.upsert(tx, { type, id, tenantId, attributes, ownerId }) when you create them. See actions and resources.
Per-input actions
Some rows of the method table decide a different action for some inputs: tunnels.open decides
compute:nodes:console when it targets a node. resource() returns that action in resource.action, and the
envelope decides it, but only if the row lists that (action, type) pair as an alternative. A method without a
row, which includes every plugin method, cannot override its action; resource.action other than its own action
is INVALID_CONFIG.
Validation
validate receives the raw JSON object (or {} for an empty body) and returns the typed input. It must reject
unknown fields, not strip them. The manifest builder does both:
import { field, manifest } from 'better-compute';
const rotateInput = manifest({
tenantId: field.string({ pattern: '^[A-Za-z0-9._:-]{1,128}$' }),
webhookId: field.string({ pattern: '^whk_[0-9a-hjkmnp-tv-z]{26,52}$' }),
keepPreviousSeconds: field.int({ min: 0, max: 86_400 }).optional(),
});
// validate: (raw) => rotateInput.parse(raw)
// → INVALID_INPUT "Unknown field x", "keepPreviousSeconds: …", "Required", …parse throws ComputeError('INVALID_INPUT') naming the first problem. Validation runs before authentication,
so a malformed request never costs an IAM call. Another validator works too, but wrap its failures in
ComputeError('INVALID_INPUT', …): any other error thrown from validate reaches the caller as INTERNAL_ERROR.
A validate function may carry its input's JSON Schema as a jsonSchema property; the generated API reference and
tool schemas read it. For a manifest, attach m.jsonSchema() to m.parse. For a hand-written validator,
describedBy(schema, validate) from @better-compute/core checks the schema's structure first (field types,
required and unknown fields, enums), reporting every structural issue at once as INVALID_INPUT with
details.issues ({ message, path }[]), then runs your validator for the domain rules; strictObject(properties, required) builds an object schema that refuses unknown fields:
import { describedBy, strictObject } from '@better-compute/core';
const validate = describedBy(
strictObject({ tenantId: { type: 'string' }, count: { type: 'integer' } }, ['tenantId']),
(raw) => parseCount(raw), // ranges, patterns, "exactly one of"
);Every built-in method carries a schema. tests/input-schema-agreement.test.ts composes every module, fails on any
registered method without validate.jsonSchema, and checks that each schema agrees with its validator on required,
unknown, enum and string fields.
The method context
The handler's MethodContext is the module context plus the call:
| Member | Meaning |
|---|---|
sql | The envelope's transaction. sql.transaction(fn) opens a savepoint; migrate and close are refused |
caller | { identityId, identityKind, tenantId, sessionId, sessionKind, mfa, agentId?, delegationId? }; absent for public methods |
credential | The credential as presented, for calls you make on the caller's behalf |
admission | The recorded decision of an allowed mutation: tenant, identity, session, action, resource, decidedAt |
requestId | The caller's X-Request-Id when it is short and plain, else a generated req_… |
traceparent | The request's W3C traceparent header when it is valid (version 00, non-zero ids); ctx.commands.enqueue then defaults each command's traceparent to it unless the command sets its own |
signal | Aborts when the transport does |
check, invoke | Composite methods only; see below |
Admission: the request as a resource
A create that should be decided on what it asks for, not only on the tenant, uses admission. The request is first
written as a with its attributes in the authz projection, IAM decides on the draft,
and only an allowed draft becomes real. This is how reservations.create lets a policy cap GPU count and duration
in one decision; see reservations.
interface MethodAdmission<I, O> {
prepare(context: MethodContext, input: I, tx: SqlTx): Promise<
{ status: 'decide'; resource: MethodResource } | { status: 'replay'; result: O }
>;
denied?(context: MethodContext, input: I, tx: SqlTx, error: unknown): Promise<void>;
describe?(input: I): string;
check?(context: MethodContext, input: I, resource: MethodResource): Promise<void>;
abandoned?(context: MethodContext, input: I, tx: SqlTx, error: unknown): Promise<void>;
}prepareruns in the first transaction and never calls IAM. It looks up the idempotency key (scoped to the tenant and the caller's identity), writes or reuses the draft and its projection row, and says what to decide on. A finished request by the same identity is areplay: its stored result is returned without a second decision. A different request under the same key must throwCONFLICT.draftId(prefix, tenantId, identityId, key)frombetter-compute/corederives a deterministic id for the draft.- The decision is one
requireon the resourcepreparereturned.resource(input)may returnundefinedfor admission methods, which learn their resource only inprepare. check(optional) runs after the decision allowed the request and before the handler, with no transaction open, so it may call IAM: for examplectx.iam.billingCheckfor a budget. Itsctx.sqlruns each statement on its own. A throw refuses the request with that error and does not calldenied: the draft stays as it is, so a retry with the same key is decided again later (say, once a budget's window resets). Compositectx.checkruns it too.handlerruns in a second transaction withctx.admissionset, and the audit row is written there.abandoned(optional) runs in a third transaction of its own when an allowed request's handler threw, for example because the resource changed between the transactions (d14564e). The handler's rollback also undid any cleanup it did, so this is where to undo, best effort, whatpreparestaged in the first transaction and would otherwise outlive the request, such as pending authz attributes anextendset, which would be merged into every later decision on the resource. Keep a retry with the same key possible. If it throws, the failure is logged and the caller still gets the handler's error.reservations.extendandsandboxes.extendpass theirdeniedclearing here too (a2288ec).deniedruns in a transaction of its own after a refusal, to mark the draft denied or clear pending changes. If it throws, the failure is logged and the refusal is still what the caller gets.- Delegated sessions. When the caller is an agent in a delegated session, the envelope asks IAM to open a
confirmation, with
describe(input)as the reason shown to the person. If the delegation holds the action for confirmation, the error becomesCONFIRMATION_REQUIREDwithdetails: { confirmationId, expiresAt?, action, resource }, anddeniedreceives it so the draft can wait asawaiting-confirmation. See delegated confirmation.
A replay-only admission needs no draft at all. projects.create derives its id from the key and returns
{ status: 'replay', result } when the project exists, else { status: 'decide', resource: { type: 'tenant', id } }.
pools.create does the same when an idempotencyKey is given.
Only mutations take an admission, and a method cannot combine it with transaction: 'manual'.
Async-with-wait
A mutation whose work finishes later (a queued placement) can let callers wait for it in the same request (PLAN.md
section 9.1). Declare poll, and accept wait: { timeoutMs } in validate:
interface MethodPoll<I, O> {
done(output: O): boolean;
refresh(context: MethodContext, input: I, output: O): Promise<O>;
}After the mutation commits (plain or admitted, and on a replay too), the envelope checks done(output). When it is
false and the input asks for a positive wait.timeoutMs, it calls refresh in read-only transactions, backing off
from 50 ms to 1 s, until done, the timeout (at most 30 seconds, wall clock) or the request's abort, and answers the
last output. A timeout is not an error: the output itself must say the work is still pending, as a reservation's
operation.state: 'running' does. reservations.create and instances.create are the built-in users; see
waiting for placement and
waiting for the instance. Keep wait out of the request hash
your idempotency check compares, as both do, so a retry with a different wait replays. The registry refuses (INVALID_CONFIG)
a poll on a method that is not a mutation, or one without both functions.
Composite methods
A mutation declared transaction: 'manual' composes other methods, each decided for the same caller. fleet.apply
is the one built in: it applies a whole fleet document through pools.create, quotas.set and the rest, all or
nothing.
apply: defineMethod<ApplyInput, ApplyResult>({
action: 'compute:fleet:admin',
kind: 'mutation',
resource: () => ({ type: 'fleet', id: 'global' }),
transaction: 'manual',
validate: parseApply,
async handler(ctx, input) {
const first = await ctx.sql.transaction((tx) => plan(ctx, tx, input));
// Phase 1: one decision per call, as the caller, with no transaction open.
for (const call of first.calls) await ctx.check!(call.path, call.input);
// Phase 2: re-plan and run everything in one transaction.
await ctx.sql.transaction(async (tx) => {
const second = await plan(ctx, tx, input);
if (second.planHash !== first.planHash) throw new ComputeError('CONFIG_DRIFT', 'plan again');
for (const call of second.calls) await ctx.invoke!(tx, call.path, call.input);
});
return { plan: first, applied: first.calls.length };
},
}),- The composite itself is decided once, like any mutation. Its handler then runs with no transaction open;
ctx.sqlruns each statement on its own and eachctx.sql.transactionis a real transaction. ctx.check(path, input)makes the target's tenant pre-check, validation and one decision exactly as the envelope would, and records a grant keyed by the path and the canonical JSON of the input. It refuses to run inside a transaction (INVALID_STATE).ctx.invoke(tx, path, input)consumes one grant (elseACCESS_DENIED), runs the target's handler in a savepoint oftxwithout another decision, and writes the sub-call's own audit row. A failed sub-call leaves nothing behind. Grants live only for the current call. When the target has anadmission.check,invokeruns the very input object that check ran on, not a fresh validation of the raw input (7911c5f), so what the check recorded in it reaches the handler:sandboxTemplates.createandupdaterecord their per-volume decisions there (decidedVolumes), which is howfleet.applycan create or change a template that mounts volumes.checkrefuses (UNSUPPORTED) other composites, admissions with adeniedhook (reservations, instances) and list methods that decide their items one by one. Replay-only admissions work:prepareruns in a short transaction atcheckand again insideinvoke.- The composite's own audit row is written in its own transaction after the handler succeeds.
Paths are the routes below the base path: pools/create, or plugins/acme/widgets/poke.
Serving plugin methods
A plugin's methods are served under its id. For plugin acme, group widgets, method poke:
curl -X POST https://compute.example.com/api/compute/plugins/acme/widgets/poke \
-H "Authorization: Bearer $BETTER_COMPUTE_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Better-Compute: 1" \
-d '{"tenantId":"acme","widgetId":"w-1"}'
# → { "data": { "poked": "w-1" } }Every route takes POST with Content-Type: application/json, the X-Better-Compute: 1 header (without either
the answer is CSRF_REJECTED) and a JSON object of at most 64 KiB. Success is { data }; failure is { error: { code, message, retryAfterMs?, details? } }
with the code's HTTP status. compute.api runs the same envelope in process.
Errors
Throw ComputeError from better-compute/core. Its code and message are shown to the caller; details carries
structured context.
throw new ComputeError('CONFLICT', 'Widget changed meanwhile', undefined, { widgetId });
throw new ComputeError('RATE_LIMITED', 'Slow down', undefined, { retryAfterMs: 20_000 });A numeric details.retryAfterMs moves to the body's top-level retryAfterMs, and the HTTP answer gets a
Retry-After header in seconds. Anything that is not a ComputeError, and any INTERNAL_ERROR, reaches the
caller as INTERNAL_ERROR with the message Internal server error, so a leaked connection string stays in your
logs. isComputeError(error, code?) recognizes errors from a second copy of the package too.
| Code | Status | Throw it when |
|---|---|---|
INVALID_INPUT | 400 | Validation fails (the manifest does this for you) |
NOT_FOUND | 404 | The object does not exist in the input's tenant. Use it for other tenants' objects too, so nothing leaks |
INVALID_STATE | 409 | The object exists but cannot do this now (cancel on an active reservation) |
CONFLICT | 409 | An idempotency key reused for a different request, or a lost race worth retrying |
CAPACITY_UNAVAILABLE, QUOTA_EXCEEDED | 409 | Nothing fits, or a quota is spent |
CONFIG_DRIFT | 409 | Live state moved since a plan was made |
UNSUPPORTED | 422 | A valid request this deployment or object does not support |
RATE_LIMITED | 429 | With details.retryAfterMs |
DRIVER_ERROR, PROVIDER_ERROR | 502 | A driver or provider call failed |
UNAVAILABLE | 503 | A dependency is unreachable for now and nothing changed; with retryAfterMs |
NOT_IMPLEMENTED | 501 | A stub: notImplemented(feature, phase) names the phase that delivers it |
INVALID_CONFIG | 500 | The deployment is misconfigured, not the request |
Leave UNAUTHENTICATED, ACCESS_DENIED, CONFIRMATION_REQUIRED, CSRF_REJECTED and the transport codes to the
envelope. The error reference lists every code.
Adding a method to Better Compute itself
This applies when you change the repository's own groups, not your plugin's. Better Compute's API surface is frozen
toward v1.0: pnpm lint:repo runs scripts/api-surface.mjs, which compares every method of core's apiSurface and
the registry (route, kind, IAM action, resource, alternatives, list flag, status, phase and input JSON Schema) and every
error code's HTTP status with the recorded generated/api-surface.json. A new method, a new optional field or a
wider schema is an addition and passes; removing a served method, changing its route, action or resource, or
narrowing its input (a new required field, a tighter bound, a lost enum value) fails. A method that is still planned
may change freely.
After adding or widening a method, regenerate the docs (node apps/docs/scripts/generate.mjs, which the check reads)
and record the addition with pnpm api:surface, so the new shape is frozen too. Until it is recorded the check only
reports it. See the API surface freeze. A new (action, resource)
pair is also covered by the authz matrix, from the §9.2 table in core, in both IAM modes.
Better Compute is created by Sean Filimon
Last updated
Modulesexperimental
A ComputeModule bundles API groups, migrations, reconcilers, command kinds and NodeSpec sections. How to write one, what its context offers, and how the host runs it.
Sinksexperimental
Event and audit sinks receive every committed row at least once, each with its own durable cursor. How dispatch works, the cursor pitfalls, poison events, and usage.