@ultimat3/action
v18.0.0
Published
The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests
Readme
@ultimat3/action ⚡
One declaration → six artifacts.
| # | Artifact | Reach it with | Guarantees |
|---|---|---|---|
| 1 | HTTP route POST /api/<resource>/<verb> | toRoute(publishPost) — the server mounts it | policy + validation + idempotency + invalidation, non-optional |
| 2 | OpenAPI 3.1 operation + document | publishPost.openapi() / buildOpenApi() | byte-stable output, diffed by x verify |
| 3 | Typed RPC client | publishPost.client({ baseUrl }) / rpc<Api['actions']>() | server typo = compile error in Solid |
| 4 | MCP tool | publishPost.tool() | identical policy evaluation to the route |
| 5 | Job handle | publishPost.job() | enqueue durable work, no rewrite |
| 6 | Contract tests | publishPost.contract() | garbage rejected, anonymous denied, spec present |
The fluent surface
An action carries its own projections, so app code never reaches through .def and
never imports a projection function:
publishPost.input // the declared input schema
publishPost.output // the declared output schema
publishPost.policy // the one policy object
publishPost.mcp // { expose, description }, as declared
await publishPost.as(actor, { postId }) // run as someone, one execution path
publishPost.tool() // MCP descriptor
publishPost.openapi() // OpenAPI operation
publishPost.client({ baseUrl }) // typed RPC method
publishPost.job() // durable-work handle
publishPost.contract() // the three generated assertionspublishPost.tool().policy === publishPost.policy — the same object, so an MCP call
cannot reach a different authz path. .as() keeps the surrounding context whole and
swaps only the actor: impersonation, not a second context.
Declare
t is re-exported here — the same object @ultimat3/schema exports, so an action file
imports one package for the primitive and its schemas, never two.
import { action, t } from '@ultimat3/action';
export const publishPost = action({
input: t.object({ postId: t.uuid, orgId: t.uuid, notify: t.boolean.default(true) }),
output: PostView,
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
cache: { invalidates: [tag.post, tag.feed] },
mcp: { expose: true, description: 'Publish a draft post' },
idempotent: true,
async handle({ input, ctx }) {
const post = await ctx.posts.publish(input.postId);
if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId });
return post;
},
});Register — one call, at boot
apps/web/api/index.ts is the whole API surface. Importing it IS the boot.
import { defineApi } from '@ultimat3/action';
import * as postActions from '../app/posts/actions';
import * as postMutators from '../app/posts/mutator';
import * as postQueries from '../app/posts/live';
export const api = defineApi({
actions: [postActions],
mutators: [postMutators],
queries: [postQueries],
});
export type Api = typeof api;Six keys, all optional:
| Key | Goes to | Why |
|---|---|---|
| actions | the action registry | the primitive |
| mutators | the action registry | a mutator IS an action, on the same authz path |
| llm | the action registry | llm() returns an action, not a ninth primitive |
| queries | @ultimat3/query's registry, via core's registrar table | query is on this tier, so importing it here would be a build error |
| jobs | @ultimat3/jobs' registry, the same way | the export name becomes the durable queue key — a job row names the handle, not a counter |
| tasks | @ultimat3/jobs' registry, the same way | handing a task over is what names its cron after its export |
Jobs register before tasks: a task's descriptor lists the jobs it enqueues by name, so the
other order would read the queue keys one boot step before they were assigned. Api carries all
four maps back — api.actions, api.queries, api.jobs, api.tasks — keyed by the name
registration stamped.
Names come from export names — that is what makes the path, the tool name and the
OpenAPI operationId derivable everywhere without a second declaration. Registration
stamps the name onto the action the module exported, so the binding you imported is the
one that projects; a projection attempted before boot is X_ACTION_UNREGISTERED. Two
features exporting one name collide with X_ACTION_DUPLICATE rather than merging, and two
names deriving one route collide with X_ACTION_PATH_DUPLICATE — pluralize leaves a trailing
s alone, so archiveOrder and archiveOrders are two exports and one POST /api/orders/archive.
registerActions is what defineApi composes for the three action-shaped keys; the other three
go through core's primitiveRegistrar(kind), because this package may not import @ultimat3/query
or @ultimat3/jobs sideways. An app calling either directly is a second path.
Call it — rpc
import { rpc } from '@ultimat3/action';
import type { Api } from '../api';
export const client = rpc<Api['actions']>({ baseUrl: '/' });Api['actions'] is the merged module type, so client.publishPost is typed from the
declaration with no codegen step. Api is imported as a type only, which is what keeps
a page's module graph free of any edge to a feature's implementation.
Flight control — createClientFlight, opt-in
Same object as @ultimat3/query's, installed the same way (rpc({ baseUrl, flight })), and the
write half is the half made of refusals:
| Rule | Why |
|---|---|
| a mutation never joins another mutation | client.ts never calls flight.keyFor, so there is no dedup path to reach; two writes are two writes |
| a fence bump never aborts a write | closing the socket does not un-commit it, it only destroys the one chance this caller had of learning whether it landed. The caller still gets X_SUPERSEDED — the answer is retired, the request is not |
| retry is honoured only alongside an idempotencyKey | a retried POST without one is a second write. Without a key the call is narrowed to a single attempt, silently and by construction |
| the same Idempotency-Key rides every attempt | that is what makes the retry a retry rather than a duplicate |
declare const api: { charge: (input: { orderId: string }, options?: {
idempotencyKey?: string; retry?: { attempts: number };
}) => Promise<unknown> };
declare const orderId: string;
await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { attempts: 3 } });createClientFlight is @ultimat3/core's, re-exported here: it is the same object
@ultimat3/query re-exports, because both packages are tier 3 and neither may import the other.
It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
this package exactly as before.
Importing rpc alone from this package is 14,759 B minified for the browser; adding
createClientFlight is 20,292 B. ClientFlight is a TYPE inside client.ts and never a
value, which is what keeps the second number off the first caller's bill. Expect ±376 B run to
run — Bun.build 1.4.0 drops @ultimat3/core's schema-error-codes.ts from some builds even
though sideEffects names it (issue #273), which is the size of the schema error titles.
Path derivation
First camelCase word is the verb; the rest is the resource, last word pluralized,
kebab-cased. The MCP tool name is not derived at all — it is the export name verbatim, because
that is what defineAppMcp's scopes: and a tools/call have to spell.
| Action | Route | MCP tool |
|---|---|---|
| publishPost | POST /api/posts/publish | publishPost |
| updateUserProfile | POST /api/user-profiles/update | updateUserProfile |
| likePost | POST /api/posts/like | likePost |
| checkout (single word) | POST /api/checkouts/invoke | checkout |
One name, four surfaces — .tool().name, openapi.json's x-ultimate.mcpTool,
describeAction().mcp.tool (what x actions describe --json, x actions list --json, the
actions.describe dev MCP tool and the /_x Routes panel show) and the catalog @ultimat3/mcp
serves. It was two until 2026-08: a toToolName() here snake_cased the first three to
publish_post while the server answered only publishPost, so an agent that read the published
contract called a tool that does not exist. toToolName is deleted, not deprecated — a second
derivation is a second name. mcp-tool.test.ts's "one name per action, on every surface" is what
keeps it that way.
x.manifest.json is not one of the four: ActionFact.mcp is { expose, description? }, so
the manifest never carried a tool name and was never wrong about one.
One invocation core
invoke() is the only execution path: parse input → evaluate policy → handle →
parse output. HTTP, MCP, jobs and direct server calls differ only in the
surface they hand to enforce() from @ultimat3/policy, which selects how a
denial renders (problem+json / tool error / failed job) — never whether authz runs.
Enforced structurally, not by convention: the declaration is held in a private
store inside invoke.ts, so handle is reachable from nowhere else. An action has
no .def. A second authz path cannot be written without deleting that store.
| Stage | Failure |
|---|---|
| parse input | X_INPUT_INVALID |
| evaluate policy | the policy's own code — X_UNAUTHENTICATED (401), X_FORBIDDEN (403) |
| handle | whatever the handler throws |
| parse output | X_OUTPUT_INVALID — and fields the schema never declared are dropped |
cache: { invalidates } fans out after the handler commits, so it never fails it: a
fan-out that refuses — an undeclared tag, X_CACHE_TAG_UNKNOWN — is one
action.invalidate.failed log line and the entries expire by TTL. A replayed idempotent
call busts nothing; the first call already did.
Registering an action without policy: throws X_ACTION_POLICY_MISSING; there is
no bypass flag. A look-alike that never came out of action() is X_ACTION_FOREIGN.
mutator = action + local twin
mutator() is built on top of action(). A mutator IS an action, so it gets all
six projections; it adds local(tx, input) for the optimistic write and a conflict
strategy for the rebase.
export const likePost = mutator({
input: t.object({ postId: t.uuid }),
output: PostLikes,
policy: can('post:like'),
// Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
// equal applying it once — `likedByMe` is what makes the second application a no-op.
local(tx, { postId }) {
tx.posts.update(postId, (p) =>
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
},
async server(ctx, { postId }) { return ctx.posts.like(postId); },
conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
});The projected surface carries the same three names the declaration used, on top of every action member above:
likePost.local(tx, { postId }) // the optimistic write, replayed on rebase
await likePost.server(ctx, { postId }) // the authoritative write
likePost.conflict // the declared strategy.server() is not a shortcut past invoke — it calls the action's own callable, so
the input parse, the policy and the output parse all still run: an actor the policy
denies is denied there exactly as over HTTP. .local() is the only half that skips
the core, because it never leaves the client; keep it a pure function of (tx, input)
— no I/O, no clock, no randomness — since every rebase replays it.
LocalTx is the client write surface (@ultimat3/realtime implements it over OPFS
SQLite). Type your tables once: declare module '@ultimat3/action' { interface
LocalTables { posts: PostRow } }.
transition() — a mutator factory over a state machine
As of 2026-08-24. A move through an entity column's state machine is a server-authoritative write
with an input schema, an output schema and a policy — which is what a mutator already is. So
transition() returns one, and the move inherits the route, the OpenAPI operation, the typed
client, the MCP tool, the job handle and its PRIMITIVE_FACTORIES row. It is not a ninth primitive
and it declares no error code of its own.
import { t, transition, type TransitionTarget } from '@ultimat3/action';
import type { Ctx } from '@ultimat3/core';
import { can } from '@ultimat3/policy';
const ORDER_STATES = ['pending', 'paid', 'shipped'] as const;
type OrderState = (typeof ORDER_STATES)[number];
const OrderView = t.object({ id: t.uuid, status: t.enum(ORDER_STATES) });
// `@ultimat3/entity`'s `orders(ctx)`: a real `Table` satisfies the seam as written.
declare function orders(ctx: Ctx): TransitionTarget<{ id: string; status: OrderState }, OrderState>;
declare const id: string;
declare const ctx: Ctx;
export const moveOrder = transition({
table: (ctx) => orders(ctx), // the request's table — tenant-scoped like every write
column: 'status', // the column whose enumerated().transitions() IS the machine
states: ORDER_STATES, // typed against the row: a state it cannot hold is a compile error
localTable: 'orders', // what the optimistic twin patches
output: OrderView,
policy: can('order:move'),
});
await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });| Rule | Why |
|---|---|
| from is required, and never defaulted or inferred | it rides in the UPDATE's own predicate, so the state observed and the state written are one decision under the row's lock. Measured on the mechanism underneath: twenty concurrent moves at one row gave 14 winners with a read-then-check-then-write and 1 winner plus 19 refusals with from in the predicate. Anything that supplies from for the caller is the lost update coming back |
| the states are the input schema, not a t.string | the union survives into InferOutput, so the typed client refuses a typo at compile time, the MCP tool's inputSchema and the OpenAPI component both publish the legal set, and a bad state is X_INPUT_INVALID before a database is touched |
| conflict: 'server-wins', not overridable | the server is the half that REFUSED the move; a local twin winning the rebase would leave the client showing a state the database rejected |
| audit is off unless the app says so | audit: true with no sink installed is X_AUDIT_SINK_MISSING, raised before the input parse — an on-by-default audit would make every transition() refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of postgresAuditSink |
| X_STATE_TRANSITION_ILLEGAL, X_STATE_CONFLICT and X_STATE_UNDECLARED propagate untouched | they are @ultimat3/entity's. A second error class over one failure is a second path |
table is typed structurally (TransitionTarget), not imported: @ultimat3/action holds no
dependency edge on @ultimat3/entity — the tier table permits one, the manifest and the lockfile do
not — and a real Table satisfies the seam as written.
Determinism + idempotency
serializeOpenApi(buildOpenApi()) sorts keys at every depth, iterates the registry
name-sorted, and reads no clock, env or random source — same registry ⇒ same bytes ⇒
x verify can diff the spec and fail on X_CONTRACT_DRIFT.
rateLimit: is the enforced limit
import type { ActionRateLimit } from '@ultimat3/action';
// The `rateLimit:` key of an `action()`: 5 held, one back every two minutes.
const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };One declaration, three places it lands: the bucket the limiter runs on (named after the action,
registered by @ultimat3/http's withRouteBuckets when the route is mounted), the
ratelimit-limit header the caller reads, and x-ultimate.rateLimit in the OpenAPI operation.
toBucket is the only conversion — capacity: limit, refillPerSecond: limit / (windowMs / 1000)
— so the published numbers and the enforced ones cannot differ. It lives in @ultimat3/http,
beside Bucket and the limiter maths, and is re-exported here: @ultimat3/query needs the same
conversion and is the same tier, so a copy in either package would be a second answer for the
other. A pair the limiter cannot run on is X_RATE_LIMIT_INVALID, at projection. An action that declares nothing
stays on the default bucket. An app that also configures http.rateLimit.buckets.<actionName>
with different numbers is X_RATE_LIMIT_BUCKET_CONFLICT at boot: neither source wins, because
the loser would go on being read as enforced.
idempotent: — and where its records live
idempotent: true + an Idempotency-Key header replays the first outcome
(x-ultimate-replayed: 1); a duplicate still in flight, or a reused key with a new payload, is
X_IDEMPOTENCY_CONFLICT.
A record belongs to one caller. The key is namespaced by action and by actor
(idempotencyKeyFor), so two callers sending the same header value hold two records — the same
value under one action used to be one shared record, which replayed one caller's response to
another. A blank Idempotency-Key: is X_IDEMPOTENCY_KEY_INVALID, never read as "no key":
Headers.get() answers '' and not null, so a blank header was itself a shared key, and the
quiet reading — run without idempotency — loses the retry protection exactly when a client's key
interpolation broke. Omit the header to run un-keyed; the published maxLength: 255 is enforced
by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
public idempotent action still share a key space: a UUID key is what keeps them apart.
A failed first attempt is replayed too, not re-run. guard() and the input parse both happen
before the idempotency gate, so everything it can see throw is post-authorization and possibly
post-commit: a handler that took the money and then failed its own output: schema is the case.
The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
code. Releasing it there is what made idempotency the cause of a double charge.
Where the records live is declared, and refused at registration. The default store is process
memory — bounded, swept on a 24h window, and scope: 'process'. An app on more than one replica
must say so and bring a store that can keep it, or the retry that lands on another replica finds
no record and runs the handler again:
// boot, before registerActions()
import {
configureIdempotency,
postgresIdempotencyStore,
setIdempotencyStore,
} from '@ultimat3/action';
import { db } from '@ultimat3/db';
const client = db();
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
// template whose positional form is `unsafe`), so that line compiles and throws on the first
// reservation.
// The framework boot installs this store for you; reach for it by hand only from a host that
// boots the framework itself, and wrap the client that host already opened.
setIdempotencyStore(
postgresIdempotencyStore({
executor: { query: (text, values) => client.query({ text, values }) },
}),
);
configureIdempotency({ scope: 'shared' });configureIdempotency({ scope: 'shared' }) over a per-process store — or over a store that
declares no scope at all — is X_IDEMPOTENCY_NOT_SHARED at registerAction, before the socket
opens. The table is SQL_IDEMPOTENCY_TABLE, applied the way SQL_JOBS_TABLE is: x db up in
development, the release-phase ROLE=migrate in production. postgresIdempotencyStore(...)
.purgeExpired() is the sweep — Postgres forgets nothing on its own, so run it from a task.
A plain mutating route can use the same gate. withIdempotency, IDEMPOTENCY_HEADER,
idempotencyKeyFor and getIdempotencyStore are all public, so a route that is not an action
reserves and replays through the one implementation rather than growing a second:
const key = req.header(IDEMPOTENCY_HEADER);
// No header is the caller declining idempotency; a BLANK one is not, and
// `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
if (key === null) return json(await refund(input));
const outcome = await withIdempotency(
getIdempotencyStore(),
// Namespaced by action AND actor: otherwise two routes share one caller's key, and two
// callers share one record.
idempotencyKeyFor('refundCharge', key, req.ctx.actor),
input,
() => refund(input),
);settle and fail take the reservation's own id — outcome's reservation, never the key alone.
Both stores fence on it AND on in-flight As of 2026-08, the way @ultimat3/jobs' SQL_ACK fences on
id = $1 and state = 'running': a reservation whose window lapsed is reclaimed by the next caller,
so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
reservation.
A query has none and never will: a read has nothing to be idempotent about.
deprecated: — a compat window, not a version
import type { Deprecation } from '@ultimat3/action';
// The `deprecated:` key of an `action()`.
const deprecated: Deprecation = {
since: '2026-08-01T00:00:00Z',
sunset: '2026-12-31T23:59:59Z',
replacedBy: 'searchOrders',
};Four things at once: Deprecation: @1754006400 (RFC 9745) and Sunset: Wed, 31 Dec 2026 …
(RFC 8594) on every response including the failures, link: </api/orders/search>;
rel="successor-version", deprecated: true plus x-ultimate.deprecation in the OpenAPI
operation, and a deprecated_calls_total{primitive,name} counter — which is the only way to
answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
X_ACTION_DEPRECATION_INVALID at projection, not on the first request.
Versioning itself is deliberately absent, and will stay absent. Running v1 and v2 of one
action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
no router feature, no path prefix and no second registry. What ships is the window: a date, a
successor, and a number.
Audit — the seam, not the row
audit: true on any action or mutator sends every attempt — allowed, denied and failed
— to the installed AuditSink. Opt-in per declaration, never a global switch: a login and a
price change are not the same event, and the framework is not the thing that knows which of
them your business has to keep.
import { setAuditSink } from '@ultimat3/action';
setAuditSink({
async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
});What the framework supplies is what it genuinely knows:
| Field | |
|---|---|
| at | when the attempt began, from ctx.now() — an instant, never a rendering |
| action / mutator | the registered name, and which primitive it was |
| surface | server | http | mcp | job — the same price change over MCP is not the same event |
| ctx | the whole context: actor, requestId, traceId, locale, and the services a sink needs to write a row |
| input | the parsed input, or undefined when the parse is what failed — never the raw payload |
| idempotencyKey / replayed | the namespaced key, and whether this was a call rather than a write |
| outcome | allowed | denied | failed |
| failure | the X_* code and the thrown value, on every outcome but allowed |
What it does not supply: an audit entity, a retention policy, a hash chain, a subject index, or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping one would make three of them wrong.
Two sinks ship, and only one of them keeps anything
| Sink | Keeps | Use it for |
|---|---|---|
| memoryAuditSink({ maxRecords }) | the newest DEFAULT_MAX_AUDIT_RECORDS (1,000) records, verbatim. It DROPS — dropped counts what it discarded | x dev, tests |
| postgresAuditSink({ executor }) | one append-only x_audit row per attempt. Drops nothing | anything that has to keep its trail |
The memory sink is bounded because a record pins a whole Ctx: at 50 audited writes a second an
unbounded array is 4.3M immortal records a day and the pod dies holding the trail it was
retaining. The trap it names out loud is that the shortest edit clearing X_AUDIT_SINK_MISSING
is setAuditSink(memoryAuditSink()), and nothing at that call site says the result is amnesiac.
// apps/web/server.ts — the app owns the connection, so the app installs the sink
import { postgresAuditSink, setAuditSink } from '@ultimat3/action';
import { db } from '@ultimat3/db';
const client = db();
setAuditSink(
postgresAuditSink({ executor: { query: (text, values) => client.query({ text, values }) } }),
);The table is applied by the boot; the sink is not. startQueue runs SQL_AUDIT_TABLE on
every start — x dev, the container's web/worker, and the release-phase ROLE=migrate — the
way SQL_IDEMPOTENCY_TABLE is applied, because a package holding no database dependency cannot
apply its own schema. Installing a sink stays your one line, deliberately: there is no default, so
audit: true with none installed keeps refusing with X_AUDIT_SINK_MISSING instead of recording
into a ring. executor is a client that already speaks (text, values) — never Bun.sql, whose
.query is undefined.
x_audit carries the framework's own facts as columns — the action, the surface, the outcome,
the actor, the correlation ids, the idempotency key — and the parsed input as jsonb, redacted
through core's own isRedactedKey table, the one defineEnv({ secret: true }) extends. So a
value that renders [redacted] in a log line cannot be plaintext in the audit table, and a
boxed Secret is redacted by value wherever its key sits. What never reaches a column: the Ctx
itself (createContext spreads every installed service onto it, and an HTTP surface's is a
RequestContext carrying the caller's Authorization and Cookie), and the thrown value behind
a failure — the row keeps failure.code, never the throwable.
The table has no purge, deliberately, and it is the one framework table that does not: a
stale idempotency row is meaningless while a stale audit row is the record, and "how long" is a
legal answer that differs per app. Pruning or partitioning x_audit is yours.
A denial is recorded because invoke wraps the whole path — guard throws before handle,
so nothing you could write around your own handler would ever see one. That is the reason this
lives in the framework and the row does not.
Failure is loud, both ways. audit: true with no sink installed is X_AUDIT_SINK_MISSING,
raised before the input parse — the one audit failure with no committed write behind it. A sink
that refuses a successful record is X_AUDIT_SINK_FAILED: the deliberate opposite of the
cache tier's bestEffort, because a dropped cache entry expires by TTL and the stack heals
itself while nothing ever re-derives an audit row that was never written. It is post-commit all
the same, and the error says so.
Its fix: branches, because only one of the two is ever true. Retrying is safe exactly when
this invocation went through the idempotency store — then the settled record replays and the
audit row is re-attempted without re-running the handler. It did not when the action is not
idempotent, and it did not when the action is idempotent but the caller sent no
Idempotency-Key: invoke reads def.idempotent === true ? (options.idempotencyKey ?? null)
: null, so both collapse to the same null. In that case the error says do not retry and
names the edit — telling a caller to re-run a committed mutator is worse than saying nothing.
meta.replayable carries the same fact to --json.
A sink that refuses a denied or failed record is logged as
audit.sink.failed and the original error still reaches the caller: answering
X_AUDIT_SINK_FAILED there would hide the X_FORBIDDEN from whoever has to act on it.
Your house rule goes in a wrapper, not in a config option — the same shape as tenantEntity():
// apps/web/shared/base/audited-mutator.ts — the app's convention, written once
import { mutator, type MutatorDef } from '@ultimat3/action';
import type { StandardSchemaV1 } from '@ultimat3/schema';
/** Every write in this app is recorded and retryable — declared once, not at forty call sites. */
export const auditedMutator = <I extends StandardSchemaV1, O extends StandardSchemaV1>(
def: MutatorDef<I, O>,
) => mutator({ ...def, audit: true, idempotent: true });Nothing downstream can tell the difference: isMutator() is structural and registerActions
names the object in place, so every projection, the manifest and admin CRUD work on it exactly
as on a hand-written one.
The row it produces is the app's, and so is every question the framework refused to answer — which fields, whose tenant, chained or not, kept how long:
setAuditSink({
async write(record) {
const { ctx } = record; // the services a sink needs to write a row
const prev = await chainHead(ctx); // hash-chained: the app's choice
await ctx.db.auditRows.insert({
orgId: orgOf(ctx.actor), // tenancy: derived from the actor
subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
prevHash: prev, hash: await sha256(prev, record),
});
},
});Contract tests
publishPost.contract() returns three assertions. Run them; they throw X_CONTRACT_DRIFT.
| Assertion | Holds when |
|---|---|
| input schema rejects garbage | the invocation fails X_INPUT_INVALID — that code, not any failure |
| policy denies an anonymous actor | the invocation fails with an ActionDeniedError |
| OpenAPI document contains its operation | the derived path is in buildOpenApi() |
The denial assertion sends an input synthesized from input:'s own schema — required keys
only, formats included — because a payload the schema rejects never reaches a policy. It
asserts the denial, not X_FORBIDDEN: a denial carries the policy decision's own code, and
can() answers a null actor with X_UNAUTHENTICATED.
publishPost.contract({
garbage: 42, // what the input schema must reject
input: { postId, orgId }, // when the synthesized one cannot fit
ctx: myCtx, // default: an anonymous context
})Pass input: when the schema carries a constraint the IR cannot invert (a bare pattern) or
when row: needs an id that resolves. Anything thrown before the policy decides is drift,
never a pass — the assertion says which code got in the way and names input: as the fix.
Errors
| Code | When | Fix |
|---|---|---|
| X_ACTION_DUPLICATE | two actions registered under one name | rename one export |
| X_ACTION_PATH_DUPLICATE | two actions derive one HTTP path (archiveOrder / archiveOrders) | rename one export |
| X_ACTION_POLICY_MISSING | registration without policy: | add policy: can('…') |
| X_RATE_LIMIT_INVALID | rateLimit: with a non-positive or non-finite half — windowMs: 0 refills infinitely. Owned by @ultimat3/http, which owns the conversion | make both positive, or delete the block |
| X_ACTION_DEPRECATION_INVALID | deprecated: with a since/sunset that is not a date | use an ISO-8601 instant |
| X_INPUT_INVALID | input failed the Standard Schema. Carries the rejections twice: the flattened line in cause, and the structured list in meta.issues — one value rendered two ways, As of 2026-08-24 | x actions describe <name> --json |
| X_IDEMPOTENCY_CONFLICT | key reused with a new payload / still in flight | new key, or retry later |
| X_IDEMPOTENCY_KEY_INVALID | Idempotency-Key: sent blank (Headers.get() answers '', not null) or past 255 characters | send one unique value per request, or omit the header |
| X_IDEMPOTENCY_NOT_SHARED | configureIdempotency({ scope: 'shared' }) over a per-process (or scope-less) store | install postgresIdempotencyStore({ executor }) at boot |
| X_IDEMPOTENCY_REPLAYED_FAILURE | a retried key replays a first attempt that failed and carried no framework code of its own | read the first attempt, then send a fresh key |
| X_IDEMPOTENCY_STATUS_UNKNOWN | x_idempotency.status holds a word this build has no branch for — written by a newer deploy | finish the rollout onto the build that writes it, then reconcile those requests — never DELETE the rows, which frees the key to run an already-committed action a second time |
| X_CONTRACT_DRIFT | client/server build skew, missing spec entry | reload / x verify --contract |
| X_RPC_FAILED | non-problem+json failure, or a body naming no X_ code | check the gateway |
| X_ACTION_UNREGISTERED | projected before registerActions() ran | register at boot |
| X_AUDIT_SINK_MISSING | audit: true and no sink installed — raised before the input parse | setAuditSink(yourSink) at boot |
| X_AUDIT_SINK_FAILED | the sink refused the record for an attempt that succeeded | fix the sink — then retry the same Idempotency-Key if this call carried one, else reconcile by hand |
Denials re-throw the policy layer's own codes (X_FORBIDDEN, X_UNAUTHENTICATED) —
this package never invents an authz code.
The client does the same with the server's: a problem+json failure comes back as a
RemoteActionError keeping the code the server sent, marked meta.origin: 'remote' because
the browser bundle may never have registered it, and linked only to a page that exists — the
server's own docs/type when it sent an http(s) one, this build's registered link when it
knows the code, otherwise the error index. A per-code URL is never synthesized for a code
nothing here declares.
A document carrying an issues member arrives parsed as well: meta.issues, read by
issuesFromWire — a wire value, so the list is rebuilt member by member and a list this build
cannot read is dropped whole rather than half-kept, leaving cause (which still holds every
rejection) as the answer. It is exported for the island that posts with a plain fetch and holds
the body itself.
Boundaries
Tier 3. Imports @ultimat3/core, schema, cache, policy, http. Never imports
query, jobs, realtime (same tier) or anything above it — those import this.
