Variants map: C1 cut map (one schema id on the wire); two operator questions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 22:50:08 +02:00
commit ebb1eb2528

View file

@ -706,3 +706,628 @@ This is what Odin enforces, Rust and Kotlin use, and Eve and Gjallar already han
- Path 1b/2b's dependence on (g).
- Path 5 being broken.
- The Aetheria `.cc` catalogs were decoded from the files, with no git.
## C1 cut: one schema id on the wire
Imagination, 2026-09-30. This maps the operator's C1 ruling. On the wire, a document's schema id is its declared
version string, `<name>.v<N>`. Every runtime stamps it and every runtime resolves it exactly. A wire binding whose
version lacks `.v<digits>` is refused. The AetheriaEve readers are archaeology, and C2 (store identity) is deferred.
**Anchors.** Line numbers are against CultLib `hands/variants-c2a` at `976b57f1`. That commit is F2 in progress, the
Rust catalog lay-back. I read it from a `git archive` snapshot and did not touch the worktree. Where main
(`4a7fa1d6`, code as of `daedfdd6`) or `hands/cultmesh-put-serve-bound` (`fa34b685`) differs in a file C1 edits, it is
said at the spot. Each anchor also names its symbol, so it can be found again after c2a merges main (see Sequencing).
### 0. Probe facts
These are new and were run, not inferred. The sources are in `scratchpad/c1-probe/`. They ran on Yggdrasil in
`ack1d-interop:2` from the scratch ref `refs/scratch/c1probe`, a detached commit on `976b57f1` that is deleted after
this pass. Output is in `scratchpad/c1-probe3.log`. The run was commit `43677b97ac`.
- **P-VER: a C# version change does not orphan stored records.** The probe used two builds of one type,
`probe.faction`, declared first with version `"1"` and then with `"probe.faction.v1"`, plus a sibling
`probe.ship "1"`. The same members.
- The id changes: `sha256:64eb…` becomes `sha256:0603…`.
- The new build reads the old store through the catalog's schema name (`Claimant`, `CultCache.cs:697`) and reports
`CompatibleDrift` with warnings `compatible_schema_id_drift` and `content_hash_drift`. The record decodes.
- **Opening and flushing without edits leaves the file byte-identical** (1529 bytes, old id).
- **Upserting restamps** that record and its catalog entry to the new id. The untouched sibling keeps its own.
- The old build then reads the rewritten store the same way.
- So a version-string change is a restamp on the next write, not an orphan. This holds only while one local type owns
the schema name. Two local versions of one name make the fallback ambiguous and it throws (`CultCache.cs:697-705`).
- C1 does not need this. It is recorded for C2.
- **P-WIRE: what C1 changes on the C# wire.**
- Binding both types by their version string: with `"1"` twice, c2a's `Register` refuses the second binding
(`CultSchemaConflictException … schema id "1"`). With `probe.faction.v1` and `"1"`, it accepts both.
- The default wire id is still sha256 in both builds.
- With an unbound registry, `DeserializeRawDocument` returned `Faction` for `"1"`, `probe.faction` (the name) and
`probe.faction.v7` (a version nobody declared). Only `some.other.v1` was refused.
- So today a C# receiver resolves a version it has never seen by stripping `.vN` and matching the name. That is the
path C1.0 deletes, and the negative check in §8 pins its death.
Earlier facts this map rests on, from the §1-§4 probes above: C# hashes the version into the id (V5). The C# wire
id is sha256 unless the binding overrides it. C# CultNet accepts any id through sniffing. TS refuses a version id.
Kotlin stamps its version.
### 1. What the consumer sweep settles
This comes from three Eyes-class sweeps over F:\Projects. I read the rows marked (R); the rest were confirmed by the
sweeping agent reading the files.
- **Aetheria's `"1"` versions never reach the wire.** Aetheria has no CultNet or CultMesh code. `Aetheria.Shared.csproj`
references only the caching assemblies. So C1's refusal never fires for its 24 declarations, and **nothing in
Aetheria changes, on disk or in source.**
- The other C# `"1"` declarations are in StreamPixels `apps/overlay-unity` (24). They are client-only and never bound:
frame ids map to types by hand (`OverlayFrameDocuments.cs:98-116`).
- Every C# type that is bound in any repo already declares `.vN`: AetheriaEve 61, AquaSynth 28, Mimir 37, Brokkr,
Eve, Gjallar, Ymir, Delvehold. **No repo outside CultLib passes `ForDocument(schemaId:)`.**
- **Live C# hosts with default (sha256) bindings:**
- AquaSynth, `AquaSynthCultNetDaemon.cs:431-449`, 19 bindings. Its peers are C# (`AquaDaemonClient.cs`).
- Mimir: `EveDashboard/Program.cs:877-886`, read by Eve Android, which ignores schema ids
(`MainActivity.kt:618-625`); and `CultMeshMedia/Program.cs:277`, C#.
- Delvehold: no server.
- All of these are C#-to-C# or id-blind. They change id together when they rebuild on C1, so no shim is needed.
- **The C# publishers to Odin bypass bindings.** AetheriaEve `Program.cs:4574-4831` and Gjallar `Program.cs:3045,3069`
build their ids by hand and serialize payloads directly. C1 does not change them. Odin's rule is at
`Odin/crates/odin-daemon/src/main.rs:1311-1326` (R).
- **Every Rust wire binding in every repo already binds `.vN`** through `cultmesh_documents!` or
`for_entry_with_schema_id` with `.vN` constants: Odin, Muninn, Ghostlight, Epiphany, Ratatoskr, Huginn. No consumer
uses bare `for_entry::<T>()`.
- **The AetheriaEve readers are archaeology:**
- EveElectron's only code consumer is AetheriaEve (`Aetheria.Rts.Web/package.json:30`).
- Hermodr was parked on 2026-09-30: `gamecult-ops/inventory.md:631-634`, `runbooks/odin-yggdrasil.md:35-48`, and
`scripts/idunn/idunn-deployment-targets.ps1:36-46` (status "archived").
- cultmesh-browser has two consumers. One is the AetheriaEve witness. The other is CultLib's own sample
`samples/eve-browser-network/browser/main.ts:8`.
- **So C1 edits neither Hermodr nor EveElectron.** Both are archived, so C1 deletes the CultLib fallbacks that exist
only for them and does not migrate them.
- **Non-C# consumers that change id under C1:**
- **Stonks is the only one that breaks.** `F:\Projects\Stonks\src\stonks-daemon.cjs:152-195` declares
`schemaVersion: "v1"` on six definitions, one of them `provider_advertisement` published to Odin at `:303`. Under
C1 its TS registration is refused. Stonks loads `../CultLib` from disk, so it breaks on the first restart after C1
reaches that checkout. The fix is in Stonks: set each `schemaVersion` to its current `schemaId`. That is six lines,
and they land before C1 is merged to main.
- Bifrost `tools/agent-transport.mjs:62-66`: the id is hyphenated (`…update-request.v0`) and the version has
underscores (`…update_request.v0`). The wire id changes to the version. The snapshot and apply sides are the same
tool on one pinned CultLib (`f67f5122`), so they change together. Its store keeps the hyphenated id.
- Brokkr Python `surfaces/blender/brokkr_bridge/blender_target.py:630-637` declares eight type-only definitions whose
type already ends in `.v0`. The Python default version would make the wire id `….v0.v1`. The fix is in Brokkr:
declare `schema_version=<the type>`. No live reader was found, so this is hygiene, not a break.
- repixelizer: `verse_state.py:139-230` declares version ≠ id. Its wire id moves to the `.v0` version, which Odin
accepts.
- Everything else already has id == version: VoidBot, Vili, weksa, Heimdall, StreamPixels, Bifrost's other tools,
Hermodr.
- VoidBot's persona stores use a vendored cultcache-ts and are untouched.
- **Raw health puts are out of C1's reach.** Vili, Stonks, weksa, Bifrost and repixelizer send hand-built
`idunn.daemon_health` with no version, and Bifrost sends `idunn.signed_daemon_health`. These never register a
binding. No receiving binding was found in Idunn or Odin main. That is Soul's consumer check, not a C1 edit.
**Q3, settled:** no declaration in any repo has to change to satisfy the refusal on a binding that actually exists.
Aetheria and the StreamPixels overlay are the `"1"` repos, and neither binds. Stonks's `"v1"` is TS; it is fixed in
Stonks and touches no store. Stonks's store keeps the ids it holds today, because TS stores under `schemaId`. So
**no declaration change touches disk.** Whether a future change of Aetheria's version string would orphan its records
is the P-VER probe in §0. It is recorded for C2 and not needed by C1.
**Q4, settled:** C1 changes no store record in any runtime. The store id and the wire id become two derivations:
- C#: sha256 on disk, version on the wire.
- TS: `schemaId ?? schemaName ?? type` on disk, version on the wire.
- Python: `schema_id or type` on disk, version on the wire.
- Rust: the store id and apply-path stamping are unchanged (see C1.4).
- Kotlin: version for both, as today.
Four places where one variable serves both today must be split. Each has its cut below:
- TS `replication.ts:363-368`, `schemaIdForEnvelope`, which serves the stored id.
- Python `cultmesh_py/node.py:767` and `interop_peer.py:1085`.
- The C# durable shard log, `CultNetShardMutationLogStorage.cs:75-88`. It persists wire-form entries, and so it is the
one C# store that holds wire ids.
- Rust `pull_rudp_catalog_snapshot`, `cultmesh-rs/src/lib.rs:742`.
### 2. Authority map
- **Owner.** The declaration's version string is the only wire identity:
- C# `CultDocumentAttribute.SchemaVersion` → `CultDocumentDescriptor.SchemaVersion`;
- TS `definition.schemaVersion`, defaulting to `${schemaName}.v1`, through `schemaIdentityOf(...).schemaVersion`;
- Python `schema_version`, defaulting to `f"{schema_name}.v1"`;
- Rust: the version given to `cultmesh_documents!` or `for_entry_with_schema_id`;
- Kotlin `codec.schemaVersion`.
- **One derivation per runtime:**
- C#: `CultNetDocumentRegistry.WireSchemaId(descriptor)` returns `descriptor.SchemaVersion`. It is the single place
`.vN` is checked, and it is also called eagerly from `Register`.
- TS: `wireSchemaIdOf(definition)` in cultnet-ts.
- Python: `wire_schema_id(definition)` in cultnet_py.
- Rust: `CultNetDocumentBinding::schema_id`, which is already the version.
- Kotlin: `codec.schemaVersion`.
- **Inputs:** the declaration only. Never a payload, a schema name, an inferred name, a record's position in a
response, or a stored record's id.
- **Outputs:** every schema id that crosses a process boundary:
- raw records, puts and deletes;
- snapshot and selection responses, headers, edges and cursors;
- database change messages and shard-log entries;
- snapshot, subscribe and selection filters, and the shard catalog;
- the document-payload schema catalog;
- CultMesh projection-source and state-binding records.
- **Resolution.** A wire id resolves to the binding that owns it (exact). Otherwise it resolves to the one binding that
*declares* it compatible (`CompatibleSchemaIds`, owner beats lister), which is the rule stores already use. **OQ-C1.2
decides whether declared compatibility applies on the wire.** Anything else is refused.
- **Derived state after C1:**
- C# `descriptor.SchemaId` (sha256) is **store-only**. It keys CultCache, `_predictedDocuments`,
`_lastWriteSequence`, and in-process dictionaries.
- TS and Python `schemaId` are **store-only**.
- `CultNetDocumentBinding.SchemaId` is **derived** (the version). It is no longer an owner.
- `schemaName` is **metadata only**, never matched.
- Payload slot 0 or `schemaVersion` is **data only**, never read for routing.
- `schemaContentHash` is a **C#-to-C# drift witness**, carried and never matched.
- **Forbidden writers, all deleted:**
- C#: `CultNetSchemaAliasMatching`, `TryResolveDescriptorBySchemaAlias`, `TryResolveDescriptorByPayloadSchema`,
`PayloadMatchesSchema`, `TryReadSchemaVersion`, both `InferSchemaName`, `ResolvePayloadSchemaCandidates`,
`ExpandSchemaBindingAliases`, the `ForDocument(schemaId:)` override (OQ-C1.1), the CultMesh `aliased` and
`byPayload` branches, and the descriptor-name and version keys in the C# and TS mesh catalogs;
- TS: `firstRecordAtKey`, cultmesh-browser's name and version arms, `shard-catalog.ts` `inferSchemaName`, and
`inferCultMeshSchemaName` / `bySchemaNameVersion`;
- Python: the name and version keys in `schema_document_map`, `_infer_schema_version_from_payload`, and every
`_infer_schema_name`;
- Rust: `strip_schema_version_suffix` and `selection::schema_alias`;
- Kotlin: `codecs[schemaId]`, the documentType fallback.
- **Shared paths.** Put, delete, snapshot (v0, v1, raw), selection, subscription, database change, shard log (live and
replayed from disk), shard catalog, schema catalog, and CultMesh peer reads all take the id from the one derivation.
A served stored record is stamped by the derivation, never by its stored id.
### 3. Deletes first (C1.0, subtraction)
C1.0 deletes every inference path and makes each comparison exact against the existing wire id: the binding id, or
sha256 for an unbound type. C#-to-C# peers keep working. Only readers that relied on a fallback stop resolving, and
those are the AetheriaEve readers, which are archaeology. C1.0 adds no behaviour; C1.1 flips the id.
**C#, `src/GameCult.Networking`:**
- `CultNetDatabase.cs:47-104`: delete the whole `CultNetSchemaAliasMatching` class (58 lines, public).
- Replace every caller with an exact `string.Equals` against the registry's wire id for the descriptor.
- Callers: `:143, :150` (`ClientAuthorityScope.Matches`); `:252, :260` (`Shard.Matches`); `:1757` (shard-catalog
filter).
- `Shard.Matches(string)` and `ClientAuthorityScope.Matches(string)` compare a configured id with the wire id.
- `EnsureClientAuthority(string)` at `:1640-1652` has no callers; delete it.
- `CultNetDocumentRegistry.cs`:
- Delete `:779-799` `TryResolveDescriptorBySchemaAlias`, `:813-830` `TryResolveDescriptorByPayloadSchema` (public),
`:832-842` `ResolvePayloadSchemaCandidates`, `:852-854` `PayloadMatchesSchema` (public), `:865-898`
`TryReadSchemaVersion`, `:900-910` `InferSchemaName`, and `:538-578` `ExpandSchemaBindingAliases` with its comment.
About 175 lines in all, doc comments included.
- `ResolveDescriptorForRawDocument` (`:755-765`) and `ResolveDescriptorForSchemaId` (`:767-777`) collapse into one
exact lookup. It uses `_bindingsBySchemaId`, or, for a registry with no bindings, the one descriptor whose wire id
equals the id. A miss throws `InvalidOperationException` naming the id.
- Call sites stay on the one method: `CultNetDatabase.cs:717, 947, 998, 1234, 1455`,
`CultNetDatabaseSubscriptionClient.cs:254`, and `CultNetDocumentRegistry.cs:691, 713`.
- `CultNetDocumentRegistry.cs:407, 507`: drop the `ExpandSchemaBindingAliases` calls. `CultNetDatabaseServer.cs:566-567`
and `CultNetDatabaseSubscriptionServer.cs:559-560` pass the selection unchanged.
- `CultNetSelection.cs:393, 453` and `CultNetSelectionEvaluator.cs:88, 422`: exact comparison against the wire id.
- The evaluator reads only descriptors, so C1.0 gives it the registry's `WireSchemaId` as a `Func<CultDocumentDescriptor,
string>`.
- After C1.1 the id is `descriptor.SchemaVersion`, and the parameter collapses to that (see C1.1).
- `CultNetSelection.cs:178` doc: "schema ids", not "or aliases".
**C#, `src/GameCult.Mesh`:**
- `CultMesh.cs:2724-2774` `ReadDocumentFromSnapshotResponse`: delete the `aliased` and `byPayload` branches
(`:2744-2745, 2754-2757`). Pick `exact` or throw. Rewrite the comment block at `:2724-2733`.
- `CultMesh.cs:3441-3464` `IsSameCultDocumentSchema`: the id is local (a `CultNetDatabaseChange` sha256). Replace with
`schemaId == descriptor.SchemaId`, or `storedDescriptor.SchemaId == descriptor.SchemaId`. Delete the comment
claiming "every … decision goes through" the matcher.
- `CultMeshSnapshots.cs:982-985` `DecodeSnapshotDocuments`: keep only `canDeserializeWithBinding`, or
`record.SchemaId == wire id of TDocument`.
- `CultMeshDocumentRegistries.cs:64-71`: the `GroupBy(SchemaId)…Reverse()` loop is left over from aliasing. With c2a's
`Register` refusal, collapse it to one `Register` per distinct type.
- `CultMeshPrimitives.cs` ~`1806-1860` and ~`1996-2050`, the C# `CultMeshDocumentCatalog` and
`CultMeshCollectionCatalog`: drop the schema-version and schema-name keys. A lookup by schema answers by the handle's
type only. **A lookup that more than one handle answers is refused, naming their document ids.** This is the "typed
catalog lookup returns the last handle added" defect, and TS has the same fix below.
**TS:**
- `cultmesh-ts/src/index.ts:6297-6328` `requestCultNetRawSnapshotDocument`: delete the HELD comment, the
`firstRecordAtKey` option, and `?? atKey[0]`. Delete the caller's `firstRecordAtKey:` at `:2691`.
- `cultmesh-ts/src/index.ts:6175-6191` `normalizeCultMeshDocumentSchema`: drop the `inferCultMeshSchemaName` arm and the
version-from-id arm, and delete `inferCultMeshSchemaName` at `:6228-6238`.
- `cultmesh-ts` catalogs: `CultMeshDocumentCatalog` `:1462-1519` and `CultMeshCollectionCatalog` `:1688-1731`.
- Delete `#bySchemaNameVersion`, `cultMeshSchemaNameVersionKey` (`:6220-6226`), and its arm in
`cultMeshRefuseSecondCatalogType` (`:6197-6218`).
- `tryDocument` and `tryCollection` answer by type or by wire id. When `#byType` holds several handles for one type
(`add` at `:1482` overwrites, so today the last one wins), the lookup is refused with the handles' document ids.
That needs `#byType: Map<string, handle[]>`.
- `cultmesh-browser/src/index.ts:770-774` `recordMatchesSchema`: exact `record.schemaId === requestedSchema`. Callers
are `:704, :721`. After C1.2, CultLib's `samples/eve-browser-network` asks by version.
- `cultnet-ts/src/shard-catalog.ts:103-130`: `schemaIdsMatch` becomes `===`. Delete `inferSchemaName`.
**Python:**
- `cultnet_py/replication.py:21-30` `schema_document_map`: key by wire id and compatible ids only; drop `schema_name` and
`schema_version` keys that differ from the wire id.
- `replication.py:158-194`: `resolve_document_for_raw_record` becomes `dict` lookup or `KeyError`. Delete
`_infer_schema_version_from_payload` and `_infer_schema_name`.
- `cultnet_py/shard_catalog.py:143-154`: exact.
- `cultmesh_py/node.py:1311-1343`: `_schema_matches_request` and `_document_matches_schema_id` become exact; delete
`_infer_schema_name`.
- `cultmesh_py/server.py:687-721`: the map copy and request filter become exact; delete `_infer_schema_name`.
- Keep `_documents_alias` / `_resolve_document_alias` (`node.py:1188-1243`). They compare local definitions, not wire
ids (follow-up F-C1-PYALIAS).
**Rust:**
- `cultmesh-rs/src/lib.rs:750-773`: delete `strip_schema_version_suffix`. `registered_document_type_for_schema` becomes
`binding_by_schema_id(..).map(|b| b.document_type.clone())` and nothing else. Delete the `documents.binding(schema_id)`
type-name arm.
- `cultnet-rs/src/selection.rs:709-851`: delete `schema_alias`, tests included. Its uses at `:1432, 1487, 1651, 2112`
become exact comparisons against the row's binding id.
- `cultnet-rs/src/snapshot_query.rs:160-181` `RawSnapshotRow`: stop defaulting `schema_name` to the id; it reaches
`schema_alias` from `serve_read_only_raw_snapshot` (`:129`).
**Kotlin:** `cultmesh-kotlin/.../CultMesh.kt:154-155` `codecForSchema`: drop `?: codecs[schemaId]`. That also fixes
`deleteBySchema` at `:170-171`.
**Not edited, archaeology:** Hermodr `hermodr-daemon.cjs:1335-1363`, EveElectron, and AetheriaEve's own readers.
**Tests deleted or inverted in C1.0.** Each rule that dies keeps a test that proves it is dead.
- C#:
- `NetworkingTests.cs:3177` (a selection by version matches through the alias): invert to not-found before C1.1.
- `NetworkingTests.cs:4022` (foreign id by sniffing): invert to refused.
- `NetworkingTests.cs:4109, 4140, 4276, 4317, 4366` (shard and scope configured by version): configure by the wire id.
- `CultNetSelectionFixBatchTests.cs:121, 158` (name alias): invert.
- `CultMeshStreamingTests.cs:2638-2708, 3062, 3105` (payload fallback): invert to refused or skipped.
- TS:
- `cultmesh.test.ts:3061-3074, 3244-3249` (HELD): invert to not-found.
- `cultmesh.test.ts:1170-1196, 1241-1286`: catalog by version against a sha256 handle.
- `cultnet.test.ts:1064-1068` (shard by name).
- `cultmesh-browser.test.ts:139, 452, 530, 536, 692-697`.
- Python:
- `test_cultnet.py:1685-1726, 2059-2063`.
- `test_cultmesh.py:291-339, 343-379` (drop the name case).
- `test_cultmesh.py:4150-4302` (sniffing and deletes by version against a sha256 id).
- Rust: `tests/selection.rs:2134, 2151, 2179, 2193, 2216-2219`.
### 4. C1.1: the C# wire id is the version
This is a behaviour cut on `src/GameCult.Networking` and `src/GameCult.Mesh`, plus the shared selection fixture with
Rust.
**The store id and the wire id stay separate.** `descriptor.SchemaId` (sha256) keeps keying CultCache and every
in-process dictionary. The wire id is `descriptor.SchemaVersion`. Once the override is deleted (OQ-C1.1), the wire id
derives from the descriptor alone, with no registry. That is what lets the selection evaluator, the mesh and the
database compute it without a binding.
**How a C# reader maps `<name>.vN` to its descriptor:**
- `_bindingsBySchemaId` is keyed by `binding.SchemaId`, which now equals `SchemaVersion`.
- An unbound registry looks the version up among its descriptors.
- C# already refuses two types for one `(name, version)` in a registry (`CultCache.cs` `RegisterDescriptor`, "One type
owns a schema"), and c2a's `Register` refuses two types for one binding id. So an exact version lookup has at most one
answer.
- Two types sharing version `"1"` cannot both be bound, because `"1"` is refused first.
File changes:
- `CultNetDocumentRegistry.cs:76-135` `ForDocument<T>` and `ForDocument(Type…)`: delete the `schemaId` parameter
(OQ-C1.1). The binding id is `descriptor.SchemaVersion`.
- `CultNetDocumentBinding`: the internal constructor at `:44` takes the id from the descriptor.
- `CultNetDocumentRegistry.cs:173` `Register`: before c2a's conflict check, refuse a binding whose id fails
`^.+\.v[0-9]+$`. Throw `CultSchemaConflictException` or `ArgumentException`, naming the type, the declared version,
and the rule. The refusal happens at registration, never at first send.
- `CultNetDocumentRegistry.cs:675-676` `WireSchemaId`: becomes `descriptor.SchemaVersion`, checked by the same rule, so
an unbound `"1"` type is refused when it is first emitted. Every emitter below calls it.
- `CultNetDocumentRegistry.cs:616-645` `ToRawRecord`: the synthesized binding at `:621-626` uses `WireSchemaId`, not
`descriptor.SchemaId`.
- `CultNetDocumentRegistry.cs:217-228` `CreateDocumentDeleteMessage(string schemaId…)`: add
`CreateDocumentDeleteMessage<T>(messageId, CultRecordHandle<T>)`, which derives the wire id. Keep the string overload,
documented as "the wire id".
- `CultNetDatabase.cs` emitters: publish the wire id wherever a `CultNetDatabaseChange.SchemaId` leaves the process.
- `CultNetDatabaseServer.cs:555/581` (removal change) sends `WireSchemaId(descriptor)`, not `change.SchemaId`. The
change record keeps the sha256 locally.
- `CultNetDatabase.cs:1554` `ToLogEntryMessage` `Delete.SchemaId` becomes the wire id.
- `CultNetDatabase.cs:1512` `ApplyCommittedDelete` resolves the incoming delete id through the one exact lookup, not
`GetRequiredBySchemaId`.
- Error text at `:1664, :1725` names the wire id.
- **Durable shard log** (`CultNetShardMutationLogStorage.cs:60-88`, `CultNetDatabase.cs:586-591, 1385-1409`):
- It persists store ids. `Append` rewrites each entry's `schemaId` (put document and delete) to
`descriptor.SchemaId`.
- `GetMutationLogMessages` restamps with `WireSchemaId` on the way out.
- `KeyOf` (`:1399-1409`, the restart rebuild of `_lastWriteSequence`) reads store ids and so agrees with the live
writes at `:1294, 1486, 1531`. That also fixes the rebuild disagreement that exists today when a binding overrides
the id.
- **Existing log files hold sha256 for default bindings, which is the store id, so nothing on disk changes.** Only
logs written through an override hold a foreign id, and only the interop peer used one. No repo outside CultLib uses
`CultNetFileShardMutationLogStore` (grep over F:\Projects).
- Request filters that send the sha256 today switch to `WireSchemaId`:
- `CultNetDatabaseSubscriptionClient.cs:70-71`;
- `CultMeshClient.cs:602, 843, 944`;
- `CultMesh.cs:2696` (`RequestPeerSnapshotAsync`) and `:2038`;
- `CultMeshSnapshots.cs:874` `ResolveDefaultSelection`.
- **Shard catalog and config:** `CultNetShardDescriptor.SchemaIds` and `CultNetClientAuthorityScope.SchemaIds` are wire
ids. They are documented so, and matched exactly (C1.0).
- CultMesh projection sources and binding records (`CultMesh.cs:1521, 1602, 1972, 2032, 2273, 2300, 2331, 2357, 2384,
2413`; `CultMeshSnapshots.cs:530, 555`) cross the wire as `CultMeshStateBindingRecord [Key(3)]`
(`CultMeshPrimitives.cs:3334`). Stamp them with the version.
- The collection and feed ids built from them (`CultMesh.cs:2298, 2329, 2382, 2411, 3266`) move to the version too.
- The cultmesh-ts type of the same shape then agrees.
- **Selection order and cursor** (`CultNetSelectionEvaluator.cs:213-214, 318, 345, 586, 593`): tie-break and cursor on
the wire id, as Rust does on its binding id.
- This changes a wire-visible order: the parity vector R-AJ flips (`leaf_a.v1 < leaf_b.v1`, where the sha256 order
was `b1f2… < ff35…`).
- Cursors minted before the upgrade stop resuming. That is acceptable, because cursors are per session.
- **Shared fixture, landed in this cut with the Rust side:**
- `contracts/cultnet/interop/selection-vectors.fixture.json`: `schemaId` becomes the version. Delete `nameAlias` and
`hashAlias`.
- Regenerate `selection-vectors.cs-written.json` and `selection-vectors.rs-written.json`.
- `SelectionParityVectorTests.cs:112-117, 156-159` assert the fixture ids equal the declared versions. Delete the
alias cases at `:235, :238, :329`; keep the refusal of an unknown id at `:325`.
- Rust `cultnet-rs/tests/selection.rs:1561-1690, 1849` (vector writer): drop the alias rows; order by the version.
- **Left alone:** `global:{descriptor.SchemaId}` record keys (`CultCache.cs:2473`, `CultNetDatabase.cs:1120`). They are
a record key, not a schema id, and changing them changes store keys. See F-C1-GLOBALKEY.
- **Schema catalog:** `CultNetSchemaRegistry` holds JSON-Schema contracts keyed by `$id` URL, and no production C# host
answers a catalog request (`Server.cs:565` is an allowlist only). The only document-payload registrations are the
interop peer's, which C1.5 changes. Nothing to cut here.
### 5. C1.2: TypeScript
Runtime packages `cultcache-ts`, `cultnet-ts`, `cultmesh-ts` and `cultmesh-browser`.
- `cultcache-ts/src/document.ts:82-91` `schemaIdentityOf` stays the store identity, unchanged.
- **cultnet-ts owns the wire:** `export function wireSchemaIdOf(definition)` returns
`schemaIdentityOf(definition).schemaVersion`. The store keeps a definition with no declared version valid; only a
wire registration refuses it.
- `cultnet-ts/src/replication.ts:52-80` `register`:
- refuse a version failing `/\.v\d+$/` with `SchemaConflictError` or `TypeError`, naming the type;
- key `#owners` by `wireSchemaIdOf`;
- `#listers` keep the declared compatible ids (OQ-C1.2).
- The conflict check at `:59-71` also compares wire ids.
- Outgoing ids: `schemaIdForBinding` (`:349-351`) splits in two.
- Outgoing messages (`:123, :178, :287` put echo) use `wireSchemaIdOf`.
- `applyRawDocumentPutMessage` (`:280-290`) keeps storing under the store id, `schemaIdentityOf(...).schemaId`.
That is the leak point: today the same function feeds both.
- **Serving** (`:196-260`, `:363-368` `schemaIdForEnvelope`, `:336-345` `#createRawDocumentRecord`):
- A served record carries `wireSchemaIdOf(binding)`, never `envelope.schemaId`.
- A record stored under a compatible id holds that version's bytes. It is decoded and re-encoded through the owning
definition before it is served under the owner's id. This matches C#, which serializes from the decoded document
(`CultNetDocumentRegistry.cs:640`).
- `answersTo` (`:354-361`) matches the requested id against the binding's wire id (and its compatible ids if
OQ-C1.2 is yes).
- Tests `cultnet.test.ts:1627-1664` (the wire id equals the store id), `:1703-1708` (served under the stored id) and
`:1713-1744` (a version id does not resolve) are inverted.
- `cultnet-ts/src/shard-catalog.ts`: exact after C1.0; shard ids are wire ids.
- **cultmesh-ts:**
- `cultMeshSchemaFromDefinition` (`index.ts:6168-6173`): `schemaId` for peer reads is `wireSchemaIdOf`.
- `syncDocumentFromPeerSnapshot` (`:3995-4019`) and `cultMeshDocumentFromPeerSnapshot` (`:2668-2710`) ask by wire id.
- `resolveCultMeshStoreDocumentRecord` / `cultMeshAcceptedSchemaIds` (`:6243-6256`) are store-local and stay on
`schemaIdentityOf`.
- The receipt filter at `:5976` compares `receiptBinding.definition.schemaId`, which is `undefined` when not
declared. Compare with `wireSchemaIdOf`.
- Put-serve overlap (not on c2a): `hands/cultmesh-put-serve-bound` adds `#rawPutEnvelope` and
`createRawSnapshotResponseForPut` to this file, on main's shape resolution (`preserveIncomingSchemaId`). After the
merge, the put-sizing response must be built by the same serving path, so it carries the wire id.
- `cultmesh-browser`: exact after C1.0. `samples/eve-browser-network` declares or asks by version.
- **Consumer action, not in CultLib:** Stonks `stonks-daemon.cjs:152-195` sets `schemaVersion` to the full id. It
lands in Stonks before C1 merges to main.
### 6. C1.3: Python
- `cultcache_py/documents.py:105-143` `catalog_entry` stays the store identity. Add `wire_schema_id(definition)` in
cultnet_py, returning `schema_version`.
- Registration in `cultnet_py` and `cultmesh_py` refuses a version failing `\.v\d+$`.
- Outgoing ids use `wire_schema_id`:
- `cultmesh_py/node.py:577` (put), `:641` (delete), `:824-827` (sync);
- `client.py:430` (subscribe);
- `node.py:767, 776` (snapshot, never the stored id; a compatible-id record is re-encoded as in TS);
- `server.py:617-633` (catalog).
- `server.py:416, 444` echo a record that has already been resolved exactly, so it carries a wire id.
- Applied records keep being stored under the local store id: `replication.py:42, 84`, `node.py:664-670`.
- Inverted tests: `test_cultmesh.py:4116-4148` (a version request is served under the sha256 id) and `:4215-4258`
(echo of a foreign id).
- `interop_peer.py:1085` serves the stored id; that moves in C1.5.
### 7. C1.4: Rust mesh and Kotlin
- **Rust.** The wire id is already the version on every consumer binding.
- `for_entry::<T>()` (`cultnet-rs/src/replication.rs:44-47`) has no consumer. It binds `T::TYPE`, which fails the
refusal unless it ends in `.vN`. Refuse it at `register` like any other id without a version. Keep the constructor,
so a type whose TYPE is `x.v1` still works.
- `for_entry_with_schema_id(id, version)` (`:50-64`): refuse `id != version` or a missing `.vN`. That is one id, and
`cultmesh_documents!` already passes the same string twice.
- `register` (`replication.rs`) applies the rule.
- The apply path keeps stamping `binding.schema_id` (`:400`). That is what Odin, Muninn and the rest store today, so
nothing on disk changes.
- `pull_rudp_catalog_snapshot` (`cultmesh-rs/src/lib.rs:714-747`) stamps the resolved binding's id. It is exact after
C1.0, so no foreign id is written.
- Rust's mixed store ids (typed put stamps `T::TYPE` at `cultcache-rs/src/lib.rs:2362`; apply stamps the version) are
left as they are. That is C2's question, F-C1-RUSTSTOREID.
- **Kotlin.**
- `register` (`CultMesh.kt:79-93`) refuses a `schemaVersion` failing `\.v\d+$`.
- The interop codecs at `CultMesh.kt:3872-3880, 4317-4322, 4351-4354, 3779-3789` put the contract URL in
`schemaVersion`. They become `cultnet.interop_note.v0` and the capability `*.v0` versions. Kotlin's own suite
(`CultMesh.kt` self-checks) moves with them.
### 7b. C1.5: the interop harness tests the default binding
The parity witness hid the mismatch because every peer names the note by its JSON-Schema `$id` URL. C1.5 removes the
overrides so each runtime's default binding meets the others.
- C# `tests/GameCult.Networking.InteropPeer/Program.cs`:
- `:77-80, :201-204`: `ForDocument<CultNetInteropNote>(payloadSerializer:…, payloadDeserializer:…)` with no id. The
id is `cultnet.interop_note.v0`.
- `:1183-1196`: four capability bindings with no id; their versions are `*.v0` (`:1560-1572`).
- Delete the `*SchemaId` URL constants.
- `LoadSchemaRegistration` (`:1116-1137`): the catalog registration's `SchemaId` becomes `InteropSchemaVersion`. The
JSON `$id` stays inside `SchemaJson`.
- The catalog server at `:682-714` stops copying `SchemaId` into `SchemaVersion`.
- TS `packages/cultnet-ts/test/interop/cultnet-interop-shared.ts:10-19, 289-306` and `cultnet-interop-peer.ts:123,
135, 896-930`: definitions declare `schemaVersion` and no `schemaId` override. The note id constant is at
`cultnet-interop.test.ts:43`.
- Python `cultnet_py/interop_peer.py:59-68, 869-884, 936-981, 1085, 1149`.
- Rust `cultnet-rs/examples/cultnet_interop_peer.rs:56-67, 1227-1261`: `for_entry_with_schema_id` with the version
only.
- Kotlin: C1.4.
- The Python cultmesh scripts inside `cultnet-interop.test.ts:3872-4060` declare `schema_version` only.
- **The added negative lane** is one scenario in `cultnet-interop.test.ts`. A C# host serves a note from a
**CultMesh-created registry** (`CultMesh.CreateCultNetDocumentRegistry`, the production path). A TS reader:
- asks by `cultnet.interop_note.v0` and gets it;
- asks by `cultnet.interop-note` (the name) and gets not-found;
- asks by the sha256 id and gets not-found.
- CI: `.github/workflows/cultnet-interop.yml:141-163` already runs every pair lane. The new scenario joins the
`TypeScript and C#` pattern.
### 8. Verification
Each rule gets a behavioural test that fails when the rule breaks, plus the ecosystem mutation tool on the cut's
diff: Stryker.NET, StrykerJS, mutmut, cargo-mutants. Kotlin has no tool, so its rules are defended by scenario.
**Positive, across runtimes, with default bindings** (C1.5 lanes, run on Yggdrasil in `ack1d-interop:2`):
- A C# host with a CultMesh-created registry publishes a note, and TS, Rust, Python and Kotlin each read it. Each asks
only by `cultnet.interop_note.v0`.
- Then each non-C# host publishes, and C# reads.
- The wire bytes carry `schemaId = cultnet.interop_note.v0`. Assert the decoded `CultNetRawDocumentRecord`, not a log
line.
**Negative checks:**
- **No fallback.** In each runtime, a record at the requested key under another id is not returned (TS, C#, browser),
and a payload whose slot 0 names a registered version but whose id is foreign is refused (C#, Python).
- Mutant: restore `?? atKey[0]`, or restore the `byPayload` or `aliased` branch. It must die.
- **Unknown id refused.**
- C# `DeserializeRawDocument` with `gamecult.unknown.v1` throws. Today it returns `Note` (probed).
- TS, Python, Rust and Kotlin `applyRawDocumentPut` with an unknown id throw.
- **No name match.** Asking by `schemaName` finds nothing, in every runtime and in the selection evaluator (C# and
Rust). Mutant: add `|| candidate == descriptor.SchemaName`.
- **Refusal.**
- Registering a C# binding for a `[CultDocument("x", "1")]` type throws at `Register` and names the type.
- An unbound `"1"` type in a registry with no bindings throws at first emission.
- TS, Python, Rust and Kotlin refuse `"v1"` and `"1"`, and accept `x.v0` and `x.v12`.
- Mutants that must die: `\.v\d*$` (accepts `x.v`), `.v` anywhere, and `\d+$` alone (accepts `"1"`).
- **The wire id is derived from the declaration, not a constant.** Two types with different versions each emit their
own. A mutant that returns `SchemaName + ".v1"` must die, so use a fixture whose version is not `<name>.v1`: the
interop note's name is `cultnet.interop-note` and its version is `cultnet.interop_note.v0`.
- **The store id is untouched.**
- A C# store written before C1 is read after it. `dump.py` shows record ids unchanged after a C1 host reads and
serves.
- A TS store with `schemaId: "p1.shared"` keeps that id after a raw put arrives under `p1.shared.v1`.
- Mutant: store under the wire id. It must die.
- **The served id is not the stored id.** A TS or Python record stored under a compatible id is served under the
owner's wire id, with bytes that decode as the owner. Mutant: serve `envelope.schemaId`.
- **Durable log.**
- A C# database with `CultNetFileShardMutationLogStore` writes, restarts, and replays to a replica. The replayed
entries carry the version, and `LastWriteSequence` after the restart equals the value before it.
- A log file written before C1 (sha256 entries) replays under the version.
- **Selection.** The parity vectors agree C#↔Rust with version ids. The tie-break vector R-AJ orders by version.
- **Catalog lookup.** A catalog with two handles of one type refuses a by-schema lookup and names both document ids
(C# and TS). Mutant: return the first or last handle.
**Builds.** These are the focused set, with no workspace-wide build:
- `dotnet test` on `GameCult.Networking.Tests` and `GameCult.Mesh.Tests`;
- `npm test` in `cultnet-ts`, `cultmesh-ts` and `cultmesh-browser`;
- `pytest` in `cultnet-py` and `cultmesh-py`;
- `cargo test -p cultnet-rs -p cultmesh-rs`;
- the Kotlin self-check;
- the interop lanes.
All of them run on Yggdrasil through `ygg-verify.sh`.
**Negative greps**, which must return nothing in `src/` and `packages/*/src`:
- `CultNetSchemaAliasMatching|InferSchemaName|inferSchemaName|_infer_schema_name|infer_schema_name`
- `strip_schema_version_suffix|schema_alias|PayloadMatchesSchema|TryReadSchemaVersion|firstRecordAtKey`
- `bySchemaNameVersion|ExpandSchemaBindingAliases`
- in `CultNetDocumentRegistry.cs`: `schemaId:` as a `ForDocument` parameter.
**Live consumer checks**, after release and before any consumer bumps:
- **Stonks** has its six versions fixed. `idunn` shows Odin holding Stonks' `provider_advertisement` after a Stonks
restart on C1.
- **AquaSynth** daemon and clients rebuilt together; the Dings host round-trips.
- **Mimir** EveDashboard is visible in Eve Android.
- **Bifrost** agent-transport snapshot and apply round-trip.
- **Odin** still ingests from Heimdall, VoidBot, weksa and StreamPixels. Those ids do not change.
- **Grep** Idunn and Odin for a receiving binding of `idunn.daemon_health` with no version. If one exists, it is
refused at startup under C1.4. It was not found in main.
### 9. Sequencing
1. **F2 finishes first.** C1 and F2 touch different layers:
- F2 is store-side: `CultCache.cs` `Claimant`, the single-file and directory stores, and the Rust catalog lay-back.
- C1 is wire-side.
- They overlap in three places:
- `cultnet-ts/src/replication.ts`. F2 and batch 10 built owner-beats-lister and the `#listers` refusal (F9, F10)
there, and C1.2 re-keys `#owners` by wire id. The lister rule is kept, with OQ-C1.2 deciding its wire reach.
- `cultnet-ts/test/cultnet.test.ts:1346-1394, 1627-1744`. C1.2 inverts tests F2's batches wrote.
- `cultnet_py/replication.py` `schema_document_map`.
- C1 does not relabel any stored record and adds no store write, so F2's rule ("never relabels … a write over it is
refused") is not in play. F2's test `976b57f1` ("each runtime's writes leave another runtime's records and catalog
entries unchanged") must stay green through C1.
- Hands on F2 should not be interrupted. C1 starts after F2's Soul pass holds.
2. **Then put-serve merges to main** (`hands/cultmesh-put-serve-bound`, based on `b04d03b6`).
- It edits `cultnet-ts/src/replication.ts` on main's resolution. Main still has `preserveIncomingSchemaId` and
payload-shape resolution, which c2a deleted in `0ee98e8c`.
- It also edits `cultmesh-ts/src/index.ts`, `cultmesh_py/server.py` and `node.py`, and `cultmesh-rs`.
3. **Then c2a merges main.** This is the conflict to resolve before C1, whatever C1 is:
- c2a's exact resolution plus put-serve's `createRawSnapshotResponseForPut`;
- main's R1 changes to `CultNetDocumentRegistry.cs` (`rowFilter`) against c2a's `Register` refusal.
- C1 re-anchors on that merge commit. Every anchor above names its symbol for that reason.
4. **C1 cuts, in order, on the variants branch** (or `hands/variants-c1id` cut from it):
- C1.0 C# subtraction;
- C1.0 TS, Python, Rust and Kotlin subtraction, one cut per runtime or together, since they are deletions only;
- C1.1 C# wire id with the shared selection fixture and Rust selection order;
- C1.2 TS;
- C1.3 Python;
- C1.4 Rust and Kotlin;
- C1.5 harness.
- The interop lane is red between C1.1 and C1.5 on the branch. Soul measures at C1.5.
- Stonks' fix lands before the variants branch merges to main.
5. **Release.** C1 is wire-breaking between C# peers of different releases, because an old host sends sha256 and a new
receiver refuses it. It rides the C0/C2a release that the 2026-09-30 ruling already gates. The release note names:
C# peers upgrade together, the refusal of versions without `.vN`, and Stonks.
### 10. Ledger estimate
| Cut | Deleted (src) | Added (src) | Tests | Notes |
|---|---|---|---|---|
| C1.0 C# | ~290: alias class 58; registry block ~175; mesh branches ~35; expand ~40 | ~40 exact lookups | ~15 tests inverted, ~6 deleted | public API removed: `CultNetSchemaAliasMatching`, `TryResolveDescriptorByPayloadSchema`, `PayloadMatchesSchema` |
| C1.0 TS/Py/Rust/Kt | ~230: selection `schema_alias` ~140 with tests; Python infer/sniff ~45; TS infer/HELD/catalog keys ~45 | ~20 | ~25 tests deleted or inverted | |
| C1.1 C# | ~30 (override parameter, sha256 fallbacks) | ~80 (refusal, log restamp, emitters) | ~20 changed; fixture and vectors regenerated | wire-visible order change |
| C1.2 TS | ~15 | ~50 (`wireSchemaIdOf`, refusal, serve re-encode) | ~10 | |
| C1.3 Py | ~10 | ~40 | ~8 | |
| C1.4 Rs/Kt | ~5 | ~25 | ~6 | |
| C1.5 harness | ~60 URL constants and overrides | ~40 negative lane | — | |
| **Net** | **~-640 / +295 src**, about **-345** | | | Nothing on disk changes. Two public C# methods and one public C# class are gone. |
### 11. Operator questions
**OQ-C1.1: delete the per-binding wire-id override?**
- The overrides are C# `ForDocument(schemaId:)`, Rust's free first argument of `for_entry_with_schema_id`, and the
implicit TS and Python `schemaId`-on-the-wire.
- A. Delete. The declared version is the one wire id, and it derives from the descriptor alone, so the selection
evaluator, the durable log and the mesh need no registry to compute it. No repo outside CultLib passes the override;
only CultLib's interop peer and two conflict tests do.
- B. Keep it, but refuse ids without `.vN`. A second declaration of identity survives beside the version, and every
descriptor-only site (evaluator order, cursor, log, projections) needs the registry again. That is today's
`WireSchemaId` split.
- **Recommendation: A.** It removes a public parameter, which the CultLib consumer rule asks to be put to you rather
than cut silently. What a reasonable consumer loses is the ability to put an id on the wire that its declaration
does not state. That is the thing C1 rules out.
- C1.1's shape depends on the answer.
**OQ-C1.2: does declared compatibility (`CompatibleSchemaIds`) resolve on the wire?**
- A. Yes, with the same rule the stores use: owner beats lister, and two listers with no owner are refused. A v2 type
that declares `x.v1` compatible accepts a v1 peer's records, so mixed-version peers work when an author declares it.
It is declared, never inferred. c2a's TS and Python registries already do this. Served records always go out under
the owner's wire id, re-encoded (§5).
- B. No, wire resolution is the owner only. Compatible ids become store-only. It is simpler, and a version bump is a
hard break between peers.
- **Recommendation: A.** It is the rule the operator ruled for stores (owner beats lister), applied to one more layer.
It is not aliasing, because nothing is guessed.
- C1.0's Python map, C1.2's `#listers`, and the C# lookup depend on the answer.
**Defaulted, not asked. Veto if wrong:**
- TS and Python keep their default version `${name}.v1` when none is declared.
- A served record stored under a compatible id is re-encoded through its owner, as C# does.
- `global:{sha256}` record keys stay.
- Rust's apply path keeps stamping the binding id.
- A catalog lookup by schema that more than one handle answers is refused.
- Stonks is fixed in Stonks.
### 12. Follow-ups (no cut owns them)
- **F-C1-GLOBALKEY:** `global:{descriptor.SchemaId}` keys (`CultCache.cs:2473`, `CultNetDatabase.cs:1120`) carry the
sha256 across the wire as a record key. This is for C2.
- **F-C1-RUSTSTOREID:** Rust stores hold `T::TYPE` for typed puts and the version for replicated puts. Its catalog
fabricates version `<TYPE>.v1` (`cultcache-rs/src/lib.rs:2694-2708`) even where `cultmesh_documents!` declares another
one. The derive cannot declare a version (`cultcache-rs-derive/src/lib.rs:24-33`), which is a gap beside the other
runtimes. This is for C2.
- **F-C1-PYALIAS:** Python cultmesh `_documents_alias` compares two local definitions by name and version. It is
redundant under one-type-per-schema, but local.
- **F-C1-HEALTHID:** Bifrost sends `idunn.signed_daemon_health` without `.v1`
(`persona-feedback-idunn-health.cjs:41`), where every other sender uses `.v1`. It is pre-existing and unrelated to
C1.
- **F-C1-AEVE-CONST:** the AetheriaEve daemon compares wire ids with version constants (`Program.cs:1591`) and so never
matched under sha256. It is archaeology; record only.