docs: correct every behavioural claim measurement contradicted #56

Merged
yuris merged 2 commits from feat/doc-truth into main 2026-08-20 11:24:37 +00:00
Owner

The last item on #24's release gate: every falsifiable behavioural claim in README.md, verified by execution against a live SpacetimeDB 2.8.2. ~155 claims exercised by 268 Lua assertions and ~45 live wire probes, treating the README as a hypothesis rather than documentation.

30 of 30 in-scope findings actioned, plus 2 extras. Four were already fixed by #53 and correctly skipped.

First, what held

The parts most likely to have rotted did not. All the subscription quirks are true byte-for-byte — including the #43 overlap shape, the identical-text collapse, and the legacy Subscribe behaviour. All the procedure quirks are true, and a third failure case was found that strengthens them (bad arguments also give InternalError). The whole special-type table is true, including the 128-bit decimal-string contract, the micros units, u128 MAX, and every numeric bound: 2^53 exactly, whole-second resolution across 4,527,877 sampled micros with zero mismatches.

So this is surgical correction, not a rewrite.

The most important fix: the README was talking itself down

It said the other direction of light mode "is still unproven against a server — that another client's transaction really does arrive light — which needs a second connection the harness does not have."

It is proven. Two sockets on one server: the light = true client received the other client's transaction as TransactionUpdateLight with keys exactly ["request_id","update"], while the ordinary socket got the full TransactionUpdate with reducer_call for the same commit.

A false disclaimer is worse than a plain error, because a reader believes it and designs around a limitation that does not exist.

The same section understated a real hazard, now sharpened by measurement: TransactionUpdateLight does carry a top-level request_id, and it is the originating client's. A probe sent 4242 from the heavy socket while the light socket's own last id was 7, and 4242 is what the light frame carried. That is worse than carrying none — a correlator reading it matches another player's call.

Claims that were false

  • session.jsonl is four blocks, not three. #51 appended overlap; IdentityToken sits at frames 1, 30, 37, 43. The per-block example listed only two.
  • A quoted FAIL message that cannot be produced — the real label gained /48 with the overlap block.
  • A fabricated log line. [html5] SMOKE: PASS procedure round trip… was spliced from two real lines. Corrected to the real PHASE4 OK … text, with the insert count taken from an actual conformance run rather than invented. The PHASE5 SMOKE: PASS line was deliberately not quoted: its text ends in a run-specific identity prefix, so quoting one would have replaced a fabricated line with another. All four other quoted log lines were checked and are real.
  • "An Option is the same shape in both encodings" — true for a scalar payload, false for a product: Option<Point> is [0,{"x":8,"y":9}] named and [0,[8,9]] positional. Same for the None shape: [1,{}] vs [1,[]].
  • Memory figure was 5× out: 0.4 MB for a 4000-row set of 57-byte rows; 2 MB needs ~470 bytes a row.
  • Positional u128/ConnectionId/Uuid — the description was weaker than the truth. u128 arrives a bare rounded number; ConnectionId and Uuid arrive as a one-element array holding one, and the helpers return nil for them.
  • updates counted per distinct query — actually counted per pool, and the new SubscribeSingle fact defines what a pool is.
  • Conformance "ignores" formatting — it normalises through stylua first.
  • nixpkgs SpacetimeDB version was 2.7.0-hotfix1, actually 2.8.1. The identical stale figure in dev/nix/spacetime.nix:6 is fixed too — outside the README-only scope, so flagged rather than done silently.

Claims that were unfalsifiable as written

  • Forgejo basic auth returning 404. Not merely untestable — what is testable contradicts the mechanism: https://user:token@…/archive/v0.1.0.zip returns 200, ?token=… returns 401, and a nonexistent repo returns 404 with and without basic auth. The false mechanism is gone; the actionable advice stays.
  • unknown "when and only when nothing matched." The only when half is true — seven exotic types including a zero-variant sum and a triple-nested tag all resolved. The when half is unreachable: every anonymising route is rejected at publish.
  • OutOfEnergy could not be constructed on a self-hosted standalone. Marked documented-but-unobserved rather than deleted, in both places a reader might land.
  • The 31s → 10s speedup is now attributed to the last containerised revision, with the honest note that the configuration is deleted so the figure is not reproducible here.

Six behaviours nobody had written down

  • Procedure replies arrive out of request order. Sent 1,2,3,4,5; received 1,4,5,2,3. Correlation by request_id is required, not an optimisation — the README said this only for one_off_query.
  • An empty result is shaped differently per message. SubscribeMultiApplied for a query matching nothing omits the relation; a legacy Subscribe matching nothing carries it with num_rows: 0.
  • SubscribeSingle shares the query pool with SubscribeMulti — only the legacy Subscribe is a separate pool.
  • OneOffQuery.message_id goes out as a byte array and comes back as a lowercase hex string ([1,1,1,1] → "01010101"). Neither direction was documented.
  • A named product declaring __identity__ keeps its own name — product_FakeIdentity, not identity. Extends the shape-collision rule from Option to the special types.
  • A table called tables is accepted by 2.8.2, which makes db.tables.tables a real case rather than a hypothetical.

Two places the verification agent's own findings were wrong

Both caught by re-running rather than re-reading.

Procedure ordering. The claim was that the InternalError replies "overtake the two that do work". But reply 3 — a real throw, also at timestamp: 0 — landed last, behind working reply 2. Written as the weaker, true statement: a failure is answered without a transaction, so nothing holds it behind the calls that do work, and it can land ahead of one sent before it.

.rows.table_rows "a bare TableUpdate with no array" — it does carry its own updates array; only the tables array is absent.

Verified beyond what was asked

The decode_typed example was broken: it passed gen.tables.player then read row.status/row.home/row.joined/row.serial, but this repo's generated player is { identity, sponsor, name, score } — a reader following it got four nils. Now uses gen.tables.all_types and every value in it is measured: status → { tag = "busy" }, point → { x = 3, y = -4 }, at → 1787163313278160, and the exact 39-digit u128, identical in both encodings.

#55 had meanwhile renamed the namespace illustration to a table called cache, which nobody had tested. A module declaring both cache and tables was published and both served rows — so #55's renamed line is now verified too.

The "second",2 view literal was softened and explained: the module returns rows[rows.length-1], and 2.8.2's iteration order is not insertion order, so with both rows present the run returned the first inserted one.

"Neighbouring integers share a double" was graded rather than left universal: 0% collide below 2^53, half the adjacent pairs just past it, ~all near u64 max. All three stated consequences hold and stay.

Verification

6 passed, 0 failed; stylua clean. Native conformance PASS both runs through PHASE5 with identity persisted across a process restart — the PHASE4 line now quoted in the README came out of that run verbatim. No fixture drift; the schema was re-derived from the published module and diffed through stylua.

Every quoted claim was located by its text rather than by line number, since #55 moved them. Nothing was unlocatable.

The last item on #24's release gate: **every falsifiable behavioural claim in `README.md`, verified by execution against a live SpacetimeDB 2.8.2.** ~155 claims exercised by 268 Lua assertions and ~45 live wire probes, treating the README as a hypothesis rather than documentation. **30 of 30 in-scope findings actioned, plus 2 extras. Four were already fixed by #53 and correctly skipped.** ## First, what held The parts most likely to have rotted did not. **All the subscription quirks are true byte-for-byte** — including the #43 overlap shape, the identical-text collapse, and the legacy `Subscribe` behaviour. **All the procedure quirks are true**, and a third failure case was found that strengthens them (bad arguments also give `InternalError`). **The whole special-type table is true**, including the 128-bit decimal-string contract, the micros units, `u128 MAX`, and every numeric bound: 2^53 exactly, whole-second resolution across 4,527,877 sampled micros with zero mismatches. So this is surgical correction, not a rewrite. ## The most important fix: the README was talking itself down It said the other direction of light mode *"is still unproven against a server — that another client's transaction really does arrive light — which needs a second connection the harness does not have."* It is proven. Two sockets on one server: the `light = true` client received the **other** client's transaction as `TransactionUpdateLight` with keys exactly `["request_id","update"]`, while the ordinary socket got the full `TransactionUpdate` with `reducer_call` for the same commit. A false disclaimer is worse than a plain error, because a reader believes it and designs around a limitation that does not exist. The same section understated a real hazard, now sharpened by measurement: `TransactionUpdateLight` **does** carry a top-level `request_id`, and it is the **originating** client's. A probe sent `4242` from the heavy socket while the light socket's own last id was `7`, and `4242` is what the light frame carried. That is worse than carrying none — a correlator reading it matches another player's call. ## Claims that were false - **`session.jsonl` is four blocks, not three.** #51 appended `overlap`; `IdentityToken` sits at frames 1, 30, 37, 43. The per-block example listed only two. - **A quoted FAIL message that cannot be produced** — the real label gained `/48` with the overlap block. - **A fabricated log line.** `[html5] SMOKE: PASS procedure round trip…` was spliced from two real lines. Corrected to the real `PHASE4 OK …` text, with the insert count taken from an actual conformance run rather than invented. The PHASE5 `SMOKE: PASS` line was deliberately *not* quoted: its text ends in a run-specific identity prefix, so quoting one would have replaced a fabricated line with another. All four other quoted log lines were checked and are real. - **"An Option is the same shape in both encodings"** — true for a scalar payload, false for a product: `Option<Point>` is `[0,{"x":8,"y":9}]` named and `[0,[8,9]]` positional. Same for the `None` shape: `[1,{}]` vs `[1,[]]`. - **Memory figure** was 5× out: 0.4 MB for a 4000-row set of 57-byte rows; 2 MB needs ~470 bytes a row. - **Positional `u128`/`ConnectionId`/`Uuid`** — the description was weaker than the truth. `u128` arrives a bare rounded number; `ConnectionId` and `Uuid` arrive as a one-element array holding one, and **the helpers return nil for them**. - **`updates` counted per distinct query** — actually counted per *pool*, and the new `SubscribeSingle` fact defines what a pool is. - **Conformance "ignores" formatting** — it normalises through stylua first. - **nixpkgs SpacetimeDB version** was 2.7.0-hotfix1, actually 2.8.1. The identical stale figure in `dev/nix/spacetime.nix:6` is fixed too — outside the README-only scope, so flagged rather than done silently. ## Claims that were unfalsifiable as written - **Forgejo basic auth returning 404.** Not merely untestable — what *is* testable contradicts the mechanism: `https://user:token@…/archive/v0.1.0.zip` returns **200**, `?token=…` returns **401**, and a nonexistent repo returns 404 with *and* without basic auth. The false mechanism is gone; the actionable advice stays. - **`unknown` "when and only when nothing matched."** The *only when* half is true — seven exotic types including a zero-variant sum and a triple-nested tag all resolved. The *when* half is unreachable: every anonymising route is rejected at publish. - **`OutOfEnergy`** could not be constructed on a self-hosted standalone. Marked documented-but-unobserved rather than deleted, in **both** places a reader might land. - The **31s → 10s** speedup is now attributed to the last containerised revision, with the honest note that the configuration is deleted so the figure is not reproducible here. ## Six behaviours nobody had written down - **Procedure replies arrive out of request order.** Sent 1,2,3,4,5; received 1,4,5,2,3. Correlation by `request_id` is required, not an optimisation — the README said this only for `one_off_query`. - **An empty result is shaped differently per message.** `SubscribeMultiApplied` for a query matching nothing omits the relation; a legacy `Subscribe` matching nothing carries it with `num_rows: 0`. - **`SubscribeSingle` shares the query pool with `SubscribeMulti`** — only the legacy `Subscribe` is a separate pool. - **`OneOffQuery.message_id` goes out as a byte array and comes back as a lowercase hex string** (`[1,1,1,1]` → `"01010101"`). Neither direction was documented. - **A named product declaring `__identity__` keeps its own name** — `product_FakeIdentity`, not `identity`. Extends the shape-collision rule from `Option` to the special types. - **A table called `tables` is accepted by 2.8.2**, which makes `db.tables.tables` a real case rather than a hypothetical. ## Two places the verification agent's own findings were wrong Both caught by re-running rather than re-reading. **Procedure ordering.** The claim was that the `InternalError` replies "overtake the two that do work". But reply 3 — a real throw, also at `timestamp: 0` — landed **last**, behind working reply 2. Written as the weaker, true statement: a failure is answered without a transaction, so nothing holds it behind the calls that do work, and it can land ahead of one sent before it. **`.rows.table_rows` "a bare `TableUpdate` with no array"** — it does carry its own `updates` array; only the `tables` array is absent. ## Verified beyond what was asked The `decode_typed` example was broken: it passed `gen.tables.player` then read `row.status`/`row.home`/`row.joined`/`row.serial`, but this repo's generated `player` is `{ identity, sponsor, name, score }` — a reader following it got four nils. Now uses `gen.tables.all_types` and **every value in it is measured**: `status → { tag = "busy" }`, `point → { x = 3, y = -4 }`, `at → 1787163313278160`, and the exact 39-digit `u128`, identical in both encodings. #55 had meanwhile renamed the namespace illustration to a table called `cache`, which nobody had tested. A module declaring both `cache` and `tables` was published and both served rows — so #55's renamed line is now verified too. The `"second",2` view literal was softened *and* explained: the module returns `rows[rows.length-1]`, and 2.8.2's iteration order is not insertion order, so with both rows present the run returned the first inserted one. **"Neighbouring integers share a double" was graded rather than left universal**: 0% collide below 2^53, half the adjacent pairs just past it, ~all near `u64` max. All three stated consequences hold and stay. ## Verification `6 passed, 0 failed`; stylua clean. Native conformance PASS both runs through PHASE5 with identity persisted across a process restart — **the PHASE4 line now quoted in the README came out of that run verbatim.** No fixture drift; the schema was re-derived from the published module and diffed through stylua. Every quoted claim was located by its text rather than by line number, since #55 moved them. Nothing was unlocatable.
docs: correct every behavioural claim measurement contradicted (#24)
All checks were successful
ci / ci (pull_request) Successful in 21s
bb003551da
Every claim here was executed against a live 2.8.2, not re-read. 14 were false,
5 were unfalsifiable as written, 5 misleading, and 6 real behaviours were
undocumented.

The one that matters most: the README said light mode's other direction "is
still unproven against a server ... which needs a second connection the harness
does not have". It is proven. Two sockets on one server: the `light = true`
client received the OTHER client's transaction as `TransactionUpdateLight` with
keys exactly `["request_id","update"]`, while an ordinary client got the full
`TransactionUpdate` for the same commit. A disclaimer is worse than a plain
error, because a reader believes it and stops looking. The same paragraph gained
the sharper hazard underneath it: a light update DOES carry a top-level
`request_id`, and it is the ORIGINATING client's, so a correlator reading it
matches another player's call against its own pending table (measured: id 4242
from the other socket, while this one's last id was 7).

Other corrections, each with the measurement behind it:

- `session.jsonl` is four blocks, not three: `IdentityToken` at frames 1, 30,
  37, 43, and `capture-fixtures.mjs` declares `BLOCKS = { player, views,
  overlap }`. The per-block example gained `overlap` too.
- Two quoted outputs could not be produced. The fixture FAIL message gained
  `/48` with the overlap block, and the smoke line spliced `SMOKE: PASS` onto
  PHASE4's text; the only PASS the script emits is PHASE5's verdict. Both now
  read what the source prints.
- The HTML5 phase list named four phases and omitted the two carrying the
  verdict: phase 1's `light = true` assertion and PHASE5's identity-keyed table.
- `sub.message` retains 0.4 MB for a 4000-row set, not "roughly 2 MB"; 2 MB
  needs ~470 bytes a row.
- An `Option` is NOT the same shape in both encodings once its payload is a
  product, and a positional `None` is `[1, []]`, not the `[1, {}]` a named
  readback carries.
- `updates[]` counts per pool, and `SubscribeSingle` shares the pool with
  `SubscribeMulti`; only the legacy `Subscribe` is separate.
- `decode_typed`'s example read four columns `gen.tables.player` does not have.
  Rewritten on `all_types` and executed against the captured frames.
- Positional `ConnectionId`/`Uuid` were a one-element array, not "a rounded
  number", and the helpers returned nil for them.
- "Neighbouring integers share a double" was graded: 0% below 2^53, half just
  past it, effectively all near `u64` max.
- The conformance diff normalises through stylua first, so "ignoring only line
  1" was false.
- Forgejo's archive endpoint does not 404 on basic auth — it serves the archive.
  Dropped the mechanism, kept the advice.
- `OutOfEnergy` and the `confirmed_reads` rationale are now marked as what they
  are: declared by the wire, or unobservable, rather than captured.
- `unknown` is unreachable from any 2.8.2 JS module, and the shape-collision
  rule extends to the special types (`product_FakeIdentity`, not `identity`).
- Stale nixpkgs figure fixed in README and `dev/nix/spacetime.nix`.

Newly documented, all consumer-visible: procedure replies arrive out of request
order (sent 1,2,3,4,5, got 1,4,5,2,3); "nothing came back" has two shapes and
table state picks one, not the message type; `OneOffQuery.message_id` goes out a
byte array and comes back a hex string; and 2.8.2 accepts tables called `cache`
and `tables`, so the namespace collision is real rather than hypothetical.

One correction is to DocTruth's own finding. It reported the empty-result shape
as depending on the message type, from two probes run against different table
states. Isolating the variable shows both message types agree on every cell and
the table's own emptiness is what decides it.

Suite 6/6, stylua clean, native conformance PASS through PHASE5, no fixture
drift.
Author
Owner

Review — automated reviewer, posted for the record

The reviewer died before yielding, but wrote findings to disk as instructed. This is that file, verbatim, salvaged in full. Its probes are under /tmp/r56/.

Headline: the PR's flagship correction is itself false. light=true is a no-op on 2.8.2 for the behaviour claimed — the discriminator is caller-vs-non-caller. Proven with a controlled 4x4 matrix. Being actioned now.


Review of PR #56 (feat/doc-truth) — Review56

Base 4ee2a24, head bb00355. Started.

Setup

  • Server: 2.8.2 standalone on 6500, STDB_STATE_DIR=/tmp/r56/state. Note: .#ci shell lacks openssl; dev-server.sh needs it on a fresh state dir -> must use .#conformance. (Observation, not a PR defect.)

TASK 1 — headline light-mode correction: PARTLY FALSE (candidate BLOCKER)

Probe /tmp/r56/p1-light.mjs, own harness /tmp/r56/w.mjs, server 6500.
Three sockets, all SubscribeMulti 'SELECT * FROM message':

  • light = ?light=true
  • heavy = ordinary, and the CALLER
  • plain = ordinary, NOT the caller
    Heavy calls send_message with request_id=987654.
    Observed:
  • light -> TransactionUpdateLight keys ["request_id","update"], top-level request_id=987654 (originator's) OK
  • heavy -> full TransactionUpdate (7 keys) with reducer_call.request_id=987654 OK (it is the caller)
  • plain -> TransactionUpdateLight keys ["request_id","update"], request_id=987654 <-- CONTRADICTS README
    Light client's own call -> full TransactionUpdate w/ reducer_call.request_id=7. OK.
    So the README's new sentence "...while an ordinary client gets the full TransactionUpdate
    for the same commit" is not reproducible: the ordinary NON-CALLER got the light frame too.
    The discriminator looks like caller-vs-not, not light-vs-ordinary. Needs the controlled run.

TASK 1b — controlled run: light is a NO-OP on 2.8.2 for this behaviour

/tmp/r56/p2-light-controlled.mjs. Four observer sockets (ordinary/light x our-token/other-identity)
plus a caller varied four ways:
| caller | ord-sameToken | lit-sameToken | ord-otherIdent | lit-otherIdent |
| HTTP /call (no socket) | Light rid=0 | Light rid=0 | Light rid=0 | Light rid=0 |
| separate ordinary sock | Light rid=555 | Light rid=555 | Light rid=555 | Light rid=555 |
| ord-sameToken itself | FULL | Light rid=111 | Light rid=111 | Light rid=111 |
| lit-sameToken itself | Light rid=222 | FULL | Light rid=222 | Light rid=222 |
=> The discriminator is CALLER-vs-NON-CALLER, not light-vs-ordinary. light=true
changed nothing observable. So README:1131-1132 "while an ordinary client gets the
full TransactionUpdate for the same commit" is FALSE as written.

TASK 1c — the flag IS parsed, so the no-op is real

/tmp/r56/p3-lightflag.mjs: ?light=true/false open; ?light=banana/?light=1 -> HTTP 400
"Failed to deserialize query string: provided string was not true or false".
So the server does read light; it simply does not change which frame a NON-CALLER gets.

TASK 1d — SDK comment now contradicts the README (pre-existing, but the PR makes it visible)

stdb/connection.lua:824-825: "Light frames are other clients' transactions, and those
report request_id = 0." Measured: light frames carry the ORIGINATOR's request_id
(555 / 111 / 222); only the HTTP-called commit reported 0 (HTTP has no request_id).
README:1124-1127 (new, correct) and connection.lua:825 (stale, wrong) now disagree.

TASK 1e — NEW FALSE CLAIM added by this PR: the confirmed=banana 400 body

README:1141-1143 "…?confirmed=banana answering 400 and naming the string that was neither."
Exact body (77 bytes, curl against 6500):
Failed to deserialize query string: provided string was not true or false
It names the two ACCEPTED values, not banana. It does not even name which parameter
was bad: ?light=banana returns the byte-identical body.

TASK 2a — Option shape per encoding: HOLDS (measured)

/tmp/r56/p4-optshape.mjs on 6500, all_types.opt_point = Option<Point>, same server, same table,
positional row from the TransactionUpdate, named row from a SubscribeMultiApplied readback.
| column | POSITIONAL | NAMED |
| opt_point Some | [0,[8,9]] | [0,{"x":8,"y":9}] |
| opt_point None | [1,[]] | [1,{}] |
| opt_int Some | [0,7] | [0,7] | (scalar identical, as claimed)
| opt_int None | [1,[]] | [1,{}] |
=> README:40-43 and README:767-773 (Option<Point> is [0,{"x":8,"y":9}] named and [0,[8,9]]
positional; None [1,{}] vs [1,[]]) are exactly right.

TASK 2b — positional u128 / ConnectionId / Uuid: HOLDS on the wire

Same run:
u_128 = 3.402823669209385e+38 (bare rounded number) <- as claimed
conn = [7.630495884497377e+37] (one-element array) <- as claimed
uid = [1.512366075204171e+36] (one-element array) <- as claimed
Named readback for the same row: conn {"__connection_id__":…}, uid {"__uuid__":…}.
Incidental: owner(identity) ["0x…"], at(timestamp) [1787216177942060], span [90061000000]
are one-element arrays positionally too, and u_256 positional is the hex STRING
"0xffff…ffff", not a number. The README's list is about which ones LOSE PRECISION, so
naming only u128/ConnectionId/Uuid is defensible, but the shape rule is broader.

TASK 2b(ii) — "the helpers refused outright": HOLDS in practice, mechanism mis-stated (nit)

/tmp/r56/h.lua (pure Lua 5.1, repo stdb.protocol):
connection_id_to_hex({12345}) -> 0x…3039 (accepted)
connection_id_to_hex({2^53}) -> 0x…20000… (accepted)
connection_id_to_hex({2^53+2}) -> nil
connection_id_to_hex({7.630495884497377e37}) -> nil (a real positional conn)
uuid_to_string({1.512366075204171e36}) -> nil (a real positional uuid)
binary search: last accepted 9007199254740992 (= 2^53), first refused 9007199254740994.
scalar() unwraps the positional [n] fine (protocol.lua:200-218); the nil comes from
u128_digits' 2^53 gate (protocol.lua:350). So the refusal is PRECISION-driven, not
shape-driven — but a server-minted 128-bit id is below 2^53 with probability 2^-75, so
README:947-949 "which the helpers refused outright" is true to any practical standard.
Nit only: the sentence attributes the nil to the array wrapper.

TASK 3 — the six new behaviours

(1) procedure replies out of request order: HOLDS, and the weakened wording is the true one

/tmp/r56/p5-six.mjs. Sent request_ids 1..5 = count_messages, always_fails, echo,
no_such_procedure, echo(bad args). Arrival order 1,4,5,3,2.
rid=1 Returned ts=1787216345372446 dur=15752
rid=4 InternalError ts=0 dur=0
rid=5 InternalError ts=0 dur=0
rid=3 Returned ts=1787216345399082 dur=406
rid=2 InternalError ts=0 dur=0 <- the real throw, LAST, behind working reply 3
=> DocTruth's "failures overtake the calls that do work" is refuted again, independently.
=> README:534-540's weaker wording ("nothing holds it behind the calls that do real work,
and it can land ahead of one sent before it") is exactly what I measured: 4 and 5 landed
ahead of 2 and 3, both sent earlier. Does not under-warn: it still says correlate on
request_id, "the only thing that pairs a reply with its call".
NIT: the quoted permutation "came back 1, 4, 5, 2, 3" is run-specific — my run gave
1,4,5,3,2. Past tense makes it an honest observation, not a fabrication, but it is a
run-specific literal in a document that quotes literals as facts.

(4) OneOffQuery.message_id byte array out / hex string back: HOLDS

Sent [1,1,1,1] -> "01010101"; sent [222,15,0,255] -> "de0f00ff". Lowercase hex string.

(3) SubscribeSingle shares the query pool: HOLDS for the delta count, but the

PR's sentence over-generalises the pool's three consequences (should-fix)

/tmp/r56/p6-pools.mjs, all four sets using the IDENTICAL text SELECT * FROM message:
A1 Multi(100) -> SubscribeMultiApplied WITH the row
A2 Multi(101) identical -> update:{"tables":[]} (replies with nothing) OK
A3 one change -> 1 updates entry OK
B1 Single(102) identical -> SubscribeApplied WITH the row, num_rows=1 <- NOT "nothing"
B2 one change (2 Multi + 1 Single) -> 1 updates entry OK (shares pool)
C1 legacy Subscribe -> InitialSubscription WITH the row
C2 one change (+legacy) -> 2 updates entries OK (separate pool)
D1/D2 unsub both Multi -> update:{"tables":[]} each (deletes nothing) OK
D3 one change (Single+legacy) -> 2 entries OK
E1 unsub the Single -> UnsubscribeApplied WITH the DELETE OK (last to go)
E2 one change (legacy) -> 1 entry
=> "The single-query form shares the query-set pool" is TRUE (delta count 1 with
Multi+Multi+Single). But README:346-352 then states the pool's sharing consequences
flatly — "Inside a pool, sharing means the second subscribe replies with nothing" —
and that is FALSE when the second subscribe is a SubscribeSingle: it replied with the
full relation. Fix: scope "replies with nothing" to a second query set
(SubscribeMulti), and say that a SubscribeSingle joining the pool still gets its own
readback while counting as the same query in updates[].

(2) the two empty-result shapes: rule HOLDS for Multi+legacy; the bullet's HEADLINE is

falsified by SubscribeSingle (should-fix)

/tmp/r56/p7-empty.mjs + /tmp/r56/p8-stopped.mjs:
| state | SubscribeMulti | legacy Subscribe | SubscribeSingle |
| table EMPTY, unfiltered | ABSENT | ABSENT | PRESENT num_rows=0 |
| table HAS rows, query matches none | PRESENT n=0 | PRESENT n=0 | PRESENT n=0 |
| drop one of two identical-text sets (released 0) | ABSENT | - | - |
| drop a query that had STOPPED matching | PRESENT n=0 | - | - |
| drop the last holder | PRESENT, real delete | - | - |
=> The four-way rule in README:363-376 is exactly right, and "Both a SubscribeMulti and a
legacy Subscribe behave identically on each" is exactly what I measured.
=> BUT the bullet's bold headline — "and the message type is not what picks one" — is
false for the third form: SubscribeApplied.rows is a REQUIRED single TableUpdate
(protocol.lua:58 lists rows as required; no tables array to be empty), so a
SubscribeSingle can NEVER report the relation absent. On an empty table it returned
PRESENT num_rows=0 where both other forms returned ABSENT.
This is compounded by the PR's own new "SubscribeSingle shares the query-set pool" line:
a reader is now told Single behaves like Multi, and on this shape it does not.
Fix: say "neither SubscribeMulti nor the legacy Subscribe picks a shape — the state
does; SubscribeSingle is the exception, it always names the relation because its
rows field is not an array."

extra fix verified — .rows.table_rows DOES carry updates

SubscribeApplied raw: "rows":{"table_id":4097,"table_name":"message","table_rows": {"table_id":4097,"table_name":"message","num_rows":1,"updates":[…]}}.
FixDocs' correction (README:365-367) is right, and DocTruth's "bare TableUpdate with no
array" was wrong. HOLDS.

converse pool order (extra control)

Single(10) FIRST -> PRESENT with the row; Multi(11) identical text SECOND -> ABSENT;
Single(12) identical text THIRD -> PRESENT with the row again.
So the "second subscribe replies with nothing" behaviour belongs to the MULTI reply path,
not to the pool: a SubscribeSingle always gets its readback. Confirms the fix above.

(5) named product declaring __identity__ keeps its own name: HOLDS

Published my own module to db r56clash on 6500 (/tmp/r56/mod/index.js), then
gen-schema.sh http://127.0.0.1:6500 r56clash:
faker.types = { "u64", "product_FakeIdentity", "product_FakeTimestamp" }
typespace ["FakeIdentity"] = { kind="product", fields={"identity"}, types={"u256"} }
["FakeTimestamp"] = { kind="product", fields={"timestamp…_"}, types={"i64"} }
Stronger than the README says, and worth it: on the WIRE the fake is byte-identical to a
real identity — named {"__identity__":"0x7"}, positional ["0x7"] — so the schema tag is
the ONLY discriminator. Same for the timestamp magic name.

(6) a table called tables is accepted: HOLDS

Same module declared tables AND cache; publish succeeded and both served rows:
served table_name=tables rows=1 inserts=["[1,"tables:hi"]"]
served table_name=cache rows=1 inserts=["[1,"cache:hi"]"]
So db.tables.tables and db.tables.cache are both reachable cases. README:314-322 holds.

TASK 5 — the four skips

SKIP 1 (Rank 10, include_dirs / "all a consuming project sees"): genuinely fixed

README:189-191 now reads "The archive also carries README.md, game.project and
patches/, but include_dirs = stdb is what Defold mounts, so require("stdb.init") is
the only thing on your require path." — verbatim DocTruth's replacement. Skip is correct.

SKIP 3 (Rank 7, stdb.url): genuinely fixed

README:1093 | stdb.url(base_url, database) | string | <base_url>/v1/database/<db>/subscribe. base_urlmust carry the scheme —ws://127.0.0.1:3000 — because nothing here adds one |.
Skip is correct.

SKIP 2 (Rank 14, archive sizes): the skip was correct WHEN READ, and THIS PR broke it

-> should-fix, and it is this PR's own doing

README.md:157-158 "takes the zip a consumer fetches from 54 entries and 220 KB down to
11 entries and 86 KB".
Measured with git archive --format=zip:
at #53 (65282fb) release zip = 87,751 B = 85.7 KiB -> "86 KB" OK
at base 4ee2a24 release zip = 88,796 B = 86.7 KiB -> "86 KB" OK
at PR head bb00355 release zip = 90,717 B = 88.6 KiB -> "88 KB" STALE
pre-export-ignore at head: 54 entries, 228,254 B = 222 KiB ("220 KB" -> 222 KB)
entry counts 54 / 11 both still exact.
Cause: this PR grows README.md from 79,760 B to 84,771 B (+5,011 B, +70 lines), and
README.md is the single largest member of the release archive. A PR that exists to make
the README's numbers true silently invalidated two of them.
Fix: 222 KB and 88 KB, or drop the KB figures and keep the entry counts (which are stable).

SKIP 2 knock-on — .gitattributes:1-11 header comment is stale AND self-refuting

It claims: "54 entries and ~220 KB of zip before, 11 entries and 87,646 B after. Of the
232,696 B that survives, README.md is 76,502 B (33%) and stdb/ is 153,302 B. The 'after'
figures are exact and stay exact; this file is itself export-ignored, so editing this
comment cannot change them."
Measured at PR head: zip 90,717 B (not 87,646); uncompressed total 241,025 B (not 232,696);
README.md 84,771 B = 35% (not 76,502 B / 33%).
Even at 65282fb — the commit that wrote the comment — the zip was 87,751 B, not 87,646 B.
"stay exact … editing this comment cannot change them" is the false premise: the figures
depend on README.md, which is IN the archive and changes constantly. DocTruth's install
slice flagged this file by name (/tmp/doctruth-install.md:338); FixDocs skipped it as
"already fixed by FixArchive", and it is not fixed.

SKIP: install slice "four things" nit — correctly rejected

README:176 already reads "Four things cannot move into dev/, each because a tool demands
the root:". Rejection reason accurate.

SKIP: bounds item 4 (decode_typed ratio content-dependence) — correctly rejected

/tmp/doctruth-bounds.md:322-327 — its own author says it belongs in
protocol.decode_typed's docstring, not the README, and that the README's as-written
~2x needs no change (1.95x-2.07x on the real row). Rejection accurate.

SKIP: install-URL v1.0.0 pin — correctly rejected

README:185,211 point at archive/v1.0.0.zip, which is the tag this release creates.
A precondition of the release order, not an error.

TASK 5 extras

EXTRA 1 — dev/nix/spacetime.nix:6 version: VERIFIED, and it belongs here

nixpkgs master pkgs/by-name/sp/spacetimedb/package.nix -> version = "2.8.1".
nixpkgs nixos-26.05 -> version = "2.2.0". license = lib.licenses.bsl11.
So both README:1408-1409 and spacetime.nix:6 are now right (2.2.0 / 2.8.1 / 2.8.2 here).
Belongs in this PR: comment-only one-liner, identical stale figure, disclosed in the PR
body, and splitting it would leave the file that PINS the version contradicting the README
about that version. Note dev/ is export-ignored, so it is not part of the shipped surface
— which is exactly why it needs to ride along rather than get its own PR.

EXTRA 2 — .rows.table_rows correction: VERIFIED (see TASK 3 above). Right call.

Other new claims I attacked

unknown is unreachable from a 2.8.2 JS module: HOLDS

Published three modules to 6500 (/tmp/r56/anon/):
anonymous t.object({a}) -> publish REJECTED (SchemaInner throw, 500)
t.object('', {a}) -> "module contains invalid identifier: Empty identifiers are forbidden."
anonymous t.enum(['A','B']) -> publish REJECTED
README:708-712 ("a compound type needs a name — an anonymous one is rejected at publish, an
empty one by the identifier check") is exactly right, including which check catches which.

TASK 7 (partial)

  • lua dev/test/runner.lua -> 6 passed, 0 failed
  • stylua --check stdb dev/test -> clean, exit 0
  • conformance: no fixture drift ("chat_schema.lua matches what the tool derives from the
    published module"). Full run in progress.

TASK 3 — PLACEMENT of the six new behaviours

| behaviour | landed at | section | would a consumer look there? |
| procedure reply ordering | README:534-540 | ### Procedures, 3rd of "Three things to know" | YES — the count was correctly bumped from "Two" to "Three" |
| empty-result shapes | README:369-377 | ### Dropping a subscription, 4th bullet | PARTLY. Half the rule is about SUBSCRIBING (a narrowed subscribe matching nothing); a reader whose subscribe came back empty would not look under "Dropping a subscription". It is still the least-bad home, since the other three delta behaviours live there. |
| SubscribeSingle pool | README:348-349 and README:616-619 | both ### Dropping a subscription and ### Reconnect's updates paragraph | YES — both places a reader meets the delta count |
| OneOffQuery.message_id | README:588-592 | inside ### Reconnect, right after the evt.matched paragraph | YES, that is where the existing message_id correlation material already is |
| __identity__ named product | README:724-726 | ### Generating the schema, appended to the structural-recognition paragraph | YES |
| a table called tables | README:317-322 | ### Per-table callbacks, in the db.tables namespace rationale | YES — it is the paragraph that motivates the namespace |
None is in a footnote. Verdict: 5 of 6 land exactly where a consumer hits them; the
empty-result one is defensible but its own text spans subscribe as well as drop.

PLACEMENT knock-on: README:342 "Four behaviours are worth knowing" — the count is now

ambiguous, and the PR bumped the analogous count elsewhere but not this one (should-fix)

Bullet counts under that sentence, measured with git show <c>:README.md:
13073c6 (#51, which WROTE "Four behaviours") 3 bullets
65282fb (#53) 3 · 4ee2a24 (#55, base) 3 · bb00355 (this PR) 4
So either bullets are the behaviours — in which case "Four" was wrong before and is right by
accident now — or the trailing "Holds are counted per primary key" paragraph was the fourth,
in which case "Four" is now wrong at five. The PR correctly changed "Two things to know" to
"Three things to know" for Procedures (README:527) after adding one bullet there, and did
not do the same here. Fix: state the number that matches, or drop the number.

TASK 4 — quoted literals I checked myself

["second",2] and the REASON given for softening it — reason NOT reproducible (should-fix)

/tmp/r56/p10-views.mjs, /tmp/r56/p11-iter.mjs.
README:853-859 now says: "Which row that is belongs to the module and the server, not to
this SDK: the view here returns rows[rows.length-1] and 2.8.2's iteration order is not
insertion order
."
First half VERIFIED: dev/server/index.js:182-186 really returns rows[rows.length - 1]
and undefined when empty.
Second half NOT REPRODUCIBLE. Nine observations on my server:
clean table, rows "first"(1) "second"(2), three consecutive looks -> ["second",2] all three
6 fresh inserts -> message_flip iteration order [3,4,5,6,7,8], latest = ["w6",8]
after clear + 3 inserts-> [9,10,11], latest = ["x3",11]
after a pk-preserving edit -> [9,10,11], latest = ["x3",11]
Iteration order was pk-ascending — i.e. insertion order for an autoinc pk — every time, and
message_latest returned the LAST inserted row every time. On a clean table ["second",2]
is in fact deterministic, which makes the added clause both unsupported and self-defeating:
it explains the softening with a mechanism that does not fire.
Fix: keep "such as ["second",2]" and keep "which row belongs to the module", drop or
attribute the iteration-order clause (e.g. "the module picks rows[rows.length-1], and
nothing in the protocol promises which row iter() yields last").

the none case carries no relation at all: HOLDS

Empty table -> "update":{"tables":[]} — no message_latest entry. README:857-859 right.

session.jsonl four blocks: HOLDS

50 frames; IdentityToken at lines 1, 30, 37, 43; capture-fixtures.mjs:235
const BLOCKS = { player: playerSteps, views: viewSteps, overlap: overlapSteps } -> main + 3.

the 4/5/24/48 FAIL line: label and format both HOLD

dev/test/test_fixtures.lua:175 label = "generated column order matches the module, which
decodes the TransactionUpdate rows in frames 4/5/24/48"; dev/test/runner.lua:26 formats
FAIL %s: expected %s, got %s. The quoted line is producible.

RUN-SPECIFIC value quoted as general: row id=1 (should-fix)

README:129-133's fenced block quotes
[html5] SMOKE: PHASE2 OK survived restart; row id=1 restored
dev/example/main/smoke.script:240 formats it from self.edited_id, a server autoinc u64.
My own conformance run printed row id=1 in run 1 and row id=4097 in run 2 of the same
invocation. So the id is run-state-dependent and is quoted as a bare fact — the exact bar the
author says they applied when they declined to quote the PHASE5 identity-prefix line.
The line is pre-existing, not added by this PR, but this PR is the audit of that block and
it edited the line directly beneath it.
Fix: row id=<n> / row id=1 restored -> row id=… restored, or drop the id.

TASK 7 (cont.) — native conformance PASS

STDB_PORT=6500 STDB_STATE_DIR=/tmp/r56/state dev/tools/conformance.sh:

  • no fixture drift
  • run 1 PHASE1..PHASE5 + SMOKE: PASS PHASE5 …, identity 0xc2007bcf…
  • run 2 same, PASS (identity … persisted across a process restart)
  • PHASE4 line printed VERBATIM as the README now quotes it (modulo the [html5]
    prefix, which is the HTML5 tier's own — html5-conformance.mjs:165 [html5] ${line}):
    SMOKE: PHASE4 OK procedure round trip; failure surfaced as an error; per-table API: 1 insert(s), 1 update
## Review — automated reviewer, posted for the record The reviewer died before yielding, but wrote findings to disk as instructed. This is that file, verbatim, salvaged in full. Its probes are under `/tmp/r56/`. **Headline: the PR's flagship correction is itself false.** `light=true` is a no-op on 2.8.2 for the behaviour claimed — the discriminator is caller-vs-non-caller. Proven with a controlled 4x4 matrix. Being actioned now. --- # Review of PR #56 (feat/doc-truth) — Review56 Base 4ee2a24, head bb00355. Started. ## Setup - Server: 2.8.2 standalone on 6500, STDB_STATE_DIR=/tmp/r56/state. Note: `.#ci` shell lacks openssl; dev-server.sh needs it on a fresh state dir -> must use `.#conformance`. (Observation, not a PR defect.) ## TASK 1 — headline light-mode correction: PARTLY FALSE (candidate BLOCKER) Probe `/tmp/r56/p1-light.mjs`, own harness `/tmp/r56/w.mjs`, server 6500. Three sockets, all `SubscribeMulti 'SELECT * FROM message'`: - `light` = `?light=true` - `heavy` = ordinary, and the CALLER - `plain` = ordinary, NOT the caller Heavy calls `send_message` with request_id=987654. Observed: - light -> TransactionUpdateLight keys ["request_id","update"], top-level request_id=987654 (originator's) OK - heavy -> full TransactionUpdate (7 keys) with reducer_call.request_id=987654 OK (it is the caller) - plain -> **TransactionUpdateLight** keys ["request_id","update"], request_id=987654 <-- CONTRADICTS README Light client's own call -> full TransactionUpdate w/ reducer_call.request_id=7. OK. So the README's new sentence "...while an ordinary client gets the full `TransactionUpdate` for the same commit" is not reproducible: the ordinary NON-CALLER got the light frame too. The discriminator looks like caller-vs-not, not light-vs-ordinary. Needs the controlled run. ### TASK 1b — controlled run: `light` is a NO-OP on 2.8.2 for this behaviour `/tmp/r56/p2-light-controlled.mjs`. Four observer sockets (ordinary/light x our-token/other-identity) plus a caller varied four ways: | caller | ord-sameToken | lit-sameToken | ord-otherIdent | lit-otherIdent | | HTTP /call (no socket) | Light rid=0 | Light rid=0 | Light rid=0 | Light rid=0 | | separate ordinary sock | Light rid=555 | Light rid=555 | Light rid=555 | Light rid=555 | | ord-sameToken itself | **FULL** | Light rid=111 | Light rid=111 | Light rid=111 | | lit-sameToken itself | Light rid=222 | **FULL** | Light rid=222 | Light rid=222 | => The discriminator is CALLER-vs-NON-CALLER, not light-vs-ordinary. `light=true` changed nothing observable. So README:1131-1132 "while an ordinary client gets the full `TransactionUpdate` for the same commit" is FALSE as written. ### TASK 1c — the flag IS parsed, so the no-op is real `/tmp/r56/p3-lightflag.mjs`: `?light=true`/`false` open; `?light=banana`/`?light=1` -> HTTP 400 "Failed to deserialize query string: provided string was not `true` or `false`". So the server does read `light`; it simply does not change which frame a NON-CALLER gets. ### TASK 1d — SDK comment now contradicts the README (pre-existing, but the PR makes it visible) `stdb/connection.lua:824-825`: "Light frames are other clients' transactions, and those report `request_id = 0`." Measured: light frames carry the ORIGINATOR's request_id (555 / 111 / 222); only the HTTP-called commit reported 0 (HTTP has no request_id). README:1124-1127 (new, correct) and connection.lua:825 (stale, wrong) now disagree. ### TASK 1e — NEW FALSE CLAIM added by this PR: the `confirmed=banana` 400 body README:1141-1143 "…`?confirmed=banana` answering `400` and naming the string that was neither." Exact body (77 bytes, curl against 6500): Failed to deserialize query string: provided string was not `true` or `false` It names the two ACCEPTED values, not `banana`. It does not even name which parameter was bad: `?light=banana` returns the byte-identical body. ## TASK 2a — Option shape per encoding: HOLDS (measured) `/tmp/r56/p4-optshape.mjs` on 6500, `all_types.opt_point` = `Option<Point>`, same server, same table, positional row from the TransactionUpdate, named row from a SubscribeMultiApplied readback. | column | POSITIONAL | NAMED | | opt_point Some | `[0,[8,9]]` | `[0,{"x":8,"y":9}]` | | opt_point None | `[1,[]]` | `[1,{}]` | | opt_int Some | `[0,7]` | `[0,7]` | (scalar identical, as claimed) | opt_int None | `[1,[]]` | `[1,{}]` | => README:40-43 and README:767-773 (`Option<Point>` is `[0,{"x":8,"y":9}]` named and `[0,[8,9]]` positional; `None` `[1,{}]` vs `[1,[]]`) are exactly right. ## TASK 2b — positional u128 / ConnectionId / Uuid: HOLDS on the wire Same run: u_128 = 3.402823669209385e+38 (bare rounded number) <- as claimed conn = [7.630495884497377e+37] (one-element array) <- as claimed uid = [1.512366075204171e+36] (one-element array) <- as claimed Named readback for the same row: conn `{"__connection_id__":…}`, uid `{"__uuid__":…}`. Incidental: `owner`(identity) `["0x…"]`, `at`(timestamp) `[1787216177942060]`, `span` `[90061000000]` are one-element arrays positionally too, and `u_256` positional is the hex STRING `"0xffff…ffff"`, not a number. The README's list is about which ones LOSE PRECISION, so naming only u128/ConnectionId/Uuid is defensible, but the shape rule is broader. ### TASK 2b(ii) — "the helpers refused outright": HOLDS in practice, mechanism mis-stated (nit) `/tmp/r56/h.lua` (pure Lua 5.1, repo `stdb.protocol`): connection_id_to_hex({12345}) -> 0x…3039 (accepted) connection_id_to_hex({2^53}) -> 0x…20000… (accepted) connection_id_to_hex({2^53+2}) -> nil connection_id_to_hex({7.630495884497377e37}) -> nil (a real positional conn) uuid_to_string({1.512366075204171e36}) -> nil (a real positional uuid) binary search: last accepted 9007199254740992 (= 2^53), first refused 9007199254740994. `scalar()` unwraps the positional `[n]` fine (protocol.lua:200-218); the nil comes from `u128_digits`' 2^53 gate (protocol.lua:350). So the refusal is PRECISION-driven, not shape-driven — but a server-minted 128-bit id is below 2^53 with probability 2^-75, so README:947-949 "which the helpers refused outright" is true to any practical standard. Nit only: the sentence attributes the nil to the array wrapper. ## TASK 3 — the six new behaviours ### (1) procedure replies out of request order: HOLDS, and the weakened wording is the true one `/tmp/r56/p5-six.mjs`. Sent request_ids 1..5 = count_messages, always_fails, echo, no_such_procedure, echo(bad args). Arrival order **1,4,5,3,2**. rid=1 Returned ts=1787216345372446 dur=15752 rid=4 InternalError ts=0 dur=0 rid=5 InternalError ts=0 dur=0 rid=3 Returned ts=1787216345399082 dur=406 rid=2 InternalError ts=0 dur=0 <- the real throw, LAST, behind working reply 3 => DocTruth's "failures overtake the calls that do work" is refuted again, independently. => README:534-540's weaker wording ("nothing holds it behind the calls that do real work, and it can land ahead of one sent before it") is exactly what I measured: 4 and 5 landed ahead of 2 and 3, both sent earlier. Does not under-warn: it still says correlate on request_id, "the only thing that pairs a reply with its call". NIT: the quoted permutation "came back 1, 4, 5, 2, 3" is run-specific — my run gave 1,4,5,3,2. Past tense makes it an honest observation, not a fabrication, but it is a run-specific literal in a document that quotes literals as facts. ### (4) OneOffQuery.message_id byte array out / hex string back: HOLDS Sent `[1,1,1,1]` -> `"01010101"`; sent `[222,15,0,255]` -> `"de0f00ff"`. Lowercase hex string. ### (3) SubscribeSingle shares the query pool: HOLDS for the delta count, but the ### PR's sentence over-generalises the pool's three consequences (should-fix) `/tmp/r56/p6-pools.mjs`, all four sets using the IDENTICAL text `SELECT * FROM message`: A1 Multi(100) -> SubscribeMultiApplied WITH the row A2 Multi(101) identical -> `update:{"tables":[]}` (replies with nothing) OK A3 one change -> 1 updates entry OK B1 **Single(102) identical -> SubscribeApplied WITH the row, num_rows=1** <- NOT "nothing" B2 one change (2 Multi + 1 Single) -> **1** updates entry OK (shares pool) C1 legacy Subscribe -> InitialSubscription WITH the row C2 one change (+legacy) -> **2** updates entries OK (separate pool) D1/D2 unsub both Multi -> `update:{"tables":[]}` each (deletes nothing) OK D3 one change (Single+legacy) -> 2 entries OK E1 unsub the Single -> UnsubscribeApplied WITH the DELETE OK (last to go) E2 one change (legacy) -> 1 entry => "The single-query form shares the query-set pool" is TRUE (delta count 1 with Multi+Multi+Single). But README:346-352 then states the pool's sharing consequences flatly — "Inside a pool, sharing means the second subscribe replies with nothing" — and that is FALSE when the second subscribe is a `SubscribeSingle`: it replied with the full relation. Fix: scope "replies with nothing" to a second *query set* (`SubscribeMulti`), and say that a `SubscribeSingle` joining the pool still gets its own readback while counting as the same query in `updates[]`. ### (2) the two empty-result shapes: rule HOLDS for Multi+legacy; the bullet's HEADLINE is ### falsified by SubscribeSingle (should-fix) `/tmp/r56/p7-empty.mjs` + `/tmp/r56/p8-stopped.mjs`: | state | SubscribeMulti | legacy Subscribe | SubscribeSingle | | table EMPTY, unfiltered | ABSENT | ABSENT | **PRESENT num_rows=0** | | table HAS rows, query matches none | PRESENT n=0 | PRESENT n=0 | PRESENT n=0 | | drop one of two identical-text sets (released 0) | ABSENT | - | - | | drop a query that had STOPPED matching | PRESENT n=0 | - | - | | drop the last holder | PRESENT, real delete | - | - | => The four-way rule in README:363-376 is exactly right, and "Both a `SubscribeMulti` and a legacy `Subscribe` behave identically on each" is exactly what I measured. => BUT the bullet's bold headline — "**and the message type is not what picks one**" — is false for the third form: `SubscribeApplied.rows` is a REQUIRED single TableUpdate (protocol.lua:58 lists `rows` as required; no `tables` array to be empty), so a `SubscribeSingle` can NEVER report the relation absent. On an empty table it returned PRESENT num_rows=0 where both other forms returned ABSENT. This is compounded by the PR's own new "SubscribeSingle shares the query-set pool" line: a reader is now told Single behaves like Multi, and on this shape it does not. Fix: say "neither `SubscribeMulti` nor the legacy `Subscribe` picks a shape — the state does; `SubscribeSingle` is the exception, it always names the relation because its `rows` field is not an array." ### extra fix verified — `.rows.table_rows` DOES carry `updates` `SubscribeApplied` raw: `"rows":{"table_id":4097,"table_name":"message","table_rows": {"table_id":4097,"table_name":"message","num_rows":1,"updates":[…]}}`. FixDocs' correction (README:365-367) is right, and DocTruth's "bare TableUpdate with no array" was wrong. HOLDS. ### converse pool order (extra control) Single(10) FIRST -> PRESENT with the row; Multi(11) identical text SECOND -> **ABSENT**; Single(12) identical text THIRD -> PRESENT with the row again. So the "second subscribe replies with nothing" behaviour belongs to the MULTI reply path, not to the pool: a `SubscribeSingle` always gets its readback. Confirms the fix above. ### (5) named product declaring `__identity__` keeps its own name: HOLDS Published my own module to db `r56clash` on 6500 (`/tmp/r56/mod/index.js`), then `gen-schema.sh http://127.0.0.1:6500 r56clash`: faker.types = { "u64", "product_FakeIdentity", "product_FakeTimestamp" } typespace ["FakeIdentity"] = { kind="product", fields={"__identity__"}, types={"u256"} } ["FakeTimestamp"] = { kind="product", fields={"__timestamp_…__"}, types={"i64"} } Stronger than the README says, and worth it: on the WIRE the fake is byte-identical to a real identity — named `{"__identity__":"0x7"}`, positional `["0x7"]` — so the schema tag is the ONLY discriminator. Same for the timestamp magic name. ### (6) a table called `tables` is accepted: HOLDS Same module declared `tables` AND `cache`; publish succeeded and both served rows: served table_name=tables rows=1 inserts=["[1,\"tables:hi\"]"] served table_name=cache rows=1 inserts=["[1,\"cache:hi\"]"] So `db.tables.tables` and `db.tables.cache` are both reachable cases. README:314-322 holds. ## TASK 5 — the four skips ### SKIP 1 (Rank 10, `include_dirs` / "all a consuming project sees"): genuinely fixed README:189-191 now reads "The archive also carries `README.md`, `game.project` and `patches/`, but `include_dirs = stdb` is what Defold mounts, so `require("stdb.init")` is the only thing on your require path." — verbatim DocTruth's replacement. Skip is correct. ### SKIP 3 (Rank 7, `stdb.url`): genuinely fixed README:1093 `| stdb.url(base_url, database) | string | <base_url>/v1/database/<db>/subscribe. `base_url` must carry the scheme — `ws://127.0.0.1:3000` — because nothing here adds one |`. Skip is correct. ### SKIP 2 (Rank 14, archive sizes): the skip was correct WHEN READ, and THIS PR broke it ### -> should-fix, and it is this PR's own doing README.md:157-158 "takes the zip a consumer fetches from 54 entries and 220 KB down to 11 entries and 86 KB". Measured with `git archive --format=zip`: at #53 (65282fb) release zip = 87,751 B = 85.7 KiB -> "86 KB" OK at base 4ee2a24 release zip = 88,796 B = 86.7 KiB -> "86 KB" OK at PR head bb00355 release zip = 90,717 B = **88.6 KiB -> "88 KB"** STALE pre-export-ignore at head: 54 entries, 228,254 B = 222 KiB ("220 KB" -> 222 KB) entry counts 54 / 11 both still exact. Cause: this PR grows README.md from 79,760 B to 84,771 B (+5,011 B, +70 lines), and README.md is the single largest member of the release archive. A PR that exists to make the README's numbers true silently invalidated two of them. Fix: 222 KB and 88 KB, or drop the KB figures and keep the entry counts (which are stable). ### SKIP 2 knock-on — `.gitattributes:1-11` header comment is stale AND self-refuting It claims: "54 entries and ~220 KB of zip before, 11 entries and 87,646 B after. Of the 232,696 B that survives, README.md is 76,502 B (33%) and stdb/ is 153,302 B. The 'after' figures are exact and stay exact; this file is itself export-ignored, so editing this comment cannot change them." Measured at PR head: zip 90,717 B (not 87,646); uncompressed total 241,025 B (not 232,696); README.md 84,771 B = 35% (not 76,502 B / 33%). Even at 65282fb — the commit that wrote the comment — the zip was 87,751 B, not 87,646 B. "stay exact … editing this comment cannot change them" is the false premise: the figures depend on README.md, which is IN the archive and changes constantly. DocTruth's install slice flagged this file by name (`/tmp/doctruth-install.md:338`); FixDocs skipped it as "already fixed by FixArchive", and it is not fixed. ### SKIP: install slice "four things" nit — correctly rejected README:176 already reads "Four things cannot move into `dev/`, each because a tool demands the root:". Rejection reason accurate. ### SKIP: bounds item 4 (decode_typed ratio content-dependence) — correctly rejected `/tmp/doctruth-bounds.md:322-327` — its own author says it belongs in `protocol.decode_typed`'s docstring, not the README, and that the README's as-written ~2x needs no change (1.95x-2.07x on the real row). Rejection accurate. ### SKIP: install-URL `v1.0.0` pin — correctly rejected README:185,211 point at `archive/v1.0.0.zip`, which is the tag this release creates. A precondition of the release order, not an error. ## TASK 5 extras ### EXTRA 1 — `dev/nix/spacetime.nix:6` version: VERIFIED, and it belongs here nixpkgs master `pkgs/by-name/sp/spacetimedb/package.nix` -> `version = "2.8.1"`. nixpkgs `nixos-26.05` -> `version = "2.2.0"`. `license = lib.licenses.bsl11`. So both README:1408-1409 and spacetime.nix:6 are now right (2.2.0 / 2.8.1 / 2.8.2 here). Belongs in this PR: comment-only one-liner, identical stale figure, disclosed in the PR body, and splitting it would leave the file that PINS the version contradicting the README about that version. Note `dev/` is export-ignored, so it is not part of the shipped surface — which is exactly why it needs to ride along rather than get its own PR. ### EXTRA 2 — `.rows.table_rows` correction: VERIFIED (see TASK 3 above). Right call. ## Other new claims I attacked ### `unknown` is unreachable from a 2.8.2 JS module: HOLDS Published three modules to 6500 (`/tmp/r56/anon/`): anonymous `t.object({a})` -> publish REJECTED (SchemaInner throw, 500) `t.object('', {a})` -> "module contains invalid identifier: Empty identifiers are forbidden." anonymous `t.enum(['A','B'])` -> publish REJECTED README:708-712 ("a compound type needs a name — an anonymous one is rejected at publish, an empty one by the identifier check") is exactly right, including which check catches which. ## TASK 7 (partial) - `lua dev/test/runner.lua` -> **6 passed, 0 failed** - `stylua --check stdb dev/test` -> clean, exit 0 - conformance: no fixture drift ("chat_schema.lua matches what the tool derives from the published module"). Full run in progress. ## TASK 3 — PLACEMENT of the six new behaviours | behaviour | landed at | section | would a consumer look there? | | procedure reply ordering | README:534-540 | `### Procedures`, 3rd of "Three things to know" | YES — the count was correctly bumped from "Two" to "Three" | | empty-result shapes | README:369-377 | `### Dropping a subscription`, 4th bullet | PARTLY. Half the rule is about SUBSCRIBING (a narrowed subscribe matching nothing); a reader whose *subscribe* came back empty would not look under "Dropping a subscription". It is still the least-bad home, since the other three delta behaviours live there. | | SubscribeSingle pool | README:348-349 **and** README:616-619 | both `### Dropping a subscription` and `### Reconnect`'s `updates` paragraph | YES — both places a reader meets the delta count | | OneOffQuery.message_id | README:588-592 | inside `### Reconnect`, right after the `evt.matched` paragraph | YES, that is where the existing `message_id` correlation material already is | | `__identity__` named product | README:724-726 | `### Generating the schema`, appended to the structural-recognition paragraph | YES | | a table called `tables` | README:317-322 | `### Per-table callbacks`, in the `db.tables` namespace rationale | YES — it is the paragraph that motivates the namespace | None is in a footnote. Verdict: 5 of 6 land exactly where a consumer hits them; the empty-result one is defensible but its own text spans subscribe as well as drop. ### PLACEMENT knock-on: README:342 "Four behaviours are worth knowing" — the count is now ### ambiguous, and the PR bumped the analogous count elsewhere but not this one (should-fix) Bullet counts under that sentence, measured with `git show <c>:README.md`: 13073c6 (#51, which WROTE "Four behaviours") 3 bullets 65282fb (#53) 3 · 4ee2a24 (#55, base) 3 · **bb00355 (this PR) 4** So either bullets are the behaviours — in which case "Four" was wrong before and is right by accident now — or the trailing "Holds are counted per primary key" paragraph was the fourth, in which case "Four" is now wrong at five. The PR correctly changed "Two things to know" to "Three things to know" for Procedures (README:527) after adding one bullet there, and did not do the same here. Fix: state the number that matches, or drop the number. ## TASK 4 — quoted literals I checked myself ### `["second",2]` and the REASON given for softening it — reason NOT reproducible (should-fix) `/tmp/r56/p10-views.mjs`, `/tmp/r56/p11-iter.mjs`. README:853-859 now says: "*Which* row that is belongs to the module and the server, not to this SDK: the view here returns `rows[rows.length-1]` and **2.8.2's iteration order is not insertion order**." First half VERIFIED: `dev/server/index.js:182-186` really returns `rows[rows.length - 1]` and `undefined` when empty. Second half NOT REPRODUCIBLE. Nine observations on my server: clean table, rows "first"(1) "second"(2), three consecutive looks -> `["second",2]` all three 6 fresh inserts -> message_flip iteration order [3,4,5,6,7,8], latest = ["w6",8] after clear + 3 inserts-> [9,10,11], latest = ["x3",11] after a pk-preserving edit -> [9,10,11], latest = ["x3",11] Iteration order was pk-ascending — i.e. insertion order for an autoinc pk — every time, and `message_latest` returned the LAST inserted row every time. On a clean table `["second",2]` is in fact deterministic, which makes the added clause both unsupported and self-defeating: it explains the softening with a mechanism that does not fire. Fix: keep "such as `["second",2]`" and keep "which row belongs to the module", drop or attribute the iteration-order clause (e.g. "the module picks `rows[rows.length-1]`, and nothing in the protocol promises which row `iter()` yields last"). ### the `none` case carries no relation at all: HOLDS Empty table -> `"update":{"tables":[]}` — no `message_latest` entry. README:857-859 right. ### `session.jsonl` four blocks: HOLDS 50 frames; `IdentityToken` at lines 1, 30, 37, 43; `capture-fixtures.mjs:235` `const BLOCKS = { player: playerSteps, views: viewSteps, overlap: overlapSteps }` -> main + 3. ### the `4/5/24/48` FAIL line: label and format both HOLD `dev/test/test_fixtures.lua:175` label = "generated column order matches the module, which decodes the TransactionUpdate rows in frames 4/5/24/48"; `dev/test/runner.lua:26` formats `FAIL %s: expected %s, got %s`. The quoted line is producible. ### RUN-SPECIFIC value quoted as general: `row id=1` (should-fix) README:129-133's fenced block quotes `[html5] SMOKE: PHASE2 OK survived restart; row id=1 restored` `dev/example/main/smoke.script:240` formats it from `self.edited_id`, a server autoinc u64. My own conformance run printed `row id=1` in run 1 and **`row id=4097` in run 2** of the same invocation. So the id is run-state-dependent and is quoted as a bare fact — the exact bar the author says they applied when they declined to quote the PHASE5 identity-prefix line. The line is pre-existing, not added by this PR, but this PR is the audit of that block and it edited the line directly beneath it. Fix: `row id=<n>` / `row id=1 restored` -> `row id=… restored`, or drop the id. ## TASK 7 (cont.) — native conformance PASS `STDB_PORT=6500 STDB_STATE_DIR=/tmp/r56/state dev/tools/conformance.sh`: - no fixture drift - run 1 PHASE1..PHASE5 + `SMOKE: PASS PHASE5 …`, identity 0xc2007bcf… - run 2 same, `PASS (identity … persisted across a process restart)` - PHASE4 line printed VERBATIM as the README now quotes it (modulo the `[html5]` prefix, which is the HTML5 tier's own — `html5-conformance.mjs:165` `[html5] ${line}`): `SMOKE: PHASE4 OK procedure round trip; failure surfaced as an error; per-table API: 1 insert(s), 1 update`
docs: the light-mode flag is a no-op, and five figures that moved (#24)
All checks were successful
ci / ci (pull_request) Successful in 1m37s
1c340e8bda
Review of #56 measured the claims this PR added. One of them inverts the
PR's headline; the rest are figures that this PR itself invalidated.

`light` is a no-op on 2.8.2, so the replacement claim was as wrong as the
disclaimer it replaced. The PR asserted that a `light = true` client gets
`TransactionUpdateLight` while an ordinary client gets the full
`TransactionUpdate` for the same commit. A controlled 4x4 matrix - four
observer sockets, ordinary and light, each on our identity and on a second
one, against a caller varied four ways - refutes it: every NON-CALLER gets
the light frame whether or not it asked for one, and the CALLER gets the
full update even when it did ask. The discriminator is caller-vs-non-caller.
`light=true` changed nothing observable, and it is not a typo being ignored:
`?light=banana` and `?light=1` are refused with 400, so the flag is parsed
and validated and then does not decide the frame. The section now says that
plainly, because a consumer choosing whether to set the option deserves to
know it buys nothing here. The old disclaimer is not restored - it was wrong
too.

`stdb/connection.lua`'s light-frame comment said those frames "report
`request_id = 0`". Measured, a light frame carries the ORIGINATING client's
request_id (555/111/222); only an HTTP-invoked commit reported 0, because
HTTP has no id to echo. No code depended on the claim - nothing in `stdb/`
branches on `request_id == 0`, ids are allocated from 1, and the light
handler never reads the field - so this is a comment fix, but the reasoning
it replaces mattered: the real hazard is that per-client counters collide,
so settling on that id answers our own call with another player's
transaction. `dev/test/test_connection.lua` carried the same `0` as fixture
padding; it now sends the id of a call that IS in flight and asserts the
handle is left pending, which is the contract the comment claims and nothing
tested.

`?confirmed=banana` does not name the string that was neither. The 77-byte
body is "Failed to deserialize query string: provided string was not `true`
or `false`" - it names the two ACCEPTED values, and not even which parameter
was bad: `?light=banana` answers with the same bytes.

Two `SubscribeSingle` over-generalisations. "The single-query form shares the
query-set pool" is true for the delta count (Multi+Multi+Single yields one
`updates` entry), but two flat statements left standing are false for that
form. "Inside a pool, sharing means the second subscribe replies with
nothing" is the `SubscribeMulti` reply path, not the pool: Single first is
present, an identical Multi second is absent, an identical Single third is
present again. And "the message type is not what picks one" fails outright -
`SubscribeApplied.rows` is a required single `TableUpdate` with no `tables`
array to be empty, so a `SubscribeSingle` can never report a relation
absent; on an empty table it returned present with `num_rows=0` where both
other forms omitted it. Both are now scoped, which matters more because this
PR's own new pool sentence tells the reader Single behaves like Multi.

Archive figures this PR broke by growing the README. 86 KB was right at the
base and is 88 KB at this PR's head, because README.md is the largest member
of the release archive and this PR adds ~5 KB to it. Pre-export-ignore is
222 KB, not 220 KB. Rather than re-pin numbers that the next commit moves
again, the byte figures are dropped and the entry counts - 54 and 11, still
exact - are kept. `.gitattributes`'s header comment needed the same
treatment twice over: its figures were already wrong at the commit that
wrote them, and it asserted they "stay exact ... editing this comment cannot
change them" on the false premise that being export-ignored insulates them.
It does not; they depend on README.md, which is in the archive.

Three quoted literals. `row id=1` in the HTML5 block is a server autoinc -
this run printed `row id=1` then `row id=4097` - so it is elided and framed,
the same bar the author applied when declining to quote the PHASE5 identity
line. The `["second",2]` softening cited "2.8.2's iteration order is not
insertion order", which was not reproducible: nine observations gave
pk-ascending order every time and the view returned the last-inserted row
every time, so the clause explained the softening with a mechanism that does
not fire. The softening and "which row belongs to the module" stay; the
claim about iteration order becomes what is actually true, that nothing in
the protocol promises which row `iter()` yields last. The procedure
permutation was quoted as `1, 4, 5, 2, 3`; a second run gave `1, 4, 5, 3, 2`,
which is stronger evidence for the rule, so both are named.

"Four behaviours are worth knowing" was right by accident - it said "Four"
over three bullets and there are now four, but two following paragraphs are
behaviours as well, so the number cannot be made to mean one thing. Dropped
rather than re-counted.

The empty-result bullet spans subscribing as well as dropping and lives
under "Dropping a subscription". Kept there, next to the other delta
behaviours, with the overlap stated in the bullet instead of moved.

Suite 6/6, stylua clean, native conformance PASS both runs with no fixture
drift.
yuris merged commit 86761ce520 into main 2026-08-20 11:24:37 +00:00
Sign in to join this conversation.
No description provided.