diff --git a/.gitattributes b/.gitattributes
index 05b1a132398..1e9bf994ead 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,5 @@
**/ModuleBindings/** linguist-generated=true eol=lf
/docs/llms/** linguist-generated=true
/docs/llms/*-details.json linguist-generated=false
+/tools/stack-bench/** text eol=lf
+/tools/stack-bench/**/*.woff2 -text -diff
diff --git a/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md
index 3a31183133f..62f86ff36fc 100644
--- a/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md
+++ b/codex-plugin/plugins/spacetimedb/skills/typescript-client/SKILL.md
@@ -18,7 +18,7 @@ Generated bindings convert snake_case names to camelCase, including row fields:
## React: main.tsx
```typescript
-import React, { useEffect, useMemo } from 'react';
+import React, { useMemo } from 'react';
import ReactDOM from 'react-dom/client';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection } from './module_bindings';
@@ -30,6 +30,7 @@ function Root() {
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
+ // Reuse the token issued on the previous connection.
.withToken(localStorage.getItem('auth_token') || undefined),
[]
);
@@ -46,6 +47,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render();
## React: App.tsx
```typescript
+import { useEffect } from 'react';
import { useTable, useSpacetimeDB } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';
@@ -53,7 +55,7 @@ function App() {
const { isActive, identity: myIdentity, token, getConnection } = useSpacetimeDB();
const conn = getConnection() as DbConnection | null;
- // Save auth token
+ // Persist the issued token for the next page load.
useEffect(() => { if (token) localStorage.setItem('auth_token', token); }, [token]);
// Subscribe when connected. Prefer typed query builders over raw SQL
diff --git a/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md
index c9f2e7343fd..ba24bf781d5 100644
--- a/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md
+++ b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md
@@ -98,6 +98,10 @@ Every column is a `t` builder value:
Modifiers: `.primaryKey()`, `.autoInc()`, `.unique()`, `.index('btree')`, `.default(value)`.
+`.primaryKey()` and `.unique()` apply to one column. For uniqueness across
+multiple columns, use a surrogate key, the multi-column index below, and a
+reducer that rejects an existing index match before inserting.
+
Use `.default(value)` only for a newly appended migration-safe field. Do not put defaults on primary-key, unique, or auto-increment columns.
Optional columns: `nickname: t.option(t.string())`
@@ -130,7 +134,9 @@ export { default } from './schema'; // re-export the schema for the module ent
## Reducers
-Reducers are created with `spacetimedb.reducer(...)`; the export name becomes the reducer name:
+Reducers are created with `spacetimedb.reducer(...)`. An exported `signUp`
+becomes `signUp` in generated clients and `sign_up` in `spacetime call` and
+`describe`:
```typescript
export const createEntity = spacetimedb.reducer(
@@ -278,9 +284,22 @@ const Shape = t.enum('Shape', {
A client subscribing to a view receives only the rows it returns. Use a per-user view
(keyed on `ctx.sender`) for per-viewer access control: deleting a row it depends on
(e.g. a membership row) automatically drops the rows it was exposing from that client.
+Use index accessors in views. Do not scan a whole table with `.iter()` when an
+indexed lookup can select the required rows.
`t.row(...)` and `t.object(...)` return schema builders, not TypeScript runtime row types. Let a view callback infer its result, or annotate a separately declared structural type such as `Array<{ sku: bigint; label: string }>`. A named output type must not reuse the generated PascalCase name of its view accessor (for example, reserve `DiscountedProduct` for a `discounted_product` view).
+A view context is `ViewCtx` (and `AnonymousViewCtx`), both exported from
+`spacetimedb/server`. It carries `sender`, a read-only `db`, and `from`; it is
+not a `ReducerCtx`, so a helper shared between a reducer and a view must accept
+either:
+
+```typescript
+import type { ReducerCtx, ViewCtx, InferSchema } from 'spacetimedb/server';
+type S = InferSchema;
+function stockOf(ctx: ReducerCtx | ViewCtx, itemId: bigint) { ... }
+```
+
Both `spacetimedb.view(...)` and `spacetimedb.anonymousView(...)` take three arguments: view options, the declared return schema, and the callback.
```typescript
@@ -288,7 +307,7 @@ Both `spacetimedb.view(...)` and `spacetimedb.anonymousView(...)` take three arg
export const activeUsers = spacetimedb.anonymousView(
{ name: 'active_users', public: true },
t.array(entity.rowType),
- (ctx) => [...ctx.db.entity.iter()].filter(e => e.active)
+ (ctx) => [...ctx.db.entity.active.filter(true)] // active: t.bool().index('btree')
);
// Per-user view (varies by ctx.sender):
diff --git a/crates/bindings-typescript/src/lib/query.ts b/crates/bindings-typescript/src/lib/query.ts
index bb93b0e6ce3..0c17e3d145a 100644
--- a/crates/bindings-typescript/src/lib/query.ts
+++ b/crates/bindings-typescript/src/lib/query.ts
@@ -248,19 +248,21 @@ export type NamespacedQueryBuilder =
* A runtime reference to a table. This materializes the RowExpr for us.
* TODO: Maybe add the full SchemaDef to the type signature depending on how joins will work.
*/
-export type TableRef = Readonly<{
- type: 'table';
- sourceName: TableDef['sourceName'];
- accessorName: string;
- cols: RowExpr;
- indexedCols: IndexedRowExpr;
- tableDef: TableDef;
+// Keep this named so TypeScript diagnostics show `TableRef` instead of its
+// expanded structure.
+export interface TableRef {
+ readonly type: 'table';
+ readonly sourceName: TableDef['sourceName'];
+ readonly accessorName: string;
+ readonly cols: RowExpr;
+ readonly indexedCols: IndexedRowExpr;
+ readonly tableDef: TableDef;
// Delegated UntypedTableDef properties for compatibility.
- columns: TableDef['columns'];
- indexes: TableDef['indexes'];
- rowType: TableDef['rowType'];
- constraints: any;
-}>;
+ readonly columns: TableDef['columns'];
+ readonly indexes: TableDef['indexes'];
+ readonly rowType: TableDef['rowType'];
+ readonly constraints: any;
+}
class TableRefImpl
implements TableRef, From
diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts
index 211b01b5add..42febd5d221 100644
--- a/crates/bindings-typescript/src/sdk/connection_manager.ts
+++ b/crates/bindings-typescript/src/sdk/connection_manager.ts
@@ -145,9 +145,7 @@ class ConnectionManagerImpl {
clearTimeout(managed.reconnectTimer);
managed.reconnectTimer = null;
managed.reconnectAttempt = 0;
- if (managed.builder) {
- this.#buildManagedConnection(managed, managed.builder);
- }
+ this.#reconnectManagedConnection(managed);
continue;
}
@@ -179,9 +177,7 @@ class ConnectionManagerImpl {
connection.disconnect();
this.#updateState(managed, { isActive: false });
managed.reconnectAttempt = 0;
- if (managed.builder) {
- this.#buildManagedConnection(managed, managed.builder);
- }
+ this.#reconnectManagedConnection(managed);
}
/** Generates a unique key for a connection based on URI and module name. */
@@ -294,6 +290,14 @@ class ConnectionManagerImpl {
}
}
+ /** Reconnect with the issued token. Explicit rebuilds use the caller's token. */
+ #reconnectManagedConnection(managed: ManagedConnection): void {
+ if (!managed.builder) return;
+ const token = managed.state.token;
+ if (token) managed.builder.withToken(token);
+ this.#buildManagedConnection(managed, managed.builder);
+ }
+
#buildManagedConnection>(
managed: ManagedConnection,
builder: DbConnectionBuilder
@@ -349,7 +353,7 @@ class ConnectionManagerImpl {
return;
}
- this.#buildManagedConnection(managed, managed.builder);
+ this.#reconnectManagedConnection(managed);
}, delay);
}
diff --git a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts
index ea24ea6887e..e9f76a909e9 100644
--- a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts
+++ b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts
@@ -12,7 +12,7 @@ type ErrorContextInterface = { isActive: boolean };
class MockConnection {
isActive = false;
identity = undefined;
- token = undefined;
+ token: string | undefined;
connectionId = ConnectionId.random();
isDisconnectRequested = false;
disconnected = false;
@@ -28,6 +28,10 @@ class MockConnection {
(ctx: ErrorContextInterface, error: Error) => void
>();
+ constructor(private readonly issuedToken?: string) {
+ this.token = undefined;
+ }
+
get isSocketClosed(): boolean {
return this.socketClosed;
}
@@ -63,6 +67,7 @@ class MockConnection {
simulateConnect(): void {
this.isActive = true;
+ this.token = this.issuedToken;
for (const cb of this.#onConnect) cb(this);
}
simulateDisconnect(error?: Error): void {
@@ -74,7 +79,9 @@ class MockConnection {
class MockBuilder {
buildCount = 0;
+ presentedTokens: Array = [];
connections: MockConnection[] = [];
+ #token: string | undefined;
#onConnect = new Set<(conn: MockConnection) => void>();
#onDisconnect = new Set<
@@ -84,9 +91,17 @@ class MockBuilder {
(ctx: ErrorContextInterface, error: Error) => void
>();
+ constructor(private readonly issuedToken?: string) {}
+
+ withToken(token?: string): MockBuilder {
+ this.#token = token;
+ return this;
+ }
+
build(): MockConnection {
- const connection = new MockConnection();
+ const connection = new MockConnection(this.issuedToken);
this.buildCount += 1;
+ this.presentedTokens.push(this.#token);
this.connections.push(connection);
for (const cb of this.#onConnect) connection.register('connect', cb);
for (const cb of this.#onDisconnect) connection.register('disconnect', cb);
@@ -201,6 +216,32 @@ describe('ConnectionManager liveness recovery', () => {
ConnectionManager.release(key);
});
+ test('reuses the issued token when reviving a dead socket', () => {
+ const key = nextKey();
+ const builder = new MockBuilder('issued-token');
+ const first = retain(key, builder);
+ expect(builder.presentedTokens).toEqual([undefined]);
+
+ first.simulateConnect();
+ first.socketClosed = true;
+ fire('win:online');
+
+ expect(builder.presentedTokens).toEqual([undefined, 'issued-token']);
+ ConnectionManager.release(key);
+ });
+
+ test('an explicit rebuild uses the caller token', () => {
+ const key = nextKey();
+ const firstBuilder = new MockBuilder('issued-token');
+ retain(key, firstBuilder).simulateConnect();
+
+ const replacement = new MockBuilder().withToken('caller-token');
+ ConnectionManager.rebuild(key, replacement as any);
+
+ expect(replacement.presentedTokens).toEqual(['caller-token']);
+ ConnectionManager.release(key);
+ });
+
test('does not rebuild a healthy connection on resume', () => {
const key = nextKey();
const builder = new MockBuilder();
diff --git a/crates/bindings-typescript/tests/table_ref_error_message.test.ts b/crates/bindings-typescript/tests/table_ref_error_message.test.ts
new file mode 100644
index 00000000000..009a9c192dd
--- /dev/null
+++ b/crates/bindings-typescript/tests/table_ref_error_message.test.ts
@@ -0,0 +1,79 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import * as ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+const bindingsRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '..'
+);
+
+function runTypecheck(source: string) {
+ const tmpDir = mkdtempSync(path.join(tmpdir(), 'stdb-tableref-diag-'));
+ const reproPath = path.join(tmpDir, 'repro.ts');
+ writeFileSync(reproPath, source);
+
+ try {
+ const options: ts.CompilerOptions = {
+ target: ts.ScriptTarget.ESNext,
+ module: ts.ModuleKind.ESNext,
+ strict: true,
+ noEmit: true,
+ skipLibCheck: true,
+ forceConsistentCasingInFileNames: true,
+ allowImportingTsExtensions: true,
+ noImplicitAny: true,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ useDefineForClassFields: true,
+ verbatimModuleSyntax: true,
+ isolatedModules: true,
+ };
+
+ const host = ts.createCompilerHost(options);
+ const program = ts.createProgram(
+ [reproPath, path.join(bindingsRoot, 'src/server/sys.d.ts')],
+ options,
+ host
+ );
+ const diagnostics = ts.getPreEmitDiagnostics(program);
+ return diagnostics.map(d =>
+ ts.flattenDiagnosticMessageText(d.messageText, '\n')
+ );
+ } finally {
+ rmSync(tmpDir, { recursive: true, force: true });
+ }
+}
+
+describe('TableRef diagnostics', () => {
+ const source = `
+import { t } from ${JSON.stringify(path.join(bindingsRoot, 'src/server/index.ts'))};
+import { table } from ${JSON.stringify(path.join(bindingsRoot, 'src/lib/table.ts'))};
+import { createTableRefFromDef } from ${JSON.stringify(path.join(bindingsRoot, 'src/lib/query.ts'))};
+import type { AllUnique } from ${JSON.stringify(path.join(bindingsRoot, 'src/lib/constraints.ts'))};
+
+const cartItem = table(
+ { name: 'cart_item' },
+ { id: t.u64().primaryKey().autoInc(), accountId: t.u64(), quantity: t.u32() }
+);
+
+const ref = createTableRefFromDef(cartItem as any, 'cartItem');
+type Boom = AllUnique;
+declare const b: Boom;
+`;
+
+ it('names the type instead of dumping its structure', () => {
+ const messages = runTypecheck(source);
+ const constraintError = messages.find(m =>
+ m.includes("does not satisfy the constraint 'UntypedTableDef'")
+ );
+
+ expect(constraintError).toBeDefined();
+ // The name, not the shape.
+ expect(constraintError).toContain('TableRef<');
+ expect(constraintError).not.toContain('type: "table"');
+ expect(constraintError).not.toContain('accessorName');
+ expect(constraintError.length).toBeLessThan(250);
+ }, 15000);
+});
diff --git a/crates/cli/build.rs b/crates/cli/build.rs
index c5bd4303464..90da9fa3bdd 100644
--- a/crates/cli/build.rs
+++ b/crates/cli/build.rs
@@ -110,6 +110,7 @@ fn generate_template_files() {
// Embed skill files from skills/*/SKILL.md
let skills_dir = repo_root.join("skills");
+ println!("cargo:rerun-if-changed={}", skills_dir.display());
let skill_names = discover_skill_names(&skills_dir);
generated_code.push_str("pub fn get_skill(name: &str) -> Option<&'static str> {\n");
diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs
index a0588b6de50..a2622c8ea0c 100644
--- a/crates/cli/src/subcommands/dev.rs
+++ b/crates/cli/src/subcommands/dev.rs
@@ -738,7 +738,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
let loaded_config_dir = loaded_config.as_ref().map(|lc| lc.config_dir.clone());
generate_build_and_publish(
- &config,
+ &mut config,
&project_dir,
loaded_config_dir.as_deref(),
&spacetimedb_dir,
@@ -853,7 +853,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
println!("\n{}", "File change detected, rebuilding...".yellow());
match generate_build_and_publish(
- &config,
+ &mut config,
&project_dir,
loaded_config_dir.as_deref(),
&spacetimedb_dir,
@@ -1000,7 +1000,7 @@ fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, databas
#[allow(clippy::too_many_arguments)]
async fn generate_build_and_publish(
- config: &Config,
+ config: &mut Config,
project_dir: &Path,
config_dir: Option<&Path>,
spacetimedb_dir: &Path,
@@ -1146,7 +1146,8 @@ async fn generate_build_and_publish(
publish_entry.insert("break-clients".to_string(), json!(true));
}
- publish::exec_from_entry(config.clone(), publish_entry, config_dir, clear_database, yes).await?;
+ // Preserve a token created during publish for logs and later rebuilds.
+ publish::exec_from_entry(config, publish_entry, config_dir, clear_database, yes).await?;
}
println!("{}", "Published successfully!".green().bold());
diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs
index 745664880f0..63d704d25af 100644
--- a/crates/cli/src/subcommands/publish.rs
+++ b/crates/cli/src/subcommands/publish.rs
@@ -450,7 +450,7 @@ pub async fn exec_with_options(
}
pub async fn exec_from_entry(
- mut config: Config,
+ config: &mut Config,
entry: HashMap,
config_dir: Option<&std::path::Path>,
clear_database: ClearMode,
@@ -465,7 +465,7 @@ pub async fn exec_from_entry(
let yes = if force { YesFlags::all() } else { YesFlags::default() };
- execute_publish_configs(&mut config, vec![command_config], true, config_dir, clear_database, yes).await
+ execute_publish_configs(config, vec![command_config], true, config_dir, clear_database, yes).await
}
async fn execute_publish_configs<'a>(
diff --git a/skills/spacetimedb-typescript-core/SKILL.md b/skills/spacetimedb-typescript-core/SKILL.md
new file mode 100644
index 00000000000..993a527a452
--- /dev/null
+++ b/skills/spacetimedb-typescript-core/SKILL.md
@@ -0,0 +1,101 @@
+---
+name: spacetimedb-typescript-core
+description: Core SpacetimeDB TypeScript server and client SDK syntax for building an application without framework or architecture guidance.
+license: Apache-2.0
+metadata:
+ author: clockworklabs
+ version: "1.0"
+ language: typescript
+---
+
+# SpacetimeDB TypeScript Core API
+
+## Server module
+
+Define tables with `table()`, bind them with `schema()`, and export the schema
+as the module default. Export reducers from the same module or its entry file.
+
+```typescript
+import { schema, table, t } from 'spacetimedb/server';
+
+const record = table(
+ { name: 'record', public: true },
+ {
+ id: t.u64().primaryKey().autoInc(),
+ label: t.string().index('btree'),
+ value: t.u32(),
+ },
+);
+
+const spacetimedb = schema({ record });
+export default spacetimedb;
+
+export const createRecord = spacetimedb.reducer(
+ { label: t.string(), value: t.u32() },
+ (ctx, { label, value }) => {
+ ctx.db.record.insert({ id: 0n, label, value });
+ },
+);
+```
+
+Table names must be snake_case. The keys passed to `schema({ ... })` are the
+server-side `ctx.db` accessor names. A split module must re-export the schema
+as the default export from its entry file.
+
+## Types and table access
+
+Common builders are `t.string()`, `t.bool()`, `t.u32()`, `t.i32()`,
+`t.u64()`, `t.i64()`, `t.identity()`, `t.timestamp()`, and
+`t.option(inner)`. The 64-bit integer builders use TypeScript `bigint` values.
+Use `0n` for an auto-increment `u64` or `i64` field during insertion.
+
+Column modifiers include `.primaryKey()`, `.autoInc()`, `.unique()`, and
+`.index('btree')`.
+
+```typescript
+const row = ctx.db.record.id.find(id); // row | null
+const inserted = ctx.db.record.insert(values); // inserted row
+if (row) ctx.db.record.id.update({ ...row, value: 2 }); // update by primary key
+ctx.db.record.id.delete(id); // delete by primary key
+const matching = [...ctx.db.record.label.filter(label)];
+const all = [...ctx.db.record.iter()];
+```
+
+`iter()` and `filter()` return iterators. Spread them before using array
+methods. Insert through the table accessor, not through an index accessor.
+
+## Generated client bindings
+
+Generated bindings convert snake_case table, reducer, and field names to
+camelCase. A server reducer named `createRecord` is called as `createRecord` in a
+TypeScript client.
+
+Create a connection with the generated `DbConnection`:
+
+```typescript
+import { DbConnection, tables } from './module_bindings';
+
+const connection = DbConnection.builder()
+ .withUri(serverUri)
+ .withDatabaseName(moduleName)
+ .onConnect(ctx => {
+ ctx.subscriptionBuilder()
+ .onApplied(() => console.log('ready'))
+ .subscribe([tables.record]);
+ })
+ .build();
+```
+
+Call reducers with an object argument:
+
+```typescript
+await connection.reducers.createRecord({ label: 'Example', value: 1 });
+```
+
+The generated database accessors support row callbacks:
+
+```typescript
+connection.db.record.onInsert((_ctx, row) => console.log(row.label));
+connection.db.record.onUpdate((_ctx, oldRow, newRow) => console.log(oldRow, newRow));
+connection.db.record.onDelete((_ctx, row) => console.log(row.id));
+```
diff --git a/skills/typescript-client/SKILL.md b/skills/typescript-client/SKILL.md
index 3a31183133f..62f86ff36fc 100644
--- a/skills/typescript-client/SKILL.md
+++ b/skills/typescript-client/SKILL.md
@@ -18,7 +18,7 @@ Generated bindings convert snake_case names to camelCase, including row fields:
## React: main.tsx
```typescript
-import React, { useEffect, useMemo } from 'react';
+import React, { useMemo } from 'react';
import ReactDOM from 'react-dom/client';
import { SpacetimeDBProvider } from 'spacetimedb/react';
import { DbConnection } from './module_bindings';
@@ -30,6 +30,7 @@ function Root() {
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
+ // Reuse the token issued on the previous connection.
.withToken(localStorage.getItem('auth_token') || undefined),
[]
);
@@ -46,6 +47,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render();
## React: App.tsx
```typescript
+import { useEffect } from 'react';
import { useTable, useSpacetimeDB } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';
@@ -53,7 +55,7 @@ function App() {
const { isActive, identity: myIdentity, token, getConnection } = useSpacetimeDB();
const conn = getConnection() as DbConnection | null;
- // Save auth token
+ // Persist the issued token for the next page load.
useEffect(() => { if (token) localStorage.setItem('auth_token', token); }, [token]);
// Subscribe when connected. Prefer typed query builders over raw SQL
diff --git a/skills/typescript-server/SKILL.md b/skills/typescript-server/SKILL.md
index c9f2e7343fd..ba24bf781d5 100644
--- a/skills/typescript-server/SKILL.md
+++ b/skills/typescript-server/SKILL.md
@@ -98,6 +98,10 @@ Every column is a `t` builder value:
Modifiers: `.primaryKey()`, `.autoInc()`, `.unique()`, `.index('btree')`, `.default(value)`.
+`.primaryKey()` and `.unique()` apply to one column. For uniqueness across
+multiple columns, use a surrogate key, the multi-column index below, and a
+reducer that rejects an existing index match before inserting.
+
Use `.default(value)` only for a newly appended migration-safe field. Do not put defaults on primary-key, unique, or auto-increment columns.
Optional columns: `nickname: t.option(t.string())`
@@ -130,7 +134,9 @@ export { default } from './schema'; // re-export the schema for the module ent
## Reducers
-Reducers are created with `spacetimedb.reducer(...)`; the export name becomes the reducer name:
+Reducers are created with `spacetimedb.reducer(...)`. An exported `signUp`
+becomes `signUp` in generated clients and `sign_up` in `spacetime call` and
+`describe`:
```typescript
export const createEntity = spacetimedb.reducer(
@@ -278,9 +284,22 @@ const Shape = t.enum('Shape', {
A client subscribing to a view receives only the rows it returns. Use a per-user view
(keyed on `ctx.sender`) for per-viewer access control: deleting a row it depends on
(e.g. a membership row) automatically drops the rows it was exposing from that client.
+Use index accessors in views. Do not scan a whole table with `.iter()` when an
+indexed lookup can select the required rows.
`t.row(...)` and `t.object(...)` return schema builders, not TypeScript runtime row types. Let a view callback infer its result, or annotate a separately declared structural type such as `Array<{ sku: bigint; label: string }>`. A named output type must not reuse the generated PascalCase name of its view accessor (for example, reserve `DiscountedProduct` for a `discounted_product` view).
+A view context is `ViewCtx` (and `AnonymousViewCtx`), both exported from
+`spacetimedb/server`. It carries `sender`, a read-only `db`, and `from`; it is
+not a `ReducerCtx`, so a helper shared between a reducer and a view must accept
+either:
+
+```typescript
+import type { ReducerCtx, ViewCtx, InferSchema } from 'spacetimedb/server';
+type S = InferSchema;
+function stockOf(ctx: ReducerCtx | ViewCtx, itemId: bigint) { ... }
+```
+
Both `spacetimedb.view(...)` and `spacetimedb.anonymousView(...)` take three arguments: view options, the declared return schema, and the callback.
```typescript
@@ -288,7 +307,7 @@ Both `spacetimedb.view(...)` and `spacetimedb.anonymousView(...)` take three arg
export const activeUsers = spacetimedb.anonymousView(
{ name: 'active_users', public: true },
t.array(entity.rowType),
- (ctx) => [...ctx.db.entity.iter()].filter(e => e.active)
+ (ctx) => [...ctx.db.entity.active.filter(true)] // active: t.bool().index('btree')
);
// Per-user view (varies by ctx.sender):
diff --git a/tools/llm-sequential-upgrade/.gitignore b/tools/llm-sequential-upgrade/.gitignore
index 14aa619a63d..35223d12b8f 100644
--- a/tools/llm-sequential-upgrade/.gitignore
+++ b/tools/llm-sequential-upgrade/.gitignore
@@ -27,4 +27,4 @@ telemetry/metrics.jsonl
**/telemetry/**/metadata.json
# Sequential-upgrade run output lives in the external spacetimedb-ai-test-results repo
-sequential-upgrade/sequential-upgrade-*/
+sequential-upgrade/
diff --git a/tools/llm-sequential-upgrade/read-guard.sh b/tools/llm-sequential-upgrade/read-guard.sh
new file mode 100644
index 00000000000..314c52e7cde
--- /dev/null
+++ b/tools/llm-sequential-upgrade/read-guard.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Write Claude Code settings that deny direct Read tool access to benchmark
+# internals. Bash is allowed, so this is not filesystem isolation.
+
+write_read_guard() {
+ local app_dir="$1" backend="$2" out siblings=""
+ out="$app_dir/.read-guard-settings.json"
+
+ local b
+ for b in spacetime postgres mongodb; do
+ [[ "$b" == "$backend" ]] && continue
+ siblings+=" \"Read(**/$b/results/**)\",
+"
+ done
+
+ cat > "$out" <
+campaign inspect
+campaign report
+```
+
+- `status` is the compact normal view.
+- `inspect` adds score, cost, duration, cleanup, evidence, and feature progress.
+- `report` rebuilds `report/report.json` and `report/report.html` from retained
+ evidence.
+
+Do not infer state from logs. Use logs only to diagnose a reported phase or
+failure. The controller never retries, extends, or grants paid work
+automatically.
+
+## Resume and repair
+
+If the controller stopped while an attempt remained live, reconcile ownership
+before any resume:
+
+```sh
+campaign reconcile --out
+```
+
+Reconciliation changes state only when private supervisor evidence proves that
+the exact owned resources are clean.
+
+Dependency campaigns can grant more repairs to selected exhausted features:
+
+```sh
+campaign grant-repairs \
+ --attempt --grant-id --level \
+ --feature --repairs
+```
+
+The grant creates a linked continuation. It does not rewrite the completed
+execution. Use `campaign resume --out ` to
+run scheduled dependency work.
+
+## Model-free trials and qualification
+
+`campaign trial` accepts only registered non-billable adapters and zero pricing.
+It validates orchestration but does not produce comparative model data.
+
+Check qualification requirements without starting work:
+
+```sh
+docker compose --env-file /var/lib/stack-bench/operator.env \
+ -f appliance/docker-compose.yaml run --rm controller \
+ qualification status --track ecommerce --level
+```
+
+Run only evidence required by that exact status. Do not repeat reference,
+mutation, or null work when its bound inputs have not changed. See the
+[reference app guide](../reference-apps/README.md) and
+[grader guide](../grader/README.md) for qualification rules.
+
+## Dashboard
+
+Start the optional dashboard:
+
+```sh
+docker compose --env-file /var/lib/stack-bench/operator.env \
+ -f appliance/docker-compose.yaml --profile dashboard up -d dashboard
+```
+
+Open `http://127.0.0.1:7331`. The dashboard reads and controls the same campaign
+state as the CLI. See [dashboard/README.md](../dashboard/README.md).
+
+## Results and cleanup
+
+Results remain under `/var/lib/stack-bench/results` after the controller exits.
+Verify and copy the complete campaign package before deleting the runner.
+
+A run removes only resources whose private ownership evidence still matches.
+If cleanup cannot be proved, it preserves the evidence and quarantines the run.
+Follow [RECOVERY.md](RECOVERY.md). Do not delete same-name resources or clear the
+shared state root by guesswork.
diff --git a/tools/stack-bench/appliance/RECOVERY.md b/tools/stack-bench/appliance/RECOVERY.md
new file mode 100644
index 00000000000..ecf413d98af
--- /dev/null
+++ b/tools/stack-bench/appliance/RECOVERY.md
@@ -0,0 +1,67 @@
+# Interruption and recovery
+
+Stack Bench never guesses that a container, listener, lock, database, or data
+directory is safe to delete. Normal teardown authenticates the run's private
+lease, compares exact container IDs and listener PIDs, and releases only locks
+whose owner record still matches that lease.
+
+Every appliance run keeps two different records:
+
+- `results/.../recovery.json` is public, contains no ownership token, and says
+ whether cleanup is `clean`, intentionally `retained`, or `quarantined`;
+- `controller-home/supervisor/.json` is private recovery authority. It
+ contains the lease token and must remain readable only by the appliance
+ operator. Normal cleanup deletes it. Refused cleanup deliberately preserves
+ it.
+
+## If a run is interrupted
+
+1. Preserve the result directory and private supervisor-state file.
+2. Read `recovery.json`. Do not publish an attempt whose status is
+ `quarantined`.
+3. Do not start another run using any lock key listed in that artifact.
+4. Retry authenticated cleanup from the controller:
+
+```sh
+docker compose --env-file /var/lib/stack-bench/operator.env \
+ -f appliance/docker-compose.yaml run --rm controller \
+ recover /var/lib/stack-bench/controller-home/supervisor/.json
+```
+
+On success the command changes `recovery.json` to `clean`, releases the exact
+owned resources, and removes the private supervisor state. It is idempotent
+when public lease evidence already proves that an earlier cleanup completed.
+
+If the parent process ended before it retained a supervisor file, recover from
+the private runtime lease instead. Supply a durable output directory outside
+the private runtime directory:
+
+```sh
+docker compose --env-file /var/lib/stack-bench/operator.env \
+ -f appliance/docker-compose.yaml run --rm controller \
+ recover-lease /var/lib/stack-bench/controller-home/runtime//backend-lease.json \
+ --out /var/lib/stack-bench/results/recovery/
+```
+
+This path uses the same ownership token, container ID, listener PID, and lock
+checks. It refuses an output directory inside the runtime directory because a
+successful recovery removes that directory.
+
+## If recovery refuses
+
+Refusal is the safety behavior. It means a live resource does not match the
+lease or its identity could not be proven. The command leaves the private state,
+lease, lock records, and public quarantine artifact intact.
+
+Compare the live container ID and listener PIDs with `recovery.json` and the
+private lease before manual action. Never delete a same-name container, kill a
+port's current listener, remove another lock, or recursively clear the shared
+state root merely because its name resembles Stack Bench. Escalate with the
+complete result directory and private state stored separately from public
+artifacts.
+
+## Intentional retention
+
+`--retain-backend` is inspection mode, not successful cleanup. It writes
+`status: "retained"` and preserves private recovery authority. No other run may
+reuse the listed locks until the recovery command completes.
diff --git a/tools/stack-bench/appliance/RELEASE.md b/tools/stack-bench/appliance/RELEASE.md
new file mode 100644
index 00000000000..ea6b102e70c
--- /dev/null
+++ b/tools/stack-bench/appliance/RELEASE.md
@@ -0,0 +1,121 @@
+# Release assembly and verification
+
+Stack Bench uses two deliberately different release states.
+
+- A `candidate` has exact image digests, checksummed files, and digest-bound
+ SPDX SBOMs. It is useful for inspecting and testing a proposed bundle, but it
+ is unsigned and cannot be called qualified.
+- A `qualified` release adds a bundled public key, a detached Sigstore bundle
+ covering `release.json`, and registry signatures for every image. Verification
+ must use a public key obtained outside the release bundle.
+
+Schema v2 is the only accepted release format.
+
+## Build the controller image
+
+Build the Linux/amd64 controller from a clean checkout. The source identity
+command refuses changed or untracked release inputs.
+
+```powershell
+$source = npm --prefix tools/stack-bench run release:source --silent | ConvertFrom-Json
+docker build --platform linux/amd64 `
+ -f tools/stack-bench/appliance/Controller.Dockerfile `
+ --build-arg SOURCE_REVISION=$($source.revision) `
+ --build-arg SOURCE_SHA256=$($source.sha256) `
+ --build-arg BINARY_SOURCE_SHA256=$($source.binarySourceSha256) `
+ -t stack-bench-controller:development .
+```
+
+The build accepts only Linux SpacetimeDB binaries recorded in
+`container/spacetimedb-binaries.json`. Rebuild them with
+`bash tools/stack-bench/container/build-linux-cli.sh` after a recorded binary
+source changes. Review and commit the updated provenance file. The binary files
+remain ignored.
+
+## Build a candidate
+
+Publish the first-party images, resolve every first- and third-party image to an
+exact single-platform `linux/amd64` manifest reference, then generate one SPDX
+SBOM for each exact reference. Do not use a multi-architecture index digest:
+Docker Scout correctly reports the selected child-manifest digest, so an index
+digest cannot satisfy the one-image/one-SBOM identity contract.
+
+```sh
+node dist/src/releases/release-bundle.js sbom registry.example/controller@sha256:DIGEST \
+ --output bundle/sbom/controller.spdx.json
+```
+
+The command uses registry resolution, refuses mutable references and existing
+output, and checks that Docker Scout's SPDX 2.3 document contains the requested
+image digest. A successful tool exit without that digest binding is rejected.
+
+Create a strict release specification with `state: "candidate"`,
+`signing: null`, and `files` entries containing only `path` and `role`. Place
+every input below the bundle root, then materialize immutable size and SHA-256
+metadata:
+
+```sh
+node dist/src/releases/release-bundle.js assemble release-spec.json \
+ --root bundle --output bundle/release.json
+node dist/src/releases/release-manifest.js verify bundle/release.json --root bundle
+```
+
+Candidate verification reports `candidate-file-integrity`. It validates all
+declared files and all five image-to-SBOM digest bindings. Candidate manifests
+must use `signing: null` and cannot include a public signing key.
+
+## Sign and qualify
+
+Signing keys are external CI inputs. Never copy a private key, registry token,
+or signing password into the source tree, image, bundle, Compose environment,
+or command transcript. Sign each exact registry image with Cosign. The
+authoritative image-signature evidence stays attached to the registry object
+and is checked directly during verification; the release does not preserve a
+redundant unverified export. Add the public half of the signing key as
+`signing/cosign.pub` with the `public-key` role.
+
+Change the specification to `state: "qualified"` and declare:
+
+```json
+{
+ "signing": {
+ "scheme": "cosign-public-key-v1",
+ "publicKeyPath": "signing/cosign.pub",
+ "manifestBundlePath": "signing/release-manifest.sigstore.json"
+ }
+}
+```
+
+Assemble `release.json` only after all other evidence exists, then sign that
+exact file with a detached Cosign bundle:
+
+```sh
+cosign sign-blob --yes --key "$COSIGN_KEY" \
+ --bundle bundle/signing/release-manifest.sigstore.json bundle/release.json
+```
+
+The detached bundle is intentionally not checksummed by `release.json`: a file
+cannot contain the hash of its own signature. Cosign authenticates it instead.
+
+Verify with the trusted public key copied to a path outside the downloaded
+bundle:
+
+```sh
+node dist/src/releases/release-manifest.js verify bundle/release.json --root bundle \
+ --trusted-key /operator/trust/stack-bench-cosign.pub
+```
+
+Qualified verification refuses an absent or bundle-local trust key, requires
+it to equal the public key bound by the signed manifest, verifies the detached
+manifest signature, and runs `cosign verify` against every exact registry image
+reference. A failed or unavailable Cosign invocation is a failed release; there
+is no downgrade to candidate verification. The controller image includes
+checksum-pinned Cosign 3.1.3 so this command is available in the delivered
+appliance rather than depending on an untracked host installation.
+
+## Trust distribution
+
+The release bundle cannot establish trust in its own key. Publish the expected
+public key and its SHA-256 fingerprint through a separately controlled channel.
+The operator must compare that fingerprint before verification. Key rotation
+requires a new release and an explicit trust-distribution update.
diff --git a/tools/stack-bench/appliance/campaign.ecommerce-progression-reference.json b/tools/stack-bench/appliance/campaign.ecommerce-progression-reference.json
new file mode 100644
index 00000000000..01a584ee447
--- /dev/null
+++ b/tools/stack-bench/appliance/campaign.ecommerce-progression-reference.json
@@ -0,0 +1,96 @@
+{
+ "schemaVersion": 7,
+ "kind": "campaign-manifest",
+ "id": "ecommerce-progression-reference",
+ "version": "2.0.1",
+ "state": "draft",
+ "title": "Ecommerce progression reference pilot",
+ "track": "ecommerce",
+ "mode": { "id": "dependency", "version": "4.3.0", "workSelection": "progressive" },
+ "repair": { "selection": "feature", "budget": { "total": 0 } },
+ "levels": [1, 2, 3, 4, 5, 6],
+ "featureCatalog": "ecommerce.questlines@2.0.2",
+ "selection": {
+ "levels": [
+ { "level": 1, "recipe": "ecommerce.progression-catalog@2.0.2" },
+ { "level": 2, "recipe": "ecommerce.progression-catalog@2.0.2" },
+ { "level": 3, "recipe": "ecommerce.progression-catalog@2.0.2" },
+ { "level": 4, "recipe": "ecommerce.progression-catalog@2.0.2" },
+ { "level": 5, "recipe": "ecommerce.progression-catalog@2.0.2" },
+ { "level": 6, "recipe": "ecommerce.progression-catalog@2.0.2" }
+ ]
+ },
+ "stacks": [
+ { "id": "mongodb", "adapterVersion": "1.4.0" },
+ { "id": "postgres", "adapterVersion": "1.5.0" },
+ { "id": "spacetime", "adapterVersion": "1.3.0" }
+ ],
+ "agents": [
+ {
+ "adapter": "reference-fixture",
+ "adapterVersion": "1.4.0",
+ "model": "reference-fixture"
+ }
+ ],
+ "conditions": [
+ {
+ "id": "reference-pilot",
+ "version": "1.0.0",
+ "guidanceProfile": "neutral@1.8.0",
+ "repairPolicy": "scored-only@1.1.0"
+ }
+ ],
+ "repetitions": 1,
+ "parallelism": 1,
+ "ordering": {
+ "method": "balanced-rotation",
+ "seed": "ecommerce-progression-reference-1"
+ },
+ "budgets": {
+ "attemptTimeoutMinutes": 180,
+ "maxCostUsdPerAttempt": null
+ },
+ "attemptPolicy": {
+ "retries": 0,
+ "retryOn": [],
+ "excludeFromAnalysis": [
+ "contaminated",
+ "harness_failure",
+ "inconclusive",
+ "ungraded"
+ ]
+ },
+ "runtime": {
+ "releaseManifestSha256": null,
+ "controllerImage": null,
+ "buildImage": null,
+ "platform": "linux/amd64"
+ },
+ "pricing": {
+ "currency": "USD",
+ "unit": "USD-per-million-tokens",
+ "capturedAt": "2026-08-25T00:00:00.000Z",
+ "source": "Reference fixtures make no provider calls.",
+ "models": {
+ "reference-fixture": {
+ "input": 0,
+ "output": 0,
+ "cacheWrite5m": 0,
+ "cacheWrite1h": 0,
+ "cacheRead": 0
+ }
+ }
+ },
+ "analysis": {
+ "primaryMetric": "finalScoreRate",
+ "secondaryMetrics": [
+ "firstBuildScoreRate",
+ "totalDurationMs",
+ "invalidAttemptRate"
+ ],
+ "dispersion": "median-iqr",
+ "invalidAttempts": "report-separately",
+ "missingData": "no-imputation",
+ "comparisonUnit": "stack-agent-condition-recipe"
+ }
+}
diff --git a/tools/stack-bench/appliance/campaign.example.json b/tools/stack-bench/appliance/campaign.example.json
new file mode 100644
index 00000000000..95d974b7002
--- /dev/null
+++ b/tools/stack-bench/appliance/campaign.example.json
@@ -0,0 +1,115 @@
+{
+ "schemaVersion": 7,
+ "kind": "campaign-manifest",
+ "id": "ecommerce-l1-example",
+ "version": "2.0.0",
+ "state": "draft",
+ "title": "Ecommerce L1 model-free example",
+ "track": "ecommerce",
+ "mode": { "id": "sequential", "version": "1.0.0" },
+ "repair": { "selection": "batch", "budget": { "total": 3 } },
+ "levels": [1],
+ "selection": {
+ "levels": [
+ {
+ "level": 1,
+ "recipe": "ecommerce.sequential-l1@2.5.0",
+ "features": [
+ "ecommerce.feature.accounts",
+ "ecommerce.feature.cart-checkout",
+ "ecommerce.feature.catalog",
+ "ecommerce.feature.purchasing",
+ "ecommerce.feature.reviews",
+ "ecommerce.feature.warehouse-admin"
+ ],
+ "checks": []
+ }
+ ]
+ },
+ "stacks": [
+ { "id": "spacetime", "adapterVersion": "1.3.0" },
+ { "id": "postgres", "adapterVersion": "1.5.0" },
+ { "id": "mongodb", "adapterVersion": "1.4.0" }
+ ],
+ "agents": [
+ {
+ "adapter": "deterministic",
+ "adapterVersion": "1.3.0",
+ "model": "deterministic"
+ }
+ ],
+ "conditions": [
+ {
+ "id": "prescribed",
+ "version": "1.1.0",
+ "guidanceProfile": "prescribed@1.2.0",
+ "repairPolicy": "scored-only@1.1.0",
+ "specifications": {
+ "levels": [
+ {
+ "level": 1,
+ "requested": [
+ "ecommerce.spec.access-control@1.2.0",
+ "ecommerce.spec.concurrency-safety@1.3.0",
+ "ecommerce.spec.external-data-sync@1.1.0",
+ "ecommerce.spec.live-state@1.2.0",
+ "ecommerce.spec.state-durability@1.1.0",
+ "ecommerce.spec.transactional-integrity@1.3.0"
+ ],
+ "expected": [],
+ "observed": []
+ }
+ ]
+ }
+ }
+ ],
+ "repetitions": 3,
+ "parallelism": 1,
+ "ordering": {
+ "method": "balanced-rotation",
+ "seed": "replace-before-measurement"
+ },
+ "budgets": {
+ "attemptTimeoutMinutes": 240,
+ "maxCostUsdPerAttempt": null
+ },
+ "attemptPolicy": {
+ "retries": 1,
+ "retryOn": ["provider_failure"],
+ "excludeFromAnalysis": ["contaminated", "harness_failure", "inconclusive", "ungraded"]
+ },
+ "runtime": {
+ "releaseManifestSha256": null,
+ "controllerImage": null,
+ "buildImage": null,
+ "platform": "linux/amd64"
+ },
+ "pricing": {
+ "currency": "USD",
+ "unit": "USD-per-million-tokens",
+ "capturedAt": "2026-08-12T00:00:00.000Z",
+ "source": "deterministic adapter makes no billable provider calls",
+ "models": {
+ "deterministic": {
+ "input": 0,
+ "output": 0,
+ "cacheWrite5m": 0,
+ "cacheWrite1h": 0,
+ "cacheRead": 0
+ }
+ }
+ },
+ "analysis": {
+ "primaryMetric": "firstBuildScoreRate",
+ "secondaryMetrics": [
+ "finalScoreRate",
+ "totalCostUsd",
+ "totalDurationMs",
+ "invalidAttemptRate"
+ ],
+ "dispersion": "median-iqr",
+ "invalidAttempts": "report-separately",
+ "missingData": "no-imputation",
+ "comparisonUnit": "stack-agent-condition-recipe"
+ }
+}
diff --git a/tools/stack-bench/appliance/campaign.product-brief-reference.json b/tools/stack-bench/appliance/campaign.product-brief-reference.json
new file mode 100644
index 00000000000..a8f7d37359c
--- /dev/null
+++ b/tools/stack-bench/appliance/campaign.product-brief-reference.json
@@ -0,0 +1,114 @@
+{
+ "schemaVersion": 7,
+ "kind": "campaign-manifest",
+ "id": "ecommerce-l1-product-brief-reference",
+ "version": "2.0.0",
+ "state": "draft",
+ "title": "Ecommerce L1 product brief and quality validation",
+ "track": "ecommerce",
+ "mode": { "id": "sequential", "version": "1.0.0" },
+ "repair": { "selection": "batch", "budget": { "total": 0 } },
+ "levels": [1],
+ "selection": {
+ "levels": [
+ {
+ "level": 1,
+ "recipe": "ecommerce.sequential-l1@2.5.0",
+ "features": [
+ "ecommerce.feature.accounts",
+ "ecommerce.feature.cart-checkout",
+ "ecommerce.feature.catalog",
+ "ecommerce.feature.purchasing",
+ "ecommerce.feature.reviews",
+ "ecommerce.feature.warehouse-admin"
+ ],
+ "checks": []
+ }
+ ]
+ },
+ "stacks": [
+ { "id": "spacetime", "adapterVersion": "1.3.0" },
+ { "id": "postgres", "adapterVersion": "1.5.0" },
+ { "id": "mongodb", "adapterVersion": "1.4.0" }
+ ],
+ "agents": [
+ {
+ "adapter": "reference-fixture",
+ "adapterVersion": "1.4.0",
+ "model": "reference-fixture"
+ }
+ ],
+ "conditions": [
+ {
+ "id": "product-brief-quality",
+ "version": "1.2.0",
+ "guidanceProfile": "neutral@1.8.0",
+ "repairPolicy": "scored-only@1.1.0",
+ "specifications": {
+ "levels": [
+ {
+ "level": 1,
+ "requested": [],
+ "expected": [
+ "ecommerce.spec.access-control@1.2.0",
+ "ecommerce.spec.concurrency-safety@1.3.0",
+ "ecommerce.spec.external-data-sync@1.1.0",
+ "ecommerce.spec.live-state@1.2.0",
+ "ecommerce.spec.state-durability@1.1.0",
+ "ecommerce.spec.transactional-integrity@1.3.0"
+ ],
+ "observed": []
+ }
+ ]
+ }
+ }
+ ],
+ "repetitions": 2,
+ "ordering": {
+ "method": "balanced-rotation",
+ "seed": "product-brief-quality-reference-1"
+ },
+ "budgets": {
+ "attemptTimeoutMinutes": 60,
+ "maxCostUsdPerAttempt": null
+ },
+ "attemptPolicy": {
+ "retries": 1,
+ "retryOn": ["harness_failure", "inconclusive"],
+ "excludeFromAnalysis": ["contaminated", "harness_failure", "inconclusive", "ungraded"]
+ },
+ "runtime": {
+ "releaseManifestSha256": null,
+ "controllerImage": null,
+ "buildImage": null,
+ "platform": "linux/amd64"
+ },
+ "pricing": {
+ "currency": "USD",
+ "unit": "USD-per-million-tokens",
+ "capturedAt": "2026-08-16T00:00:00.000Z",
+ "source": "reference fixture adapter makes no billable provider calls",
+ "models": {
+ "reference-fixture": {
+ "input": 0,
+ "output": 0,
+ "cacheWrite5m": 0,
+ "cacheWrite1h": 0,
+ "cacheRead": 0
+ }
+ }
+ },
+ "analysis": {
+ "primaryMetric": "firstBuildScoreRate",
+ "secondaryMetrics": [
+ "finalScoreRate",
+ "totalCostUsd",
+ "totalDurationMs",
+ "invalidAttemptRate"
+ ],
+ "dispersion": "median-iqr",
+ "invalidAttempts": "report-separately",
+ "missingData": "no-imputation",
+ "comparisonUnit": "stack-agent-condition-recipe"
+ }
+}
diff --git a/tools/stack-bench/appliance/controller.ts b/tools/stack-bench/appliance/controller.ts
new file mode 100644
index 00000000000..c1d14f3b9d8
--- /dev/null
+++ b/tools/stack-bench/appliance/controller.ts
@@ -0,0 +1,169 @@
+#!/usr/bin/env node
+
+import { spawn } from 'node:child_process';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { STACK_BENCH_ROOT } from '../src/package-root.js';
+
+const RUNTIME_ROOT = join(STACK_BENCH_ROOT, 'dist');
+
+const COMMANDS = Object.freeze({
+ 'init-deps': [join(RUNTIME_ROOT, 'appliance', 'dependency-volume.js'), 'init'],
+ 'verify-deps': [join(RUNTIME_ROOT, 'appliance', 'dependency-volume.js'), 'verify'],
+ 'preflight': [join(RUNTIME_ROOT, 'commands', 'preflight.js')],
+ 'qualify-reference': [join(RUNTIME_ROOT, 'src', 'references', 'reference-live.js')],
+ 'qualify-null': [join(RUNTIME_ROOT, 'commands', 'null-control.js')],
+ 'qualification': [join(RUNTIME_ROOT, 'commands', 'qualification-cli.js')],
+ 'pack-budget': [join(RUNTIME_ROOT, 'commands', 'pack-budget.js')],
+ 'campaign': [join(RUNTIME_ROOT, 'commands', 'campaign-cli.js')],
+ 'dashboard': [join(RUNTIME_ROOT, 'dashboard', 'dashboard-server.js')],
+ 'repair': [join(RUNTIME_ROOT, 'commands', 'repair-cli.js')],
+ 'run': [join(RUNTIME_ROOT, 'commands', 'bench.js')],
+ 'verify-release': [join(RUNTIME_ROOT, 'src', 'releases', 'release-manifest.js'), 'verify'],
+ 'recover': [join(RUNTIME_ROOT, 'commands', 'recovery.js'), 'recover'],
+ 'recover-lease': [join(RUNTIME_ROOT, 'commands', 'recovery.js'), 'recover-lease'],
+} satisfies Record);
+
+const COMMANDS_REQUIRING_AGENT_AUTH = new Set(['preflight', 'dashboard', 'run']);
+
+export function controllerCommandRequiresAgentAuth(command: string | undefined,
+ args: string[] = []): boolean {
+ if (command && COMMANDS_REQUIRING_AGENT_AUTH.has(command)) return true;
+ return command === 'campaign' && args[0] === 'run';
+}
+
+export interface ResolvedControllerCommand {
+ executable: string;
+ args: string[];
+}
+
+export function resolveControllerCommand(argv: string[]): ResolvedControllerCommand | null {
+ const [command, ...rest] = argv;
+ if (!command || command === '--help' || command === 'help') return null;
+ if (!Object.hasOwn(COMMANDS, command)) {
+ throw new Error(`unknown controller command ${JSON.stringify(command)}`);
+ }
+ return { executable: process.execPath,
+ args: [...COMMANDS[command as keyof typeof COMMANDS], ...rest] };
+}
+
+export function controllerChildEnvironment(source: NodeJS.ProcessEnv = process.env,
+ { requireAgentAuth = true }: { requireAgentAuth?: boolean } = {}): NodeJS.ProcessEnv {
+ const env = { ...source };
+ delete env.ANTHROPIC_API_KEY;
+ delete env.ANTHROPIC_API_KEY_FILE;
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
+ delete env.CLAUDE_CODE_OAUTH_TOKEN_FILE;
+ if (!requireAgentAuth) return env;
+ const mode = source.STACK_BENCH_AGENT_AUTH ?? 'subscription-token';
+ if (!['subscription-token', 'api-key'].includes(mode)) {
+ throw new Error('STACK_BENCH_AGENT_AUTH must be subscription-token or api-key');
+ }
+ if (mode === 'api-key') {
+ const path = source.STACK_BENCH_ANTHROPIC_API_KEY_FILE?.trim();
+ if (!path) throw new Error('api-key auth requires STACK_BENCH_ANTHROPIC_API_KEY_FILE');
+ env.ANTHROPIC_API_KEY_FILE = path;
+ } else {
+ const path = source.STACK_BENCH_CLAUDE_OAUTH_TOKEN_FILE?.trim();
+ if (!path) {
+ throw new Error('subscription-token auth requires STACK_BENCH_CLAUDE_OAUTH_TOKEN_FILE');
+ }
+ env.CLAUDE_CODE_OAUTH_TOKEN_FILE = path;
+ }
+ return env;
+}
+
+interface SignalChild {
+ kill(signal: NodeJS.Signals): unknown;
+}
+
+interface SignalSource {
+ on(signal: NodeJS.Signals, listener: () => void): unknown;
+ off(signal: NodeJS.Signals, listener: () => void): unknown;
+}
+
+export function forwardControllerSignals(child: SignalChild,
+ source: SignalSource = process): () => void {
+ const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
+ const listeners = new Map void>(signals.map(signal =>
+ [signal, () => { child.kill(signal); }]));
+ for (const [signal, listener] of listeners) source.on(signal, listener);
+ return () => {
+ for (const [signal, listener] of listeners) source.off(signal, listener);
+ };
+}
+
+function help(): void {
+ process.stdout.write('Stack Bench controller\n'
+ + '\n'
+ + 'A campaign compares stacks by building the same product on each. Point\n'
+ + 'every command at the state root /var/lib/stack-bench.\n'
+ + '\n'
+ + 'Run a campaign\n'
+ + ' preflight --backend --track