Probed at b7a68884: only the C# cache arm retypes a key (cache {A,B}
replaces B's record with A); cacheless writes already refuse. TS, Rust
and Python key records by (type, key), so two types coexist at one key
and no leaf has a retype path; leaf parity needs key identity first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.9 KiB
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 openvariants: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 | C, and both records live at k | C, both live at k | C, both live at k |
| P2 cache {A} only, store holds B at k, write A at k | R CultSchemaConflictException (B is foreign, carried) |
R at load: "No schema is registered for persisted schema id" | R at load, same | R at load: "Unknown persisted document type" |
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) |
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) askRegistry.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 throughCatalogEntryLookup'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'sdeclaredis the union over every written entry (:4752-4754), andUndeclaredOnDiskmemoises 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) asksRegistry.Ownsfor every key, upserts included. It must take the same per-key rule. - TypeScript, Rust, Python: records are keyed by
(type, key)(Rustentries: BTreeMap<(String, String), _>,lib.rs:2659; 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). None carries a foreign record; a store holding an unregistered type does not load (P2). So no leaf has a "write meets another type's record" path to fix. Bringing the ruling to the leaves first needs C#'s key identity (one record per key) and C#'s foreign carry, which are parity changes of the leaves' store model, not a write-ownership rule. Owned by the new follow-upvariants:follow_up:leaf-key-identity-parity. - 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 ofwrittenmay 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.Ownsis demoted to the removal/check rule and to load-time read-only marking; it no longer decides upserts.Claimantis read machinery. - Forbidden writers:
Registry.Ownson an upsert key;CatalogEntryLookupwith a registry; the per-batch uniondeclared; 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-fileJudgeChecks(:4726, checks only), directory store judge,WriteRecord,WriteDocument, unattachedPushAll, Mesh'sWriteSingleFileDocumentand raw-payload write.
Absorb or leave
variants:follow_up:drift-write-ownership-leaves: leave, but it now waits onleaf-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: directPush/CommitBatchreach 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-storepush_allreading 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
cut-write-ownership-cs(spec r1, admitted, blocked onvariants:question:written-type-undeclared-drift): C# reference, both arms, both store kinds, docs. Lands before the 3.0.0 release cut.leaf-key-identity(one per leaf, TS, Rust, Python; follow-upleaf-key-identity-parity): one record per key and foreign carry at C# parity, with a cross-runtime vector. Not mapped.leaf-write-ownership(one per leaf, after 2):WrittenOwnsper leaf; closesdrift-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.