diff --git a/docs/cultnet-selection-cut.md b/docs/cultnet-selection-cut.md index de2f9045..fa144f75 100644 --- a/docs/cultnet-selection-cut.md +++ b/docs/cultnet-selection-cut.md @@ -5,9 +5,12 @@ No code in this document has been written; nothing here is committed by Imagination. This is its own campaign with its own map; it does not touch `docs/typescript-quic-realtime-cut.md` or any code in that tree. -Status: cut map for one cut, **Cut 1: the selection vocabulary in the C# -reference runtime and the Rust runtime**, with the follow-on runtime cuts -and the Huginn consumer cut named and not mapped. +Status: cut map for two cuts. **Cut 1: the selection vocabulary in the C# +reference runtime and the Rust runtime** (sections 1-17), with the +follow-on runtime cuts and the Huginn consumer cut named and not mapped. +**Cut 2: watching a selection** (section 18), mapped to the same standard, +landing after Cut 1 and depending on it. Cut 1 goes to Hands without +waiting on anything in Cut 2. **Rulings this map is written under (operator, 2026-09-17):** @@ -88,8 +91,22 @@ Baselines: `src/GameCult.Networking` 14,848 lines; `src/GameCult.Mesh` member as a reference; the descriptor records `IsReference`, `IsMany`, `TargetSchemaName` (`CultCache.cs:1069-1080`). So a field predicate's key is an index alias and a hop's role is a reference member, both declared - by the document's owner and both readable from `CultDocumentDescriptor`. - No cache change is needed for addressing. + by the document's owner. +- **The declarations are public; the values are not.** `ToCatalogEntry()` + (`CultCache.cs:151-176`) is public and carries every member's `Slot`, + `MemberName`, `TypeName`, `IsReference`, `IsMany`, `TargetSchemaName`, + `IsName`, `IndexAlias`. But `CultDocumentDescriptor.Members` (`:149`) and + `IndexAccessors` (`:148`) are **`internal`**, and `GameCult.Caching` + declares no `InternalsVisibleTo` for `GameCult.Networking` (the only + `InternalsVisibleTo` in `src/` outside the vendored Unity tree is + `GameCult.Caching.MessagePack` → `GameCult.Caching.Tests`). That is why + `CreateRawSnapshotResponse` reaches for reflection at + `CultNetDocumentRegistry.cs:325-342`. **An evaluator outside the cache + assembly cannot read a declared index's value today**, by any means the + cache offers. The earlier claim in this map that no cache change is + needed for addressing was wrong: addressing is declared publicly and + *readable* only inside the cache. Section 6 carries the read surface as + an add; sections 7 and 14 carry its cost. - **Schemas are hand-written JSON** in `contracts/cultnet/*.schema.json`, listed by file name in `CultNetSchemaRegistry.cs:360-385`; TypeScript compiles them with Ajv and refuses unknown fields @@ -134,6 +151,100 @@ Baselines: `src/GameCult.Networking` 14,848 lines; `src/GameCult.Mesh` `storedAt` and the cursor refuses on any advance (section 3 says what that costs). +**The Aetheria catalog as a hostile example, verified at +`F:\Projects\Aetheria\Assets\Scripts\ServerShared`:** + +- **Inheritance.** `ItemData.cs:272` `abstract class ItemData` (`Name` + `:275`, `Mass` `:284`), `:313` `abstract class CraftedItemData : ItemData` + (which re-declares `Name` at `:327`, hiding the base's), and only the + leaves carry `[CultDocument]`: `SimpleCommodityData` `:300`, + `CompoundCommodityData` `:330`, `GearData` `:449`, and more. The cache's + `DiscoverMembers` (`CultCache.cs:838-875`) walks `current.BaseType`, so a + leaf's descriptor carries every inherited member with its attributes + (`GetCustomAttribute` reads the member's own attribute wherever it is + declared). Inherited addressing therefore already resolves per leaf. + **Corrected on a second read:** duplicate *slots* are refused + (`:931-935`), and a **hiding member is already refused too** — `:937-943` + groups keyed members by `Member.Name` and rejects any name carried at two + different slots (`HiddenMemberMessage`), while a hiding member re-using + one slot is caught by the duplicate-slot rule. So `CraftedItemData`'s + `Name` at `ItemData.cs:327` hiding `:275` is **not registrable in + CultCache as it stands**, however the aliases fall; that is a finding for + the Aetheria campaign, not a hole here. What is *not* refused by name is + two members with **different names** carrying the **same index alias** + (base `[CultIndex("mass")] Mass`, leaf `[CultIndex("mass")] Weight`). + That does not collide silently either: the alias map is built by + `ToDictionary` at `:462-468`, which throws `ArgumentException("An item + with the same key has already been added")` — a real refusal, from the + wrong owner, naming neither schema nor member nor alias. D10 is therefore + a **renaming of an existing refusal**, not a new rule, and is smaller + than it first looked. +- **A reference to an abstract target.** `FactionProduct.cs:24` + `CultRecordRef Design`. `PersistedMember.FromMember` + (`CultCache.cs:1069-1071`) takes the target type and reads + `CultDocumentAttribute` from it — an abstract base has none, so + **`TargetSchemaName` is `null` today for exactly this reference**. The + registry can compute the leaf set by assignability (it already does for + index lookups, `:1506`, and store routing, `:1828`) but records nothing. +- **A dictionary reference with a value.** `ItemData.cs:333-334` + `[CultReference(typeof(PersonalityAttribute), many: true)] Dictionary, float> DemandProfile`. + The descriptor records `IsMany` (`:1080`) and nothing else: **no code in + the cache enumerates a many-reference's targets**, and the only accessor + is `Getter = document => getValue(document)?.ToString()` (`:1082`), which + on a dictionary yields the CLR type name. The edge and its payload are + declared and unreadable. +- **Numbers.** `PersistedMember.MemberType` (`:1053`) knows the member is a + `float`, and the catalog persists it as `TypeName` (`:38`, `:192`, + compared by `CompareSchemaShapes` `:605-629`), so **a member's numeric + type is already declared and persisted**; what is missing is a typed + accessor beside the string one. Adding an accessor is a runtime change + with no catalog-shape or hash change, because `TypeName` is already in + the canonical shape. + +**The cache's index maps are unique indexes, and the evaluator must not use +them.** `Index(stored)` (`CultCache.cs:1849-1862`) writes +`MapOf(_indexes, (type, alias))[value] = key` — **one key per value, last +writer wins**. Two rows of one type sharing an index value do not collide +loudly; the second silently displaces the first in the map. `GetByIndex` +(`:1502-1508`) reads that map and `Single` (`:1838-1848`) throws only +when *several types* assignable to `T` each contribute a key, never for two +rows of one type. So `GetByIndex` answers "the row that most recently +claimed this value", not "the rows with this value". + +The consequence for this cut is a rule, not a caveat: **the evaluator +evaluates every predicate per row, through the accessor, and never through +`_indexes` or `GetByIndex`.** The declared alias is an *addressing* name — +the cache's own lookup map is a different, lossier thing that happens to be +built from the same declaration. A "use the index, it is already there" +optimisation would turn `kind any_of [weapon]` into a one-row answer. S23 +pins it, `WatchByIndex` already has the bug (section 18), and the +unique-index behaviour itself is left exactly as it is: it is the cache's +own contract for a single-record lookup and this cut does not renegotiate +it. + +**A fourth selector engine, in Rust, that this map's first draft missed.** +`packages/cultnet-rs/src/snapshot_query.rs` (339 lines, `pub use` at +`lib.rs:29`) already serves and asks snapshot requests: + +- `serve_read_only_raw_snapshot` `:64-130` is an engine — it walks the + source's records, refuses a duplicate identity and an unregistered + schema, applies a `CultNetReadOnlySnapshotPolicy` allowlist of + `(schema_id, record_key)` pairs, then filters by the request's + `schema_ids` and `record_keys` (`:112-125`). The policy is + **authorization** and stays; `:112-125` is the fifth copy of the same two + allowlists and goes, as the C# three do. +- `CultNetRawSnapshotQuery` `:158-285` is the client side, and it exposes + the sharpest limit of the two-allowlist model: `request()` `:195-217` + turns a set of exact `(schema, key)` pairs into the **cross product** of + their schemas and their keys, and `accept_response` `:219-284` then + *errors* on any record the cross product dragged in + (`"snapshot response contains unexpected record"`). Asking for `(A,x)` + and `(B,y)` asks for four and refuses two. **v1 does not fix this**: + `schemas × keys` is still a cross product (section 13, Q-M). What v1 does + change is that `accept_response`'s check is a *declared expectation*, not + a re-filter of a page the server got right, and section 8's forbidden + writer is worded to keep that distinction honest. + **The CultMesh audit** (Explore pass over `src/GameCult.Mesh` and `CultNetDocumentRegistry.cs`, 92 sites classified) is in section 11 with its numbers. @@ -156,7 +267,12 @@ Selection { limit: uint32? // clamped 1..=200 cursor: string? // opaque; minted by the answering server } -FieldPredicate { index: string, anyOf: string[] } // anyOf non-empty; index must be declared on some schema the selection can reach, else refused +FieldPredicate { // one operator per predicate, named by `op`; no union, no untagged + index: string // a declared index alias, reachable on some schema the selection can reach, else refused + op: "any_of" | "lt" | "le" | "ge" | "gt" + values: string[]? // present iff op = any_of; non-empty + number: float64? // present iff op is a comparison; the alias must be declared numeric on every reachable schema that declares it +} Citation { target: RecordRef, role: string? } // target validated as a reference the row owner recognises Incoming { role: string, exists: bool } RecordRef { schemaId: string, recordKey: string } @@ -166,8 +282,15 @@ SelectionPage { // cultnet.snapshot_r asOf: uint64 // the snapshot the page is exact for next: string? // absent on the last page headers: RawDocumentHeader[]? | documents: RawDocumentRecord[]? // exactly one present, by projection + edges: Edge[]? // present iff the selection has `cites` or `cited`: the edges the hop traversed shardId?, shardEpoch?, shardLogSequence? // as v0 carries them } +Edge { + from: RecordRef // the citing row + role: string + to: RecordRef // the cited row, its schema resolved to the leaf actually stored + payloadEncoding: "messagepack"?, payload: bytes? // the value attached to the edge (a dictionary's value), typed by the member's declared value type; present under `document`, absent under `header` +} RawDocumentHeader = RawDocumentRecord minus payload, payloadEncoding ``` @@ -192,33 +315,72 @@ Messages: `cultnet.snapshot_request.v1 { messageId, selection, shardId?, shardEp replay is not this cut's); Huginn never refuses (append-only, one writer). One contract, two honest servers. A cursor that does not decode, or whose digest is not this selection's, is `cursor_invalid`. -- **Fields** are any-of over string values of one declared index, conjoined - across predicates. A row of a schema that does not declare the index - never matches it. An `index` no reachable schema declares, an empty - `anyOf`, an empty `keys` or `schemas` list, and a `role` no schema - declares are refused typed at the door (`selection_invalid { field, value }`), - never answered as an empty page. Values are strings because the cache's - indexes are strings (`CultCache.cs:1084`); an enum-typed field on the - consumer's side is a string on the wire and the row owner refuses a value - its enum does not carry — the closed key set survives as "declared - aliases only", and the fixed operator survives as any-of. +- **Fields** are predicates over one declared index each, conjoined across + predicates: `any_of` over string values, or one of four comparisons + (`lt`, `le`, `ge`, `gt`) against one number over a declared numeric + member. A row of a schema that does not declare the index never matches + it. An `index` no reachable schema declares, an empty `values`, a + comparison on an alias any reachable schema declares non-numeric, an + empty `keys` or `schemas` list, and a `role` no schema declares are + refused typed at the door (`selection_invalid { field, value }`), never + answered as an empty page. `any_of` values are strings because the + cache's indexes are strings (`CultCache.cs:1084`); comparison numbers are + IEEE float64 on the wire, and a member of an integer type wider than 53 + bits compares at float precision — a stated limit, not a hidden one. No + range operator (two predicates on one alias make a range), no `ne`, no + nesting. +- **Inheritance.** A schema is a leaf: only a type with `[CultDocument]` + has a schema name, and `schemas` names leaves. A predicate on an alias + applies to every reachable leaf whose descriptor carries a member with + that alias, inherited or own — the cache already discovers inherited + members per leaf, so "gear or commodities with `mass` over 10" is + `schemas: [geardata, simplecommoditydata], fields: [{index: mass, op: gt, number: 10}]` + with `mass` declared once on the abstract base. Two leaves that each + declare a member with the same alias independently are two members; the + predicate reads each leaf's own. One leaf that resolves the same alias to + two members (a base member and a hiding re-declaration both carrying the + alias) is refused at registration (D10). A numeric comparison on an alias + that is numeric on one reachable leaf and a string on another is refused + at the door, because the row owner cannot compare a string. - **The hop** reads edges the row owner declares: in the reference, members - with `CultReferenceAttribute` (role = the member's index alias or name; - target = the referenced key, or each key when `Many`); in Rust, the - `Row::references` the consumer implements. The citer's status, fields and - authorization are not consulted by the hop; one hop, no second. + with `CultReferenceAttribute` (role = the member's index alias or name); + a one-reference yields one edge, a many-reference yields one edge per + element, and a dictionary keyed by references yields one edge per key + **with the value as the edge's payload**. In Rust, `Row::references()` + yields `(role, target, payload)`. **A reference's target is a set of + leaves**: the declared target type's own schema if it is a leaf, else + every registered leaf assignable to it (`CraftedItemData` → + `compoundcommoditydata`, `consumableitemdata`, …). The hop follows an + edge into any row whose schema is in that set. **A stored edge naming a + row whose schema is outside the set refuses the selection** + (`reference_outside_target { from, role, to }`), not skips it: an edge the + declaration forbids is corrupt data and a read does not paper over it. + The citer's status, fields and authorization are not consulted by the + hop; one hop, no second. +- **Edges in the answer.** A selection with `cites` or `cited` returns, beside + the rows, the edges the hop traversed, each with its payload under + `document` projection and without it under `header`. A weighted reference + and a bill of materials are the same shape: follow the edge, return the + payload, filter the rows. **Filtering on the payload as part of the + traversal is not expressible** (section 13): that is a join condition. - **Projection.** `header` is the record without its payload; `document` is the record. The consumer decides what a header *means* for typed rows (Huginn's typed summary is its header; the substrate carries whichever type the page was instantiated with). - **Not on the wire:** the admission window (`admittedAfter/Before`) of Huginn's Cut 9 — `descending` plus the cursor answer "the latest"; a date - window is a range, and ranges are out (Q-G). + window is a range, and ranges are out (R-3). -**The bound, carried forward in force.** Closed key set (declared aliases), -fixed operator (any-of), no path language, no expression terms, no OR, one -negation, one hop, no depth field, no sort field, no range, no text. -Section 13 lists what is deliberately impossible. +**The bound, carried forward in force, with one widening the operator +ruled.** Closed key set (declared aliases), a closed operator set (any-of +and four comparisons, one per predicate, no nesting), no path language, no +expression terms, no OR, one negation, one hop with its edges returned, no +depth field, no sort field, no text, no predicate on an edge's payload in +the traversal. Section 13 lists what is deliberately impossible. The +capability set is now: **C1** typed predicates over declared members, +string any-of and numeric comparison; **C2** projection; **C3** order and +cursor; **C4** one hop, both directions, returning typed edge payloads; +**C5** existence and count without bodies. **Nothing on the wire is serde-untagged — rule, with evidence.** Probe `scratchpad/untagged-probe/` (result in `untagged-probe-result.txt`), run @@ -253,7 +415,7 @@ Python reads only the fields it knows and would answer a v0 selector it did not understand with the wrong rows. A v1 version string makes every runtime not in this cut refuse or drop by its own dispatch rather than mis-answer. The cost is that v0 stays answerable by the reference until the -follow-ups land (Q-F). +follow-ups land (R-1, a scheduled deletion with a named trigger). **D4. One evaluator in the reference, three engines deleted.** `Select` over `(descriptor, key, document)` rows from the cache replaces @@ -276,7 +438,7 @@ subscription server already owns a diff-against-delivered reconcile subscription runs it on every change instead of the per-change fast path. That is the mechanism by which "watch a question" falls out, and this cut does **not** add a local `CultNetDatabase.Watch(Selection)`; section 11 -records what that later cut would collapse (Q-H). +records what that later cut would collapse; it is Cut 2, section 18 (R-5). **D7. The Rust runtime carries the evaluator, not only the types.** Huginn evaluates selections over its own rows; if `select` lived only in C#, Rust @@ -284,6 +446,108 @@ consumers would each write one. `packages/cultnet-rs/src/selection.rs` holds the types, the cursor, and `select` over a `Row` trait, with no subscription client in this cut (Huginn does not subscribe). +**D8. Numbers are addressable through the type the cache already declares; +the cache gains accessors, not attributes.** A comparison needs a numeric +value the string getter cannot give. `CultCache.cs:1053` holds the CLR +`MemberType` and the catalog already persists `TypeName` in the canonical +shape, so numeric-ness is declared; what is added is runtime: on each +member, `ValueOf(document) -> object?` and `NumberOf(document) -> double?` +(non-null exactly for the closed set of CLR numeric types), and the public +member entry exposes `IsNumeric` derived from `TypeName`. No new attribute, +no catalog-shape change, no schema hash moves, no migration. Rust mirrors +it as `Row::number(index)` and `RowSet::is_numeric(schema, index)`; the +consumer decides from its own types. + +**Verified by source read, because everything downstream rests on it:** +`CultDocumentMemberDescriptor.TypeName` is set at `CultCache.cs:474` from +`CultSchemaTypeNames.FromType(member.MemberType)`, which renders a CLR full +name (`System.Single`, `System.Nullable`, +`System.Collections.Generic.Dictionary, +System.Single>`); `ToCatalogEntry()` `:168` copies it into +`CultSchemaMemberCatalogEntry.TypeName` `:42`; `CompareSchemaShapes` +compares it inside `CanonicalSchemaMember` `:605-629`. Numeric-ness is +declared and persisted today, and the claim holds. + +**Two corrections to the estimate.** First, `IsNumeric` has two possible +authorities — the CLR `MemberType` in hand and the `TypeName` string read +back from a stored catalog. They must agree, or a schema validates one way +locally and another way against a persisted catalog. **The CLR set is the +authority** (a closed set: the eight integer types, `float`, `double`, +`decimal`, and `Nullable` over any of them); the string set is generated +from it by `CultSchemaTypeNames.FromType`, never hand-written. S22 pins +that. + +Second, and larger: the accessor is not the only thing missing. Per section +1, `IndexAccessors` and `Members` are `internal` and `GameCult.Networking` +is not a friend assembly, so the evaluator cannot read *any* declared +value — string or number — without the reflection the map is deleting. The +cache add is therefore a **public read surface over declared members**, of +which the numeric accessor is one method: +`TryGetIndexValue(object document, string alias, out string? value)`, +`TryGetIndexNumber(object document, string alias, out double value)`, +`ReferencesOf` (D11), and a public member view carrying `IndexAlias`, +`IsReference`, `IsMany`, `IsNumeric` and the target **`Type`** (D9 needs +the type, not the name). Section 14 carries the revised cost. + +**D9. A reference's target set is resolved by the registry at evaluation, +not persisted.** Persisting `TargetSchemaNames` would change the canonical +member shape and every stored catalog's hash for such schemas. Resolving at +evaluation by assignability over registered leaves (`:1506`, `:1828` do +this today) costs nothing and is correct as of the registry in hand. The +catalog's `TargetSchemaName` stays what it is (`null` for an abstract +target) and is not read by the hop. + +The resolution needs a **`Type`**, and `CultDocumentMemberDescriptor` +carries only `TargetSchemaName`, a string that is `null` in exactly the +case that matters. So the public member view exposes the target `Type` +(held already on `PersistedMember.MemberType` / the attribute's +`TargetType`, `CultCache.cs:1069-1070`), and the leaf set is +`AllDescriptors` (public, `:254-255`) filtered by +`target.IsAssignableFrom(d.DocumentType)` — the same predicate +`GetByIndex` uses at `:1506` and store routing at `:1828`. It must be +computed **against the registry at evaluation, not at descriptor build**: +`BuildDescriptor` runs per type as it is registered, and a leaf registered +later would be missing from a set frozen early. A cached set keyed by +target type is legitimate only if it is invalidated by `Refresh()` +(`:257-265`) and by `RegisterDescriptor` (`:381-410`); simplest is not to +cache it. + +**D10. The duplicate-alias refusal is renamed, not invented.** The Aetheria +hiding case turned out to be refused already (section 1: `HiddenMemberMessage` +at `CultCache.cs:937-943`), and two differently-named members sharing one +alias already throw — from `ToDictionary` at `:462-468`, as an +`ArgumentException` that names neither the schema, nor the members, nor the +alias. D10 is to replace that with a rejection in `DiscoverMembers`' +`rejections` list, in the shape of its neighbours: one grouping over +`IndexAlias` beside the two at `:931-943`, one message beside +`DuplicateSlotMessage` / `HiddenMemberMessage`. The behaviour barely +changes; what changes is that the refusal can be read. Nothing in the +selection depends on it — a colliding catalog never registers either way — +so it is in this cut only because the cut is already in this file and the +message is four lines. + +**D11. Many-references are enumerated by the cache, with payloads.** On each +reference member, `ReferencesOf(document) -> IEnumerable<(CultRecordKey target, object? payload)>`: +a `CultRecordRef` yields one; an `IEnumerable>` yields +each with no payload; an `IDictionary, V>` yields each key +with its value. Anything else declared `many` is refused at registration +(`unsupported_reference_shape`), so a declaration the cache cannot walk is +never silently walked as nothing. + +The enumeration reads `ICultRecordRef.Key` (`CultDocumentContracts.cs:92-95`), +which every `CultRecordRef` implements (`:97-99`), so it needs no +generic dispatch. Two shape rules fall out of `ResolveReferenceTarget` +(`CultCache.cs:1100-1113`) and belong in the same refusal: a member +declared `many` whose element type is not a `CultRecordRef<>` and whose +attribute names no `TargetType` has **no resolvable target at all** +(`IsReference` is true only because the attribute exists, and +`TargetSchemaName` is `null` for a reason that is not inheritance); and a +`many` member that is neither `IEnumerable>` nor +`IDictionary, V>` cannot be walked. Both are +`unsupported_reference_shape` at registration. A `many: false` member whose +type is not `CultRecordRef` is the same fault and takes the same +refusal. + **Agreement with our own abandoned prior art** (`ThreadEpiphanyGraphQuery`, Epiphany `5c62a650`, deleted with its host `5f6f2441`): direction and edge kind as data, schema derived — agreed; differs as before (predicate not @@ -320,9 +584,11 @@ Mongo/ES magic-key documents; every string-parsed evaluator surveyed. | `CultMesh.cs:2701-2719, 2838-2858, 2875-2915` | ~20 net | selector construction for peer snapshots and body subscriptions | | `CultMeshSnapshots.cs:139-201, 434-516, 611-623` overlay logic | ~40 net | overlaying caller `schemaIds`/`recordKeys` onto defaults, four times | | **Mesh total** | **≈ 365 gross, ≈ 331 net** | see section 11 for what does not go | +| `packages/cultnet-rs/src/snapshot_query.rs:100-130` | ~31 | the fifth selector engine: the `requested_schemas` / `requested_keys` sets and the two `is_some_and` filters inside `serve_read_only_raw_snapshot` become one `select` over a lowered v0 selection. The duplicate-identity and unregistered-schema refusals (`:90-110`) and the whole `CultNetReadOnlySnapshotPolicy` (`:132-157`, authorization) stay | +| `src/GameCult.Caching/CultCache.cs` | **0** | the cache deletes nothing. The string `Getter`/`GetterNullable` pair (`:1081-1082`) stays: it is what `any_of` compares and what the index maps are built from (`:1858`). The numeric accessor is beside it, not instead of it | | `tests/GameCult.Networking.Tests/NetworkingTests.cs`: `CultNetDatabaseServer_Creates_Filtered_SnapshotResponse` `:3124`, `…_ForCompatibleSchemaAlias` `:3158`, `CultNetDatabaseServer_Creates_Filtered_SubscriptionChange` `:5547`, `…_ForSchemaAlias` `:5601`, `DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding` `:2256`, `…ByRequestedSchemaAlias` `:2310`, and the `projectRecord` lambda in `…AuthorizesRequestAndFiltersSnapshotAndLiveRecords` `:1601-1605` | ~250 | rewritten over v1 selections against the one evaluator (section 10); the rules they pin survive | -**Not deleted in this cut, by ruling (Q-F):** the v0 message classes and +**Not deleted in this cut, by ruling (R-1), and scheduled for deletion on FU-v0's trigger:** the v0 message classes and their three schema files, the v0 arms in `CultNetSchemaMessageSerialization`, and the v0 handling in the two servers — kept as a **lowering**: a v0 request lowers to `Selection { schemas: schemaIds, keys: recordKeys, projection: document }` @@ -351,14 +617,17 @@ They die with the last runtime follow-up. | Add | Owner | Live consumer | Protected invariant | What it replaces | |---|---|---|---|---| +| `src/GameCult.Caching/CultCache.cs`: a **public read surface over declared members** — `CultDocumentDescriptor.DeclaredMembers` (a public view: `MemberName`, `Slot`, `IndexAlias`, `IsName`, `IsReference`, `IsMany`, `IsNumeric`, `TargetType`), `TryGetIndexValue(document, alias, out string?)`, `TryGetIndexNumber(document, alias, out double)`, `ReferencesOf(document, member)` (D11), `ResolveTargetLeaves(targetType)` over `AllDescriptors` (D9) | `GameCult.Caching` | the evaluator; `CultNetDocumentRegistry`; later any runtime-side reader | **the cache owns addressing and its values**; a reader outside the assembly reads declarations and values through one surface instead of reflecting over documents | the reflection loop at `CultNetDocumentRegistry.cs:325-342`, and the absence that forced it | +| `CultCache.cs` registration refusals: one alias-collision rejection beside `:931-943` (D10), one `unsupported_reference_shape` rejection for a `many`/reference member the cache cannot walk (D11) | `GameCult.Caching` | every registering catalog | a declaration the cache cannot read is refused when it is declared, not answered as nothing when it is read | an `ArgumentException` from `ToDictionary` `:462-468` that names nothing; a silent unwalkable reference | | `contracts/cultnet/cultnet.selection.schema.json` (`$id`, referenced by `$ref`), `cultnet.snapshot-request.v1.schema.json`, `cultnet.database-subscribe.v1.schema.json`, `cultnet.snapshot-response-raw.v1.schema.json`; registry entries in `CultNetSchemaRegistry.cs` | CultLib contracts | C#, Rust; TS/Py/Kotlin follow-ups | one published shape of a selection, hand-written like its siblings, pinned by vectors in both runtimes | `schemaIds`/`recordKeys`/`includeSnapshot` as the wire's only selection | -| `src/GameCult.Networking/CultNetSelection.cs`: `CultNetSelection`, `CultNetFieldPredicate`, `CultNetCitation`, `CultNetIncoming`, `CultNetSelectionProjection`, `CultNetRecordRef`, `CultNetRawDocumentHeader`, `CultNetSelectionPage`; `Validate(IReadOnlyList)` (declared aliases and roles, non-empty lists) | `GameCult.Networking` | the three message classes, the evaluator, Mesh | a selection is typed data with one validation | `_projectRecord`, the allowlist pair | +| `src/GameCult.Networking/CultNetSelection.cs`: `CultNetSelection`, `CultNetFieldPredicate`, `CultNetCitation`, `CultNetIncoming`, `CultNetSelectionProjection`, `CultNetRecordRef`, `CultNetRawDocumentHeader`, `CultNetSelectionPage`, **`CultNetEdge`** (`From`, `Role`, `To`, `PayloadEncoding?`, `Payload?`), **`CultNetSelectionOperator`** (`AnyOf`, `Lt`, `Le`, `Ge`, `Gt`, serialised as the `op` string, never as a union); `Validate(IReadOnlyList)` — declared aliases and roles, non-empty lists, exactly one of `values`/`number` per `op`, and every reachable leaf declaring a compared alias declares it numeric | `GameCult.Networking` | the three message classes, the evaluator, Mesh | a selection is typed data with one validation | `_projectRecord`, the allowlist pair | | `src/GameCult.Networking/CultNetSelectionEvaluator.cs`: `Select(cache, descriptors, selection, ordinals, asOf)` → ordered, hopped, cursored, projected page; `Matches(selection, descriptor, key, document)` for a single change; the cursor mint/parse with `cursor_stale` / `cursor_invalid` | `GameCult.Networking` | `CultNetDocumentRegistry` (snapshot), both servers (changes), `Reconcile` | one evaluator; order, snapshot and hop have one owner | the three engines (section 4) | | `CultNetDatabase.LastWriteSequence(schemaId, key)` (a per-key ordinal kept on apply, ~20 lines) | `CultNetDatabase` | the evaluator's order and cursor | a row's ordinal is its last commit's sequence | `storedAt` as the only time | | v1 message classes beside the v0 ones; the v0→`Selection` lowering (one function) | `GameCult.Networking` | both servers | v0 has no engine of its own | — | -| `packages/cultnet-rs/src/selection.rs`: the types (`Serialize`, `Deserialize`, no `untagged`), `Row` trait (`id`, `ordinal`, `values(index)`, `references()`), `RowSet` trait (`declared_indexes`, `declared_roles`), `Malformed`, `select`, cursor mint/parse, `LIMIT_MAX` | `cultnet-rs` | Huginn (section 12), later Rust services | the same evaluator semantics as the reference, pinned by vectors both ways | Huginn's own `select.rs` from the earlier design | +| `packages/cultnet-rs/src/selection.rs`: the types (`Serialize`, `Deserialize`, no `untagged`; `SelectionOperator` an **externally tagged / string** enum, `Edge { from, role, to, payload_encoding, payload }`), `Row` trait (`id`, `ordinal`, `values(index) -> &[String]`, **`number(index) -> Option`**, **`references() -> Vec<(role, RecordRef, Option>)>`**), `RowSet` trait (`declared_indexes`, `declared_roles`, **`is_numeric(schema, index)`**, **`target_leaves(role)`**), `Malformed`, `select`, cursor mint/parse, `LIMIT_MAX` | `cultnet-rs` | Huginn (section 12), later Rust services | the same evaluator semantics as the reference, pinned by vectors both ways | Huginn's own `select.rs` from the earlier design | +| `snapshot_query.rs`: `serve_read_only_raw_snapshot` calls `select` over a lowered v0 selection; the policy check stays where it is | `cultnet-rs` | its existing callers and tests | the fifth engine has no rule of its own | the filter at `:112-125` | | `contracts.rs`: `SnapshotRequestV1`, `DatabaseSubscribeV1`, `SnapshotResponseRawV1` variants | `cultnet-rs` | Huginn's daemon | the wire spelling equals the reference's | — | -| `contracts/cultnet/selection-vectors.cs-written.json` and `selection-vectors.rs-written.json`: a fixture row set (`cultnet.interop-selection-row` document type with declared indexes `kind`, `severity`, `tags[]` and references `parent`, `related[]`), twenty selections, and each selection's expected page as ids + `matched` + `next` presence | tests, both runtimes | `NetworkingTests`, `cultnet-rs/tests/selection.rs` | **vectors written by the reference decode and evaluate identically in Rust, and vectors written by Rust decode and evaluate identically in the reference** | a within-runtime round trip, which pins nothing about parity | +| `contracts/cultnet/selection-vectors.cs-written.json` and `selection-vectors.rs-written.json`: a fixture row set (`cultnet.interop-selection-row` document type; declared indexes `kind`, `severity`, `tags[]` and **`mass` (numeric, declared on the abstract middle type, not on the leaves)**; references `parent`, `related[]` and **`components` (a dictionary keyed by reference with a float payload)** and **`design` (declared target = the abstract middle, resolving to two leaves)**; **two leaf schemas under one abstract middle**, so inheritance, the abstract target, the dictionary edge and the comparison are all exercised by the fixture rather than argued about in prose), **twenty-eight** selections, and each selection's expected page as ordered ids + `matched` + `next` presence + **the expected `edges` with their payload bytes**. The fixture must also carry, because tests depend on it and a loosening that cannot fail is the fixture's defect: a row whose `mass` **equals** a compared number exactly (S16), **three** rows sharing one index value (S23), one edge pointing outside its declared target (S18), two dictionary entries with **different** payload bytes (S19), and a nullable numeric member and a `decimal` one (S22). **Expected to grow** (R-4): it is chosen for coverage now, not pinned as final, and adding rows or selections to it is a normal change | tests, both runtimes | `NetworkingTests`, `cultnet-rs/tests/selection.rs` | **vectors written by the reference decode and evaluate identically in Rust, and vectors written by Rust decode and evaluate identically in the reference** | a within-runtime round trip, which pins nothing about parity | | `contracts/cultnet/interop/cultnet.interop-selection-row.schema.json` | contracts | the vectors | — | — | | `docs/cultnet-selection-cut.md` (this file), a paragraph in `contracts/cultnet/cultnet-distributed-database.md` under "Implemented", and the runtime table row in `docs/runtime-parity-scope.md` | docs | — | describe the live system | — | @@ -367,6 +636,12 @@ No dependency in either runtime (`schemars` is **not** added to Huginn's derived schema for its own request embeds `Selection` through a hand-maintained `JsonSchema` impl in Huginn, not through the substrate — see section 12). No new package, binary, transport, or store format. +**`packages/cultcache-rs` is untouched**: the C# cache change buys the +evaluator a read surface it does not have, and in Rust the row owner +already supplies exactly that through `Row`/`RowSet`. The asymmetry is +real and intended — the reference reads documents the cache owns, Rust +reads rows the consumer owns — and it is the reason the parity obligation +is on the *vectors* and not on the two implementations' shapes. ## 7. Per-file changes (code that exists, anchored; new code by name and rule) @@ -401,6 +676,43 @@ otherwise `Matches` on the changed row; `:294-322` evaluate + authorize + project; `:424-515` deleted. Module doc `:14-18` rewritten: projection is a value on the request. +**`src/GameCult.Caching/CultCache.cs`** (the only cache file this cut +touches): + +- `:112-150` `CultDocumentDescriptor`'s constructor and fields: `Members` + `:149` and `IndexAccessors` `:148` stay `internal`; a **public** + `DeclaredMembers` view and the three value accessors are added beside + them. Do not widen `Members` itself — `CultDocumentMemberDescriptor` is a + mutable internal DTO whose shape `ToCatalogEntry` `:151-176` depends on, + and publishing a mutable descriptor list makes the catalog's shape a + public contract by accident. The public view is a readonly struct or + sealed record built from it. +- `:445-490` `BuildDescriptor`: the alias `ToDictionary` at `:462-468` is + the site of the D10 refusal's *symptom*; the refusal itself belongs in + `DiscoverMembers` so it lands in `rejections` with its neighbours. +- `:838-946` `DiscoverMembers`: two new groupings beside the two at + `:931-943` — one over `IndexAlias` (D10), one over reference shape + (D11) — and two message helpers beside `DuplicateSlotMessage` / + `HiddenMemberMessage`. Nothing in the existing walk changes. +- `:1050-1114` `PersistedMember`: `Getter`/`GetterNullable` `:1081-1082` + untouched; `NumberOf` (non-null exactly for the closed CLR numeric set, + `Nullable` unwrapped) and `ReferencesOf` added beside them; + `MemberType` `:1052` and the attribute's `TargetType` surface through the + public view as the target `Type`. +- `:254-255` `AllDescriptors` is the input to `ResolveTargetLeaves`; the + set is computed per call, not cached, because `Refresh()` `:257-265` and + `RegisterDescriptor` `:381-410` can both add a leaf after any given + descriptor was built. +- `CultSchemaTypeNames.FromType` (`CultSchemaTypeNames.cs:9-31`) is + `internal` and generates the `TypeName` strings; the `IsNumeric` string + set is generated from the CLR set through it, never written by hand + (S22). + +**`packages/cultnet-rs/src/snapshot_query.rs`**: `:100-130` the two +allowlist sets and their filters become one `select` over the lowered +selection; `:64-99` and `:132-157` untouched; `:158-285` +`CultNetRawSnapshotQuery` untouched in this cut and named in Q-M. + **`CultNetSelection.cs`**, **`CultNetSelectionEvaluator.cs`** (new): as section 6. Rules that must die under their own mutation are in section 10. @@ -431,10 +743,16 @@ and the C#-written pattern the QUIC campaign established (`docs/typescript-quic- `CultNetSelectionEvaluator` owns matching, the hop, the order, the snapshot and the cursor; `CultNetSchemaAliasMatching` owns schema identity; `CultNetDatabase` owns a row's ordinal. **The cache owns - addressing**: which names are indexes and which members are references, - and what a value of each is (a string), through `CultIndexAttribute` and - `CultReferenceAttribute` read from `CultDocumentDescriptor`. The - vocabulary reads those declarations and adds none. `packages/cultnet-rs` + addressing and every value of it**: which names are indexes, which + members are references, whether a member is numeric, what leaves a + reference may target, what a row's value at an alias is, and what edges + and payloads a row carries — through `CultIndexAttribute` and + `CultReferenceAttribute`, the persisted `TypeName`, and the public read + surface of section 6. The vocabulary reads those declarations and adds + none: no new attribute, no persisted field, no schema-hash movement, and + no opinion in the evaluator about what a CLR type means. **The evaluator + never reflects over a document**; if it needs something the cache does + not expose, the cache gains a method, not the evaluator a `GetProperty`. `packages/cultnet-rs` is the Rust spelling of the same owner, at wire parity, with the row owner supplying what the cache supplies in C# (`Row`, `RowSet`). - **Inputs.** The cache's rows and descriptors, the shard log's sequences, @@ -453,17 +771,33 @@ and the C#-written pattern the QUIC campaign established (`docs/typescript-quic- `IsSameCultDocumentSchema`, the Mesh `InferSchemaName` and the registry's `MatchesRequestedSchema` are no longer owners of schema identity. - **Forbidden writers.** Neither server may match a record by a rule of its - own; Mesh may not re-filter a page the server answered; nothing may mint a + own; **`serve_read_only_raw_snapshot` may not filter by schema or key** + (its policy is authorization and stays; its allowlist filter goes); Mesh + may not re-filter a page the server answered — a caller *may* verify a + page against expectations it declared before the request + (`CultNetRawSnapshotQuery::accept_response`, which errors rather than + quietly dropping, and so cannot hide a server that answered wrongly), but + no caller may narrow a page and call the narrowed result the answer; + nothing may mint a cursor a caller could construct; nothing may answer a page over a different `asOf` than its cursor's; the evaluator may not read a payload - by path, only declared indexes and references; no type on the wire is - `untagged`; the Rust `select` may not diverge from the reference on any - vector. + by path, only declared indexes and references, **and may not reflect over + a document at all**; **the evaluator may not read the cache's `_indexes` + map or call `GetByIndex`** — those answer a unique-index lookup, not a + predicate (section 1, S23); no rule may consult an edge's payload while + traversing (section 13); nothing outside `GameCult.Caching` may decide + whether a member is numeric or what leaves a target resolves to; no type + on the wire is `untagged`; the Rust `select` may not diverge from the + reference on any vector. - **Shared paths.** The evaluator under snapshot, change, and reconcile; `CultNetSchemaAliasMatching` under every schema match in both assemblies; `ToRawRecord` under snapshot and change; the v0 lowering under both servers. -- **Deletion line.** Commit 1: the shape, the evaluator and the v1 +- **Deletion line.** Commit 0: the cache's public read surface and its two + registration refusals, with S17-S22 green and `GameCult.Networking` + untouched — the cache change is independently true and lands first, so + that if the rest of the cut is cut, the substrate is not left with half a + surface. Commit 1: the shape, the evaluator and the v1 messages beside v0, with the three engines rewired and deleted and the seven tests rewritten — green. Commit 2: `_projectRecord` and the Mesh collapse. Commit 3: Rust. Commit 4: vectors both ways and entries. @@ -471,7 +805,7 @@ and the C#-written pattern the QUIC campaign established (`docs/typescript-quic- - **Where the line to CultMesh falls.** Mesh is a client that builds selections and reads pages; it evaluates nothing. Where Mesh watches a cache locally by name or index (audit sites #2, #4-9, #16-21), it keeps - its own loops until `CultNetDatabase.Watch(Selection)` exists (Q-H). + its own loops until `CultNetDatabase.Watch(Selection)` exists, which is Cut 2 (section 18). ## 9. Parity: what this cut covers, and what the other runtimes do @@ -491,8 +825,11 @@ directions (section 10), not by a round trip inside one runtime. The one runtime that mis-answers today is none: nothing sends v1 to a runtime that does not speak it until its follow-up lands, and a stray v1 is -refused (TS, Kotlin, C#) or dropped (Python). Python's drop is a silence, -not a refusal, and is recorded as the first line of its follow-up. +refused (TS, Kotlin, C#) or dropped (Python). **Python's drop is fixed in +this cut, not in its follow-up** (R-2): after this cut the table's Python +row reads "refuses with `unsupported_schema_version`", and every runtime on +the wire answers an unknown version with something a caller can read. The +build budget gains one Python test run for it. **Follow-ups, recorded with triggers:** @@ -500,9 +837,12 @@ not a refusal, and is recorded as the first line of its follow-up. `Selection` type; `cultmesh-ts` builders. Trigger: the first TypeScript caller that needs a filtered snapshot or subscription (the browser Eve runtime, when it reads pipeline state). Until then TS refuses v1 loudly. -- **FU-Py.** `cultnet-py` and `cultmesh-py`: reply `cultnet.error.v0 - { code: "unsupported_schema_version" }` for an unknown version (five - lines, the honest minimum, may land before the rest); then the v1 +- **FU-Py.** Its first line is **not** a follow-up: by R-2, `cultnet-py` + and `cultmesh-py` reply `cultnet.error.v0 + { code: "unsupported_schema_version" }` for an unknown version **in this + cut** (five lines at `interop_peer.py:360-387` and the delegating path at + `cultmesh_py/server.py:341`, with one test per site that a v1 message + produces a refusal and not an empty list). The rest of FU-Py is the v1 contracts, and the **three** copies of the selector match (`interop_peer.py:110-125`, `cultmesh_py/server.py:449-470`, `cultmesh_py/node.py:1322-1345`) collapsed into one evaluator. Trigger: @@ -513,9 +853,12 @@ not a refusal, and is recorded as the first line of its follow-up. - **FU-v0.** Retire v0's classes, schemas and lowering once FU-TS, FU-Py, FU-Kt have landed and the interop lanes send v1. Trigger: the last of the three. -- **FU-Watch.** `CultNetDatabase.Watch(Selection)` in the reference and the - Mesh collapse it enables (section 11, ≈ −240). Trigger: the first - consumer that watches a question locally, or the operator's call (Q-H). +- **Not a follow-up any more:** `CultNetDatabase.Watch(Selection)` is + **Cut 2**, mapped in section 18 with its own deletes, authority map, + verification and numbers. It lands after this cut. The audit's ≈ 240 + local-watch lines are counted there, and the real figure turned out + larger than the audit's estimate (≈ −378 in Mesh, section 18.3), because + the audit did not count the nine `Collection*` overloads as selection. `docs/runtime-parity-scope.md`'s table gains, per runtime, "typed selection v1: C# and Rust claimed; TS, Python, Kotlin not claimed (refuse / refuse / @@ -523,8 +866,11 @@ drop)". The claim is written where the parity claims live, not here alone. ## 10. Verification -**Builds.** `dotnet build` of `GameCult.Networking`, `GameCult.Mesh` and -their test projects; `cargo test -p cultnet-rs`; the TypeScript workspace +**Builds.** `dotnet build` of `GameCult.Caching`, `GameCult.Networking`, +`GameCult.Mesh` and their test projects — the cache is now in the build +budget, and it is the assembly the rest of CultLib depends on, so +`dotnet test tests/GameCult.Caching.Tests` is the gate on commit 0 and runs +on every later commit; `cargo test -p cultnet-rs`; the TypeScript workspace untouched but `npm run test --workspace packages/cultnet-ts` run once to prove the interop lane still passes on v0. Host = target = workstation for C# and Rust; no native code. Nothing cleaned. @@ -549,8 +895,31 @@ a loosening where one exists.** | S13 (Rust) | `selection_is_never_untagged` (grep over `packages/cultnet-rs/src` for `untagged`, empty) and `selection_round_trips_named_and_positional` (both encodings) | the encoding rule | an `untagged` shape added | — | | S14 (Rust) | `select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses` | D7, no CultCache knowledge in the evaluator | as S1-S8 | as S1-S8 | | S15 | `Mesh_ReadsASnapshotPageWithoutReFilteringIt` (Mesh tests) | the client trusts the page | the four-tier fallback restored | schema re-filter restored | +| S16 | `Evaluator_ComparesNumbersAtTheBoundaryForEachOfTheFourOperators` | D8's operators; one operator per predicate; two predicates make a range | the comparison ignored (every row matches) | `le` evaluated as `lt` (and `ge` as `gt`) — **kills only if the fixture carries a row whose `mass` equals the compared number exactly**; the fixture must, and if it does not, that is the fixture's defect, not licence to mutate something easier | +| S17 | `Evaluator_AppliesAnInheritedAliasToEveryReachableLeaf` | the inheritance rule; the cache's `BaseType` walk reaching the evaluator | the walk stopped at the leaf (`CultCache.cs:847`) — kills S17 and S12 together | "reachable" narrowed from *every* leaf matching `schemas` to the first descriptor consulted, so a two-leaf selection on an alias declared only on the abstract middle answers with one leaf's rows and no refusal — **requires the fixture to declare `mass` only on the middle and to carry rows of both leaves** | +| S18 | `Evaluator_ResolvesAnAbstractTargetToItsLeavesAndRefusesAnEdgeOutsideThem` | D9; the typed `reference_outside_target` | the target set taken as `{TargetSchemaName}`, which is `null` for an abstract target, so the hop follows nothing | the out-of-target edge **skipped** instead of refused — the option section 3 rejected, and the mutant that proves the rejection is load-bearing; **requires the fixture to carry one edge pointing at a schema outside the declared target** | +| S19 | `Cache_EnumeratesADictionaryReferenceAsEdgesCarryingItsValues` (cache tests) | D11 | `ReferencesOf` yields nothing for a dictionary — today's behaviour, restored | the keys enumerated and the **values dropped** (`payload` null), which is the shape a careless `IEnumerable` implementation would have; **requires two dictionary entries with different payload bytes**, or the assertion passes on shape alone | +| S20 | `Evaluator_ReturnsTraversedEdgesOnlyForHoppingSelections_AndNoPayloadUnderHeader` | the `edges` rule | `edges` never emitted | payload carried under `header`; and, separately, `edges` emitted for a selection with neither `cites` nor `cited` — a widening rather than a loosening, and killed by the same test | +| S21 | `Cache_RefusesADuplicateIndexAliasAndAnUnwalkableReferenceByName` (cache tests) | D10, D11's shape rule | the rejections removed — **kills only if the test asserts the message names the schema, the two members and the alias**, since without them `ToDictionary` still throws an `ArgumentException` and a shallow `Assert.Throws` passes either way. That is the entire content of D10 | the alias grouping keyed by `MemberName` instead of `IndexAlias`, which refuses nothing the existing `HiddenMemberMessage` (`:937-943`) did not already refuse | +| S23 | `Evaluator_MatchesEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the evaluator reads values per row, never `_indexes` / `GetByIndex` | the predicate answered from `GetByIndex`, so an `any_of` over a value **three** fixture rows share answers with one | the per-row read kept but short-circuited after the first match when `matched` is all the caller asked for — a plausible optimisation that makes `matched` a boolean in disguise; **requires three rows sharing one index value**, which the fixture must carry | +| S22 | `Cache_DerivesIsNumericFromTheClrSetAndTheTypeNameSetAgrees` (cache tests) | D8's two authorities agreeing | `IsNumeric` true for every member, so a comparison on a string alias validates and compares `ToString()` lexically | the `TypeName`-string set hand-written and missing `System.Nullable` or `System.Decimal`, so a member is numeric against the live descriptor and non-numeric against the same schema read back from a persisted catalog — **requires the fixture to declare a nullable numeric member and a `decimal` one** | -**Negative greps:** `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` +**On the parity vectors and the four findings.** S12 is not a new test but +a widened one: the fixture of section 6 carries the abstract middle, the +two leaves, the inherited numeric alias, the dictionary reference with +float payloads and the abstract-target reference, and the vector set +includes at least one selection per finding — a comparison on the +inherited alias, a hop into the abstract target, a hop over the dictionary +returning payloads, and the same hop under `header`. Both directions: +**vectors written by the reference are evaluated in Rust, and vectors +written by Rust are evaluated by the reference**. A round trip inside one +runtime pins nothing and is not counted. The Rust side's fixture rows +implement `Row` over the same declared values, so the vectors test the +*semantics* both ways while each runtime keeps its own source of rows. + +**Negative greps:** `rg -n "GetProperty|GetField|GetCustomAttribute" src/GameCult.Networking` +empty (the evaluator reflects over nothing); +`rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty; `rg -n "InferSchemaName" src/GameCult.Mesh` empty and `src/GameCult.Networking` only in `CultNetSchemaAliasMatching` and the registry's payload-schema resolution; `rg -n "MatchesRequestedSchema|MatchesSchema\(" src` @@ -559,8 +928,10 @@ empty; `rg -n "_projectRecord|projectRecord" src` empty; allowlist pair assembled by hand); `rg -n "CloneSnapshot(Facade)?RequestOptions" src` at most one. **Mutations.** `tools`-style entries in `scripts/mutate-cultmesh.mjs`'s -format with two new targets, `networking` (killer `dotnet test -tests/GameCult.Networking.Tests`) and `rust` (killer `cargo test -p cultnet-rs`), +format with three new targets, `caching` (killer `dotnet test +tests/GameCult.Caching.Tests`, carrying S19, S21 and S22), `networking` +(killer `dotnet test tests/GameCult.Networking.Tests`) and `rust` (killer +`cargo test -p cultnet-rs`), one entry per rule above with revert and loosening; the runner's byte-rewrite/verified-restore discipline applies unchanged. The runner is the QUIC campaign's file and is dirty in the tree; **this campaign adds its @@ -590,7 +961,7 @@ collapses is what has a replacement in this cut. | Peer snapshot / body subscribe selector construction | #10, #14, #15 | 81 | ~61 | −20 | | Snapshot facade overlay logic | #64, #66-70, #72 | ~110 | ~70 | −40 | | **Collapses in this cut** | | | | **≈ −326** | -| Local cache/database watches by name and index and their predicates | #2, #4-9, #16-21 | ≈ 240 | ≈ 240 | 0 now; **≈ −200 under FU-Watch** | +| Local cache/database watches by name and index and their predicates | #2, #4-9, #16-21 | ≈ 240 | ≈ 240 | 0 in this cut; **≈ −378 in Cut 2** (section 18.3 — larger than this audit estimated, because the nine `Collection*` overloads at `CultMesh.cs:2278-2480` were not classified as selection and are) | | Handle catalogs by type/schema, surface catalog, operation payload | #41-49 | 233 | 233 | 0 — these index *handles*, not records | | Verse discovery selection and reshaping | #36, #38 (part), #39, #40, #83, #85 | ~110 | ~110 | 0 — over Verse observations, not documents | | Single-file catalog membership | #58-62 | 81 | 81 | 0 — file-format membership | @@ -598,8 +969,9 @@ collapses is what has a replacement in this cut. | (B) sites | 24 sites | 429 | 429 | 0 by definition | **Plain statement.** `src/GameCult.Mesh` shrinks by about **326 lines of -21,720 (1.5%)** in this cut; a further ≈ 200 collapse only when -`CultNetDatabase.Watch(Selection)` exists (FU-Watch); about 590 of the +21,720 (1.5%)** in this cut, and a further **≈ 378 in Cut 2** — the two +together are about 3.2% of the assembly, and Cut 2 is where the audit's +expectation of a real reduction is met. About 590 of the audit's (A) lines stay because they select handles, Verse observations or file-catalog entries, not database records. The operator's expectation of a net reduction in CultMesh holds, modestly; it does not hold for CultLib as a @@ -633,8 +1005,12 @@ against a `cultnet-rs` dependency Huginn already has):** leaf, and the deletion of `open_items`/`history` with the relation table unchanged (`history(Subject)` = `kinds: [resolution], cites { target, role: subject }`; the open lists = five selections). -- **Dropped from the organ's wire:** `PipelineQuery`, `admitted_after/before` - (Q-G), `QUERY_LIMIT_MAX`. Huginn's `Query { instance, selection }` carries +- **Deleted from the organ's wire (R-3, ruled, not proposed):** + `PipelineQuery`; **`admitted_after` and `admitted_before`**, which existed + only to approximate an order and are replaced by `descending` plus the + cursor — they are a delete in the Huginn cut's own deletes-first table, + with their query-builder call sites, their schema fields and the tests + that pin them, not a deprecation; and `QUERY_LIMIT_MAX`. Huginn's `Query { instance, selection }` carries the substrate's type; its published request schema embeds `Selection` through a hand-maintained `JsonSchema` impl for the `cultnet-rs` types inside Huginn (the substrate publishes JSON, not `schemars`), pinned by the @@ -653,19 +1029,45 @@ paths, sort fields, counts beyond `matched`; a page not tied to `asOf`; selection across shards or minds; a cursor the caller constructs. Each for the reason the earlier specification gave, unchanged. +Named individually, because each has an obvious-looking one-line +implementation and a caller who will ask for it: + +| Not expressible | The obvious ask | Why it is the door | +|---|---|---| +| **A predicate on an edge's payload during traversal** — "components whose quantity is over 4", "demand profile entries weighted above 0.5" | one `if` inside the hop loop, over a payload the evaluator is already holding | **It is a join condition.** The moment a predicate can read a value that belongs to neither the row nor the selection, the evaluator is combining two sources under a condition, and everything that follows is a query planner: which side to drive from, whether to build the incoming-edge index before or after filtering, how `matched` counts a row whose only surviving edge was filtered, what the cursor means when the page is a join result, and whether the payload's type is comparable at all when its declared type differs per leaf. None of that is answerable in a bounded vocabulary. **The caller follows the edge, receives the payload in `edges`, and filters the result** — one extra step for the caller, an entire category of machinery this organ never grows | +| **A set of exact `(schema, key)` pairs** | `keys` already exists; let an entry name its schema | `schemas` × `keys` is a cross product and stays one. `CultNetRawSnapshotQuery` (section 1) shows the cost honestly: it over-requests and verifies. The fix is either a second list shape or a pair type on the wire, and both are the beginning of a term language. A caller who needs exact pairs asks once per schema, or accepts the cross product and verifies against what it declared. Recorded as Q-M, recommendation A: state the limit | +| **A predicate that depends on how many edges a row has** — "hulls with more than three components" | `cited` already counts to one | `exists: true/false` is a boolean, and a threshold is a range over a derived aggregate. Aggregation is a second evaluator with its own ordering and paging semantics | +| **`edges` without rows, or rows the hop excluded** | a `projection: "edges"` | the page's rows are the answer and the edges explain it; an edge-only page is a different question (a graph read) and would need its own order, cursor and bound | + ## 14. Subtraction ledger (estimate) | | Removed | Added | |---|---:|---:| -| `src/GameCult.Networking` | 368 | ~350 (`CultNetSelection` 140, evaluator + cursor 180, ordinals 20, v1 classes and lowering ~40 minus shared) | +| `src/GameCult.Caching` | 0 | **~150**: the public member view ~40, `TryGetIndexValue` / `TryGetIndexNumber` + the CLR numeric set ~35, `ReferencesOf` over the three shapes ~40, `ResolveTargetLeaves` ~10, two rejections with their messages ~20 | +| `src/GameCult.Networking` | 368 | ~400 (`CultNetSelection` + `CultNetEdge` + the operator 170, evaluator + cursor + the incoming-edge index 190, ordinals 20, v1 classes and lowering ~40 minus shared) | | `src/GameCult.Mesh` | ≈ 365 gross | ≈ 40 (one `WithSelection`, one exact read) | -| `packages/cultnet-rs/src` | 0 | ~390 (`selection.rs` 350, `contracts.rs` 40) | +| `packages/cultnet-rs/src` | ~31 (`snapshot_query.rs`'s filter) | ~430 (`selection.rs` 390 with edges and numbers, `contracts.rs` 40) | +| `packages/cultnet-py`, `cultmesh-py` | 0 | ~5 (the `unsupported_schema_version` reply, ruled into this cut) | | `contracts/cultnet` | 0 | 4 schema files (~200 lines JSON), 1 interop row schema, 2 vector files (generated) | -| tests, C# | ~250 rewritten | ~450 (S1-S12, S15, the vector writer) | -| tests, Rust | 0 | ~300 (S12-S14) | -| entries | 0 | ~30 entries | +| tests, C# | ~250 rewritten | ~600 (S1-S12, S15-S18, S20 in Networking; S19, S21, S22 in Caching; the vector writer) | +| tests, Rust | 0 | ~340 (S12-S14 with the edge and number vectors) | +| entries | 0 | ~38 entries across three targets | | docs | — | this map; two paragraphs | -| **net source outside tests** | | **≈ −18 in C#, +390 in Rust; ≈ +370 across the repo** | +| **net source outside tests** | | **≈ +32 in C# Networking, +150 in C# Caching, +399 in Rust; ≈ +585 across the repo** | + +**The estimate moved, and in the wrong direction.** The committed version of +this ledger said ≈ +370 and ≈ −18 in C#. Both were wrong, for one reason: +the map assumed the cache needed no change, and the cache needs about 150 +lines of new public surface before the evaluator can read a single declared +value from outside the assembly (section 1). The honest figure is ≈ +585. +What the repo buys for it is unchanged in kind and larger in size: one +evaluator where **five** stood (three in C#, one in Mesh's re-filter, one +in Rust), a read surface where the substrate's own consumers were +reflecting over documents, edges and their payloads where a declared +reference was unreadable, numeric comparison over a type the catalog has +persisted all along, and a Rust runtime with the capability at all. Nothing +here is a reason to shrink the cut; it is a reason not to claim the cut +shrinks the repo. The repo grows by about 370 source lines for: a typed selection with a derived-by-vector schema where two allowlists were the only language; @@ -679,12 +1081,59 @@ a read that could return the wrong key. ## 15. Build budget -C#: two assemblies and two test projects, debug, workstation. Rust: +C#: **three** assemblies and three test projects (`GameCult.Caching` joins +the budget; it is a dependency of the other two, so touching it rebuilds +them), debug, workstation. Rust: `cultnet-rs` lib + tests, warm target dir, +0 to +60 paths. Nothing cleaned. No native, no TypeScript build beyond one interop-lane run. ## 16. Operator questions, one batch +**Ruled 2026-09-17 and no longer questions.** Four of the batch are +settled; they are recorded here as rulings and carried in the sections +they govern. + +- **R-1 (was Q-F). v0 retires on a named trigger, not "eventually."** The + reference keeps answering v0 by lowering it into a `Selection`, with no + engine of its own. That lowering is a **scheduled deletion**: it is + deleted when FU-TS, FU-Py and FU-Kt have all landed and the interop lanes + send v1, and that is FU-v0's trigger in section 9. A shim with no + deletion date is a permanent shim; this one has a date written as a + condition, and the condition is checkable by grep over the three + runtimes' contract files. Section 4's "not deleted in this cut" row and + section 5's keeps both point here. +- **R-2 (was the second half of Q-F). Python's silent drop is fixed in this + cut.** Every other runtime refuses a message it does not understand; + Python returns `[]` (`interop_peer.py:387`) and the caller reads a hang + rather than a rejection. The five lines that reply `cultnet.error.v0 + { code: "unsupported_schema_version" }` land here, ahead of FU-Py's own + trigger, because a silence is a worse failure than a missing feature. + Carried in section 9's FU-Py entry and section 14's ledger. +- **R-3 (was Q-G). `descending` is the order; the time windows go.** + `admittedAfter` / `admittedBefore` existed to approximate an order the + vocabulary now states outright, and two filters standing in for an order + are worse than the order. They are deleted from Huginn's wire in the + consumer cut (section 12), not kept as an organ-side alias any-of. "The + latest N" is `descending, limit: N`. +- **R-4 (was Q-I). The fixture is chosen for coverage and expected to + grow.** The vector row type is the dedicated + `cultnet.interop-selection-row` family of section 6, shaped to *exercise* + the four hostile findings rather than describe them: shared fields on an + abstract middle with two leaves under it, a reference whose declared + target is that middle, and a many-reference carrying a payload. It is + **not pinned as final** — the fixture is expected to gain rows and + selections as the vocabulary meets more catalogs, and a change to it is a + normal change, not a contract break. What is pinned is the parity + obligation over whatever the fixture holds: both directions, ordered ids, + `matched`, `next` presence, and the edges with their payloads. +- **R-5 (was Q-H). Watching a selection is its own cut, mapped in section + 18 of this document**, after this one and depending on it. Nothing of it + is smuggled into the selection cut and nothing here is designed around it + speculatively; the seam already drawn — one selector type serving both a + query and a subscription — is all this cut owes it. + +**Still open:** + - **Q-A. Leaf `Short` titles for question and ruling** (Huginn/Epiphany). Unchanged: **A, not now** (recommended); B, a leaf follow-up before Cut 13; C, a text projection (Cut 11's question). Depends: the organ's header row @@ -703,36 +1152,47 @@ No native, no TypeScript build beyond one interop-lane run. cannot know a consumer's enums; the organ keeps the typed refusal at its door). Depends: everything in sections 2-10. - **Q-E.** Withdrawn: the seam question is answered by the placement ruling. -- **Q-F. v0's life.** **A, keep v0 answerable in the reference as a - lowering until FU-TS, FU-Py, FU-Kt land** (recommended; the interop CI - witness stays green, and v0 has no engine of its own); B, delete v0 now - and break the three runtimes' lanes; C, add the new fields to v0 — - refused by D3 because Python would mis-answer. Also under Q-F: whether - FU-Py's five-line "reply with `unsupported_schema_version`" lands ahead of - its trigger (recommended yes; it is a refusal where there is a silence). -- **Q-G. `descending` in place of the admission window.** **A** (recommended): - one fixed order, reversible; "the latest N" is `descending, limit: N`; - Huginn's `admitted_after/before` leave its wire. **B**: keep a window as - the one range, on the ordinal only (`sinceOrdinal`), which is a second way - to say what a cursor says. **C**: keep Huginn's date window as an - organ-side alias any-of over `admitted_on` dates — expressible today with - no substrate change, weaker than a range, honest. Depends: Huginn's - filter list; nothing in the substrate. -- **Q-H. `CultNetDatabase.Watch(Selection)` as a named follow-up cut.** - **A, yes, after the runtime follow-ups** (recommended): it is the - "watch a question" affordance the seam was drawn for and the ≈ 200 further - Mesh lines; B, fold into this cut — refused by the coordinator's own - constraint not to design the watch now; C, never — leaves the local - watch helpers as they are. -- **Q-I. Vectors: the fixture row type.** **A, a dedicated - `cultnet.interop-selection-row`** (recommended; declares indexes and - references purposely, both runtimes register it); B, reuse - `cultnet.interop-note` and add index/reference declarations to it — - touches the live interop lane's document. +- **Q-J. Number encoding on the wire.** **A, IEEE float64, with the 2^53 + limit stated in the schema and in this map** (recommended: one encoding, + no parse rule per runtime, and no catalog in hand declares an integer + member wide enough to lose). **B**, decimal strings — exact, but a second + value encoding beside `any_of`'s strings and a per-runtime parse rule, + and two ways to spell a number is how a vocabulary starts growing terms. + **C**, add `integer: int64` beside `number` now, sharing `op` — additive + and untagged-free, and can be added later without breaking A, which is + why adding it now is premature. Depends: the schema files and both + runtimes' predicate types. +- **Q-K. Family names for abstract bases on the wire.** **A, not now** + (recommended): an abstract type has no `[CultDocument]`, no schema name + and no catalog entry, so there is nothing to name; a caller who wants + "every crafted item" enumerates the leaves from the schema catalog it + already reads. **B**, a `[CultDocumentFamily]` declaration plus a catalog + concept plus a matching rule in every runtime, in a later cut. Note this + is only about the `schemas` list: the *hop* already resolves an abstract + target to its leaves (D9), so the hostile example works under A. +- **Q-L. Refusing an out-of-target reference at write time.** **A, + follow-up (FU-Ref)** (recommended): this cut refuses it at read + (`reference_outside_target`, S18), which is where the corruption is + noticed; refusing at write touches the cache's put path and every + runtime's apply path and is a change of a different size. **B**, in this + cut. Under A, the read-side refusal is the only thing standing between a + corrupt edge and a wrong answer, which is why it refuses instead of + skipping. +- **Q-M. The `(schema, key)` cross product.** **A, state the limit** + (recommended): `schemas` × `keys` stays a cross product; a caller needing + exact pairs asks once per schema or verifies what it declared, as + `CultNetRawSnapshotQuery` already does. **B**, allow a `refs: + RecordRef[]` list beside `keys` — expressible without a term language and + genuinely useful, at the cost of a third addressing list and a rule for + how three lists combine. **C**, let a `keys` entry carry a schema — a + path language in miniature, refused. Depends: section 13's table and + `snapshot_query.rs`'s client. What depends on each: S1-S8 on Q-D A; S5 on the shard-log assumption -(section 1) and on Q-B for Huginn; S9 on D6; S10 on Q-F A; S12 on Q-I; -the Huginn cut on Q-B, Q-C, Q-G; FU-Watch on Q-H. +(section 1) and on Q-B for Huginn; S9 on D6; S16 and the schema files on +Q-J; S18 on Q-L A; the Huginn cut on Q-B and Q-C. Settled by R-1 to R-5: +S10, S12, the fixture, FU-v0's trigger, FU-Py's five lines, Huginn's +window deletion, and the watch cut's existence. ## 17. What was probed, read, and not settled @@ -749,3 +1209,244 @@ landing); whether MessagePack-CSharp needs a union attribute for the page's `headers | documents` (it does not if both are optional arrays with exactly one present, which is the spelling chosen so no union is needed — Hands confirms the schema says "exactly one" with `oneOf` over `required`). + +**Read on the second pass, and what it changed.** Each of these was read at +the source, and three of them contradicted something this map or its +predecessor's notes asserted: + +| Read | Held | Changed | +|---|---|---| +| `CultCache.cs:474`, `:151-176`, `:598-629`, `CultSchemaTypeNames.cs:9-31` | **Yes.** A member's type name is derived from the CLR type, persisted in the catalog entry and compared inside the canonical shape. Numeric addressing needs no new declaration, no hash movement and no migration — the load-bearing claim of this whole widening | — | +| `CultCache.cs:112-150` | — | **No.** `Members` and `IndexAccessors` are `internal` with no friend assembly, so the evaluator cannot read a declared value from outside `GameCult.Caching` at all. "No cache change is needed for addressing" was false; the cache add is ~150 lines, not ~60, and the ledger moved from ≈ +370 to ≈ +585 | +| `CultCache.cs:931-943`, `:462-468` | — | **No.** A hiding member is already refused (`HiddenMemberMessage`), and a duplicate alias already throws from `ToDictionary`. D10 shrank from "a missing refusal" to "a refusal that names nothing", and its test only means anything if it asserts the message | +| `packages/cultnet-rs/src/snapshot_query.rs` | — | **No.** Rust has a fifth selector engine (`serve_read_only_raw_snapshot:112-125`) that "Rust: none" concealed, and its client (`:195-217`) shows the cross-product limit that v1 does not fix (Q-M) | +| `CultDocumentContracts.cs:92-99`, `CultCache.cs:1100-1113`, `:1495-1509`, `:1818-1838`, `:254-265` | **Yes.** `ICultRecordRef` is the enumerable handle D11 needs; assignability is already how the cache resolves a base type to concrete documents (D9); `AllDescriptors` is public | — | + +**Still not settled, and who settles it:** + +- Whether every committed write takes a shard-log sequence (unchanged; + Hands, at the append site). +- Whether `CultDocumentDescriptor`'s public member view can be a readonly + struct without forcing an allocation per row per predicate in the + evaluator's inner loop. Nothing here depends on the answer being fast, + but a `Select`-per-row over a fresh list would be a silly cost to build + in; Hands measures if a fixture of a few thousand rows is slow, and not + before. +- The exact split of mutation entries against the QUIC campaign's runner + (unchanged; Self at landing). +- Whether `serve_read_only_raw_snapshot`'s existing tests survive the + lowering unchanged; they should, since the behaviour is identical, but + they were not read line by line. +- Q-J, Q-K, Q-L, Q-M: the operator. + +**Not probed, and deliberately:** nothing was built or run against CultLib. +The tree is hosting a live QUIC campaign, and no claim in this map needs a +build to be true — every one of them is a source read, and the three that +were wrong were wrong because the first pass did not read far enough, not +because a build would have caught them. + +--- + +# Cut 2: watching a selection + +Ruled 2026-09-17 (R-5): the operator wants this and wants it soon, so it is +mapped here rather than left as a follow-up trigger. It is **its own cut**, +landing after Cut 1 and depending on it. Nothing below is smuggled into Cut +1, and Cut 1 is not shaped around it: the seam Cut 1 draws — one selector +type serving a query and a subscription alike — is the whole of what Cut 1 +owes this one. Cut 1 can go to Hands without waiting for a ruling on +anything here. + +Same discipline as Cut 1: facts by source read, deletes first, an authority +map, verification with a mutation per rule including a loosening, and a +subtraction estimate with real numbers. + +## 18.1 Body facts + +**The local watch surface today** (`src/GameCult.Networking/CultNetDatabase.cs`): + +- `Watch()` `:1051-1057` filters the change stream by CLR type; + `WatchAllChanges()` `:1063-1067`; `WatchRecord(key)` `:1072-1075`; + `WatchGlobal()` `:1080-1083`; `WatchByName(name)` `:1088-1094`; + `WatchByIndex(alias, value)` `:1100-1107`. +- **`WatchByName` and `WatchByIndex` do not do what they are named.** Both + read the cache's *current single winner* for the lookup and compare it to + the change's document by `ReferenceEquals`. Combined with section 1's + finding that the cache's index maps hold **one key per value**, this + means `WatchByIndex("kind", "cannon")` observes changes to + whichever single `Weapon` most recently claimed `cannon`, not to the + weapons whose kind is `cannon`. It also re-runs the lookup *at change + time*, so a change that clears the value is evaluated against the state + after the clear. The same two lines exist again in CultMesh as + `MatchesName` / `MatchesIndex` (`CultMesh.cs:3312-3333`). +- The rule is set-dependent even without a hop, and nothing tracks + membership: there is no "was in the set, now is not" anywhere in the + local path. `Reconcile` (`CultNetDatabaseSubscriptionServer.cs:251-292`) + is the only membership tracker in the reference and it belongs to a + server, over a delivered set, per peer. + +**The CultMesh collection surface** (`src/GameCult.Mesh/CultMesh.cs`): nine +`Collection*` overloads at `:2278-2480` — three shapes (all / by name / by +index) by three sources (`CultCache` `:2278`, `:2302`, `:2331`; +`CultNetDatabase` `:2362`, `:2386`, `:2413`; `CultMeshNode` `:2442`, +`:2456`, `:2471`, which delegate to the database trio in three lines each). +`CollectionByIndex` over a cache resolves its contents as +`Optional(cache.GetByIndex(alias, value))` — a "collection" of +**at most one row**, whose live feed then filters by `MatchesIndex`. Three +shapes exist because the shape *is* the query language; a selection +replaces the shape axis entirely and leaves the source axis alone. + +**What Cut 1 leaves in place for this cut:** `CultNetSelection` and its +validation, `CultNetSelectionEvaluator.Matches(selection, descriptor, key, +document)` for a single row, the cache's public read surface, and D6's rule +that a hop-bearing selection is set-dependent and must reconcile rather +than decide per change. + +## 18.2 The shape + +``` +CultNetDatabase.Watch(CultNetSelection selection) + -> Observable + +CultNetSelectionChange { + kind: Entered | Left | Updated // membership, not storage + schemaId, recordKey + document: object? // present on Entered/Updated under `document` projection + previous: object? // present on Left/Updated + cause: Self | Edge // this row changed, or a row it cites / is cited by did +} +``` + +**Rules, each one owner:** + +- **Membership, not mutation.** The stream reports a row entering the + selection, leaving it, or changing while inside it. A write to a row that + was outside and stays outside emits nothing. A write that changes a value + the selection filters on emits `Entered` or `Left`, **not** `Updated` — + which is precisely what `WatchByIndex` cannot express today. +- **`cause: Edge`** is how a hop-bearing selection stays honest: when a + change to row B moves row A in or out, the subscriber is told about A, + with the cause marked, and never about B unless B is itself in the set. +- **No order, no cursor, no `asOf`, no `limit`.** A live feed is not a + page. A selection carrying `limit`, `cursor` or `descending` is **refused + at the door** (`selection_invalid { field }`) rather than silently + ignored: "the latest ten, live" is a different question with a different + answer (it needs an eviction rule), and this cut does not answer it. + `projection` is honoured; `schemas`, `keys`, `fields`, `cites` and + `cited` are honoured. +- **The initial set is not a snapshot message.** `Watch` is local and + in-process; it emits `Entered` for every row already matching, at + subscribe time, under the cache's lock, before any live change. There is + no `asOf` because there is no wire and no second reader. +- **A hop-bearing selection recomputes; a plain one decides per row.** + Without `cites`/`cited`, membership is `Matches(selection, row)` and + costs one row's read. With a hop, a change anywhere can move anything, so + the watch re-evaluates the selection over the cache and diffs against the + membership set it holds. That is O(rows) per change and it is stated + plainly rather than hidden (Q-N). + +## 18.3 Deletes first + +| Path:lines | Lines | What | +|---|---:|---| +| `CultNetDatabase.cs:1080-1107` | 28 | `WatchGlobal`, `WatchByName`, `WatchByIndex` — the three that encode a query in a method name, two of them wrongly. `WatchGlobal` lowers to `keys: ["global:{schemaId}"]`, the other two to a `fields` predicate | +| `CultMesh.cs:3312-3333` | 22 | `MatchesName`, `MatchesIndex` — the same `ReferenceEquals`-against-the-winner rule, second copy | +| `CultMesh.cs:2278-2480` | 203 | the nine `Collection*` overloads; **after: three**, one per source (`CultCache`, `CultNetDatabase`, `CultMeshNode`), each taking a `CultNetSelection`, about 75 lines | +| `CultMeshSnapshots.cs` and `CultMesh.cs` local-watch predicates, audit sites #2, #4-9, #16-21 | ~240 | the remaining local watch loops and their inline predicates, about 40 after | +| **Total** | **~493** | **~115 after; net about −378** | + +`Watch()`, `WatchAllChanges()` and `WatchRecord(key)` **stay**, as +Cut 1's v0 lowering stays: a CLR-type filter and a single-key watch are +conveniences that lower to a selection and have no rule of their own. If +they grow a rule again, W6 catches it. + +## 18.4 Adds and authority + +| Add | Owner | Live consumer | Protected invariant | Replaces | +|---|---|---|---|---| +| `CultNetSelectionWatch` in `GameCult.Networking` (~130 lines): the membership set, the per-change decision, the hop recompute, `CultNetSelectionChange` | `CultNetDatabase` | Mesh's three `Collection` overloads; any local consumer that watches a question | membership is tracked in one place, and a row that leaves the set is reported leaving | `WatchByName` / `WatchByIndex` / `MatchesName` / `MatchesIndex`, four spellings of one wrong rule | +| `CultNetDatabase.Watch(CultNetSelection)` (~15 lines) | `CultNetDatabase` | the above | one entry point | three named-query methods | +| Rust: **nothing in this cut** | — | — | — | no Rust caller watches locally; when one does, it gets the same shape over `Row`/`RowSet` | + +- **Owner.** `CultNetSelectionWatch` owns membership and only membership. + `CultNetSelectionEvaluator.Matches` owns whether a row is in the set — + the watch never re-implements a predicate. `CultNetDatabase` owns the + change stream. The cache owns values, as in Cut 1. +- **Inputs.** The change stream, the cache's rows through the public read + surface, the validated selection. Not inputs: a clock, a transport, the + shard log's sequences (there is no order here), a peer's authorization. +- **Outputs.** `Entered` / `Left` / `Updated` with a cause, and one typed + refusal for a selection carrying page-shaped fields. +- **Derived state.** The membership set, and the incoming-edge index when + the selection hops. **Demotions:** `Watch` and `WatchRecord` are no + longer queries, they are lowerings; Mesh's collection *shape* is no + longer a language, it is a `Selection`; `MatchesIndex` is not an owner of + anything. +- **Forbidden writers.** Nothing outside `CultNetSelectionWatch` may decide + membership; no watch path may call `GetByIndex` or `GetByName` to decide + whether a change is interesting (that is the bug being deleted, in both + its copies); Mesh may not filter a feed the watch answered; the watch may + not emit `Updated` for a row that crossed the predicate boundary; the + watch may not accept `limit`, `cursor` or `descending`. +- **Shared paths.** `Matches` under both the query and the watch — a row + that a page would return and a row the watch reports entering are decided + by the same function, or the two truths drift. The subscription server's + `Reconcile` keeps its own per-peer delivered set (it answers a different + question: what this peer has been told) and **does not** become the local + membership tracker. +- **Deletion line.** Commit 1: `CultNetSelectionWatch` and + `Watch(Selection)`, with the three named-query methods deleted and their + tests rewritten. Commit 2: Mesh's nine overloads collapsed to three, both + matchers deleted. Nothing lands with `WatchByIndex` alive beside it. + +## 18.5 Verification + +| # | Test | Pins | Revert kills | Loosening kills | +|---|---|---|---|---| +| W1 | `Watch_ReportsARowLeavingWhenItsFilteredValueChanges` | membership, not mutation — the bug being deleted | `Left` never emitted (today's behaviour restored: the change is simply not delivered) | `Updated` emitted instead of `Left`, so a subscriber holding a list keeps a row that no longer matches | +| W2 | `Watch_ReportsEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the deletion of the `ReferenceEquals`-against-the-winner rule | `MatchesIndex` restored | membership decided by the winner *plus* the changed row, which passes a two-row fixture and fails a three-row one — **the fixture must carry three rows sharing the value** | +| W3 | `Watch_EmitsTheInitialSetBeforeAnyLiveChange` | the subscribe-time set | no initial emission | the initial set emitted *after* the subscription goes live, so a write racing the subscribe is delivered twice or not at all — needs a test that writes between subscribe and first emission | +| W4 | `Watch_ReportsAnEdgeCausedEntryWhenTheCiterChanges` | `cause: Edge`, D6 locally | a hop-bearing watch decided per changed row (the citee never moves) | the citee reported with `cause: Self`, which loses the subscriber's ability to tell a row's own change from a set change | +| W5 | `Watch_RefusesAPageShapedSelection` | no order, cursor or limit on a feed | the refusal dropped and the fields ignored | `limit` refused but `descending` ignored — one field's worth of silent ignoring, which is how a "harmless" ignored field becomes a bug report | +| W6 | `Watch_AndSelect_AgreeOnMembershipForTheSameSelection` (property-shaped: for each fixture selection, the set the watch holds equals the ids a page returns) | the shared `Matches` | the watch given its own predicate | the watch's copy agreeing on the plain selections and differing on the hop-bearing ones — kills only if the fixture's selections include hops, which section 6's fixture does | +| W7 | `Mesh_CollectionTakesASelectionAndFiltersNothingItself` (Mesh tests) | the collapse | an overload restored | the three surviving overloads re-filtering the feed after the watch answered | + +**Negative greps:** `rg -n "WatchByIndex|WatchByName|MatchesIndex|MatchesName" src` +empty; `rg -n "GetByIndex|GetByName" src/GameCult.Mesh src/GameCult.Networking` +empty; `rg -n "CollectionByIndex|CollectionByName" src` empty. + +**Mutations.** Entries in this campaign's own file, target `networking` +(killer `dotnet test tests/GameCult.Networking.Tests`) plus the Mesh tests +for W7, one per rule above with its revert and its loosening. + +## 18.6 Subtraction estimate + +| | Removed | Added | +|---|---:|---:| +| `src/GameCult.Networking` | 28 | ~145 | +| `src/GameCult.Mesh` | ~465 | ~115 | +| tests | ~80 rewritten | ~260 (W1-W7) | +| **net source outside tests** | | **about −233** | + +This is the cut where CultMesh actually shrinks: about −350 in +`CultMesh.cs` and the snapshot facade against about +117 in the database +organ. It is also the cut that deletes a wrong answer rather than a +duplicate one — four spellings of "watch the row that currently wins a +unique-index lookup", replaced by one membership tracker over the selection +the caller actually asked about. Cut 1 pays about +585 for the vocabulary; +Cut 2 spends it. + +## 18.7 Operator questions for this cut + +- **Q-N. The hop-bearing watch's recompute.** **A, recompute and diff on + every change** (recommended: obviously correct, O(rows) per change, and + the local caches this runs over hold thousands of rows, not millions). + **B**, maintain an incremental incoming-edge index invalidated per write + — faster, and a second piece of derived state that can disagree with the + cache. Take B only with a measured reason. +- **Q-O. Does `WatchRecord` survive?** **A, yes, as a lowering** + (recommended; it is one key and no rule). **B**, delete it too and make + every caller say `keys: [k]` — cleaner, noisier at its call sites. +- **Q-P. Ordering on a feed, later.** Out of this cut by the rules above. + If "the latest N, live" is wanted, it is a third cut with an eviction + rule, not a field added here.