CultLib/docs/write-ownership-map.md

132 lines
11 KiB
Markdown

# Write-ownership map
Imagination, 2026-10-27/28, session `self-2026-10-27c-eureka`. Base: CultLib `main` at `b7a68884` (C2a landed).
Serves `variants:follow_up:write-ownership-cut`. Spec: `variants:cut_spec:cut-write-ownership-cs.r1`. Question:
`variants:question:written-type-undeclared-drift`.
## The rulings: what decides and what illustrates
- `variants:ruling:cache-refuses-retype`. Her words decide: a stored record's schema id identifies its type, so a write of
another type at that key is the wrong type and is refused, by a cache exactly as by a cacheless write. Retyping is an
explicit remove plus add. The Household / `_pet` / rake analogy is an illustration, not a rule about object fields. Self's
gloss (ownership becomes per staged key) is Self's reading, not hers.
- `variants:ruling:write-ownership-by-written-type`. Her words decide one thing: a record under the written type's own id is
equivalent by construction, so which type owns that id cannot change the write. The full rule ("own id or one the written
type declares it migrates from; anything else is refused; no registry, Shared included, and no owner/lister precedence
enters a write's ownership decision") is Self's reading, which she accepted in the same exchange. Her remark that ids
indicating structural equivalence beats demanding names match is context for the open
`variants:question:store-schema-id-derivation`, not a ruling here.
- In force and touched: `variants:ruling:drift-record-write-ownership` (operator: a type that reads a record as compatible
drift owns it and restamps it, as 2.0.1 does), `variants:ruling:mesh-writes-bound-by-declaration`,
`variants:ruling:undeclared-read-only`, `variants:ruling:unheld-removal-refused-at-commit`,
`variants:ruling:unheld-removal-multi-store-cost-ruled`.
The two Self readings disagree on one case: a record under an undeclared older id of the written schema name, read whole
(an undeclared version bump). Literal "anything else is refused" makes it read-only; `drift-record-write-ownership` makes it
owned. That is `variants:question:written-type-undeclared-drift`, and only the operator can settle it.
## Body facts (probed at b7a68884)
Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (one job each); TS and Rust on Yggdrasil
(`ygg-verify.sh`, scratch commit `78f604fd`, not pushed to the forge); Python locally. Types A (`tests.wo_a`) and B
(`tests.wo_b`) share one shape; N1/N2 are two versions of `tests.wo_n`, nothing declared.
| Probe | C# | TypeScript | Rust | Python |
|---|---|---|---|---|
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | R `SchemaConflictError` naming k, A not written (leaf cut) | R `SchemaConflictError`, A's write refused, B kept (was C, both live) |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R `SchemaConflictError` (B loads as foreign, carried; leaf cut) | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) |
| P3 cacheless `WriteDocument<A>` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write |
| P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a |
| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a |
| P6 N2 over an N1 record (undeclared name drift), cache {N2} / cacheless reg {N2} | C restamped / C restamped | not probed | not probed | C (cache) |
The C#-written probe the leaves are held to is `tests/vectors/key-identity` (12 rows: retype, foreign carry, key in two stores), walked by
`KeyIdentityVectorTests`; each leaf cut corrects its own column above.
What this establishes:
- C#: one record per key. The only path that retypes is the cache arm (P1): `CacheBackingStore.RefuseUndeclared`
(`CultCache.cs:3994-4009`) and the on-disk judge (`OwnsOnDisk`, `:4731-4763`) ask `Registry.Owns(schemaId, catalog)`
(`:841-842`), which answers per schema id for the whole registry: any registered type declaring the id makes every
registered type's write to that key owned. The cacheless arm already refuses the retype (P3) but decides it through
`CatalogEntryLookup`'s registry half (`:596-634`) and the claimant precedence, which is what the ruling removes.
The judge is also per batch, not per key: the cacheless predicate's `declared` is the union over every written entry
(`:4752-4754`), and `UndeclaredOnDisk` memoises one answer per schema id (`:4784-4797`).
- Retype as remove plus add already works in the cache (P4, P5); the cut adds no new primitive.
- Directory store: its own judge (`DirectoryMessagePackBackingStore.cs:287-306`) asks `Registry.Owns` for every key,
upserts included. It must take the same per-key rule.
- TypeScript and Python: records are keyed by `(type, key)` (TS `#entryId(definition.type, key)`, `cult-cache.ts:323`;
Python sorts and stores by `(type, key)`). A write of A at k never meets B's record: both live at k (P1). Neither carries
a foreign record; a store holding an unregistered type does not load (P2). So neither has a "write meets another type's
record" path to fix: bringing the ruling to them first needs C#'s key identity (one record per key) and C#'s foreign
carry, parity changes of their store model, not a write-ownership rule (follow-up `variants:follow_up:leaf-key-identity-parity`).
- Rust is that follow-up's first leaf cut (`cut-leaf-key-identity-rs`). The cache keeps its `(type, key)` map; one judge
(`refuse_retype`) refuses another registered type at a key the cache holds, or any write at a key a store carries
foreign, on every write path, pull and SoA load, comparing types, not the ids envelopes claim. At a key the cache does not
hold (a fresh key, or one another writer filled after this cache's pull) the single-file store's commit guard judges by
the stored and claimed schema ids, so an envelope claiming another registered type's id is refused earlier, at every
envelope admission (`refuse_claimed_id`, the pull's own `resolve_registered_type` lookup); an id naming no registered type
passes, trusted as claimed: an envelope under such an id can replace another writer's record stored under it at a key
this cache does not hold, or be served as another type by a reader that registers the type the id names (Soul g8a, g8c,
open: which ids a Rust type owns is not declared to CultCache). Store-level writers called directly (`push`,
`insert_entry_if_absent`, compare-and-swap) trust the claimed id.
The cache carries an unregistered type's records as foreign and walks the vector's 12 rows.
- Interop (unproven): a Python store holding A and B at k fails C# decode first on payload encoding (Python's
lambda-defined document wrote JSON payloads), so what C# does with two records at one key was not reached.
## Model and authority table
| Value | Today (b7a68884) | After the cut |
|---|---|---|
| May an upsert of T replace the stored record at k? | cache: `Registry.Owns(id)` (any registered type); cacheless: union of written entries' declarations plus claimant over written entries and the lent registry | **Owner: `CultDocumentRegistry.WrittenOwns(T's entry, stored id, catalog)`**, per key, both arms, both store kinds |
| May a removal (or check) at k proceed? | cache: `Registry.Owns`; cacheless: the written-entries predicate | unchanged in kind: cache `Registry.Owns`; cacheless: owned when some entry the call writes `WrittenOwns` it (no registry) |
| Which type reads a record (claimant precedence) | `Claimant<T>` | unchanged; read machinery only, no write asks it except through `WrittenOwns`' one-entry lookup (option `own-entry-reads-whole`) |
| Lent registry in a cacheless write | encodes, decides copied-record v3 header, adds claimants | encodes and decides the copied-record header (`CopiedHoldIds`, `:4600-4630`); **adds no claimants** |
| `CatalogEntryLookup` registry half (`_others`, `Other`, `Others`) | live | **deleted** |
| `CultDocumentRegistry.Lookup` | live | **deleted** |
| `Owns<T>`'s `mayClaim` | live | **deleted** (the lookup holds only the written entry) |
| Foreign record at k | refused for every write | unchanged |
Authority map (also in the spec):
- Owner: `WrittenOwns(written, storedId, catalog)` decides whether an upsert of `written` may replace the record stored at
its key. Inputs: the written catalog entry, the stored record's schema id, the catalog that publishes it. Nothing else.
- Derived: `Registry.Owns` is demoted to the removal/check rule and to load-time read-only marking; it no longer decides
upserts. `Claimant` is read machinery.
- Forbidden writers: `Registry.Owns` on an upsert key; `CatalogEntryLookup` with a registry; the per-batch union `declared`;
the per-schema-id memo across different written entries.
- Shared paths: cache staging (`Claim` -> `RefuseUndeclared`, `:3156`), single-file on-disk judge (`ApplyWriteSet` ->
`RefuseUndeclaredOnDisk`, `:4565`, `:4736`), single-file `JudgeChecks` (`:4726`, checks only), directory store judge,
`WriteRecord`, `WriteDocument`, unattached `PushAll`, Mesh's `WriteSingleFileDocument` and raw-payload write.
## Absorb or leave
- `variants:follow_up:drift-write-ownership-leaves`: **leave**, but it now waits on `leaf-key-identity-parity`. Its premise
(give each leaf the C# Owns rule) assumes one record per key, which no leaf has (P1). The leaf write-ownership cuts in the
sequence below close it together with this follow-up's leaf half.
- `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API
shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk
judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate.
- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is
a reader/writer tolerance question, not ownership; this cut touches no leaf.
## Deferred doc findings this cut fixes
The ownership-docs rewrite in part 1 is the fix for (each resolved `Deferred` to `write-ownership-cut`):
`variants:finding:cut-claimant-one-owner.s2.changelog-underdescribes-refusals`,
`variants:finding:cut-claimant-one-owner.s3.changelog-misstates-subset`,
`variants:finding:cut-claimant-one-owner.s5.refusal-list-ignores-declared-win`,
`variants:finding:cut-claimant-one-owner.s5.shared-read-stated-as-contract`.
Sites: `unity/org.gamecult.cultlib/CHANGELOG.md:16`, `src/GameCult.Caching/Contracts/cultcache-persistence-format.md:209-219`,
`CultCache.cs` `WriteRecord`/`WriteDocument` registry docs (`:4476-4519`). Self closes them `Fixed` after Soul holds.
## Cut sequence
1. `cut-write-ownership-cs` (spec r1, admitted, blocked on `variants:question:written-type-undeclared-drift`): C# reference,
both arms, both store kinds, docs. Lands before the 3.0.0 release cut.
2. `leaf-key-identity` (one per leaf, TS, Rust, Python; follow-up `leaf-key-identity-parity`): one record per key and
foreign carry at C# parity, with a cross-runtime vector. Not mapped.
3. `leaf-write-ownership` (one per leaf, after 2): `WrittenOwns` per leaf; closes `drift-write-ownership-leaves`. Not mapped.
Leaves ship on their own versions (cultcache-ts 0.14.2), not in the 3.0.0 Unity package, so 2 and 3 do not gate 3.0.0;
whether they must land first is Self's call.