Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/commands/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Task, SendMessage, Ta

# /feature

You are a **senior engineer on Ultimate** — a Bun-only, opinionated full-stack framework whose *primary user is an AI agent*. Read [`CLAUDE.md`](../../CLAUDE.md) and [`docs/architecture/15-adding-a-feature.md`](../../docs/architecture/15-adding-a-feature.md) before designing anything. The seven design axioms override any instinct that conflicts with them.
You are a **senior engineer on Ultimate** — a Bun-only, opinionated full-stack framework whose *primary developer is an AI agent*. Read [`CLAUDE.md`](../../CLAUDE.md) and [`docs/architecture/15-adding-a-feature.md`](../../docs/architecture/15-adding-a-feature.md) before designing anything. The seven design axioms override any instinct that conflicts with them.

**Done means merged and green — nothing less counts.** understand → falsify the spec → explore → slice → build → **`bun run verify` green** → PR → **merged** → **CI green on `main`** → docs, wiki and `examples/dummy/` left true → **npm published**, when a release was in scope. This repo deploys nowhere itself: there is no production, so the arc genuinely ends at merged (or at a published `@ultimat3/*` version). A green local gate is not done; an open PR is not done; a version bump that never published is not done. Report what you actually verified, not what you assume happened.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repository.

## Ultimate

A full-stack, **Bun-only**, opinionated web framework. Rails' philosophy on a Bun + Postgres + SolidJS stack, where **the primary user is an AI agent** and the secondary user is a tired senior engineer working through their own AI agent and AI reviewer.
A full-stack, **Bun-only**, opinionated web framework. Rails' philosophy on a Bun + Postgres + SolidJS stack, where **the primary developer is an AI agent** and the secondary developer is a tired senior engineer working through their own AI agent and AI reviewer.

Rails' actual promise, applied to agents: **reduce the number of problems the author has to worry about**, so the work goes into the app's features instead of the app's infrastructure. Every decision the framework makes is a decision an agent does not have to.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Ultimate
</h1>

<p align="center"><strong>The full-stack framework where the primary user is an AI agent.</strong></p>
<p align="center"><strong>The full-stack framework where the primary developer is an AI agent.</strong></p>

<p align="center"><em>Rails' opinions. Bun's speed. One command that means shippable.</em></p>

Expand Down
2 changes: 1 addition & 1 deletion docs/idea/00-thesis.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Thesis

Rails' philosophy — convention over configuration, generators, one blessed path, batteries included — applied to Bun + Postgres + SolidJS, where **the primary user is an AI agent**.
Rails' philosophy — convention over configuration, generators, one blessed path, batteries included — applied to Bun + Postgres + SolidJS, where **the primary developer is an AI agent**.

## Who it is for

Expand Down
2 changes: 1 addition & 1 deletion docs/idea/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Why Ultimate exists, what it locks down, and what it refuses to build. Read [`00

| Doc | Hook |
|---|---|
| [`00-thesis.md`](./00-thesis.md) | Rails' philosophy, but the primary user is an AI agent — plus the inspire-explicitly table and the 8 axioms. |
| [`00-thesis.md`](./00-thesis.md) | Rails' philosophy, but the primary developer is an AI agent — plus the inspire-explicitly table and the 8 axioms. |
| [`01-stack.md`](./01-stack.md) | One locked choice per layer; Bun natives delete ~40 dependencies before you write a line. |
| [`02-primitives.md`](./02-primitives.md) | Eight primitives. `action` projects to six artifacts. Two authz systems is how every Meteor-like framework died. |
| [`03-realtime.md`](./03-realtime.md) | Channels → live queries → local-first: a ladder, not three products. Tier 2 → 3 is a config flag. |
Expand Down
16 changes: 8 additions & 8 deletions examples/dummy/apps/web/app/contact/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ export const contactSales = action({
* `default` is 120 burst / 2 per second per IP (`DEFAULT_RATE_LIMIT`, packages/http), which is a
* page's read allowance, not a contact form's.
*
* Declared with the numbers a human filling this in cannot exceed. Two honest caveats, reported
* rather than papered over: the limiter keys per ACTOR, then org, then IP (`rateLimitKey`), so
* "per recipient" is not a scope an app can ask for — the job's idempotency key is what stops a
* replayed enquiry mailing twice. And the numbers below reach `openapi.json` but not the
* limiter: `toRoute` selects the bucket named `contactSales`, `defineConfig` has no `http` block
* to declare one in, and nothing derives a bucket from this declaration — so what is enforced
* today is still `default`. Stricter-than-enforced, never the reverse, and the gap is the
* framework's to close.
* Declared with the numbers a human filling this in cannot exceed, and these are the numbers
* that run: `toRoute` registers a bucket named `contactSales` from this declaration, and the
* same `toBucket` conversion feeds `openapi.json`, so the published contract and the limiter
* cannot disagree.
*
* One honest caveat: the limiter keys per ACTOR, then org, then IP (`rateLimitKey`), so "per
* recipient" is not a scope an app can ask for — the job's idempotency key is what stops a
* replayed enquiry mailing twice.
*/
rateLimit: { limit: 5, windowMs: 600_000 },
async handle({ input }) {
Expand Down
27 changes: 26 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ultimate

> A full-stack, Bun-only, opinionated web framework: Rails' philosophy applied to Bun + Postgres + SolidJS, where the primary user is an AI agent and the secondary user is a tired senior engineer working through their own AI agent and AI reviewer. Eight primitives, one authz system, and errors that carry an exact fix command. 1.2.0 as of 2026-08 — 28 `@ultimat3/*` packages plus the unscoped `create-ultimate`, 29 in all, publish to npm in lockstep at one version, and semver applies from here: a breaking change to a documented API needs a major. Install with `bunx create-ultimate myapp`.
> A full-stack, Bun-only, opinionated web framework: Rails' philosophy applied to Bun + Postgres + SolidJS, where the primary developer is an AI agent and the secondary developer is a tired senior engineer working through their own AI agent and AI reviewer. Eight primitives, one authz system, and errors that carry an exact fix command. 1.2.0 as of 2026-08 — 28 `@ultimat3/*` packages plus the unscoped `create-ultimate`, 29 in all, publish to npm in lockstep at one version, and semver applies from here: a breaking change to a documented API needs a major. Install with `bunx create-ultimate myapp`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Everything in the framework is one of eight primitives: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task`. One `action` declaration projects into six artifacts (HTTP route, OpenAPI operation, typed client function, job handle, MCP tool, test scaffold) that all share the same `policy` — there is never a second authorization system. The CLI binary is `x`; every command and every error has a `--json` form, and every framework error carries a stable `X_*` code, a concrete cause, and the exact command that fixes it. `x verify` is the single gate, 17 steps in cost order: typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, e2e, eval, drift, contract-diff, budgets, manifest, roadmap. The stack is locked (Bun >= 1.3, Postgres with no ORM dependency — `entity()` projects to hand-written SQL through `postgresDriver()`, Standard Schema behind `t` with a dependency-free builtin provider as the shipped default, Better Auth, SolidJS 2, SCSS modules + tokens, OpenTelemetry always on) and the exclusions are permanent (GraphQL, multi-runtime, multi-ORM, a second CSS system, RSC, vendor edge/KV primitives, a plugin API in 1.x). An app extends the framework by **wrapping**, never by forking, patching or petitioning: `entity()`, `action()`, `mutator()`, `query()` and `job()` are plain functions returning values, registration happens inside the call, `isAction` is structural, and no `x verify` step matches source text for a primitive call — so an app's own factory (`tenantEntity`, `auditedMutator`) yields primitives the registry, the manifest, the five projections, admin and MCP treat identically. Axiom 8: Ultimate ships mechanism; your app ships convention — mechanisms and *structural* conventions (file naming, the four surfaces, the tier order, one flat i18n catalog per locale) ship as build errors, *business* conventions (an org model, a plan tier, what an audit row says) never do. Deployment is containers only: one image, six roles selected by `ROLE`.

Expand Down
10 changes: 10 additions & 0 deletions packages/action/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
drives a row-level action over the real pipeline and counts the evaluations: exactly one.
- **`rateLimit:` reaches the limiter, not only the spec.** `toRoute` sets `meta.rateLimit` (the
bucket name) **and** `meta.rateLimitBucket` (`toBucket(name, def.rateLimit)`), and
`@ultimat3/http`'s `withRouteBuckets` registers the second under the first. Until 2026-08 only
the name was set, so `bucketFor` fell through to `default` — 120 burst / 2 per second for an
action that declared 5, with the declared numbers published in `x-ultimate.rateLimit` all the
same: looser in practice than what the author wrote, which is the dangerous direction. `toBucket`
is the **only** conversion between `{ limit, windowMs }` and `{ capacity, refillPerSecond }`, and
both projections that read the declaration call it, so the spec cannot publish a pair the limiter
refuses: a non-positive or non-finite half is `X_ACTION_RATE_LIMIT_INVALID`, because
`windowMs: 0` is an infinite refill — a declared limit that enforces nothing.
- An action has no `.def`. Inside the package read it with `defOf(target)`; outside,
read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`.
- **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased
Expand Down
16 changes: 16 additions & 0 deletions packages/action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ LocalTables { posts: PostRow } }`.
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

```ts
rateLimit: { limit: 5, windowMs: 600_000 }, // 5 held, one back every two minutes
```

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. 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: true` + an `Idempotency-Key` header replays the first response
(`x-ultimate-replayed: 1`); a duplicate still in flight, or a reused key with a new
payload, is `X_IDEMPOTENCY_CONFLICT`. Store is swappable via `setIdempotencyStore()`.
Expand Down Expand Up @@ -322,6 +337,7 @@ never a pass — the assertion says which code got in the way and names `input:`
| `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_ACTION_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely | make both positive, or delete the block |
| `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
| `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
Expand Down
19 changes: 19 additions & 0 deletions packages/action/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
X_ACTION_RATE_LIMIT_INVALID: 'an action declares a rate limit that is not a positive allowance',
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
Expand Down Expand Up @@ -159,6 +160,24 @@ export class ActionPolicyMissingError extends UltimateError {
}
}

/**
* A `rateLimit` the limiter cannot run on, raised where the declaration is converted — so both
* projections that read it, the route and the OpenAPI operation, refuse the same numbers.
* `windowMs: 0` refills the bucket infinitely fast, which is a declared limit that enforces
* nothing; a non-positive `limit` is an endpoint no caller can reach. Neither is worth guessing at.
*/
export class ActionRateLimitInvalidError extends UltimateError {
constructor(action: string, limit: { readonly limit: number; readonly windowMs: number }) {
super({
code: 'X_ACTION_RATE_LIMIT_INVALID',
cause: `action "${action}" declares rateLimit { limit: ${limit.limit}, windowMs: ${limit.windowMs} }; both must be finite and greater than zero`,
fix: `edit the \`rateLimit:\` on ${action} to a positive allowance over a positive window — e.g. { limit: 5, windowMs: 600_000 } — or delete it to keep the default bucket`,
docs: docs('X_ACTION_RATE_LIMIT_INVALID'),
meta: { action, limit: limit.limit, windowMs: limit.windowMs },
});
}
}

export class InputInvalidError extends UltimateError {
constructor(name: string, detail: string) {
super({
Expand Down
39 changes: 34 additions & 5 deletions packages/action/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
*/

import { isMcpExposed, isUltimateError } from '@ultimat3/core';
import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
import type { Bucket, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
import { json, problem, redirect, takeRedirect } from '@ultimat3/http';
import type { ActionRateLimit, AnyAction } from './action';
import { ActionRateLimitInvalidError } from './errors';
import { actionName, defOf, invoke } from './invoke';
import {
derivePath,
Expand Down Expand Up @@ -82,7 +83,12 @@ export function toRoute(target: AnyAction): Route {
input: def.input,
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
tags: [resource],
...(def.rateLimit === undefined ? {} : { rateLimit: name }),
// Name AND numbers. The name alone selected a bucket the limiter's table never held, so
// `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
// numbers ride along and `withRouteBuckets` registers them at construction.
...(def.rateLimit === undefined
? {}
: { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
};

Expand Down Expand Up @@ -131,13 +137,36 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
// advertised one for every action, so an agent reading the spec asked for a tool the MCP
// catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives.
mcpTool: isMcpExposed(def.mcp) ? toToolName(name) : null,
rateLimit: rateLimitMeta(def.rateLimit),
rateLimit: rateLimitMeta(name, def.rateLimit),
},
};
}

function rateLimitMeta(limit: ActionRateLimit | undefined): Record<string, number> | null {
return limit === undefined ? null : { limit: limit.limit, windowMs: limit.windowMs };
/**
* `{ limit, windowMs }` as the limiter's own vocabulary: `limit` is the burst a caller may spend
* at once, and the window is what refills it — `5 / 600_000ms` is 5 held, one back every two
* minutes. The only conversion between the declaration and the enforcement, so the numbers
* `toOpenApiOperation` publishes and the numbers `withRouteBuckets` registers cannot drift.
*
* Refused rather than derived from nonsense: `windowMs: 0` is an infinite refill — a bucket that
* never empties, i.e. the declaration read as no limit at all — and a non-positive `limit` is an
* endpoint nobody can call. Both are the kind of quiet answer axiom 3 says must be a stop.
*/
export function toBucket(name: string, limit: ActionRateLimit): Bucket {
const ok = (value: number): boolean => Number.isFinite(value) && value > 0;
if (!ok(limit.limit) || !ok(limit.windowMs)) throw new ActionRateLimitInvalidError(name, limit);
return { capacity: limit.limit, refillPerSecond: limit.limit / (limit.windowMs / 1000) };
Comment thread
sebyx07 marked this conversation as resolved.
Resolved
}

function rateLimitMeta(
name: string,
limit: ActionRateLimit | undefined,
): Record<string, number> | null {
if (limit === undefined) return null;
// Validated through the same call the route makes, so the spec cannot publish a pair the
// limiter would have refused to run on.
toBucket(name, limit);
return { limit: limit.limit, windowMs: limit.windowMs };
}

const IDEMPOTENCY_PARAMETER: Record<string, unknown> = {
Expand Down
2 changes: 2 additions & 0 deletions packages/action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export {
ActionForeignError,
ActionPathDuplicateError,
ActionPolicyMissingError,
ActionRateLimitInvalidError,
ActionUnregisteredError,
AuditSinkFailedError,
AuditSinkMissingError,
Expand All @@ -74,6 +75,7 @@ export {
BUILD_ID_HEADER,
IDEMPOTENCY_HEADER,
REPLAYED_HEADER,
toBucket,
toOpenApiOperation,
toRoute,
} from './http';
Expand Down
Loading
Loading