Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 11 additions & 1 deletion framework.manifest.json

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

6 changes: 6 additions & 0 deletions packages/action/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the
same filter, so `rpc<Api['actions']>()` offers only what registered.
- `rpc` is the only name for the map-wide typed client. There is no `createClient` alias.
- **`registerAction` guards the derived PATH as well as the name.** `X_ACTION_DUPLICATE` only ever
asked about the name, so `archiveOrder` and `archiveOrders` — one route, by `pluralize`'s
deliberate "a trailing `s` is already plural" rule — both registered and both projected: the
router table seated whichever came last and the other was unreachable over HTTP while its
OpenAPI operation and MCP tool still advertised it. `paths` is a second index, cleared by
`resetRegistry` with the first, and the refusal is `X_ACTION_PATH_DUPLICATE`.
- No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
- `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock.
- `client.ts` stays free of server imports — it is bundled into the browser.
Expand Down
5 changes: 4 additions & 1 deletion packages/action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Names come from **export names** — that is what makes the path, the tool name
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.
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` / `registerQueries` are what `defineApi` composes. An app calling them
directly is a second path.
Expand Down Expand Up @@ -226,6 +228,7 @@ never a pass — the assertion says which code got in the way and names `input:`
| 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_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 |
Expand Down
18 changes: 18 additions & 0 deletions packages/action/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const docs = errorDocsUrl;
*/
const OWNED_TITLES: Readonly<Record<string, string>> = {
X_ACTION_DUPLICATE: 'two actions are registered under one name',
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_UNREGISTERED: 'an action was projected before it was registered',
Expand Down Expand Up @@ -128,6 +129,23 @@ export class ActionDuplicateError extends UltimateError {
}
}

/**
* Two distinct action names, one derived route. `X_ACTION_DUPLICATE` guards the NAME; nothing
* guarded the path, so `archiveOrder` and `archiveOrders` both registered, both projected to
* `POST /api/orders/archive`, and whichever the router seated last silently shadowed the other —
* while the shadowed action's OpenAPI operation and MCP tool went on advertising it.
*/
export class ActionPathDuplicateError extends UltimateError {
constructor(input: { name: string; existing: string; path: string }) {
super({
code: 'X_ACTION_PATH_DUPLICATE',
cause: `actions "${input.name}" and "${input.existing}" both derive ${input.path}`,
fix: `rename one export so the two derive different paths — x actions list --json prints every derived route`,
docs: docs('X_ACTION_PATH_DUPLICATE'),
});
}
}

export class ActionPolicyMissingError extends UltimateError {
constructor(name: string) {
super({
Expand Down
1 change: 1 addition & 0 deletions packages/action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export {
ActionDeniedError,
ActionDuplicateError,
ActionForeignError,
ActionPathDuplicateError,
ActionPolicyMissingError,
ActionUnregisteredError,
ContractDriftError,
Expand Down
51 changes: 51 additions & 0 deletions packages/action/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test';
import { can } from '@ultimat3/policy';
import { t } from '@ultimat3/schema';
import { type ActionDef, action } from './action';
import { derivePath } from './naming';
import {
describeActions,
getAction,
Expand Down Expand Up @@ -119,3 +120,53 @@ describe('registry', () => {
expect(getAction('publishPost')).toBeUndefined();
});
});

describe('one derived path, one action', () => {
beforeEach(() => {
resetRegistry();
});

const declare = define;

// `pluralize` leaves a trailing `s` alone by design, so these are two names and one route.
// `X_ACTION_DUPLICATE` only guards names: both registered, both projected, and the router
// seated whichever came last — the other unreachable over HTTP while its OpenAPI operation
// and MCP tool went on advertising it.
test('refuses a second action deriving a path another already owns', () => {
registerAction('archiveOrder', declare());

expect(() => registerAction('archiveOrders', declare())).toThrow('X_ACTION_PATH_DUPLICATE');
expect(getAction('archiveOrders')).toBeUndefined();
expect(derivePath('archiveOrder').path).toBe(derivePath('archiveOrders').path);
});

test('the refusal names both actions and the path they collide on', () => {
registerAction('archiveOrder', declare());
const failure = (() => {
try {
registerAction('archiveOrders', declare());
return undefined;
} catch (error: unknown) {
return error as { cause?: string };
}
})();

expect(failure?.cause).toBe(
'actions "archiveOrders" and "archiveOrder" both derive /api/orders/archive',
);
});

test('two actions with different paths both register', () => {
registerAction('archiveOrder', declare());
registerAction('publishOrder', declare());

expect(getAction('archiveOrder')).toBeDefined();
expect(getAction('publishOrder')).toBeDefined();
});

test('re-registering the same action under the same name is still one registration', () => {
const target = declare();
registerAction('archiveOrder', target);
expect(() => registerAction('archiveOrder', target)).not.toThrow();
});
});
18 changes: 17 additions & 1 deletion packages/action/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@

import type { ActionDescriptor, AnyAction } from './action';
import { isAction, nameAction } from './action';
import { ActionDuplicateError, ActionPolicyMissingError } from './errors';
import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
import { derivePath } from './naming';

const registry = new Map<string, AnyAction>();

/**
* Derived route -> the action name that owns it. A second index because the name is not the
* path: `pluralize` leaves a trailing `s` alone by design, so `archiveOrder` and `archiveOrders`
* are two names and one route. Nothing downstream can refuse that — the router seats whichever
* came last and the shadowed action stays in the OpenAPI document and the MCP tool list.
*/
const paths = new Map<string, string>();

/**
* Register one action under an explicit name. The name lands on the action you
* passed, so the module's own export is projectable after boot and there is no
Expand All @@ -28,8 +37,14 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
if (target.policy === undefined || target.policy === null) {
throw new ActionPolicyMissingError(name);
}
const { path } = derivePath(name);
const owner = paths.get(path);
if (owner !== undefined && owner !== name) {
throw new ActionPathDuplicateError({ name, existing: owner, path });
}
const named = nameAction(target, name);
registry.set(name, named);
paths.set(path, name);
return named;
}

Expand Down Expand Up @@ -63,6 +78,7 @@ export function describeActions(): readonly ActionDescriptor[] {
/** Test-only. Production registers once at boot and never unregisters. */
export function resetRegistry(): void {
registry.clear();
paths.clear();
}

function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
Expand Down
13 changes: 12 additions & 1 deletion packages/ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,24 @@ local `=== true`. An in-app agent and an external one must be offered exactly th
- A budget throws `X_AI_BUDGET_EXCEEDED` **before** the provider call. Never truncate.
- Anthropic body: no `temperature`/`top_p`/`top_k`, no `budget_tokens`, `effort` inside
`output_config`. All 400s otherwise.
- **`MODEL_IDS` is ordered MOST CAPABLE FIRST, and `moreCapableThan` is the only reader of that
order.** A refusal is worth retrying upward and nowhere else: `MODEL_IDS.find((id) => id !==
refused)` answered a refusal on the default model with the next entry DOWN, so `X_LLM_REFUSED`'s
fix line told an operator to buy the same refusal from a weaker model. The ladder needs no
second list — the ordering is the catalogue's own, and `models.test.ts` pins it against the
prices. When there is no rung above, `alternative` is `undefined` and the fix line drops the
suggestion rather than inventing a downgrade.
- **The reasoning half of the body is PER MODEL, and `models.ts` owns which model takes what.**
`output_config.effort` and adaptive thinking arrived with 4.6, so one body sent to the whole
catalogue is a guaranteed 400 on the oldest entry — which is how `claude-haiku-4-5` shipped
blessed and uncallable. A control the caller never asked for is omitted; a control they DID
ask for is refused locally with `X_AI_REQUEST_INVALID`, never dropped, because a declaration
reading `effort: 'max'` that quietly runs at the default is the failure nobody can see. Adding
a model is a row in `MODELS`, never an `if` in the request builder.
a model is a row in `MODELS`, never an `if` in the request builder. Omission is literal: an
absent `thinking` sends no block at all, where `(thinking ?? 'adaptive')` sent an adaptive one
for every adaptive-capable model — harmless on the wire, since adaptive is the server default,
but it made a defaulted control indistinguishable from a declared one, which is the whole
distinction this rule draws.
- Model IDs are exact aliases. Never append a date suffix.
- The introductory price on a model is deliberately not modelled. A price that lapses on a date
makes a recorded cost depend on when it was read, and under-reporting spend after the lapse is
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const answer = await ai.scope({ actorKey: actor.id, orgKey: actor.orgId }, async
| A control the model lacks is **refused**, never dropped | a declaration reading `effort: 'max'` that quietly runs at the default is the failure nobody can see |
| A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
| A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
| The refusal's `alternative` is only ever a **more capable** model | `MODEL_IDS` is ordered most-capable-first and `moreCapableThan` walks it upward; retrying a refusal on a weaker model is the one retry that cannot help, so an unbeatable model gets no suggestion at all |
| The repair turn replays the tool call's arguments, never an empty `text` | an answer through the `respond` tool leaves `text` empty, and an empty text block is a 400 — the repair came back as `X_AI_PROVIDER_UNAVAILABLE` |
| `reserve()` **debits** the estimate and takes a turn | three concurrent calls otherwise read the same `spent()`, all pass, and all three record against a ceiling only one of them fitted; `record` reconciles and `release` gives it back |
| A refusal is never cached | a cached one keeps serving a classifier decision after the prompt was fixed |
| Retries use **full jitter** | synchronised retries from N workers reproduce the rate limit |
| A 4xx is never retried | the same body gets the same rejection and burns the budget |
Expand Down
90 changes: 86 additions & 4 deletions packages/ai/src/budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { describe, expect, test } from 'bun:test';
import type { BudgetStore } from './budget';
import { BudgetLedger, estimateSpend, MemoryBudgetStore } from './budget';
import type { GenerateRequest } from './provider';

Expand Down Expand Up @@ -105,10 +106,13 @@ describe('derive tightens, never widens', () => {
const parent = new BudgetLedger({ limits: { actor: 1_000 }, actorKey: 'actor:u1', store });
const child = parent.derive({ costPerCall: usd(500) });

await child.reserve(estimateSpend(request('hi')));
// The reservation is handed to `record`, so the estimate it debited is reconciled away and
// what remains is the provider's real count.
const reservation = await child.reserve(estimateSpend(request('hi')));
await child.record(
{ inputTokens: 900, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
usd(1),
reservation,
);
expect(store.spent('actor:u1')).toBe(950);
await expect(child.reserve(estimateSpend(request('hi')))).rejects.toMatchObject({
Expand All @@ -118,8 +122,86 @@ describe('derive tightens, never widens', () => {

test('an unset scope on either side stays unset rather than defaulting to zero', async () => {
const child = new BudgetLedger({ limits: {} }).derive({});
await expect(
child.reserve(estimateSpend(request('x'.repeat(100_000)))),
).resolves.toBeUndefined();
await expect(child.reserve(estimateSpend(request('x'.repeat(100_000))))).resolves.toMatchObject(
{ tokens: expect.any(Number) },
);
});
});

describe('the ceiling holds under parallelism', () => {
/** A store whose `spent` yields to the loop, so three reads can genuinely interleave. */
function slowStore(): BudgetStore & { read(key: string): number } {
const inner = new MemoryBudgetStore();
return {
async spent(key: string): Promise<number> {
await Promise.resolve();
return inner.spent(key);
},
add: (key, tokens) => inner.add(key, tokens),
reset: (key) => inner.reset(key),
read: (key) => inner.spent(key),
};
}

// Measured: `budget: { actor: 10_000 }` and three concurrent calls estimating ~4k tokens each
// all read `spent() === 0`, all passed, and 12k was recorded against a 10k ceiling.
test('three concurrent reserves cannot all pass one actor ceiling', async () => {
const store = slowStore();
const ledger = new BudgetLedger({ limits: { actor: 10_000 }, actorKey: 'actor:u1', store });
const call = () => ledger.reserve(estimateSpend(request('hi', 4_000)));

const outcomes = await Promise.allSettled([call(), call(), call()]);

expect(outcomes.filter((o) => o.status === 'rejected')).toHaveLength(1);
expect(store.read('actor:u1')).toBeLessThanOrEqual(10_000);
});

test('the same holds for the org ceiling', async () => {
const store = slowStore();
const ledger = new BudgetLedger({ limits: { org: 10_000 }, orgKey: 'org:o1', store });
const call = () => ledger.reserve(estimateSpend(request('hi', 4_000)));

const outcomes = await Promise.allSettled([call(), call(), call()]);

expect(outcomes.filter((o) => o.status === 'rejected')).toHaveLength(1);
expect(store.read('org:o1')).toBeLessThanOrEqual(10_000);
});

// A refusal must not reject the reservations queued behind it on the turnstile.
test('a refused reservation lets the next one through on its own merits', async () => {
const store = slowStore();
const ledger = new BudgetLedger({ limits: { actor: 10_000 }, actorKey: 'actor:u1', store });

await expect(ledger.reserve(estimateSpend(request('hi', 20_000)))).rejects.toMatchObject({
cause: expect.stringContaining('actor:u1'),
});
await expect(ledger.reserve(estimateSpend(request('hi', 100)))).resolves.toMatchObject({
tokens: expect.any(Number),
});
});

test('release gives an unspent reservation back in full', async () => {
const store = new MemoryBudgetStore();
const ledger = new BudgetLedger({ limits: { actor: 10_000 }, actorKey: 'actor:u1', store });

const reservation = await ledger.reserve(estimateSpend(request('hi', 4_000)));
expect(store.spent('actor:u1')).toBeGreaterThan(0);

await ledger.release(reservation);
expect(store.spent('actor:u1')).toBe(0);
});

test('record reconciles down to the real count, never on top of the estimate', async () => {
const store = new MemoryBudgetStore();
const ledger = new BudgetLedger({ limits: { actor: 10_000 }, actorKey: 'actor:u1', store });

const reservation = await ledger.reserve(estimateSpend(request('hi', 4_000)));
await ledger.record(
{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 },
usd(1),
reservation,
);

expect(store.spent('actor:u1')).toBe(15);
});
});
Loading