Skip to content

P1 subset: close the store-open races and stop the docs lying about the shell#764

Draft
macanderson wants to merge 2 commits into
mainfrom
worktree-p1-unblocked-subset
Draft

P1 subset: close the store-open races and stop the docs lying about the shell#764
macanderson wants to merge 2 commits into
mainfrom
worktree-p1-unblocked-subset

Conversation

@macanderson

@macanderson macanderson commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What this is

The ask was "fix all P1 issues in one PR." That is not possible as stated, and the reason is structural rather than a matter of effort — so this PR lands the part that is landable and puts the rest in front of you as decisions.

I classified all 105 open checkbox rows across the six P1 issues against the code on main:

Issue Open rows Unblocked Decision-gated Already done
#618 public API / protocol break 29 4 23 2
#617 on-disk state 6 2 3 1
#616 budgets & caps 26 2 23 1
#615 trust boundary 13 5 7 1
#612 async boundary 31 4 23 4
#306 CI merge queue repo-settings change, owner-only
Total 105 17 79 9

79 of 105 rows cannot be closed by anyone but you, and the issues say so themselves — each opens with "What decision this asks for." #618's two answers (take one coordinated protocol break vs. declare the types frozen) lead to opposite diffs and it explicitly notes freezing is "equally valid and much cheaper." #616's rows are blocked on numbers nobody has written down. #617 needs a ruling on two destructive operations. Guessing any of these wrong is expensive, so I didn't.

This PR is the 17 unblocked rows minus the cloud/serve surface you asked me to skip — and it grew, because closing one of them surfaced three further real bugs on the same path.

#617 items 8 + 10 — four races on the workspace-open path

Item 8 was "the version read is outside the transaction." Fixing it and adding the test the issue asked for by name (four threads, one fresh path) turned up three more, all real:

  1. apply_migration re-applied a step another process had already stamped. Now IMMEDIATE, re-reading user_version under the write lock. Losing the race is now the no-op it should always have been — which is also what makes a future non-replay-safe step (ALTER TABLE … ADD COLUMN) safe to append.
  2. Store::open failed outright with UNIQUE constraint failed: enterprise_export_migration.singleton — an existence check followed by an unguarded INSERT. Now ON CONFLICT DO NOTHING, which is what its own sibling branch already did.
  3. PRAGMA journal_mode=WAL ran before busy_timeout was set, and SQLite skips the busy handler on a WAL upgrade — so the one statement needing an exclusive lock was the one statement with no retry budget. Now behind a bounded retry that mirrors stella-media's initialize_journal_database, whose doc comment already described this exact hazard. The main store never got the same treatment.
  4. The generated .stella/.gitignore used check-then-rename, so concurrent openers all renamed over the target; a racer validating the winner's file saw nlink() == 0 and failed closed with "must be an owner-controlled single-link regular file" — for a file nothing had attacked. Now O_EXCL | O_NOFOLLOW exclusive create; losers read the settled file. Two new tests pin that a planted symlink is still rejected and that the file stays committable-mode (0644), not 0600.

Verified end-to-end, not just in tests. Five concurrent stella init runs against one fresh workspace:

database is locked
origin/main 3
this branch 0

Every fix was negative-controlled by reverting it and confirming the specific failure came back. The four-thread test went 40/40 and the store suite 6/6 under full parallel load.

The same version-read-outside-the-transaction shape was sitting as an unfixed audit note in stella-context and stella-fleet — the fleet one explicitly warning that a future non-replay-safe step would be applied twice. Both now use IMMEDIATE, and the notes describe what the code does.

#615 — the shipped docs inverted a security claim

#710 moved bash and the key-free web family to registered-by-default and never swept the prose. Every release since has told operators the opposite of the surface they actually had:

  • README.md ("Bash is opt-in. The default tool surface has no free-form shell tool")
  • AGENTS.md, stella-tools/README.md, docs/why-stella.md, docs/design/semantic-resolution-bridge.md
  • docs/design/threat-model.md — "bash is opt-in… the default surface has no shell at all"
  • the model's own system prompttool_steering! carried "There is no shell unless the workspace enables it", and prompt.rs's own test pinned that false sentence in place

A false claim in a threat model is worse than no claim, and a false one in the system prompt is read every turn.

web.rs is the sharpest case: it justified having no SSRF guard (localhost, cloud metadata endpoints reachable) on the grounds that the family was opt-in — "the gate is the settings opt-in, not a network allowlist." That gate no longer exists. R4 in the threat model says the same. Both now record it as an open ruling rather than presenting a retired guarantee as current. I did not add an SSRF guard: that's strict-vs-permissive and would break "fetch my internal dev server."

New stella-tools/tests/doc_truth.rs gates all of it, so the claim cannot invert silently again. It caught one instance I'd missed while I was writing it.

Item 1 (the run_testsbash -c injection) was already fixed in #624. I verified it properly rather than taking the checkbox's word: per-token shell_quote, and the test runs a real bash -c with a negative control. Tick it and don't reopen.

Not fixed, recorded instead: screenshot, ci_status and start_work_on_issue compose bash -c lines that never reach command_line_for, so a policy denying command execution cannot see three real shell invocations — contradicting the MUST stated right there in the doc comment. None is injectable (all shell-quote). I left it as a note at that function because closing it makes those tools newly deniable by an existing operator policy, which is an operator-visible change that should land deliberately.

Three small ones

Things worth your attention

Gate

make gate exits 0 (checked directly — piping it to tail masks the exit code). Rebased onto eb663f0d; origin/main had moved 0.5.52 → 0.5.56 under me and #760 restructured AGENTS.md, so the doc-truth test and baseline were re-verified after the rebase.

The file-size baseline moves three lines. driver.rs (+11), stella-store/src/lib.rs (+13) and registry.rs (+5) are in-place code plus the rationale the surrounding style expects, and can't be extracted. The 50-line doc-truth test was extracted to its own file rather than raising a baseline for it.


Update — #617 items 12 and 5, per the owner's ruling

Both destructive rows are now handled, neither by deleting anything.

Item 12: fix the conflation, then sweep

The sweep was unsafe because SessionRegistry::list cannot tell "no record" from "unreadable record" — it drops whatever fails to parse, and upsert's own comment records that a power cut leaves a zero-length <id>.json. Anything keyed on what list accounts for therefore deletes the sidecar of a session whose record was merely truncated.

So the conflation is fixed first. scan() now reports three states where list reported one:

state meaning swept?
healthy record parsed no
damaged <id>.json present but unusable (zero-length / truncated) — sidecar intact never
orphan_sidecars no <id>.json beside it at any readability yes

The orphan test is answered from the directory entry, never from a parse. list() delegates to scan().healthy, so there is one implementation rather than two definitions that can drift. prune_orphan_sidecars() re-checks for a record under the same name immediately before removing, so a session that started since the scan is safe.

Damaged records are reported, not repaired, and the type documents why: id, pid and started_at_ms are recoverable from the filename (ids are self-minted ses-<ms>-<pid>), but workspace is held nowhere else on disk — not in journal.jsonl, not in history.json — and resume needs it. Silently hiding the session (what happens today) and silently deleting it (as filed) are both wrong; surfacing it is what's actually available.

The witness test asserts the full chain — list can't see the record, scan calls it damaged with its sidecar noted, the sweep is a no-op, history.json survives — and is negative-controlled against the filed definition (orphan = "not in list"), which classifies that session as an orphan and deletes it.

Item 5: constrain new ledgers, report on old ones

Fresh fleet.db files get the four REFERENCES runs (id) constraints via a new FRESH_SCHEMA, so a ledger created by this build cannot acquire an orphan. Existing files take the unchanged ladder and are never rebuilt — no migration can brick a fleet.db, and no history is deleted.

The tradeoff is stated at FRESH_SCHEMA rather than left implicit: SCHEMA_VERSION no longer determines shape by itself, since both variants are v2. A future step that rebuilds one of these four tables must reproduce the right variant or read the DDL from sqlite_master. That's a real wart; the alternatives were deleting history or leaving new databases unconstrained forever.

Ledger::orphan_rows() reports what an unconstrained file holds and enforces_run_references() says which variant a file is. Rather than land as uncalled API, it's surfaced as a second stella doctor check. It passes either way — inert rows on an old ledger aren't a fault, and failing the command over them would make doctor unusable on exactly the workspaces with history worth keeping. --repair deliberately does not act on it, asserted by a test that checks the rows are still present afterwards.

Driven against the real binary on a legacy ledger holding 4 orphans across 3 tables:

✓ fleet ledger — .stella/private/fleet.db: 4 row(s) reference a run that is no longer recorded
    tasks.run_id: 2 row(s)
    attempts.run_id: 1 row(s)
    spend.run_id: 1 row(s)
    → nothing to do — these rows are inert (no query resolves a row by its run) and they are
      kept because removing them would delete fleet history
    → this ledger predates the run-reference constraints; a fleet.db created by this build
      cannot acquire new orphans

2 checks: 2 ok, 0 failed

After stella doctor --repair: all 4 rows and the $4.20 of recorded spend still there.

doctor's module doc claimed "today there is exactly one check" — corrected, and its test now asserts check names rather than a count.

make gate exits 0. Two rustdoc failures caught on the way (a public doc linking a private FRESH_SCHEMA, and a FleetLedger type that is actually Ledger) are fixed.

…he shell

Lands the rows across the five P1 umbrella issues that need no maintainer
ruling. The other ~79 rows are decision-gated by construction and are left
alone; see the PR body for the audit.

**#617 item 8 + 10 — concurrent first open of a workspace.** The version read
that decides which migration to run sat outside the transaction that applies
it, so two processes opening one fresh workspace could both act on the same
observation. Closing that surfaced three further real races on the same path,
each found by the test the issue asked for (four threads, one fresh path):

- `apply_migration` re-applied a step another process had already stamped.
  Now IMMEDIATE and re-reads `user_version` under the write lock, so losing
  the race is the no-op it should always have been — which is also what makes
  a future non-replay-safe step safe to append.
- `enterprise_export_migration`'s state row was inserted after an unguarded
  existence check, so the loser failed `Store::open` outright on
  `UNIQUE constraint failed`. Now `ON CONFLICT DO NOTHING`, matching what its
  sibling branch already did.
- `PRAGMA journal_mode=WAL` ran before `busy_timeout` was set, and SQLite
  skips the busy handler on a WAL upgrade, so the one statement needing an
  exclusive lock had no retry budget. Now behind a bounded retry, mirroring
  `stella-media`'s `initialize_journal_database`, which already documented
  this exact hazard.
- The generated `.stella/.gitignore` was written with check-then-rename, so
  concurrent openers all renamed over the target and a racer validating the
  winner's file saw `nlink() == 0` and failed closed. Now an
  `O_EXCL | O_NOFOLLOW` exclusive create; losers read the settled file.

Verified end-to-end, not just in tests: five concurrent `stella init` runs on
one fresh workspace report "database is locked" three times on `origin/main`
and zero times here. Each fix was negative-controlled by reverting it and
watching the specific failure return.

The same version-read-outside-the-transaction shape was an unfixed audit note
in `stella-context` and `stella-fleet`; both now use IMMEDIATE too, and the
notes are replaced with what the code does.

**#615 — the shipped docs inverted a security claim.** #710 moved `bash` and
the key-free web family to registered-by-default and never swept the prose, so
every release since told operators the opposite of the surface they had:
`README.md`, `AGENTS.md`, `stella-tools/README.md`, `docs/why-stella.md`,
`docs/design/threat-model.md` and the semantic-resolution ADR all still said
the shell was opt-in. The model was told too — `tool_steering!` carried "There
is no shell unless the workspace enables it", and the prompt's own test pinned
that false sentence. `web.rs` additionally justified having no SSRF guard
(localhost, cloud metadata) on the grounds that the family was opt-in, a
compensating control that no longer exists; that is now recorded as an open
ruling rather than a retired guarantee presented as current.

`stella-tools/tests/doc_truth.rs` gates all of it so the claim cannot invert
again silently.

Item 1 (the `run_tests` shell injection) was already fixed in #624 — verified,
including its negative control. The `command.started` fence gap on
`screenshot` / `ci_status` / `start_work_on_issue` is recorded at
`command_line_for` rather than fixed: closing it makes those tools newly
deniable by an existing policy, which is an operator-visible change.

**#618 item 5, 17 · #616 item 8 — three small ones.**
- Journal failures were reported as `MediaError::Artifact`, sending operators
  to `.stella/artifacts/` for a fault in the journal sidecar. New `Journal`
  variant.
- The speculation `select!` had an `unreachable!` on a provider-driven path;
  it now reports a typed terminal error (invariant 5 outranks asserting a
  structural claim).
- `upsert` handed the embedder every missing body in one request, so a
  full-workspace first index put the whole corpus in a single call. Batched at
  `warm::BATCH`, the width the warm indexer has always used.

The file-size baseline moves three lines: the growth in `driver.rs`,
`stella-store/src/lib.rs` and `registry.rs` is in-place code plus its
rationale and cannot be extracted. The 50-line doc-truth test was moved to its
own file rather than raising a baseline for it.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Jul 27, 2026 7:38am

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

Both rows were left out of the previous commit because both destroy data. The
owner ruled: fix the root cause before sweeping, and constrain new fleet
ledgers only.

**Item 12 — the orphan session-sidecar sweep.** The blocker was that
`SessionRegistry::list` cannot tell "no record" from "unreadable record": it
drops anything that fails to parse (`.ok()?`), and `upsert`'s own comment
records that a power cut leaves a zero-length `<id>.json`. A sweep keyed on
what `list` accounts for therefore deletes the sidecar of a session whose
record was merely truncated — and `history.json` in there is the only
continuable copy of the conversation.

So the conflation is fixed first. `scan` reports three states instead of one:
healthy records, `damaged` records (present but unusable, sidecar intact), and
`orphan_sidecars` (no `<id>.json` beside them at any readability). The orphan
test is answered from the directory entry, never from a parse. `list` now
delegates to `scan().healthy`, so there is one implementation.

`prune_orphan_sidecars` then deletes only genuine orphans, re-checking for a
record under the same name immediately before removing — a session that
started since the scan has written one.

Damaged records are reported rather than repaired, and the type says why:
`id`, `pid` and `started_at_ms` are recoverable from the filename (ids are
self-minted `ses-<ms>-<pid>`), but `workspace` is held nowhere else on disk —
not in `journal.jsonl`, not in `history.json` — and resuming needs it. Silently
hiding the session (today) and silently deleting it (as filed) are both wrong;
surfacing it is what is actually available.

The power-cut test asserts the whole chain: `list` cannot see the record,
`scan` calls it damaged with its sidecar noted, the sweep is a no-op, and
`history.json` survives. Negative-controlled against the filed definition
(orphan = "not in `list`"), which classifies that session as an orphan and
would delete it.

**Item 5 — the fleet run references.** Retrofitting `REFERENCES runs (id)`
onto a deployed `fleet.db` cannot succeed while an orphan row exists: the
migration runner aborts on `pragma_foreign_key_check`, so the file stops
opening, and the only way through is deleting fleet history — which tasks ran,
what was attempted, what it cost. The issue filed this as a routine schema
tidy-up and did not say so.

Fresh files now get the constraints via a new `FRESH_SCHEMA` (the current
post-v2 shape plus the four `run_id` references), so a ledger created by this
build cannot acquire an orphan. Existing files take the unchanged ladder and
are not touched. The tradeoff is stated at `FRESH_SCHEMA`: `SCHEMA_VERSION` no
longer determines shape alone, so a future step rebuilding one of these tables
must reproduce the right variant or read the DDL from `sqlite_master`.

`Ledger::orphan_rows` reports what an unconstrained file holds, and
`enforces_run_references` answers which variant a file is — read from
`sqlite_master`, because both are v2.

That report is surfaced by a second `stella doctor` check rather than landing
as uncalled API. It passes either way: inert rows on an old ledger are not a
fault, and failing the command over them would make `doctor` unusable on
exactly the workspaces with history worth keeping. `--repair` deliberately
does not act on it, asserted by a test that checks the rows are still there
afterwards.

Verified against the real binary: a legacy ledger holding 4 orphan rows across
3 tables reports each class with its count and an explanation, exits 0, and
`stella doctor --repair` leaves every row and the $4.20 of recorded spend
intact.

`doctor`'s module doc said "today there is exactly one check"; it now describes
both, and the count assertion in its tests checks names rather than a number.
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