State Inspector — debugging Fabric state from the durable store
The thesis you can't derive: the durable store the server already wrote is the
flight recorder. Every commit, every per-scope revision, every read a commit
observed is on disk. cf inspect opens a space SQLite file read-only and
answers who/what/when/why-different questions with no live runtime. Reach for it
whenever you'd otherwise reason about runtime state from the outside — it
replaces a guess with the ground truth the engine itself reads.
Full command reference + flags: packages/state-inspector/README.md. This skill
is the map — what the tool sees, what to trust, and which question each
command answers. Run commands with deno task cf inspect <cmd>; every command
takes --json for machine reading, and a <space> is a DID, a unique
DID-prefix, a space name (resolved the way the runtime derives it), or a path
(local DBs auto-discovered — start with cf inspect spaces).
What it can — and can't — answer
It reads ONE durable store, offline and read-only. That single boundary tells
you when to trust it and when to reach for something else:
It answers authoritatively (the same bytes the engine reads): what is
stored for any entity at any (branch, seq); who/what/when wrote it (per
commit, UTC wall-clock to the second); how a value got there
(history/diff/timeline); whether identities see different values
(overlay); whether the store is internally consistent (anomalous stale
reads); whether the same id agrees across spaces (converge); and the structure
of it all (entities/piece/graph).
It can't — and reaching for it here will mislead you:
- Anything client-side — optimistic writes, cursor lag, what a browser is
actually rendering. "Converged" / "consistent" describes the durable store,
not what any client is showing.
- A live or production bug from a local snapshot — a clean local store does
not explain a prod-only misbehavior; at most it says the local data is
healthy, so the cause is concurrency / scale / timing or client-side. Staging
spaces can be fetched with
--remote (see below); the dump endpoint is
deliberately hard-off in production, so a prod space still means copying the
.sqlite off the box (ssh+scp).
- How long anything took — it has logical order (
seq) plus
second-granularity commit times ("what happened and when"), never latencies or
durations.
- What was rejected — the engine rejects stale reads before they persist, so
they are not here. Zero anomalies means consistent, not "no concurrency."
- The live reactive graph — the dependency graph lives in runtime memory only;
the sole durable scheduler state is the
scheduler_basis index (doc-granular
ids + seqs — no paths, no payloads), empty until the serving loop writes it.
Entity/commit history always works.
- Change anything — it is read-only; it explains, it never reproduces or
fixes.
So reach for it whenever you would otherwise guess at durable or multiplayer
state from outside a live runtime; reach for something else for live behavior,
client rendering, performance profiling, or reproducing a bug.
The mental model the output assumes
You will misread the tool without these — they are facts about how memory-v2
stores state, not things a model infers from the data:
- An entity is a tree of top-level paths, not a bare value:
value plus
meta paths argument / result / patternIdentity / internal / schema /
cfc. The tool classifies an entity (piece / module / stream / schema /
owned-cell / free-cell) by which paths exist, and resolves lineage from them
— so "what is this entity" is answerable structurally, and entities /
piece / graph speak that vocabulary.
- An entity holding no document says which kind of nothing it is. A
tombstone is its own kind,
deleted. unknown is everything else the tool
cannot make sense of, and its label says which: (undecodable) for a payload
that does not decode, (no data) for a set that stored none, (absent) for
an id with no visible row, and {paths} for the one case that DID decode — a
document whose shape no other kind recognizes. Ask --kind deleted for
deletions and --kind unknown for trouble; do not read a tombstone as damage,
and do not read the revision count as evidence either way (it includes the
delete op). What the entity WAS is not in the listing — it is gone at HEAD,
and history <id> plus value-at --seq before the delete is what recovers
it.
scope_key partitions an entity by identity. The same cell id can hold
a shared space value AND a per-user:<DID> override AND a
per-session:<DID>:<sid> override, stored side by side and genuinely
different. This is where "looks different for me" multiplayer bugs live. The
runtime resolves what one identity sees by following a link planted at the
base scope at write time — not by a read-time session→user→space
fallback (see the honesty contract).
- Multi-USER ≠ multi-SESSION. A cell written by two sessions of one person
(many tabs/devices) is benign; two distinct principals is real cross-user
contention. The tool draws this line for you — trust the
multiUser flag, not
the raw session count.
- Two at-rest value formats coexist, both handled: modern
data-model
codec-json (ids of:fid1:…) and legacy plain-JSON sigils (ids of:baedrei…).
You don't route between them; just know an id's shape tells you the era.
- Reconstruction is engine-faithful, and proven so. State-at-
(branch, seq)
replays through the server's own applyPatch, honors branch inheritance and
snapshots exactly like read(), and is locked to the real engine by a parity
test that drives it. So a reconstructed value is what the runtime would read
— you can build on it without hedging.
The honesty contract — what is ground truth vs. a hint
The tool is deliberate about this, and so must you be when you report findings.
Confusing an approximation for truth is the failure mode that matters here:
overlay <space> <id> is the ground truth for "who sees what." It shows
the entity's value in every scope side by side and flags real divergence
(compared on the raw stored value, depth-complete). For "this cell looks
different for two users," this is the answer.
value-at --as <DID> is an APPROXIMATION — the most-specific stored scope
that holds the id. It cannot, from an id alone, know which declared scope a
real read targets or follow the base-scope link. Useful as a quick "roughly
what they see"; never quote it as the runtime read. Prefer overlay.
conflicts stale-reads are an ANOMALY detector, not lost-update history.
The engine validates every confirmed read before committing, so a healthy
store yields zero. A hit means an invariant violation / corruption —
surface it loudly as such, and read "0 anomalies" as "the store is
consistent," not "no concurrency happened." (The separate writer-timeline /
multiUser contention view is normal history.)
converge / converge-scan are server-view only — durable values
compared. "Converged" means the stored values agree, not that every client is
rendering them; client cursor lag and optimistic writes aren't visible here.
- Same id across spaces is usually NOT replica drift. Content-addressed ids
mean two spaces often hold independent instances of the same pattern, which
legitimately differ. The scan labels
cross-space-linked (real replica →
drift bug) vs no-cross-space-link (likely independent instance). Don't cry
wolf on the latter.
- A
schema under $link is what the link stores; a $schemaSummary beside
it describes a schema too large to print. A link's schema is a JSON Schema,
so true and false are values it can really hold — true selects every
value, declaring no constraint at all, where a schema with properties in it
pins a shape at that link. Never read one as the other. The summary is
{ keys, bytes, digest } and is a SIBLING of schema, never a value under
it, because a link can store a schema of any shape and a summary in the
schema slot could be a schema some link really holds. The two never both
appear: a schema key means that is the stored schema, a $schemaSummary key
means it was too large to print, and neither means the link stores no schema.
Different digests prove two schemas differ; equal ones make agreement
overwhelmingly likely without proving it, since the hash is truncated — reach
for --full-depth when you need certainty or the text itself. An absent
digest could not be computed, so two summaries that both lack one say
nothing about whether they agree; an absent keys means the stored schema was
not an object and so had none.
Which question → which command
Start from the symptom; let the model below generate the path rather than a
fixed recipe. The recurring debugging questions and where they resolve:
- "What's actually in this space / what is this entity?" →
spaces →
summary / entities / piece; graph for how pieces, cells, modules and
streams connect (--root <id> --depth to drill, --dot for Graphviz).
- "This cell looks different for user A vs B." →
overlay <space> <id>
(truth); scopes / users to see who has per-user/session state;
identity <DID> for one identity's whole world (its spaces + the scopes it
owns).
- "A write seems lost / overwritten / a read was stale." →
conflicts <space>
to find contested cells, then conflicts <space> <id> for the writer
timeline + the anomaly analysis. Remember: a clean result means consistent,
and a hit is an anomaly worth escalating.
- "How did this entity reach its current value?" →
history (every write,
who), timeline <space> <id> (value after each write),
diff <space> <id> --from --to (what changed between two seqs),
value-at … --seq (state at a point).
- "What collaborative operation history is retained, and are its checkpoints
healthy?" →
operations <space> [id] for field epochs, cursors, submissions,
integrated rows, retained floors, and consistency checks.
- "Is this space writing more than it should be / has it settled?" →
churn <space> (commits + revisions per time bucket, and the entities driving
the busiest one). hot ranks by all-time writes and so cannot separate a
burst from the same writes spread over a week — churn is the shape-in-time
view: a storm starting, and a settle completing. Use it before and after a
setsrc on a populated space; --bucket/--since/--until frame the
window. Pass --until with the moment you stopped watching: the curve then
covers the window you asked about rather than ending at the last write, which
is the difference between showing a storm and showing it SETTLE. It reports
rates and never judges them — what counts as "settled" is yours to decide.
- "Did a migration preserve this space's content?" →
cf space (a sibling
command, not inspect — a clone exists to be written to, so it lives outside
inspect's read-only contract). `cf space clone --from --to
- "Is this entity the same across spaces / did a replica drift?" →
converge <id> --all / converge-scan --all, trusting the
replica-vs-instance label.
- "I want to explore everything for a space interactively." →
html <space> --out file.html (self-contained: tree + graph + detail pane
with value, schema, CFC labels, lineage, links, source; --app-url adds live
deep-links).
- "The space I need is on staging/a server, not local." → add
--remote [url]
(defaults to CF_API_URL) to ANY command: it downloads a read-only snapshot
into ~/.cache/cf-inspect/<host>/ and inspects it offline. spaces --remote
lists what's dumpable; pull --all --remote caches everything so subsequent
commands run offline. Signs with CF_IDENTITY/--identity; the server gates
on a DID allowlist.
Gotchas that will mislead you if unflagged
scheduler_basis is the only durable scheduler state (besides the
watermark machinery; serving-loop.md §3b). The retired
persistentSchedulerState observation tables were deleted in server-execution
v2 stage C.2, and the tool no longer reads them even from an old snapshot that
still contains them. The entity-history surface always works; the basis table
absent (pre-migration store) or empty is normal, not a broken DB.
- A capped result announces itself — and the caps that don't are the ones to
watch. The space-wide scans (
entities, graph, html) print a cap notice
on stderr in both modes, so silence there means you hold the whole set; a
--json consumer that discards stderr discards the only warning it gets.
entities --kind selects during the scan, so --limit counts entities of
that kind, not entities walked to find them. Scripting a backup or rollback
payload? Pass --require-complete — a capped scan then exits nonzero with
nothing on stdout. Check that status where it is produced: a shell pipeline
reports its LAST command's status, so a scan piped into jq and a redirect
refuses, and the redirect still writes an empty file and reports success.
Capture the scan on its own, or set pipefail. It refuses for either kind of
incompleteness — a cap reached, or an entity that would not reconstruct, which
a higher --limit never recovers. The other caps stay silent —
history/hot/contention row limits, and the HTML stale-read pass, which caps
per bundle and marks un-analyzed cells rather than showing them clean. There
a count equal to a round cap is still the tell.
--json is the agent path. Human output elides; for anything you parse or
chain, pass --json.
- It reads DBs it didn't write. A corrupt/partial row degrades that one
entity, not the whole command — but if a value looks absent where you expect
data, check for a decode error before concluding the entity is empty.
1---2name: state-inspector3description: Debug Common Fabric runtime/multiplayer state from the durable store, offline, with `cf inspect`. Use when investigating "what is actually stored", a cell that looks different for two users, a lost/overwritten write, divergence across spaces, how state reached its current value, who/what touched a space, or any memory-v2 question you'd otherwise guess at from a live runtime. Reads space SQLite DBs read-only — no live server, no capture step.4---56# State Inspector — debugging Fabric state from the durable store78The thesis you can't derive: **the durable store the server already wrote is the9flight recorder.** Every commit, every per-scope revision, every read a commit10observed is on disk. `cf inspect` opens a space SQLite file read-only and11answers who/what/when/why-different questions with no live runtime. Reach for it12whenever you'd otherwise reason about runtime state from the outside — it13replaces a guess with the ground truth the engine itself reads.1415Full command reference + flags: `packages/state-inspector/README.md`. This skill16is the **map** — what the tool sees, what to trust, and which question each17command answers. Run commands with `deno task cf inspect <cmd>`; every command18takes `--json` for machine reading, and a `<space>` is a DID, a unique19DID-prefix, a space name (resolved the way the runtime derives it), or a path20(local DBs auto-discovered — start with `cf inspect spaces`).2122## What it can — and can't — answer2324It reads ONE durable store, offline and read-only. That single boundary tells25you when to trust it and when to reach for something else:2627**It answers authoritatively** (the same bytes the engine reads): what is28_stored_ for any entity at any `(branch, seq)`; who/what/when wrote it (per29commit, UTC wall-clock to the second); how a value got there30(`history`/`diff`/`timeline`); whether identities _see different values_31(`overlay`); whether the store is _internally consistent_ (anomalous stale32reads); whether the same id agrees across spaces (`converge`); and the structure33of it all (`entities`/`piece`/`graph`).3435**It can't — and reaching for it here will mislead you:**3637- _Anything client-side_ — optimistic writes, cursor lag, what a browser is38 actually rendering. "Converged" / "consistent" describes the durable store,39 not what any client is showing.40- _A live or production bug from a local snapshot_ — a clean local store does41 not explain a prod-only misbehavior; at most it says the local data is42 healthy, so the cause is concurrency / scale / timing or client-side. Staging43 spaces can be fetched with `--remote` (see below); the dump endpoint is44 deliberately hard-off in production, so a prod space still means copying the45 `.sqlite` off the box (ssh+scp).46- _How long anything took_ — it has logical order (`seq`) plus47 second-granularity commit times ("what happened and when"), never latencies or48 durations.49- _What was rejected_ — the engine rejects stale reads _before_ they persist, so50 they are not here. Zero anomalies means consistent, not "no concurrency."51- _The live reactive graph_ — the dependency graph lives in runtime memory only;52 the sole durable scheduler state is the `scheduler_basis` index (doc-granular53 ids + seqs — no paths, no payloads), empty until the serving loop writes it.54 Entity/commit history always works.55- _Change anything_ — it is read-only; it explains, it never reproduces or56 fixes.5758So **reach for it whenever you would otherwise guess at durable or multiplayer59state from outside a live runtime**; reach for something else for live behavior,60client rendering, performance profiling, or reproducing a bug.6162## The mental model the output assumes6364You will misread the tool without these — they are facts about how memory-v265stores state, not things a model infers from the data:6667- **An entity is a tree of top-level paths**, not a bare value: `value` plus68 meta paths `argument` / `result` / `patternIdentity` / `internal` / `schema` /69 `cfc`. The tool classifies an entity (piece / module / stream / schema /70 owned-cell / free-cell) by _which paths exist_, and resolves lineage from them71 — so "what is this entity" is answerable structurally, and `entities` /72 `piece` / `graph` speak that vocabulary.73- **An entity holding no document says which kind of nothing it is.** A74 tombstone is its own kind, `deleted`. `unknown` is everything else the tool75 cannot make sense of, and its label says which: `(undecodable)` for a payload76 that does not decode, `(no data)` for a `set` that stored none, `(absent)` for77 an id with no visible row, and `{paths}` for the one case that DID decode — a78 document whose shape no other kind recognizes. Ask `--kind deleted` for79 deletions and `--kind unknown` for trouble; do not read a tombstone as damage,80 and do not read the revision count as evidence either way (it includes the81 delete op). What the entity WAS is not in the listing — it is gone at HEAD,82 and `history <id>` plus `value-at --seq` before the delete is what recovers83 it.84- **`scope_key` partitions an entity by identity.** The _same_ cell id can hold85 a shared `space` value AND a per-`user:<DID>` override AND a86 per-`session:<DID>:<sid>` override, stored side by side and genuinely87 different. This is where "looks different for me" multiplayer bugs live. The88 runtime resolves what one identity sees by following a link planted at the89 base scope at _write_ time — **not** by a read-time session→user→space90 fallback (see the honesty contract).91- **Multi-USER ≠ multi-SESSION.** A cell written by two _sessions_ of one person92 (many tabs/devices) is benign; two distinct _principals_ is real cross-user93 contention. The tool draws this line for you — trust the `multiUser` flag, not94 the raw session count.95- **Two at-rest value formats coexist, both handled:** modern `data-model`96 codec-json (ids `of:fid1:…`) and legacy plain-JSON sigils (ids `of:baedrei…`).97 You don't route between them; just know an id's shape tells you the era.98- **Reconstruction is engine-faithful, and proven so.** State-at-`(branch, seq)`99 replays through the server's own `applyPatch`, honors branch inheritance and100 snapshots exactly like `read()`, and is locked to the real engine by a parity101 test that drives it. So a reconstructed value _is_ what the runtime would read102 — you can build on it without hedging.103104## The honesty contract — what is ground truth vs. a hint105106The tool is deliberate about this, and so must you be when you report findings.107Confusing an approximation for truth is the failure mode that matters here:108109- **`overlay <space> <id>` is the ground truth** for "who sees what." It shows110 the entity's value in _every_ scope side by side and flags real divergence111 (compared on the raw stored value, depth-complete). For "this cell looks112 different for two users," this is the answer.113- **`value-at --as <DID>` is an APPROXIMATION** — the most-specific stored scope114 that holds the id. It cannot, from an id alone, know which declared scope a115 real read targets or follow the base-scope link. Useful as a quick "roughly116 what they see"; never quote it as the runtime read. Prefer `overlay`.117- **`conflicts` stale-reads are an ANOMALY detector, not lost-update history.**118 The engine validates every confirmed read _before_ committing, so a healthy119 store yields **zero**. A hit means an invariant violation / corruption —120 surface it loudly as such, and read "0 anomalies" as "the store is121 consistent," not "no concurrency happened." (The separate writer-timeline /122 `multiUser` contention view _is_ normal history.)123- **`converge` / `converge-scan` are server-view only** — durable values124 compared. "Converged" means the stored values agree, not that every client is125 rendering them; client cursor lag and optimistic writes aren't visible here.126- **Same id across spaces is usually NOT replica drift.** Content-addressed ids127 mean two spaces often hold independent _instances of the same pattern_, which128 legitimately differ. The scan labels `cross-space-linked` (real replica →129 drift bug) vs `no-cross-space-link` (likely independent instance). Don't cry130 wolf on the latter.131- **A `schema` under `$link` is what the link stores; a `$schemaSummary` beside132 it describes a schema too large to print.** A link's schema is a JSON Schema,133 so `true` and `false` are values it can really hold — `true` selects every134 value, declaring no constraint at all, where a schema with properties in it135 pins a shape at that link. Never read one as the other. The summary is136 `{ keys, bytes, digest }` and is a SIBLING of `schema`, never a value under137 it, because a link can store a schema of any shape and a summary in the138 `schema` slot could be a schema some link really holds. The two never both139 appear: a `schema` key means that is the stored schema, a `$schemaSummary` key140 means it was too large to print, and neither means the link stores no schema.141 Different digests prove two schemas differ; equal ones make agreement142 overwhelmingly likely without proving it, since the hash is truncated — reach143 for `--full-depth` when you need certainty or the text itself. An absent144 `digest` could not be computed, so two summaries that both lack one say145 nothing about whether they agree; an absent `keys` means the stored schema was146 not an object and so had none.147148## Which question → which command149150Start from the symptom; let the model below generate the path rather than a151fixed recipe. The recurring debugging questions and where they resolve:152153- _"What's actually in this space / what is this entity?"_ → `spaces` →154 `summary` / `entities` / `piece`; `graph` for how pieces, cells, modules and155 streams connect (`--root <id> --depth` to drill, `--dot` for Graphviz).156- _"This cell looks different for user A vs B."_ → `overlay <space> <id>`157 (truth); `scopes` / `users` to see who has per-user/session state;158 `identity <DID>` for one identity's whole world (its spaces + the scopes it159 owns).160- _"A write seems lost / overwritten / a read was stale."_ → `conflicts <space>`161 to find contested cells, then `conflicts <space> <id>` for the writer162 timeline + the anomaly analysis. Remember: a _clean_ result means consistent,163 and a hit is an anomaly worth escalating.164- _"How did this entity reach its current value?"_ → `history` (every write,165 who), `timeline <space> <id>` (value after each write),166 `diff <space> <id> --from --to` (what changed between two seqs),167 `value-at … --seq` (state at a point).168- _"What collaborative operation history is retained, and are its checkpoints169 healthy?"_ → `operations <space> [id]` for field epochs, cursors, submissions,170 integrated rows, retained floors, and consistency checks.171- _"Is this space writing more than it should be / has it settled?"_ →172 `churn <space>` (commits + revisions per time bucket, and the entities driving173 the busiest one). `hot` ranks by all-time writes and so cannot separate a174 burst from the same writes spread over a week — churn is the shape-in-time175 view: a storm starting, and a settle completing. Use it before and after a176 `setsrc` on a populated space; `--bucket`/`--since`/`--until` frame the177 window. Pass `--until` with the moment you stopped watching: the curve then178 covers the window you asked about rather than ending at the last write, which179 is the difference between showing a storm and showing it SETTLE. It reports180 rates and never judges them — what counts as "settled" is yours to decide.181- _"Did a migration preserve this space's content?"_ → `cf space` (a sibling182 command, not `inspect` — a clone exists to be written to, so it lives outside183 inspect's read-only contract). `cf space clone <did> --from <snapshot> --to184 <dir>` builds a writable rehearsal copy plus a manifest; `verify` reports what185 moved against that baseline and `reset` restores it for the next attempt.186 Two things decide whether a rehearsal means anything, and both are easy to get187 wrong: `verify` is strict by default (any change exits nonzero, right for an188 untouched clone, wrong after a migration — pass `--expect-migration`, which189 gates on entities REMOVED instead), and it cannot see a clobber either way,190 because overwriting authored content is a change rather than a removal. Stop191 the server before `reset`: unlinking the database does not reach a process192 holding it open, so `reset` refuses rather than let a served clone keep193 serving the discarded attempt — a tripwire for a forgotten stop, not a194 substitute for it. Full procedure:195 `docs/development/space-clone-rehearsal.md`.196 `cf space fingerprint <space>` runs the content check alone against any store.197 Compiler-generated internal cells are excluded by default: a pattern update198 rotates their identities on purpose, so counting them would change the199 fingerprint on every legitimate migration.200- _"Is this entity the same across spaces / did a replica drift?"_ →201 `converge <id>202 --all` / `converge-scan --all`, trusting the203 replica-vs-instance label.204- _"I want to explore everything for a space interactively."_ →205 `html <space> --out206 file.html` (self-contained: tree + graph + detail pane207 with value, schema, CFC labels, lineage, links, source; `--app-url` adds live208 deep-links).209- _"The space I need is on staging/a server, not local."_ → add `--remote [url]`210 (defaults to `CF_API_URL`) to ANY command: it downloads a read-only snapshot211 into `~/.cache/cf-inspect/<host>/` and inspects it offline. `spaces --remote`212 lists what's dumpable; `pull --all --remote` caches everything so subsequent213 commands run offline. Signs with `CF_IDENTITY`/`--identity`; the server gates214 on a DID allowlist.215216## Gotchas that will mislead you if unflagged217218- **`scheduler_basis` is the only durable scheduler state** (besides the219 watermark machinery; serving-loop.md §3b). The retired220 `persistentSchedulerState` observation tables were deleted in server-execution221 v2 stage C.2, and the tool no longer reads them even from an old snapshot that222 still contains them. The entity-history surface always works; the basis table223 absent (pre-migration store) or empty is normal, not a broken DB.224- **A capped result announces itself — and the caps that don't are the ones to225 watch.** The space-wide scans (`entities`, `graph`, `html`) print a cap notice226 on stderr in _both_ modes, so silence there means you hold the whole set; a227 `--json` consumer that discards stderr discards the only warning it gets.228 `entities --kind` selects _during_ the scan, so `--limit` counts entities of229 that kind, not entities walked to find them. **Scripting a backup or rollback230 payload? Pass `--require-complete`** — a capped scan then exits nonzero with231 nothing on stdout. Check that status where it is produced: a shell pipeline232 reports its LAST command's status, so a scan piped into `jq` and a redirect233 refuses, and the redirect still writes an empty file and reports success.234 Capture the scan on its own, or set `pipefail`. It refuses for either kind of235 incompleteness — a cap reached, or an entity that would not reconstruct, which236 a higher `--limit` never recovers. The other caps stay silent —237 history/hot/contention row limits, and the HTML stale-read pass, which caps238 per bundle and _marks_ un-analyzed cells rather than showing them clean. There239 a count equal to a round cap is still the tell.240- **`--json` is the agent path.** Human output elides; for anything you parse or241 chain, pass `--json`.242- **It reads DBs it didn't write.** A corrupt/partial row degrades that one243 entity, not the whole command — but if a value looks absent where you expect244 data, check for a decode error before concluding the entity is empty.