Skip to content

setup_sql: let a caller adjust a new instance's rows with SQL at reset - #48

Merged
scosman merged 10 commits into
mainfrom
claude/intelligent-pasteur-hpp1zo
Oct 8, 2026
Merged

scosman merged 10 commits into
mainfrom
claude/intelligent-pasteur-hpp1zo

Conversation

@scosman

@scosman scosman commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Summary

This adds a framework-level setup_sql parameter beside fixture, startup and now on world.instance(...) and the OpenEnv reset. With it, a caller can change a new instance's rows using SQL, without writing a startup hook. The spec is in specs/projects/startup_sql/.

env.reset(
    fixture="agency",
    setup_sql="INSERT INTO users (...) VALUES (...); UPDATE issues SET priority = 0 WHERE key = 'ENG-3'",
    startup={"user_id": "..."},
)

Order of work at instance creation:

  1. The fixture is copied, or the blank schema is built.
  2. setup_sql runs in one transaction on one hardened connection to the root, with every added node attached by schema name (payments.charges, payments__tax.rates).
  3. The transaction commits.
  4. The startup hooks run and see those rows.

Change log and state: setup rows are not in the change log, and call_count stays 0. The state document echoes setup_sql beside startup.

Statements: ; separates statements, split using apsw.complete, so a ; inside a string literal is safe.

Determinism: random() draws from its own seeded stream (SETUP_STREAM), so the instance's own draws do not change. The clock functions read the instance's clock.

Guard: a default-deny authorizer, based on the sandbox Authorizer, limits it to reading and writing rows.

  • It refuses schema changes, transaction control, ATTACH/DETACH, pragmas other than the reporting ones, and functions outside the run_sql allowlist.
  • Every failure is a loud WorldBug that names the statement (setup_sql statement 2 of 3 was refused: … <fix>) and rolls everything back. No instance directory is left behind.
  • Over OpenEnv, a failed reset leaves the session fresh. HTTP PUT answers 400.

Entry points: world.instance, OpenEnv reset (in process and over the WebSocket), HTTP PUT, seahaven mcp --reset-options, and the /ui console. The console shows a multi-line "Setup Sql" box with no console code change, because the field is one nullable string.

Commits

  • Phase 1, core: seahaven/setup_sql.py, the parameter on World.instance, InstanceManager.create and Instance, the pytest marker, and about 95 tests in tests/test_setup_sql.py.
  • Phase 2, entry points: the state envelope, SeahavenState, SeahavenResetRequest, RESET_ORDER, the console mock and smoke order, every pinned test, and entry-point tests including a projecttracker test.
  • Phase 3, docs: updates to six existing pages, with one runnable example.

Deviations from the spec (decided during review)

  • World trigger bodies are trusted for function calls and reads, matched by trigger name. Without this, a world trigger that uses changes() or json_each would make every INSERT into its table fail.
    • Views are not trusted, because SQLite names a view while it resolves the caller's own WHERE clause in DML on an INSTEAD OF view.
    • Accepted residual: a CTE named after a world trigger shares that trigger's trust for reads and functions. A test pins it, and the code says why it is accepted: setup_sql comes from the eval author, and the guard catches mistakes rather than attacks.
  • Read-only pragmas are allowed (the inspection connection's list).
  • A failure at COMMIT (a deferred constraint) is also reported as a WorldBug.

Test plan

  • Every check in AGENTS.md (ruff, ty split, framework, projecttracker and xmlrpc suites, licence gates) in both the serve and mcp environments.
  • Console e2e smoke test against both mocks.
  • Tests run through world.instance(...) and through each real entry point.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz


Generated by Claude Code

scosman and others added 10 commits October 8, 2026 16:49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
`world.instance(..., setup_sql=...)` takes a string of SQL statements. Instance
creation runs it after the fixture copy or blank build and before any node
connection is opened. It runs on one writable connection to the root's file
with every added node attached under its schema name (`payments`,
`payments__tax`), in one transaction, committed and closed before the startup
hooks run. Hooks see the rows it wrote. Its rows are not in the change log,
and `call_count` stays 0.

- New `seahaven/setup_sql.py`: `run_setup_sql`, `split_statements` (splits on
  `;` only where `apsw.complete` agrees), and `SetupAuthorizer`, the sandbox's
  default-deny authorizer, writable over every table, view and FTS5 shadow
  table, recording the first refusal per statement. Every failure is a
  `WorldBug`, raised from the original error, of the form
  "setup_sql statement {i} of {n} {was refused|failed}: {reason} {fix}" plus
  the statement, whitespace collapsed and cut at 200 characters.
- `ids.SETUP_STREAM`: `random()` and `randomblob()` draw from the root's seed
  on a stream of their own, whichever node a row lands in. The instance's own
  draws are the same with and without setup SQL.
- `InstanceManager.create` and `World.instance` take `setup_sql`. A non-string
  value is refused before anything is built. `Instance.setup_sql` holds the
  string as given. `_attachment_pairs` is shared with
  `Instance._attachments()`. `synchronous OFF` is set on every attached schema
  as well as `main`.
- The pytest marker signature lists `setup_sql=None`.
- Tests: `tests/test_setup_sql.py`, mostly through `world.instance(...)`, cover
  composed worlds, FTS5, fixtures, startup hooks, seeding, the clock, the
  change log, every refusal, rollback leaving no instance directory, deferred
  constraints, triggers and views. Review fixes included: a module docstring
  line in setup_sql.py and an `instance_dirs` assertion in the tests.

Deviations from the spec:
- Read-only pragmas: the architecture says read-only pragmas are allowed, but
  the base `Authorizer` allows only `data_version`. `SetupAuthorizer` also
  allows `db._READ_ONLY_PRAGMAS`, the inspection connection's list, so the
  error message "Only read-only pragmas such as table_info are allowed" is
  true.
- Triggers: the spec holds every action to the run_sql function and table
  allowlists. The code trusts world trigger bodies, matched by name, for
  function calls and reads. Without that, a world trigger using `changes()`
  or `json_each` made every `INSERT` into its table fail. A caller cannot
  create a trigger. Views are not trusted: SQLite names a view while it
  resolves the caller's own WHERE clause in DML on an INSTEAD OF view. A name
  shared by a trigger and a table or view is not trusted either. Accepted
  residual: SQLite also names a CTE, so a CTE named after a world trigger
  shares its trust for functions and reads. setup_sql comes from the eval
  author and these checks catch mistakes, not attacks. A test pins the
  residual.
- COMMIT: the architecture's error table covers statement failures only. A
  failure at COMMIT (a deferred constraint) is also a `WorldBug`, "setup_sql
  failed at commit: ...", because creation must fail with `WorldBug`.
- `sqlite_sequence`: the architecture calls a write to any `sqlite_*` table a
  schema change. A write to `sqlite_sequence` is still refused, but with the
  generic reason; only `sqlite_master`, `sqlite_schema` and
  `sqlite_temp_master` writes are called schema changes.
- Pins: the architecture leaves the reset-key pins to phase 2. The key lists in
  `tests/test_cli_mcp.py` and `tests/test_mcp_process.py` are updated here,
  because `RESET_OPTION_KEYS` is read off the `World.instance` signature and
  takes `setup_sql` from this phase on. `tests/test_world.py`,
  `tests/test_ids.py` and `tests/test_composite_instance.py` pick up the new
  parameter and stream.

Note for the docs phase: SQLite looks up an unqualified table name in the
attached schemas too, so an unqualified `charges` reaches `payments.charges`
when the root has no `charges` table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
…ocument

`setup_sql` now reaches an instance through every entry point, and the state
document reports it.

- State document: the envelope gains `setup_sql` directly after `startup`:
  the string exactly as given (an empty string stays ""), null when there
  was none and before the first reset.
- OpenEnv: `SeahavenEnv.reset` takes `setup_sql` and forwards it to
  `InstanceManager.create`. `SeahavenResetRequest` publishes it as a nullable
  string titled "Setup Sql", ordered after `startup` by `RESET_ORDER`, so the
  console renders it as a multi-line box. `SeahavenState.setup_sql` carries it
  to clients.
- HTTP `PUT` and `seahaven mcp --reset-options` needed no code: both read
  their keys off `World.instance`. The stale "five names" comment in
  `cli/mcp.py` is fixed.
- Console: the mock reset schema has the field, and the e2e smoke test
  checks the form order and that the field is a textarea. No console source
  change; the vendored build is unchanged.
- Pins updated: reset parameters and declared descriptions, reset schema
  order, `state_v1.schema.json`, envelope key order and pre-reset nulls,
  the WebSocket whole-document episode, and the client's document frame. The
  client's pre-reset test now also asserts `clock_mode` is null, and its
  docstring counts seven null fields.

Tests: in-process `reset` with `setup_sql` and a failing one leaving the
session fresh; a WebSocket round trip with a refused statement as an
EXECUTION_ERROR frame followed by a good reset; the whole document over the
wire carrying `setup_sql` with its row absent from the log; a `PUT` body
with `setup_sql` and two refused bodies answering 400; `--reset-options`
in the CLI unit tests and through a real MCP process; and in projecttracker,
`agency` plus SQL inserting a user (with an ISO `created_at` literal) plus
`startup={"user_id": ...}`, where `create_issue` is attributed to that user
and `users` is not in the change log.

Deviation: the architecture said the projecttracker test "resets agency".
The code drives it in process through the `@pytest.mark.seahaven` marker,
which goes through `world.instance(...)`, because the world's OpenEnv
module already covers the wire and the attribution is the same either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
The bundled docs now cover `setup_sql`. Six existing pages were updated and no
page was added.

- db_schema_and_fixtures.md: a new section, "Adjusting a fixture per episode",
  with a table-of-contents row. It says what `setup_sql` is for and when it
  runs (after the fixture copy or the blank schema build, before the startup
  hooks, in one transaction). It also says that hooks see its rows, that those
  rows are not in the change log, and that the state document records it. The
  advice is to write rows only, with a link to the refusal list. The section
  covers composed-world table names and how the seed and clock behave, and a
  table on when to use a new fixture, `setup_sql` or a startup hook. One
  runnable example freezes a fixture on an inline world, then adjusts it with
  two statements, one of which has a `;` inside a literal.
- serving_and_openenv.md: a `setup_sql=` row in the reset table, one example
  reset message, a `setup_sql` row in the `SeahavenState` table, and
  `setup_sql` in the list of fields that are null before the first reset.
- state.md: `setup_sql` in the example envelope and the envelope table. The
  "not in the log" rule now covers `setup_sql` rows. The starting state is
  the fixture, then the setup_sql rows, then the hooks' writes.
- composition.md: one paragraph and a fragment showing one statement that
  reads the root's `staff` and writes `payments.charges`.
- reference/api.md: `setup_sql=None` in the `world.instance` row and in the
  marker signature, an `inst.setup_sql` row, and a `### setup_sql`
  subsection. The subsection says what the connection sees and allows, and
  holds the list of what is refused: schema changes, transaction control,
  ATTACH/DETACH, any pragma but the reporting ones, functions outside
  ALLOWED_FUNCTIONS (MATCH included), table-valued functions such as
  json_each() and pragma_table_info(), and writes to FTS5 shadow tables or
  SQLite's own tables.
- reference/cli.md: a `--reset-options '{"setup_sql": ...}'` example. The
  paragraph names `"setup_sql"` beside `"startup"`.

Review fixes are included:
- The pragma rule now says any pragma that does not only report is refused,
  with or without a value.
- The blank-instance case ("or the blank schema is built") is in both the
  guide and the reference.
- One term is used consistently.
- The cli.md sentence is reworded.
- A sentence in the guide is split in two.
- The composition fragment inserts charges with a distinct id ('c-' || id).

No deviation from the spec or the architecture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D327Rt1yFbLSkhA7ocsWhz
@scosman
scosman merged commit 8dbabb3 into main Oct 8, 2026
3 checks passed
@scosman
scosman deleted the claude/intelligent-pasteur-hpp1zo branch October 8, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant