feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7) #34

Merged
whitlocktech merged 2 commits from feat/asset-bridge-p7 into edge 2026-09-14 07:36:57 +00:00
Member

Phase 7 of the Asset Bridge — docs/link/v8.md §10. Pairs with Module-uo#40 and docs#242. link needed nothing, for the third phase running.

What this closes

SPAWN_ATLAS.md required the website to read the ServUO tree — "same host, a bind mount, or a shared volume". That was the one place the platform's own rule (only the sidecar bridges the shard) was broken, and it was broken by the component that faces the internet. The shard now serves those five labelled groups over the loopback link.

The measurement changed the phase, again

§10 said the shard would serve tree/<label> → bytes. It cannot. Spawns/trammel.xml is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as one base64 row is 5.4 MiB — dropped, timed out, re-requested forever, with no error in it anywhere. Two files on a stock tree are in that state.

Carriage Wire Pages Undeliverable
Raw base64 15.1 MB 31 2 files
Whole-file gzip 1.21 MB 3 none on this tree
512 KiB chunks, each gzipped 1.26 MB 3 none, by construction

The winner is not the smallest. The chunk is the bound and the compression is only the saving: nothing guarantees an operator's files compress, so the ceiling has to hold when they do not — a 512 KiB chunk that refuses to compress is ~683 KiB of base64, inside the wire cap that AssetBatchBytes' deliberate factor of two leaves room for. Whole-file gzip works on every tree anyone would test and fails on the first one nobody did.

The key gains a depth segment — tree/Spawns/trammel.xml/c0 — which is §5's scheme doing the same job it does for body/400/a0/f0, and needing no protocol change to do it.

A family, not two new commands

§14 planned tree.manifest / tree.fetch with /tree/* REST. Phase 5 had already learned that the command is the transport and the family is a property of the key, and §5 had already written tree/Spawns/Trammel.xml as a key. So this registers a tree family and generalises assets.manifest the same way — reusing the single slot, the byte budget, the paging envelope, the key ceiling, the catalog guard and the 425 backoff, none of them written twice.

It gets its own consent, though: Bridge.TreeEnabled. AssetsEnabled is an operator agreeing the website may read their EA-licensed UO client. This is the shard's own configuration, which they wrote, and which the public bestiary is built from. One switch could not express both, and the casualty for an operator who declined the first would have been their spawn atlas. So the consent check moved out of the front of assets.fetch and into the family lookup, and assets.sources now answers whenever either plane is on, with families filtered to what is actually enabled.

Verified live, all four combinations:

assets=true  tree=true   -> families: body, land, static, tree
assets=false tree=true   -> families: tree          (sources 200, files: 0; body manifest 403; cliloc 403)
assets=true  tree=false  -> families: body, land, static   (tree manifest 403, naming Bridge.TreeEnabled)
assets=false tree=false  -> families: ''            (sources 403)

Two defects, both fixed plane-wide, and which harness found which

Both are the same kind of thing, and it is the kind a stock install finds: a value meaning "nothing here" has two spellings, and you handled one of them.

1. An empty catalog is not an absent one. expected != null treats "" as a real fingerprint, so a caller that serialises a missing value as an empty string has every fetch refused — with a sentence naming no catalog at all (catalog is now 8159778b), which reads as a shard fault rather than a caller one. Found by an offline probe that passed one by accident.

It was inline in three places by then — the body catalogue (phase 3), statics and land (phase 5), and the tree. All three now go through one BridgeAssets.CatalogMismatch; three copies of a comparison are three chances for the next family to get it wrong in a way only a differently-written client would reveal. BridgeLeases keeps its own expected != null and is deliberately untouched: there the value is a world property, where an empty string is a legitimate thing to expect.

2. GZipStream writes NOTHING for zero bytes of input. The gzip header is emitted lazily on the first write, so a stream opened and closed without one yields a zero-length buffer, not the 20-byte empty member. Stock ServUO 57.4 ships two empty decoration files (Felucca/ambitious solen queen quest.cfg, Tokuno/terrible hatchlings quest.cfg), so this broke every import off an untouched tree. The offline probe reassembled all 141 files and reported success — .NET's own decompressor reads an empty stream as empty data, and the chunk's declared length (0) and hash (of nothing) both agreed with it. Only the live walk, through a reader on a different runtime, disagreed.

The whole plane, against a stock install

Every family, each asked three ways — with a real catalog, with the field absent, and with an empty string:

PASS  assets.sources                          families: body, land, static, tree
PASS  cliloc.table walk                       67,496 rows, 12 pages
PASS  body manifest                           1,095 rows, 1 page
PASS  body fetch                              real catalog / absent / "" — all ok
PASS  static/land carry their OWN catalog     art 66a112c1 vs body 323f284f
PASS  static fetch                            real catalog / absent / "" — all ok
PASS  land fetch                              real catalog / absent / "" — all ok
PASS  a cross-family catalog is refused       422 UNREADABLE
PASS  static manifest refused by name         400 (fetched by key, has no manifest)
PASS  tree manifest                           141 files, 1 page
      stock empty files: 2                    both decoration files ServUO ships empty
PASS  tree fetch incl. both empty files       real catalog / absent / "" — all ok
PASS  empty files carry a VALID gzip member   2 rows gunzip to 0 bytes
PASS  a STALE catalog is still refused        body, static and tree all 422

The label set is this shard's, never the caller's

This is the only family whose keys look like paths, and the caller is the internet-facing component. A fetch resolves its label against the set the shard itself enumerated — refused before any file is opened, whatever it spells — the five groups and their extensions are fixed in code, and the resolved path is still checked to be under the tree root. Live:

tree/../../Scripts/Custom/Bridge/BridgeTree.cs/c0 -> absent
tree/Config/Bridge.cfg/c0                         -> absent
tree/Saves/Accounts/accounts.xml/c0               -> absent

Measured end to end

Live ServUO shard → real sidecar → module-uo's own reader:

Manifest 141 rows, one page, 32 KB, 65–92 ms
Full read 158 chunks, 3 pages, 1.33 MB on the wire, 512 ms
Verification all 141 files byte-identical to the tree on disk
Atlas parity identical to the one built off the disk
Drift re-check 141 rows, ~70 ms, no file bytes

dotnet build Scripts.csproj -c Release → 0 warnings, 0 errors.

Protocol

Stays 8, and EXTRACTOR_VERSION stays 3. This family derives nothing — it forwards an operator's own file unchanged — so the number that tracks our derivation has nothing to say about it. overlay.toml is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Phase 7 of the Asset Bridge — `docs/link/v8.md` §10. Pairs with **Module-uo#40** and **docs#242**. `link` needed **nothing**, for the third phase running. ## What this closes `SPAWN_ATLAS.md` required the **website** to read the ServUO tree — "same host, a bind mount, or a shared volume". That was the one place the platform's own rule (only the sidecar bridges the shard) was broken, and it was broken by the component that faces the internet. The shard now serves those five labelled groups over the loopback link. ## The measurement changed the phase, again §10 said the shard would serve `tree/<label>` → bytes. **It cannot.** `Spawns/trammel.xml` is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as one base64 row is 5.4 MiB — dropped, timed out, re-requested forever, with no error in it anywhere. Two files on a *stock* tree are in that state. | Carriage | Wire | Pages | Undeliverable | |---|---|---|---| | Raw base64 | 15.1 MB | 31 | **2 files** | | Whole-file gzip | 1.21 MB | 3 | none *on this tree* | | **512 KiB chunks, each gzipped** | **1.26 MB** | **3** | **none, by construction** | The winner is not the smallest. **The chunk is the bound and the compression is only the saving**: nothing guarantees an operator's files compress, so the ceiling has to hold when they do not — a 512 KiB chunk that refuses to compress is ~683 KiB of base64, inside the wire cap that `AssetBatchBytes`' deliberate factor of two leaves room for. Whole-file gzip works on every tree anyone would test and fails on the first one nobody did. The key gains a depth segment — `tree/Spawns/trammel.xml/c0` — which is §5's scheme doing the same job it does for `body/400/a0/f0`, and needing no protocol change to do it. ## A family, not two new commands §14 planned `tree.manifest` / `tree.fetch` with `/tree/*` REST. Phase 5 had already learned that *the command is the transport and the family is a property of the key*, and §5 had already written `tree/Spawns/Trammel.xml` as a key. So this registers a `tree` family and generalises `assets.manifest` the same way — reusing the single slot, the byte budget, the paging envelope, the key ceiling, the `catalog` guard and the 425 backoff, none of them written twice. **It gets its own consent, though: `Bridge.TreeEnabled`.** `AssetsEnabled` is an operator agreeing the website may read their *EA-licensed UO client*. This is the shard's *own configuration*, which they wrote, and which the public bestiary is built from. One switch could not express both, and the casualty for an operator who declined the first would have been their spawn atlas. So the consent check moved out of the front of `assets.fetch` and into the family lookup, and `assets.sources` now answers whenever *either* plane is on, with `families` filtered to what is actually enabled. Verified live, all four combinations: ``` assets=true tree=true -> families: body, land, static, tree assets=false tree=true -> families: tree (sources 200, files: 0; body manifest 403; cliloc 403) assets=true tree=false -> families: body, land, static (tree manifest 403, naming Bridge.TreeEnabled) assets=false tree=false -> families: '' (sources 403) ``` ## Two defects, both fixed plane-wide, and which harness found which Both are the same kind of thing, and it is the kind a stock install finds: **a value meaning "nothing here" has two spellings, and you handled one of them.** **1. An empty `catalog` is not an absent one.** `expected != null` treats `""` as a real fingerprint, so a caller that serialises a missing value as an empty string has *every* fetch refused — with a sentence naming no catalog at all (`catalog is now 8159778b`), which reads as a shard fault rather than a caller one. Found by an offline probe that passed one by accident. It was inline in **three** places by then — the body catalogue (phase 3), statics and land (phase 5), and the tree. All three now go through one `BridgeAssets.CatalogMismatch`; three copies of a comparison are three chances for the next family to get it wrong in a way only a differently-written client would reveal. `BridgeLeases` keeps its own `expected != null` and is deliberately untouched: there the value is a world property, where an empty string is a legitimate thing to expect. **2. `GZipStream` writes NOTHING for zero bytes of input.** The gzip header is emitted lazily on the first write, so a stream opened and closed without one yields a zero-length buffer, not the 20-byte empty member. **Stock ServUO 57.4 ships two empty decoration files** (`Felucca/ambitious solen queen quest.cfg`, `Tokuno/terrible hatchlings quest.cfg`), so this broke every import off an untouched tree. The offline probe reassembled all 141 files and reported success — .NET's own decompressor reads an empty stream as empty data, and the chunk's declared length (0) and hash (of nothing) both agreed with it. **Only the live walk, through a reader on a different runtime, disagreed.** ## The whole plane, against a stock install Every family, each asked three ways — with a real catalog, with the field absent, and with an empty string: ``` PASS assets.sources families: body, land, static, tree PASS cliloc.table walk 67,496 rows, 12 pages PASS body manifest 1,095 rows, 1 page PASS body fetch real catalog / absent / "" — all ok PASS static/land carry their OWN catalog art 66a112c1 vs body 323f284f PASS static fetch real catalog / absent / "" — all ok PASS land fetch real catalog / absent / "" — all ok PASS a cross-family catalog is refused 422 UNREADABLE PASS static manifest refused by name 400 (fetched by key, has no manifest) PASS tree manifest 141 files, 1 page stock empty files: 2 both decoration files ServUO ships empty PASS tree fetch incl. both empty files real catalog / absent / "" — all ok PASS empty files carry a VALID gzip member 2 rows gunzip to 0 bytes PASS a STALE catalog is still refused body, static and tree all 422 ``` ## The label set is this shard's, never the caller's This is the only family whose keys look like paths, and the caller is the internet-facing component. A fetch resolves its label against the set the shard itself enumerated — refused before any file is opened, whatever it spells — the five groups and their extensions are fixed in code, and the resolved path is still checked to be under the tree root. Live: ``` tree/../../Scripts/Custom/Bridge/BridgeTree.cs/c0 -> absent tree/Config/Bridge.cfg/c0 -> absent tree/Saves/Accounts/accounts.xml/c0 -> absent ``` ## Measured end to end Live ServUO shard → real sidecar → module-uo's own reader: | | | |---|---| | Manifest | 141 rows, one page, 32 KB, **65–92 ms** | | Full read | 158 chunks, 3 pages, **1.33 MB on the wire**, **512 ms** | | Verification | all 141 files byte-identical to the tree on disk | | Atlas parity | **identical** to the one built off the disk | | Drift re-check | 141 rows, **~70 ms**, no file bytes | `dotnet build Scripts.csproj -c Release` → 0 warnings, 0 errors. ## Protocol **Stays 8, and `EXTRACTOR_VERSION` stays 3.** This family derives nothing — it forwards an operator's own file unchanged — so the number that tracks *our derivation* has nothing to say about it. `overlay.toml` is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-14 07:02:07 +00:00
The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-14 07:33:43 +00:00
Phase 7 found this on the tree family and fixed it there. It was inline in THREE
places: the body catalogue (phase 3), statics and land (phase 5), and the tree.
`expected != null` treats "" as a real fingerprint, so a caller that serialises a
missing value as an empty string has EVERY fetch refused -- with a sentence that
names no catalog at all ("catalog  is now 8159778b"), which reads as a shard
fault rather than a caller one.

All three now go through one BridgeAssets.CatalogMismatch. Three copies of a
comparison are three chances for the next family to get it wrong in a way only a
differently-written client would ever reveal.

BridgeLeases keeps its own `expected != null` and is deliberately untouched:
there the value is a world property, where an empty string is a legitimate thing
to expect.

Verified against a live shard on a stock ServUO install, every family asked three
ways -- with a real catalog, with the field absent, and with an empty string:

  cliloc.table walk                     67,496 rows, 12 pages
  body manifest / fetch                 1,095 rows; ok all three ways
  static + land fetch                   ok all three ways
  static/land carry their OWN catalog   art 66a112c1 vs body 323f284f
  a cross-family catalog                refused 422
  tree manifest / fetch                 141 files incl. BOTH empty ones, all three ways
  empty files carry a VALID gzip member 2 rows gunzip to 0 bytes
  a STALE catalog                       still refused on body, static and tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 2539764cf7 into edge 2026-09-14 07:36:57 +00:00
whitlocktech deleted branch feat/asset-bridge-p7 2026-09-14 07:36:58 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#34
No description provided.