Specify canonical CultCache persistence format
This commit is contained in:
parent
123f0147e7
commit
1cac14574a
2 changed files with 363 additions and 0 deletions
11
notes/operator-interventions.md
Normal file
11
notes/operator-interventions.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Operator Interventions
|
||||
|
||||
## 2026-05-08
|
||||
|
||||
- External Codex session from `E:\Projects\EpiphanyAgent` added
|
||||
`src/GameCult.Caching/Contracts/cultcache-persistence-format.md`.
|
||||
- Intent: write down the next canonical CultCache persistence-format design in
|
||||
the C# source-of-truth repo before Rust/TS implementations harden the wrong
|
||||
shape.
|
||||
- Existing unrelated worktree changes in `src/GameCult.Networking/PlayerData.cs`
|
||||
and `src/GameCult.Unity/Assets/UI/Default Resolver.asset` were left alone.
|
||||
352
src/GameCult.Caching/Contracts/cultcache-persistence-format.md
Normal file
352
src/GameCult.Caching/Contracts/cultcache-persistence-format.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# CultCache Persistence Format
|
||||
|
||||
This document is the canonical design receipt for the next CultCache storage
|
||||
format. `GameCult.Caching` in C# is the source of truth for this contract. Rust,
|
||||
TypeScript, and other siblings should follow once the shape stabilizes here.
|
||||
|
||||
The goal is to preserve CultCache's best property: application code should work
|
||||
with domain data, not with a little parade of storage metadata stapled onto
|
||||
every object until the object forgets what it was for.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
The current stack has two conflicting virtues:
|
||||
|
||||
- domain ergonomics: users want plain typed objects
|
||||
- interop durability: caches in different languages need enough persisted
|
||||
information to read each other's data, compare schemas, and perform soft
|
||||
migration when shapes drift
|
||||
|
||||
The old C# shape leaned hard toward ergonomics by exposing `DatabaseEntry.ID`
|
||||
directly and letting backing stores carry a lot of implicit responsibility. The
|
||||
newer Rust draft leaned hard toward persistence machinery by wrapping payloads
|
||||
in a heavier envelope. Both capture part of the truth. Neither is the final
|
||||
machine.
|
||||
|
||||
The intended format keeps storage metadata out of domain objects while making
|
||||
the persisted store self-describing enough for cross-language readers and
|
||||
training-data archaeology.
|
||||
|
||||
## Core Rule
|
||||
|
||||
Domain payloads contain domain truth only.
|
||||
|
||||
These are **not** domain fields and should not be exposed on app-facing types:
|
||||
|
||||
- CultCache record key / GUID
|
||||
- persistence schema id
|
||||
- storage timestamp
|
||||
|
||||
Those belong to the CultCache persistence layer.
|
||||
|
||||
## Canonical Store Shape
|
||||
|
||||
A persisted CultCache file should contain:
|
||||
|
||||
1. store header
|
||||
2. embedded schema catalog
|
||||
3. record set
|
||||
|
||||
High-level shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": "cultcache.store.v1",
|
||||
"catalog": {
|
||||
"catalogVersion": "cultcache.schema_catalog.v1",
|
||||
"schemas": []
|
||||
},
|
||||
"records": []
|
||||
}
|
||||
```
|
||||
|
||||
The on-disk encoding does not have to be JSON. MessagePack is the expected
|
||||
default. The shape above is explanatory.
|
||||
|
||||
## Store Header
|
||||
|
||||
Required header fields:
|
||||
|
||||
- `formatVersion`
|
||||
- `catalog`
|
||||
- `records`
|
||||
|
||||
Optional header fields:
|
||||
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `storeId`
|
||||
- `producer`
|
||||
- `notes`
|
||||
|
||||
These are store-level bookkeeping fields, not per-record domain metadata.
|
||||
|
||||
## Embedded Schema Catalog
|
||||
|
||||
The store must embed a catalog entry for every schema referenced by the records
|
||||
it contains.
|
||||
|
||||
This is not decorative paperwork. It is what allows another CultCache
|
||||
implementation to inspect an old store, resolve the schema for each record, and
|
||||
decide whether it can:
|
||||
|
||||
- read directly
|
||||
- soft-migrate with warnings
|
||||
- or refuse the data honestly
|
||||
|
||||
### Schema Catalog Entry
|
||||
|
||||
Each schema descriptor should contain at least:
|
||||
|
||||
- `schemaId`
|
||||
- `schemaName`
|
||||
- `schemaVersion`
|
||||
- `contentHash`
|
||||
- `canonicalSchema`
|
||||
|
||||
Recommended optional fields:
|
||||
|
||||
- `migrationFamily`
|
||||
- `supersedesSchemaIds`
|
||||
- `compatibleReaderFamilies`
|
||||
- `persistenceEncoding`
|
||||
- `notes`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaId": "sha256:8b52c6...",
|
||||
"schemaName": "gamecult.player-data",
|
||||
"schemaVersion": "1.2.0",
|
||||
"contentHash": "sha256:52ff9c...",
|
||||
"migrationFamily": "player-data",
|
||||
"supersedesSchemaIds": ["sha256:0ce41a..."],
|
||||
"persistenceEncoding": "messagepack-array-v1",
|
||||
"canonicalSchema": {
|
||||
"kind": "messagepack-array-object",
|
||||
"fields": [
|
||||
{ "slot": 0, "name": "Name", "type": "string", "required": true },
|
||||
{ "slot": 1, "name": "Email", "type": "string", "required": true },
|
||||
{ "slot": 2, "name": "Level", "type": "int32", "required": false, "default": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `schemaId`
|
||||
|
||||
`schemaId` is the stable machine key. It should be derived from the canonical
|
||||
semantic persistence schema, not from:
|
||||
|
||||
- repo path
|
||||
- C# type name
|
||||
- Rust type name
|
||||
- TypeScript interface name
|
||||
- generated file name
|
||||
|
||||
If two implementations describe the same persisted shape, they should derive
|
||||
the same `schemaId` regardless of origin language.
|
||||
|
||||
### `contentHash`
|
||||
|
||||
`contentHash` is separate from `schemaId`.
|
||||
|
||||
- `schemaId` answers: "is this the same semantic persistence shape?"
|
||||
- `contentHash` answers: "is this the exact same canonical schema document?"
|
||||
|
||||
This distinction matters because descriptions, examples, and other non-semantic
|
||||
surface details may change without changing the actual persisted shape.
|
||||
|
||||
## Record Shape
|
||||
|
||||
Each persisted record should carry only the storage metadata needed to route and
|
||||
reconstruct the domain object:
|
||||
|
||||
- `key`
|
||||
- `schemaId`
|
||||
- `storedAt`
|
||||
- `payload`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "e1439d7e-8da8-4b63-a0b1-238c815a8a17",
|
||||
"schemaId": "sha256:8b52c6...",
|
||||
"storedAt": "2026-05-08T12:00:00Z",
|
||||
"payload": "<messagepack bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
The payload is the domain object only. It does not repeat the key, schema id,
|
||||
or stored timestamp.
|
||||
|
||||
## Canonical Semantic Schema Hash
|
||||
|
||||
`schemaId` should be derived from a canonical semantic schema hash.
|
||||
|
||||
The canonicalization step must ignore noise that should not change storage
|
||||
identity, such as:
|
||||
|
||||
- whitespace
|
||||
- key ordering
|
||||
- documentation prose
|
||||
- examples
|
||||
- source-generator quirks
|
||||
|
||||
It must preserve the parts that *do* define persistence compatibility:
|
||||
|
||||
- field slot numbers
|
||||
- field types
|
||||
- required versus defaulted presence
|
||||
- nullability
|
||||
- collection/container semantics
|
||||
- enum/value constraints when those affect stored meaning
|
||||
- persistence encoding shape, such as MessagePack array layout
|
||||
|
||||
The output should be a stable string such as:
|
||||
|
||||
- `sha256:<hex>`
|
||||
|
||||
This is the value persisted in each record's `schemaId`.
|
||||
|
||||
## Soft Migration
|
||||
|
||||
Soft migration is a first-class requirement. The format must support a reader
|
||||
encountering a record whose embedded schema is not byte-identical to its local
|
||||
native schema while still consuming the record safely when the slot contract is
|
||||
compatible enough.
|
||||
|
||||
Reader flow:
|
||||
|
||||
1. Read record header.
|
||||
2. Resolve `schemaId` against the embedded catalog.
|
||||
3. Resolve the local registered schema for the requested native type.
|
||||
4. Compare embedded versus local canonical persistence schemas.
|
||||
5. Choose one:
|
||||
- `exact_match`
|
||||
- `soft_migrate`
|
||||
- `reject`
|
||||
|
||||
### Exact Match
|
||||
|
||||
Read directly when:
|
||||
|
||||
- schema ids match exactly
|
||||
- or equivalent schema comparison says the persisted and local canonical shapes
|
||||
are identical
|
||||
|
||||
### Soft Migration
|
||||
|
||||
Allowed when:
|
||||
|
||||
- persisted schema has missing slots that the local schema can default
|
||||
- persisted schema has extra slots the local reader can safely ignore or preserve
|
||||
- slot meanings remain compatible
|
||||
|
||||
Soft migration should emit warnings, not silent shrugs.
|
||||
|
||||
Typical warnings:
|
||||
|
||||
- missing slot defaulted
|
||||
- unknown extra slot ignored
|
||||
- nullable/default behavior widened
|
||||
- field preserved but not surfaced natively
|
||||
|
||||
### Reject
|
||||
|
||||
Reject when:
|
||||
|
||||
- slot meanings conflict
|
||||
- required local data cannot be reconstructed
|
||||
- stored type and local expected type are incompatible
|
||||
- persistence encoding families differ in a non-compatible way
|
||||
|
||||
Do not bluff here. A false clean read is worse than a refusal.
|
||||
|
||||
## API Ergonomics
|
||||
|
||||
The public CultCache API should hide persistence metadata by default.
|
||||
|
||||
Desired feel:
|
||||
|
||||
- callers work with `PlayerData`, not `PlayerData` plus storage sludge
|
||||
- cache methods own key generation or key routing
|
||||
- global and named-entry flows stay ergonomic
|
||||
- advanced callers can still inspect raw records and catalogs when needed
|
||||
|
||||
This implies a split between:
|
||||
|
||||
- domain-facing API
|
||||
- raw persistence/interop API
|
||||
|
||||
### Domain-Facing Surface
|
||||
|
||||
Domain callers should interact with:
|
||||
|
||||
- typed adds/puts
|
||||
- typed gets
|
||||
- named lookups
|
||||
- index lookups
|
||||
- global entry helpers
|
||||
|
||||
They should not have to care about `schemaId`, `storedAt`, or raw payload bytes
|
||||
unless they are explicitly doing migration, tooling, or replication work.
|
||||
|
||||
### Raw Persistence Surface
|
||||
|
||||
The lower-level API should still expose:
|
||||
|
||||
- record inspection
|
||||
- schema catalog inspection
|
||||
- raw record import/export
|
||||
- migration diagnostics
|
||||
- exact payload preservation for bit-compatible neighbors
|
||||
|
||||
That is the cache's responsibility, not the domain model's.
|
||||
|
||||
## CultNet Alignment
|
||||
|
||||
CultCache and CultNet should not grow rival schema religions.
|
||||
|
||||
The schema descriptor shape used by the CultCache embedded catalog should align
|
||||
with the CultNet schema catalog as closely as practical. In particular:
|
||||
|
||||
- `schemaId`
|
||||
- `schemaVersion`
|
||||
- `contentHash`
|
||||
- canonical schema body
|
||||
|
||||
should mean the same thing across persistence and wire discovery.
|
||||
|
||||
Where CultNet needs additional wire-oriented metadata, extend around the shared
|
||||
core instead of redefining the core.
|
||||
|
||||
## Migration From Current C# Shape
|
||||
|
||||
The current C# `DatabaseEntry` base type exposes `ID` directly. That is useful
|
||||
today but not the desired long-term boundary.
|
||||
|
||||
Planned direction:
|
||||
|
||||
1. define the v1 persistence format here
|
||||
2. implement store header, catalog, and record shape in C#
|
||||
3. move storage identity out of the domain-facing `DatabaseEntry` contract
|
||||
4. keep compatibility shims long enough to read older stores
|
||||
5. let Rust, TypeScript, and other siblings follow the stabilized C# contract
|
||||
|
||||
The transition should preserve the existing soft-migration advantage rather than
|
||||
trading it for fake purity.
|
||||
|
||||
## Near-Term Rules
|
||||
|
||||
Until this format is implemented:
|
||||
|
||||
- treat this document as the canonical target
|
||||
- do not add new storage metadata to domain payloads
|
||||
- prefer schema catalogs over repeated per-document metadata
|
||||
- preserve the distinction between semantic schema identity and exact schema
|
||||
document hash
|
||||
- keep cross-language interop as a first-class design constraint, not a later
|
||||
apology
|
||||
Loading…
Reference in a new issue