Skip to content

docs(reborn): design — native hot-store primitives on the unified RootFilesystem trait - #5269

Merged
henrypark133 merged 3 commits into
mainfrom
firat/native-storage-primitives-design
Jun 26, 2026
Merged

henrypark133 merged 3 commits into
mainfrom
firat/native-storage-primitives-design

Conversation

@serrrfirat

Copy link
Copy Markdown
Collaborator

What

A design doc (no code) for making the reborn Postgres access pattern native — per-row, indexed, partial-update — without changing the RootFilesystem abstraction or losing dual-backend (libSQL + Postgres). Adds docs/plans/2026-06-26-native-storage-primitives.md. It generalizes the #5232 lease-sidecar pattern across the remaining hot stores.

Why

The four hot stores funnel every mutation through a whole-blob read-modify-write CAS on one Entry, serializing logically-disjoint writers. The worst is the process-global /resources/snapshot.json under ResourceScope::system() — CAS-contended across all tenants with no retry. (v1 never had this: it used native per-row INSERT/UPDATE on a 30-deep pool. The unification PR #3659 explicitly deferred native-record adoption to per-consumer follow-ups, which the hot stores never did.)

The design (keeps the trait frozen)

  • Decomposition with ZERO new ops does most of the work: hot stores write fine-grained per-entity records (per-run, per-account), each CAS'd individually, instead of one giant blob. Disjoint writers stop colliding. No trait change, no caller fork.
  • Exactly two new optional native primitives — put_batch (atomic multi-put) and adjust_indexed (atomic conditional numeric delta) — each with a default impl built from existing ops (byte-only/in-memory backends unchanged) plus native Postgres AND native libSQL overrides, gated behind capability bits and mirrored on ScopedFilesystem. (A third proposed primitive, merge_indexed, was dropped to avoid a PG/SQLite null-divergence hazard.)
  • Migration-safety invariant + CI parity test: both backends store byte-identical logical records, so libSQL↔Postgres stays a copy, not a transform.

Hot stores

  • Resource governor (top priority): global system() snapshot → one record per account (sha256(canonical_json(account)) key) → two tenants CAS disjoint paths and never contend; same-tenant serializes only on the shared tenant ledger. The global hot row is eliminated.
  • Turn-state: one blob → per-run records + the event log un-embedded onto the native append plane.
  • Threads: already per-file → native indexes + adopt put_batch + a ForceCas escape hatch (no row decomposition).

Hard cases resolved honestly (not hand-waved)

  • CAS-only (libSQL) multi-account admission hole → a single atomic admission gate on the broadest (tenant) ledger via guarded adjust_indexed; Postgres uses put_batch version-CAS serialization. Per-tenant contention, never global.
  • account_seg injectivity (Display renders absent slots as literal _ → collision) → hex(sha256(canonical_json(account))).
  • libSQL has no RETURNING-in-txn today → init-time probe + in-txn SELECT fallback + advertise the capability only after the probe confirms.
  • Premise correction: /turns/state.json is system()-scoped and multiplexes all tenants' runs (not per-tenant); migration re-keys each run by its own TurnScope.
  • Honest downgrade: turn events are at-least-once with idempotent recovery, not exactly-once (the append plane has no CAS); non-terminal events are lossy on a crash between the run-CAS and the append — stated plainly.

Rollout

Per-store StoreModel flag (Snapshot → DualWrite → Native), each store independent (disjoint prefixes), reversible at every step (rematerialize_snapshot() is a first-class artifact), green on both backends at every promotion. Order: 4 filesystem-primitive PRs first (trait+defaults+ScopedFilesystem wrappers → PG native → libSQL native+probe → in-memory atomicity audit), then Governor → Turn-state → Event-log → Threads.

Provenance

Synthesized via a 5-phase multi-agent design workflow: ground (4 readers over the actual trait + backends + hot stores) → 3 independent design angles → adversarial review of each → synthesis → invariant verification. Verify signoff: ACCEPT, all six invariants pass, no blockers (it even verified operation_allowed is an exhaustive match, so the new permission arms are compile-required). One residual to enforce at implementation time: in-memory default-path atomicity for adjust_indexed (PR-4).

Status / next steps

Design only — accepted, not implemented. Natural first implementation step is PR-1: the trait + capability surface + ScopedFilesystem wrappers + parameterized contract infra (pure addition, zero behavior change). Companion to the write-behind/lease design (#5249).

🤖 Generated with Claude Code

…tFilesystem trait

Design for making the reborn Postgres access pattern "native" (per-row,
indexed, partial-update) WITHOUT changing the RootFilesystem abstraction or
losing dual-backend (libSQL + Postgres). Generalizes the #5232 lease-sidecar
pattern across the remaining hot stores.

Root cause it addresses: the four hot stores funnel every mutation through a
whole-blob read-modify-write CAS on one Entry, serializing logically-disjoint
writers — worst of all the process-global /resources/snapshot.json under
ResourceScope::system(), CAS-contended across all tenants with no retry. (v1
never had this: it used native per-row INSERT/UPDATE on a 30-deep pool.)

The design keeps RootFilesystem frozen as the one contract and goes native via:
- Decomposition with ZERO new ops: hot stores write fine-grained per-entity
  records (per-run, per-account) CAS'd individually instead of one giant blob.
- TWO new optional native primitives — put_batch (atomic multi-put) and
  adjust_indexed (atomic conditional numeric delta) — each with a default impl
  built from existing ops (byte-only/in-memory backends unchanged) plus native
  Postgres AND native libSQL overrides, gated behind capability bits.
- A stated migration-safety invariant + CI parity test: both backends store
  byte-identical logical records, so libSQL<->Postgres stays a COPY not a
  transform.

Resolves the hard cases honestly: the CAS-only multi-account admission hole
(atomic gate on the broadest tenant ledger), account-seg injectivity
(sha256(canonical_json)), libSQL RETURNING-in-txn unavailability (init probe +
in-txn SELECT fallback + advertise-only-after-probe), and the turn-state
system()-scope premise correction. Per-store Snapshot->DualWrite->Native
rollout, reversible, green on both backends; Governor first.

Provenance: synthesized via a 5-phase multi-agent design workflow (ground ->
3 independent angles -> adversarial review -> synthesis -> invariant verify).
Verify signoff: ACCEPT, all six invariants pass, no blockers.

Design doc only — no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5269 June 25, 2026 22:27 Destroyed
@github-actions github-actions Bot added scope: docs Documentation size: XS < 10 changed lines (excluding docs) risk: low Changes to docs, tests, or low-risk modules labels Jun 25, 2026
@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Documentation
    • Added an implementation-grade proposal for “Native Hot-Store Decomposition” built on a unified RootFilesystem contract.
    • Introduced standardized migration-safety and parity requirements, plus optional native primitives (put_batch, adjust_indexed) with capability advertising.
    • Documented decomposition plans for resource accounting and turn-state/event logging, including operation ordering and migration/backfill behavior.
    • Specified CI parity tests, native-vs-default equivalence harness, and staged rollout/feature-flag guidance with risk and mitigation notes.

Walkthrough

Adds a storage design proposal for decomposing hot-store records under RootFilesystem. It defines two optional native primitives, updates resource governor and turn/event-log handling, and specifies parity tests, rollout flags, and risk controls across libSQL and Postgres.

Changes

Native storage primitives proposal

Layer / File(s) Summary
Problem statement and invariants
docs/plans/2026-06-26-native-storage-primitives.md
Introduces the proposal framing, invariants, and trait-preservation model for the unified RootFilesystem contract.
Capability surface and scoped wrappers
docs/plans/2026-06-26-native-storage-primitives.md
Adds capability bits, operation attribution, mount validation, and ScopedFilesystem wrapper requirements for the new operations.
put_batch primitive
docs/plans/2026-06-26-native-storage-primitives.md
Specifies put_batch request semantics, all-or-nothing behavior, default fallback, and backend-native atomic execution paths.
adjust_indexed primitive
docs/plans/2026-06-26-native-storage-primitives.md
Specifies adjust_indexed guard semantics and backend-native guarded numeric update paths.
Resource governor decomposition
docs/plans/2026-06-26-native-storage-primitives.md
Describes per-account and per-reservation records, reservation flow, account keying, indexes, and online migration/backfill.
Turn-state, event-log, and threads
docs/plans/2026-06-26-native-storage-primitives.md
Describes per-run records, append sequencing, query indexes, DualWrite backfill, and thread writepath changes.
Parity, rollout, and risks
docs/plans/2026-06-26-native-storage-primitives.md
Adds parity checks, staged rollout flags, contract tests, fault injection, risk notes, and open questions.

Estimated Code Review Effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

Hot blobs split into quieter streams,
two native primitives steer the beams.
One batch, one delta, paths stay true,
parity watches libSQL and Postgres too.
The plan settles clean, with edges in view.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is informative, but it does not follow the required template and omits several mandatory sections. Restructure it to match the template and add the missing Summary, Change Type, Linked Issue, Validation, Security, DB Impact, Rollback, and Review sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits style and accurately describes the design-doc change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the contributor: core 20+ merged PRs label Jun 25, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/plans/2026-06-26-native-storage-primitives.md`:
- Around line 69-77: Clarify the availability contract for put_batch across
RootFilesystem and ScopedFilesystem: the current text says the default
implementation works identically everywhere, but the later CAS-only N>1
Unsupported case makes that ambiguous. Update the §6.2 / §6.1 wording around
put_batch, adjust_indexed, Capability gating, and the mount-time requirement so
it clearly states when the primitive is callable, when it may fall back to the
default path, and when callers must treat the capability bit as required and
handle Unsupported explicitly.
- Around line 533-541: The thread append path still falls back to sequential CAS
when neither BatchPut nor begin is available, which conflicts with the stated
atomicity requirement. Update the native storage plan around the message write
path to remove the non-atomic reserve_sequence + sequential-CAS fallback from
the main flow and make TransactionalMessageWrite use only the
transactional/put_batch path, with ForceCas as the explicit opt-out escape
hatch. Keep the capability check in the store’s append logic, but ensure the
default behavior in the relevant message/thread write flow does not permit torn
multi-op writes.
- Around line 132-145: The capability expansion for in_memory_full() is too
aggressive for the current PR-4 state. Update the plan text so in_memory_full()
remains capped and do not add BatchPut or AdjustIndexed to NEW_AXES in the
catalog.rs validator yet; keep the wording aligned with the open per-op lock
retention audit and only describe the broader sql_typical_hotpath() coverage
once that guarantee is actually approved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c79f4491-06d5-46aa-9419-d081f275701a

📥 Commits

Reviewing files that changed from the base of the PR and between 799eb15 and 8281cfe.

📒 Files selected for processing (1)
  • docs/plans/2026-06-26-native-storage-primitives.md

Comment thread docs/plans/2026-06-26-native-storage-primitives.md Outdated
Comment on lines +132 to +145
`in_memory_full()` is widened to `sql_typical_hotpath()` (the in-memory backend serves both via default impls under its per-op lock — see parity notes).

**Mount validator (catalog.rs)** — add both bits to `NEW_AXES`:

```rust
const NEW_AXES: &[Capability] = &[
Capability::Records, Capability::Query,
Capability::IndexExact, Capability::IndexPrefix,
Capability::IndexFts, Capability::IndexVector,
Capability::Events,
Capability::BatchPut, Capability::AdjustIndexed,
];
```
That is the entire validator change; the existing `declared.has(cap) && !backend.has(cap) → DescriptorOverclaims` loop handles the new bits by construction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '^(docs/plans/2026-06-26-native-storage-primitives\.md|CLAUDE\.md|AGENTS\.md|\.claude/rules|src/db/CLAUDE\.md|FEATURE_PARITY\.md)$'

echo
echo "== outline the plan doc =="
wc -l docs/plans/2026-06-26-native-storage-primitives.md
sed -n '110,170p' docs/plans/2026-06-26-native-storage-primitives.md

echo
echo "== search for in_memory_full/sql_typical_hotpath/BatchPut/AdjustIndexed =="
rg -n "in_memory_full|sql_typical_hotpath|BatchPut|AdjustIndexed|NEW_AXES|DescriptorOverclaims|per-op lock|holds the lock" docs src .claude CLAUDE.md AGENTS.md FEATURE_PARITY.md

echo
echo "== inspect relevant guidance files if present =="
for f in CLAUDE.md AGENTS.md src/db/CLAUDE.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    sed -n '1,220p' "$f"
  fi
done

Repository: nearai/ironclaw

Length of output: 41601


🏁 Script executed:

set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '^(docs/plans/2026-06-26-native-storage-primitives\.md|CLAUDE\.md|AGENTS\.md|\.claude/rules|src/db/CLAUDE\.md|FEATURE_PARITY\.md)$'

echo
echo "== outline the plan doc =="
wc -l docs/plans/2026-06-26-native-storage-primitives.md
sed -n '110,170p' docs/plans/2026-06-26-native-storage-primitives.md

echo
echo "== search for in_memory_full/sql_typical_hotpath/BatchPut/AdjustIndexed =="
rg -n "in_memory_full|sql_typical_hotpath|BatchPut|AdjustIndexed|NEW_AXES|DescriptorOverclaims|per-op lock|holds the lock" docs src .claude CLAUDE.md AGENTS.md FEATURE_PARITY.md

echo
echo "== inspect relevant guidance files if present =="
for f in CLAUDE.md AGENTS.md src/db/CLAUDE.md; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    sed -n '1,220p' "$f"
  fi
done

Repository: nearai/ironclaw

Length of output: 41601


🏁 Script executed:

set -euo pipefail

echo "== docs/plans/2026-06-26-native-storage-primitives.md around the cited lines =="
nl -ba docs/plans/2026-06-26-native-storage-primitives.md | sed -n '120,155p'

echo
echo "== same doc: references to PR-4 and parity notes =="
rg -n -C 2 "PR-4|parity notes|in_memory_full|sql_typical_hotpath|BatchPut|AdjustIndexed" docs/plans/2026-06-26-native-storage-primitives.md

echo
echo "== any repo guidance about docs/plan assertions or parity docs =="
rg -n -C 2 "FEATURE_PARITY|parity notes|plan|docs/plans|advertise|capability" CLAUDE.md AGENTS.md .claude src docs | head -n 200

Repository: nearai/ironclaw

Length of output: 273


Keep in_memory_full() capped for now. PR-4 still treats per-op lock retention as the open audit, so advertising BatchPut/AdjustIndexed here overstates the current guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-06-26-native-storage-primitives.md` around lines 132 - 145,
The capability expansion for in_memory_full() is too aggressive for the current
PR-4 state. Update the plan text so in_memory_full() remains capped and do not
add BatchPut or AdjustIndexed to NEW_AXES in the catalog.rs validator yet; keep
the wording aligned with the open per-op lock retention audit and only describe
the broader sql_typical_hotpath() coverage once that guarantee is actually
approved.

Comment on lines +533 to +541
1. **Adopt `put_batch`** for the message append, replacing the hand-rolled `begin`/`StorageTxn` 4-op txn + `TransactionalMessageWrite::Unsupported` enum. The store **requires** atomicity, so it gates on the `BatchPut` capability (or `begin` availability) and keeps its existing `reserve_sequence`+sequential-CAS fallback for backends advertising neither. Public API unchanged.
2. **Native query indexes** replace bespoke index files:
```rust
ensure_index("/threads", IndexSpec{ name:"msg_by_seq", keys:["thread_seg","sequence"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"msg_by_run", keys:["run_id"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"threads_by_scope", keys:["scope_seg"], kind: Exact });
```
Message `indexed` gains `{ "thread_seg": Text, "sequence": I64, "run_id": Text }`; thread `indexed` gains `{ "scope_seg": Text, "updated_at": I64, "owner_user": Text }`. `list_threads_for_scope` becomes `query(/threads, Eq{scope_seg})` + Rust sort by `updated_at`, retiring the N+1 `list_dir`+per-file `get` (`filesystem_service.rs:1992-2048`). Range "messages [lo,hi]" → `query(Range{key:"sequence", lo:I64, hi:I64})`.
3. **`ForceCas` escape hatch:** an operational flag (`ThreadWritePath { TxnOrCas (default), ForceCas }`) to disable the transactional path instantly if a backend's `begin`/`put_batch` misbehaves. Zero data migration, reversible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remove the non-atomic thread fallback.

This paragraph says the store requires atomicity, but the fallback for backends without BatchPut/begin is sequential CAS. That fallback cannot satisfy the stated requirement and reintroduces torn multi-op writes on the exact backends that need the escape hatch.

Suggested wording
- The store **requires** atomicity, so it gates on the `BatchPut` capability (or `begin` availability) and keeps its existing reserve_sequence+sequential-CAS fallback for backends advertising neither.
+ The store **requires** atomicity, so it gates on the `BatchPut` capability (or `begin` availability). If neither is available, reject the atomic write instead of falling back to sequential CAS.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
1. **Adopt `put_batch`** for the message append, replacing the hand-rolled `begin`/`StorageTxn` 4-op txn + `TransactionalMessageWrite::Unsupported` enum. The store **requires** atomicity, so it gates on the `BatchPut` capability (or `begin` availability) and keeps its existing `reserve_sequence`+sequential-CAS fallback for backends advertising neither. Public API unchanged.
2. **Native query indexes** replace bespoke index files:
```rust
ensure_index("/threads", IndexSpec{ name:"msg_by_seq", keys:["thread_seg","sequence"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"msg_by_run", keys:["run_id"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"threads_by_scope", keys:["scope_seg"], kind: Exact });
```
Message `indexed` gains `{ "thread_seg": Text, "sequence": I64, "run_id": Text }`; thread `indexed` gains `{ "scope_seg": Text, "updated_at": I64, "owner_user": Text }`. `list_threads_for_scope` becomes `query(/threads, Eq{scope_seg})` + Rust sort by `updated_at`, retiring the N+1 `list_dir`+per-file `get` (`filesystem_service.rs:1992-2048`). Range "messages [lo,hi]" → `query(Range{key:"sequence", lo:I64, hi:I64})`.
3. **`ForceCas` escape hatch:** an operational flag (`ThreadWritePath { TxnOrCas (default), ForceCas }`) to disable the transactional path instantly if a backend's `begin`/`put_batch` misbehaves. Zero data migration, reversible.
1. **Adopt `put_batch`** for the message append, replacing the hand-rolled `begin`/`StorageTxn` 4-op txn + `TransactionalMessageWrite::Unsupported` enum. The store **requires** atomicity, so it gates on the `BatchPut` capability (or `begin` availability). If neither is available, reject the atomic write instead of falling back to sequential CAS. Public API unchanged.
2. **Native query indexes** replace bespoke index files:
ensure_index("/threads", IndexSpec{ name:"msg_by_seq", keys:["thread_seg","sequence"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"msg_by_run", keys:["run_id"], kind: Exact });
ensure_index("/threads", IndexSpec{ name:"threads_by_scope", keys:["scope_seg"], kind: Exact });
Message `indexed` gains `{ "thread_seg": Text, "sequence": I64, "run_id": Text }`; thread `indexed` gains `{ "scope_seg": Text, "updated_at": I64, "owner_user": Text }`. `list_threads_for_scope` becomes `query(/threads, Eq{scope_seg})` + Rust sort by `updated_at`, retiring the N+1 `list_dir`+per-file `get` (`filesystem_service.rs:1992-2048`). Range "messages [lo,hi]" → `query(Range{key:"sequence", lo:I64, hi:I64})`.
3. **`ForceCas` escape hatch:** an operational flag (`ThreadWritePath { TxnOrCas (default), ForceCas }`) to disable the transactional path instantly if a backend's `begin`/`put_batch` misbehaves. Zero data migration, reversible.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-06-26-native-storage-primitives.md` around lines 533 - 541,
The thread append path still falls back to sequential CAS when neither BatchPut
nor begin is available, which conflicts with the stated atomicity requirement.
Update the native storage plan around the message write path to remove the
non-atomic reserve_sequence + sequential-CAS fallback from the main flow and
make TransactionalMessageWrite use only the transactional/put_batch path, with
ForceCas as the explicit opt-out escape hatch. Keep the capability check in the
store’s append logic, but ensure the default behavior in the relevant
message/thread write flow does not permit torn multi-op writes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request proposes a design plan for decomposing hot-store whole-blob CAS operations into fine-grained per-entity records on the unified RootFilesystem trait to eliminate writer contention. The review feedback provides valuable improvements, including handling potential Postgres casting errors during adjust_indexed disambiguation, ensuring the libSQL version-readback probe rolls back its scratch transaction, implementing ownership checks for user-owned resources, and centralizing overflow-safe period rollover calculations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

RETURNING ((indexed->>$key)::bigint) AS value, version;
```
- 1 row ⇒ applied; `value`/`version` from `RETURNING`.
- 0 rows ⇒ disambiguate with one `SELECT (indexed->>$key)::bigint, version WHERE path=$path` on the same connection:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In the proposed Postgres native implementation of adjust_indexed, when the UPDATE statement matches 0 rows, the design suggests disambiguating with a SELECT (indexed->>$key)::bigint, version WHERE path=$path query.

However, if the row exists but the value stored at $key is non-numeric (e.g., a string or boolean), casting it directly to bigint via (indexed->>$key)::bigint will throw a database runtime error (invalid input syntax for type bigint) instead of allowing the code to handle it gracefully.

To prevent this, the disambiguation query should retrieve the raw JSONB value (or use jsonb_typeof to guard the cast) and let the Rust driver validate and parse the type.

Suggested change
- 0 rows ⇒ disambiguate with one `SELECT (indexed->>$key)::bigint, version WHERE path=$path` on the same connection:
- 0 rows ⇒ disambiguate with one SELECT indexed->$key, version FROM root_filesystem_entries WHERE path=$path on the same connection (validating the type and parsing in Rust to avoid a Postgres cast error if the value is non-numeric):

**Native libSQL impl (libsql.rs):** `BEGIN IMMEDIATE … COMMIT` (the dialect gotcha — *not* deferred, so the write lock is taken up front; a deferred txn that upgrades mid-statement can hit `SQLITE_BUSY` after partial work and violate all-or-nothing). Per-put SQL is the libsql `?N`/`is_dir=0`/`strftime` variant.

**Version-readback (review B — libsql `put()` never reads version back today).** libsql `put()` returns version arithmetically (`expected.next()` / `from_backend(1)`), and there is **no `RETURNING` usage in libsql.rs today**. `put_batch` needs the real assigned version. Resolution, in order:
1. At store init, **probe once** whether the bundled libSQL build supports statement-level `RETURNING` inside `BEGIN IMMEDIATE` (run `INSERT … RETURNING version` against a scratch row in a txn). Cache the result.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The proposed libSQL version-readback probe runs an INSERT … RETURNING version against a scratch row in a transaction at store initialization.

To ensure that this probe does not leave any persistent garbage or scratch rows in the database, the design should explicitly specify that this transaction must be rolled back (ROLLBACK) rather than committed.

Suggested change
1. At store init, **probe once** whether the bundled libSQL build supports statement-level `RETURNING` inside `BEGIN IMMEDIATE` (run `INSERT … RETURNING version` against a scratch row in a txn). Cache the result.
1. At store init, probe once whether the bundled libSQL build supports statement-level RETURNING inside BEGIN IMMEDIATE (run INSERT … RETURNING version against a scratch row in a txn and then immediately roll it back). Cache the result.

```
Message `indexed` gains `{ "thread_seg": Text, "sequence": I64, "run_id": Text }`; thread `indexed` gains `{ "scope_seg": Text, "updated_at": I64, "owner_user": Text }`. `list_threads_for_scope` becomes `query(/threads, Eq{scope_seg})` + Rust sort by `updated_at`, retiring the N+1 `list_dir`+per-file `get` (`filesystem_service.rs:1992-2048`). Range "messages [lo,hi]" → `query(Range{key:"sequence", lo:I64, hi:I64})`.
3. **`ForceCas` escape hatch:** an operational flag (`ThreadWritePath { TxnOrCas (default), ForceCas }`) to disable the transactional path instantly if a backend's `begin`/`put_batch` misbehaves. Zero data migration, reversible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

When implementing services that interact with user-owned resources (such as the newly decomposed thread and run records), we must always perform an ownership check (e.g., verifying that the authenticated user ID matches the resource owner) before any read or write operations.

Ensure that the underlying resource coordinator or filesystem query is not invoked if the ownership check fails. This is a critical security boundary to prevent unauthorized cross-tenant or cross-user access.

Suggested change
Message indexed gains { "thread_seg": Text, "sequence": I64, "run_id": Text }; thread indexed gains { "scope_seg": Text, "updated_at": I64, "owner_user": Text }. list_threads_for_scope becomes query(/threads, Eq{scope_seg}) + Rust sort by updated_at (after performing an ownership check to verify the authenticated user ID matches the resource owner), retiring the N+1 list_dir+per-file get (filesystem_service.rs:1992-2048). Range "messages [lo,hi]" → query(Range{key:"sequence", lo:I64, hi:I64}).
References
  1. When implementing services that interact with user-owned resources, always perform an ownership check (e.g., verifying the authenticated user ID matches the resource owner) before any read or write operations. Ensure the underlying resource coordinator is not invoked if the ownership check fails.


### 7.5 Period rollover must be persisted (review C)

`advance_period_if_rolled_over` mutates `ledger.usage`/`period_end_at_anchor` in memory during the Phase-1 read. In the per-record model this in-memory mutation **must be committed via the Phase-2 write even when the reserve check is otherwise non-mutating** (e.g. a check that ultimately denies, or a pure rollover) — otherwise rollover silently never persists and the period never advances. Concretely: if Phase-1 advanced an anchor, that account's ledger is included in the Phase-2 `put_batch`/admission write with its rolled-over `usage`+`anchor`, CAS'd by its read version, **even if `requested` is zero or the reserve is denied on a different account**. A rollover write that loses its CAS simply retries (the next read sees the advanced anchor). This is an explicit per-account CAS write inside the cascade, with the same retry/idempotency as the reserve.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When calculating sliding-window cutoffs or period rollovers (such as period_end_at_anchor or advance_period_if_rolled_over), extremely large windows or anchors must not cause underflow or overflow that collapses the cutoff to the current time (which would incorrectly trim all entries).

Instead, ensure they saturate or default to a timestamp far in the past/future so that nothing is incorrectly trimmed or rolled over. This logic should be centralized in a canonical shared function to prevent behavioral drift across different backend implementations.

Suggested change
`advance_period_if_rolled_over` mutates `ledger.usage`/`period_end_at_anchor` in memory during the Phase-1 read. In the per-record model this in-memory mutation **must be committed via the Phase-2 write even when the reserve check is otherwise non-mutating** (e.g. a check that ultimately denies, or a pure rollover) — otherwise rollover silently never persists and the period never advances. Concretely: if Phase-1 advanced an anchor, that account's ledger is included in the Phase-2 `put_batch`/admission write with its rolled-over `usage`+`anchor`, CAS'd by its read version, **even if `requested` is zero or the reserve is denied on a different account**. A rollover write that loses its CAS simply retries (the next read sees the advanced anchor). This is an explicit per-account CAS write inside the cascade, with the same retry/idempotency as the reserve.
advance_period_if_rolled_over mutates ledger.usage/period_end_at_anchor in memory during the Phase-1 read (using a centralized, overflow-safe function to prevent extremely large windows from collapsing the cutoff to the current time). In the per-record model this in-memory mutation must be committed via the Phase-2 write even when the reserve check is otherwise non-mutating (e.g. a check that ultimately denies, or a pure rollover) — otherwise rollover silently never persists and the period never advances. Concretely: if Phase-1 advanced an anchor, that account's ledger is included in the Phase-2 put_batch/admission write with its rolled-over usage+anchor, CAS'd by its read version, even if requested is zero or the reserve is denied on a different account. A rollover write that loses its CAS simply retries (the next read sees the advanced anchor). This is an explicit per-account CAS write inside the cascade, with the same retry/idempotency as the reserve.
References
  1. When calculating sliding-window cutoffs, ensure that extremely large windows (which may cause underflow or overflow) do not collapse the cutoff to the current time (which would incorrectly trim all entries). Instead, ensure they saturate or default to a timestamp far in the past so that nothing is trimmed. Centralize this logic in a canonical shared function to prevent behavioral drift across different backend implementations.

…ployment)

The hosted profile is RebornProfile::HostedSingleTenant — one tenant
(reborn-cli), many users — with per-user resource limits and no tenant-wide
cap. The generic design's headline ("different tenants never contend") gives
nothing on a single-tenant instance, and as written every reserve still writes
the shared tenant ledger.

Add §7.8 with the principle "maintain a hot per-account counter only where there
is a limit to enforce" → the limitless tenant/system levels leave the hot path;
the broadest hot account becomes the user ledger. Result: different users CAS
disjoint paths (zero cross-user contention); same user serializes on their own
ledger (correct). Tenant/global totals, if needed, are derived-on-read, never a
hot counter. Reconcile §7.3/§7.4 (admission gate is the broadest *limited*
account = user) and Open Q1 accordingly. Also preserves per-user independence as
the precondition for a future per-user shard/cell split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5269 June 25, 2026 22:38 Destroyed

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/plans/2026-06-26-native-storage-primitives.md (1)

362-367: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Don't call a SHA-256 path key injective.

A hash is fixed-length and path-safe, but it is not injective. This overstates the guarantee, and the proposed test only checks examples, not the invariant. If injectivity is required, use a reversible escaped encoding; otherwise reword this to “collision-resistant” and keep the hash as an implementation detail.

Suggested wording
- account_seg = hex(sha256(canonical_json(&account)))   // fixed-length, path-safe, structurally injective
+ account_seg = hex(sha256(canonical_json(&account)))   // fixed-length, path-safe, collision-resistant
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-06-26-native-storage-primitives.md` around lines 362 - 367,
The plan currently overclaims that the hashed storage key is “injective”; a
SHA-256-derived account_seg is collision-resistant and path-safe, but not
reversible, so adjust the wording and requirement in canonical_json/account_seg
to avoid promising injectivity. If true injectivity is needed, switch to a
reversible escaped encoding instead of hashing; otherwise keep the hash approach
and describe it as collision-resistant while preserving body.account and
indexed.tenant/indexed.kind_tag for identity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/plans/2026-06-26-native-storage-primitives.md`:
- Around line 411-413: The libSQL admission-gate paragraph is using
tenant-ledger wording that conflicts with the per-user/no-tenant-cap design.
Update the `libSQL (CAS-only floor)` description to consistently say the
admission point is the broadest limited account, specifically the `user` ledger
for this deployment, and remove references to per-tenant
serialization/contention so the text matches §7.8 and the `adjust_indexed`
admission flow.

---

Outside diff comments:
In `@docs/plans/2026-06-26-native-storage-primitives.md`:
- Around line 362-367: The plan currently overclaims that the hashed storage key
is “injective”; a SHA-256-derived account_seg is collision-resistant and
path-safe, but not reversible, so adjust the wording and requirement in
canonical_json/account_seg to avoid promising injectivity. If true injectivity
is needed, switch to a reversible escaped encoding instead of hashing; otherwise
keep the hash approach and describe it as collision-resistant while preserving
body.account and indexed.tenant/indexed.kind_tag for identity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 416a1d16-b42f-423f-8d75-7e7eb3aa33a9

📥 Commits

Reviewing files that changed from the base of the PR and between 8281cfe and 440e25a.

📒 Files selected for processing (1)
  • docs/plans/2026-06-26-native-storage-primitives.md

Comment on lines +411 to +413
- **libSQL (CAS-only floor):** there is no multi-key transaction, so we introduce a **narrow atomic admission record at the broadest cascade account that carries a limit**. Under this deployment's **per-user limits with no tenant cap, that is the `user` ledger** (§7.8); it would be the tenant ledger only if a tenant-wide cap existed. The reserve does its Phase-1 check, then performs the **commit as a single `adjust_indexed` on the admission ledger's `reserved_*` counter with `AtMost(limit)` guard** — this one conditional `UPDATE` takes the libSQL write lock and is the linearization point. If it `applied=false` (guard violation under concurrency), the reserve is denied — **correctly, atomically, no overshoot**. Only after the admission leg succeeds does the reserve apply the narrower-account deltas (project/agent/…) and write the reservation record, each idempotent (see below). The deeper accounts are *subordinate* to the admission; they apply with ordinary CAS retries because a breach of a deeper, narrower limit is caught by *its own* `adjust_indexed` `AtMost` guard in the same way — the key property is that **each account's own limit is enforced by its own atomic guarded adjust**, and the broadest *limited* account is the single serialization point that all reserves **sharing that budget** funnel through. Because limits are per-user, reserves from *different* users funnel through *different* admission ledgers and never contend.

This re-introduces a contention point, but a **per-tenant** one (the tenant ledger), not a **global** one. That is the correct and minimal surface: reserves in *different* tenants still never contend; reserves in the *same* tenant serialize on the tenant admission, which they must, because they share the tenant budget. This is strictly better than today's global, no-retry blob.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the libSQL admission gate on the user ledger.

This paragraph still talks about a per-tenant contention point, but §7.8 already makes this deployment per-user/no-tenant-cap. As written, the implementation would serialize the wrong row. Rewrite it to say “broadest limited account” or “user ledger” here and drop the tenant wording.

Suggested wording
- This re-introduces a contention point, but a per-tenant one (the tenant ledger), not a global one. That is the correct and minimal surface: reserves in different tenants still never contend; reserves in the same tenant serialize on the tenant admission, which they must, because they share the tenant budget.
+ This re-introduces a contention point on the broadest limited account for this deployment: the user ledger. Different users still never contend; same-user reserves serialize on their own budget, which they must.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **libSQL (CAS-only floor):** there is no multi-key transaction, so we introduce a **narrow atomic admission record at the broadest cascade account that carries a limit**. Under this deployment's **per-user limits with no tenant cap, that is the `user` ledger** (§7.8); it would be the tenant ledger only if a tenant-wide cap existed. The reserve does its Phase-1 check, then performs the **commit as a single `adjust_indexed` on the admission ledger's `reserved_*` counter with `AtMost(limit)` guard** — this one conditional `UPDATE` takes the libSQL write lock and is the linearization point. If it `applied=false` (guard violation under concurrency), the reserve is denied — **correctly, atomically, no overshoot**. Only after the admission leg succeeds does the reserve apply the narrower-account deltas (project/agent/…) and write the reservation record, each idempotent (see below). The deeper accounts are *subordinate* to the admission; they apply with ordinary CAS retries because a breach of a deeper, narrower limit is caught by *its own* `adjust_indexed` `AtMost` guard in the same way — the key property is that **each account's own limit is enforced by its own atomic guarded adjust**, and the broadest *limited* account is the single serialization point that all reserves **sharing that budget** funnel through. Because limits are per-user, reserves from *different* users funnel through *different* admission ledgers and never contend.
This re-introduces a contention point, but a **per-tenant** one (the tenant ledger), not a **global** one. That is the correct and minimal surface: reserves in *different* tenants still never contend; reserves in the *same* tenant serialize on the tenant admission, which they must, because they share the tenant budget. This is strictly better than today's global, no-retry blob.
- **libSQL (CAS-only floor):** there is no multi-key transaction, so we introduce a **narrow atomic admission record at the broadest cascade account that carries a limit**. Under this deployment's **per-user limits with no tenant cap, that is the `user` ledger** (§7.8); it would be the tenant ledger only if a tenant-wide cap existed. The reserve does its Phase-1 check, then performs the **commit as a single `adjust_indexed` on the admission ledger's `reserved_*` counter with `AtMost(limit)` guard** — this one conditional `UPDATE` takes the libSQL write lock and is the linearization point. If it `applied=false` (guard violation under concurrency), the reserve is denied — **correctly, atomically, no overshoot**. Only after the admission leg succeeds does the reserve apply the narrower-account deltas (project/agent/…) and write the reservation record, each idempotent (see below). The deeper accounts are *subordinate* to the admission; they apply with ordinary CAS retries because a breach of a deeper, narrower limit is caught by *its own* `adjust_indexed` `AtMost` guard in the same way — the key property is that **each account's own limit is enforced by its own atomic guarded adjust**, and the broadest *limited* account is the single serialization point that all reserves **sharing that budget** funnel through. Because limits are per-user, reserves from *different* users funnel through *different* admission ledgers and never contend.
-
**libSQL (CAS-only floor):** there is no multi-key transaction, so we introduce a **narrow atomic admission record at the broadest cascade account that carries a limit**. Under this deployment's **per-user limits with no tenant cap, that is the `user` ledger** (§7.8); it would be the tenant ledger only if a tenant-wide cap existed. The reserve does its Phase-1 check, then performs the **commit as a single `adjust_indexed` on the admission ledger's `reserved_*` counter with `AtMost(limit)` guard** — this one conditional `UPDATE` takes the libSQL write lock and is the linearization point. If it `applied=false` (guard violation under concurrency), the reserve is denied — **correctly, atomically, no overshoot**. Only after the admission leg succeeds does the reserve apply the narrower-account deltas (project/agent/…) and write the reservation record, each idempotent (see below). The deeper accounts are *subordinate* to the admission; they apply with ordinary CAS retries because a breach of a deeper, narrower limit is caught by *its own* `adjust_indexed` `AtMost` guard in the same way — the key property is that **each account's own limit is enforced by its own atomic guarded adjust**, and the broadest *limited* account is the single serialization point that all reserves **sharing that budget** funnel through. Because limits are per-user, reserves from *different* users funnel through *different* admission ledgers and never contend.
This re-introduces a contention point on the broadest limited account for this deployment: the `user` ledger. Different users still never contend; same-user reserves serialize on their own budget, which they must.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-06-26-native-storage-primitives.md` around lines 411 - 413,
The libSQL admission-gate paragraph is using tenant-ledger wording that
conflicts with the per-user/no-tenant-cap design. Update the `libSQL (CAS-only
floor)` description to consistently say the admission point is the broadest
limited account, specifically the `user` ledger for this deployment, and remove
references to per-tenant serialization/contention so the text matches §7.8 and
the `adjust_indexed` admission flow.

@henrypark133

Copy link
Copy Markdown
Collaborator

Multi-lens design review — native hot-store primitives

Reviewed through five lenses (approach, maintainability, local-patterns, thermo-nuclear architecture, and a dedicated concurrency/CAS/TOCTOU pass). Every load-bearing code anchor in the doc was verified against the real tree (23/25 match — the two misses are called out below).

First: this is a genuinely strong doc. The grounding is accurate, the parity invariant (§3.7) is well-argued, the honest downgrades (at-least-once events §8.3, premise correction §8.0, account_seg sha256 §7.2, dropping merge_indexed §4, period-rollover-must-persist §7.5) are exactly the things weaker designs paper over. The critique below is concentrated almost entirely on the resource-governor portion; turns / event-log / threads are sound.


TL;DR verdict

  • Turns + event-log + threads decomposition: sound — ship it. Per-entity records + append-plane events + expression indexes are the right shape for genuinely KV/log-shaped data.
  • Resource governor: not shippable as written. Three independent reviewers (concurrency, thermo-arch, maintainability) converged on the same critical defects, all triggered by the normal reserve→reconcile→reserve cycle, not edge cases.
  • The Postgres path is airtight; the libSQL path is where it breaks. put_batch with per-leg version-CAS inside one BEGIN…COMMIT is a true linearization point across all dimensions and accounts. The libSQL adjust_indexed admission gate is not.

Critical (governor)

C1 — The libSQL "single linearization point" is architecturally false for multi-dimensional limits (§7.4).
adjust_indexed takes one key, one delta. But ResourceTally/ResourceLimits are 8-dimensional (usd, input_tokens, output_tokens, wall_clock_ms, output_bytes, network_egress_bytes, process_count, concurrency_slots — verified in crates/ironclaw_resources/src/lib.rs). An 8-dimension reserve on libSQL = 8 sequential adjust_indexed calls = 8 independent BEGIN IMMEDIATE lock acquire/release cycles. The write lock is released between calls. Over-admission interleaving (same user, limit usd=10000/concurrency=2):

T1 [A] Phase1 reads usd=0, conc=0 → pass
T2 [B] Phase1 reads usd=0, conc=0 → pass
T3 [A] adjust usd +6000 AtMost(10000) → 6000  ✓ applied
T4 [B] adjust usd +4000 AtMost(10000) → 10000 ✓ applied
T5 [A] adjust conc +1 AtMost(2) → 1 ✓ applied
T6 [B] adjust conc +2 AtMost(2) → 3 ✗ NOT applied

B now holds a committed +4000 usd in indexed with no reservation record (record is written only after all dims succeed). If B crashes before its compensating usd -= 4000, the phantom is invisible to the sweeper (which scans reservation records — there is none) and blocks future USD reserves until a full ledger-vs-reservation-sum reconciliation that the design never specifies. The §7.4 claim "this one conditional UPDATE is the linearization point" only holds for one-dimensional consumption.

C2 — The crash-sweeper that applied_reservations depends on does not exist (§7.4).
The libSQL idempotency guarantee rests on applied_reservations: BTreeSet<ResId> being "pruned on reconcile/release; a crash-sweeper GC (already conceptually present) reclaims leaks." Global search: no resource-reservation sweeper anywhere in the tree. The only "sweeper" is ironclaw_product_workflow/src/ledger.rs (unrelated durable-ledger TTL). So: (a) the set grows unbounded on every crashed/leaked reserve, bloating the JSONB body that adjust_indexed reads+rewrites on every hot call; (b) the idempotency guarantee is unimplemented. The doc should drop "already conceptually present" and make the sweeper a named, blocking prerequisite PR with a race proof against the live admit path — or remove the need for it (see fix below).

C3 — adjust_indexed writes indexed but never body; Phase 1 reads body → persistent false denials (§7.2 + §6.2).
§7.2: counter "kept in sync on every write" in two places (body for fidelity, indexed for native ops). But §6.2's native SQL is SET indexed = jsonb_set(...) with body explicitly untouched. Phase 1 (§7.3) evaluates limits from ledger.reserved/usage deserialized from body. After any put_batch/migration writes body non-zero, then a native adjust_indexed reconcile drops indexed below body, body is stale-high forever:

put_batch/migration: body.reserved=80, indexed.reserved=80   (sync)
native reconcile:    indexed.reserved=0,  body.reserved=80    (stale-high)
next reserve: Phase1 reads body=80, limit=100, req=70 → 150>100 DENIED
              actual indexed: 0+70=70<100 → should ALLOW

The gap widens monotonically with each reconcile → admission rate degrades over process lifetime. This is duplicate-truth (maintainability rubric: same value, 2 places, hot path writes only one, divergence breaks behavior).

C4 — mid-cascade partial commit on libSQL has no compensation path (§7.3/§7.4). Reserve admits the broadest limited ledger, then applies deeper-account deltas. If a deeper guard fails (or a tenant cap is ever added per §7.8), the already-committed broader adjust_indexed has no specified rollback. "Symmetric inverse" is defined only for successful reservations.


The convergent fix — pick one, in preference order

All four non-local reviewers independently pointed at the same resolution space. C1–C4 are all artifacts of forcing an all-or-nothing, multi-dimensional, relational quota onto a single-key JSONB primitive.

Option A (recommended) — Governor as a relational sidecar, exactly like triggers already did. ironclaw_triggers already has trigger_records/trigger_run_history as real typed tables with its own TriggerRepository trait on both backends (crates/ironclaw_triggers/src/postgres.rs:31). The "one trait, no forks" freeze the doc cites from the 2026-05-14 ADR is already negotiated for exactly this case — a genuinely relational store. The governor has the identical shape (typed multi-dim tallies, period anchors, reservation↔account relationships). A sidecar gives:

UPDATE resource_account_ledgers
SET reserved_usd = reserved_usd + $d_usd, reserved_input_tokens = reserved_input_tokens + $d_tok, ...
    version = version + 1
WHERE account_id=$id AND version=$v
  AND reserved_usd + $d_usd <= $lim_usd
  AND reserved_input_tokens + $d_tok <= $lim_tok ...
RETURNING version;

One atomic multi-dimensional guarded UPDATE → C1, C2 (no sweeper), C3 (real columns, no dual-truth), C4 all vanish, plus Postgres HOT updates for free (no index churn — see P1). Cost: two native schemas instead of one trait copy, and the governor loses the generic query/tail migration tool. Keep the KV trait for turns/events/threads where it genuinely fits. This is the thermo-arch reviewer's strong recommendation and the approach reviewer's anchored alternative.

Option B — Pull Open Question §12.4 into PR-3 as a prerequisite. Raise libSQL to TxnCapability::MultiKey using the BEGIN IMMEDIATE machinery the put_batch native override already needs, then route the governor through the same put_batch commit path on both backends. Retires the admission gate, applied_reservations, and the sweeper. Caveat: the batch must still guard all 8 dimensions atomically — encode the full tally as one record CAS'd as a unit, not N single-key adjusts. This keeps the trait frozen but deletes the libSQL special case (C1/C2/C4).

Option C — Drop adjust_indexed entirely, the way merge_indexed was dropped in §4, for the same reason. §7.8 already makes ledgers per-user, so different users never contend and same-user concurrent reserves are bounded by that user's own fan-out — plain get→check→put(Version) with the restored 16-retry loop suffices. This removes a 10-concept × 4-impl primitive (default RMW / PG / libSQL / in-memory + create-race retry + guard-as-normal-result + version-readback probe) whose only consumer is the governor (speculative-generality by the rubric). Combine with Option A or B for the multi-account atomicity.

My read: A is the cleanest and matches the repo's own precedent; B is the smallest diff if the trait-freeze is treated as inviolable; C should fold in regardless.


Performance gaps the doc under-sells (independent of the above)

P1 — JSONB write-amplification on hot counters (§6.2). jsonb_set re-serializes the entire indexed value and bumps every expression index on the row each call → MVCC dead tuples + index churn + VACUUM bloat precisely on the hottest rows. This forfeits Postgres HOT updates, which native typed counter columns get for free. At reserve+reconcile per model call this is real at single-user scale, not a "future sharding concern" (§12 Q1). Either benchmark + document aggressive autovacuum on root_filesystem_entries as a deployment prereq, or use typed columns (→ Option A).

P2 — Expression indexes aren't prefix-partial → planner degradation at scale (§7.6/§8.4). ensure_index emits a global CREATE INDEX … ((indexed->>'key')) with no path predicate (postgres.rs:254-259). For /turns/runs at millions of rows, a scope_seg query either full-scans the path range or cross-pulls all rows with that value before the path filter narrows. The FTS index already does the right thing — partial WHERE path LIKE … (postgres.rs:292-298). Mirror that for Exact/Prefix, and add EXPLAIN ANALYZE assertions to the CI parity suite that the intended index is actually used.

P3 — Row-count explosion + event purge gap (§8.2). Decomposition multiplies 1 turn blob → 1 run row + 5–10 UPDATEs each → millions of dead tuples; the doc doesn't quantify VACUUM surface. And event_retention_floor/head_seq only filter — no purge of root_filesystem_events is specified. Does "LLM-data-never-deleted" apply to the events table (→ unbounded growth) or not (→ purge mechanism missing)? State it.

P4 — Vector honesty gap (§3.4 vs reality). Mounts advertise IndexVector but vector search is brute-force Rust cosine over the full prefix (postgres.rs:157-163). The doc adds thread indexes but leaves this untouched while §3.4 says "capabilities are honest or you don't mount." Either address pgvector or stop advertising IndexVector on these mounts.


Correctness of the doc itself — fix before implementers start

These will cause compile errors or wasted diagnosis:

  • FilesystemOperation::HeadSeq already exists (types.rs:41, Display at :61, operation_allowed arm at scoped.rs:431). The §6.0 snippet lists it as new → duplicate-variant compile error. Only PutBatch/AdjustIndexed are net-new; fix the snippet.
  • pool_max_size=16 / PR [codex] Add hosted single-tenant Postgres profile #5081 are not in the codebase. The filesystem pool (PostgresRootFilesystem::new, postgres.rs:39) takes an external handle with no internal max; the only 30s-checkout/size constants live in the event-store pool with DEFAULT_POSTGRES_POOL_MAX_SIZE=2 (ironclaw_reborn_event_store/src/lib.rs:55). The MAX_BATCH_PUTS safety argument (§6.1) rests on a floor that doesn't exist — cite the real pool-config site in reborn_composition/factory.rs.
  • cascade minimum is 2 (tenant+user), not "1–6" (lib.rs:222) → say "2–6".
  • Stale line anchors: libsql.rs:1590 → :1595 (guard is at 1595; 1590 is a comment); lifecycle.rs:533 → :530.
  • PR [codex] durable runner lease sidecar #5232 / docs(reborn): design — write-behind lease durability + side-effect gate #5249 have no in-code references — verify or drop the citations.

Naming (local-patterns)

StoreModel collides with the repo's LLM-routing *Model convention (ModelSelectionMode, ModelSlot, RebornModelRoutesState). Operational modes use *Mode. Rename → StoreWriteMode and env keys → IRONCLAW_{RESOURCES,TURNS,EVENTLOG}_WRITE_MODE. ThreadWritePath is fine.


Lower-severity concurrency (worth pinning)

  • Event duplicate beyond RECOVERY_SCAN_WINDOW (§8.3) is tolerated only if every consumer is idempotent on (run_id, transition_version). The doc doesn't enumerate consumers — an SSE push / audit webhook that fires per-event would double-fire. Verify each downstream consumer re-reads the authoritative run record before promoting event-log to Native.
  • DualWrite period-rollover skew (§9.3/§7.5) is safe only if verify_parity is enforced as a hard gate. Add an assertion that rejects Native promotion on unclean parity, so the non-self-healing anchor skew can't slip through and double-clear usage.
  • Lock order (§7.3): make the reservation-record position in the reconcile/release puts vector explicit so "symmetric inverse" can't be read as deep→shallow (deadlock vs a concurrent shallow→deep reserve).
  • put_batch N>1 on libSQL pre-PR-3 returns Unsupported. Make ScopedFilesystem::put_batch return a typed error that names the missing BatchPut capability, and add a contract test asserting that (rather than a panic) in the PR-1→PR-2 window.

What's already strong (keep)

Postgres put_batch linearization, the §3.7 parity invariant + §9.4 CI test, the §8.0 turn-scope premise correction, account_seg = sha256(canonical_json), dropping merge_indexed, the honest at-least-once/non-terminal-loss downgrade, and the reversible per-store rollout. The bones are right; the governor just wants to be relational.


Review synthesized from a 5-lens agent pass (approach / maintainability / local-patterns / thermo-nuclear architecture / concurrency-TOCTOU), each grounding its claims against the live tree.

@railway-app

railway-app Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-5269 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Jun 26, 2026 at 12:07 am

Address the 2026-06-25 multi-lens review (approach / maintainability /
local-patterns / thermo-nuclear / concurrency-TOCTOU) plus CodeRabbit and
Gemini. The governor's four critical concurrency/correctness defects are
resolved by a single structural change rather than wording tweaks.

Core change — governor commits via ONE put_batch path on both backends:
- Drop the adjust_indexed primitive entirely. Its single-key conditional
  delta could not make the 8-dimensional ResourceTally admission atomic
  (concurrent same-user reserves could over-admit), re-introduced a
  body/indexed dual-truth hazard, and leaned on a crash-sweeper that does
  not exist in the tree.
- Raise libSQL to MultiKey via the BEGIN IMMEDIATE the put_batch override
  already needs; route the governor cascade through put_batch (full tally
  per account record) on Postgres AND libSQL. This retires the libSQL
  admission-gate special case, applied_reservations, and the phantom
  sweeper, and keeps counters single-source in body.

Also: honest libSQL file-global write-lock disclosure; correct the shared
Postgres pool analysis (one pool, default size 2, must raise in PR-2);
fold event-log into the turns write-mode flag (no split-brain state);
prefix-partial expression indexes; IndexVector filtered via existing
BackendCapabilities::without; period-rollover overflow-safe + denied-reserve
posture; ownership checks on decomposed query paths; probe ROLLBACK; rename
StoreModel -> StoreWriteMode; fix stale code anchors (HeadSeq already exists,
cas_snapshot.rs:357, libsql.rs:1595, lifecycle.rs:530-567, cascade min 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5269 June 26, 2026 00:06 Destroyed
@henrypark133

Copy link
Copy Markdown
Collaborator

Revision pushed (0e18f93) — all review feedback addressed

Ran the design through 3 iterative review rounds (approach / maintainability / local-patterns / thermo-nuclear architecture / concurrency-TOCTOU) plus the CodeRabbit and Gemini findings, revising until clean. Final architecture verdict: ship-the-plan. Summary of what changed:

Governor — the 4 critical concurrency/correctness defects, fixed structurally

The earlier adjust_indexed + libSQL admission-gate path had: (C1) a multi-dimensional over-admission hole — ResourceTally is 8-dimensional, a single-key conditional delta can't admit it atomically; (C2) a dependency on a crash-sweeper that does not exist in the tree; (C3) a body/indexed dual-truth divergence; (C4) no mid-cascade compensation.

Resolution — one mechanism, both backends: drop adjust_indexed entirely; commit the governor cascade via a single put_batch over per-account records (full tally per Entry) on Postgres and libSQL (raised to MultiKey via the BEGIN IMMEDIATE the override already needs). All-or-nothing version-CAS is the linearization point across all dimensions and accounts → C1–C4 all close, applied_reservations + the phantom sweeper are gone, counters are single-source in body.

CodeRabbit

  • put_batch availability contract stated precisely (N==1 always; N>1 needs MultiKey; else typed Unsupported, never silent).
  • Thread append rejects when atomicity unavailable — no sequential-CAS fallback; ForceCas is the only explicit opt-out.
  • in_memory_full() kept capped until the PR-4 per-op-lock audit.
  • sha256 key described as collision-resistant, not "injective."
  • libSQL admission wording removed (the gate itself is gone).

Gemini

  • Postgres 0-row disambiguation no longer does a bare ::bigint cast (moot — adjust_indexed dropped; put_batch writes whole entries).
  • libSQL version-readback probe now ROLLBACKs its scratch txn.
  • Ownership checks made explicit on the decomposed query paths (§3.6, §8.5).
  • Period rollover routed through a centralized overflow-safe (saturating) helper.

Honesty / perf gaps now disclosed (not hidden)

  • libSQL BEGIN IMMEDIATE is a file-global write lock (one shared DB file) — disclosed plainly with the per-user-shard escalation path, not framed as "per-account."
  • Postgres pool is one shared pool, default size 2 across 4 consumers — PR-2 must raise it before the governor/threads migration (this is the [codex] Add hosted single-tenant Postgres profile #5081 class).
  • JSONB write-churn / autovacuum tuning; IndexVector filtered off these mounts via existing BackendCapabilities::without; event-log retention decision stated.

Maintainability / naming / anchors

  • Event-log folded into IRONCLAW_TURNS_WRITE_MODE (the TURNS=Native, EVENTLOG=DualWrite split-brain is now unrepresentable).
  • StoreModel → StoreWriteMode (avoids collision with the LLM-routing *Model family).
  • Fixed stale code anchors: HeadSeq already exists (only PutBatch is net-new), cas_snapshot.rs:357, libsql.rs:1595, lifecycle.rs:530-567, cascade min is 2 (tenant+user).

§12 records the relational-sidecar (triggers-style) alternative as considered and deferred — the put_batch-on-both-backends path resolves the criticals while preserving the single-copy migration invariant; the sidecar stays the documented escape hatch if JSONB churn becomes the bottleneck.

@henrypark133
henrypark133 merged commit 6985e63 into main Jun 26, 2026
44 checks passed
@henrypark133
henrypark133 deleted the firat/native-storage-primitives-design branch June 26, 2026 03:24

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-5269 — 0e18f923 Deployed Jun 26, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: docs Documentation size: XS < 10 changed lines (excluding docs)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants