Compare commits

..

1 Commits

2 changed files with 683 additions and 426 deletions
-426
View File
@@ -1,426 +0,0 @@
# Content Moderation & Age Verification Architecture
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, backend auth (JWT)
## 1. Overview
This document specifies the **content moderation** and **age verification**
architecture for **agp-game**, a Godot-based multiplayer game built by
autonomous agents. It defines:
- How **content flags** propagate through the system across the three primary
content types: **assets**, **rooms**, and **scripts**.
- How flags are **evaluated, aggregated, and enforced** at load time, at
runtime, and at the network boundary.
- The **age verification flow**, combining a lightweight **self-declaration**
with an optional **ID check hook** for higher-assurance verification.
The goal is a **single, consistent moderation pipeline** that applies the same
rules to user-generated content (UGC) regardless of whether it is a 3D asset, a
room definition, or a script, and that gates access to mature content behind a
verifiable age gate.
### 1.1 Design Goals
- **Uniformity:** One flag vocabulary and one propagation mechanism across
assets, rooms, and scripts.
- **Composability:** Flags on a room aggregate the flags of the assets and
scripts it contains; flags on a world aggregate the flags of its rooms.
- **Enforcement at the boundary:** The server is authoritative for what content
a client may load or receive, mirroring the server-authoritative networking
model.
- **Privacy-preserving age checks:** Self-declaration requires no PII; the ID
check hook is optional, pluggable, and never stores raw identity documents.
- **Auditability:** Every flag decision is logged with a reason, an actor, and
a timestamp so moderation actions are reviewable.
---
## 2. Content Flag Vocabulary
All content carries a set of **content flags**. A flag is a typed, immutable
label with a **severity** and a **scope**. Flags are the atomic unit of
moderation; everything else (aggregation, enforcement, gating) operates on
flags.
### 2.1 Flag Definition
A flag is a structured record:
| Field | Type | Description |
|------------|-----------|--------------------------------------------------------------------------|
| `id` | `string` | Stable machine-readable identifier, e.g. `violence.gore`. |
| `label` | `string` | Human-readable name, e.g. "Graphic Gore". |
| `category` | `enum` | One of `violence`, `sexual`, `language`, `substance`, `gambling`, `horror`, `hate`, `custom`. |
| `severity` | `enum` | One of `none`, `mild`, `moderate`, `severe`. |
| `scope` | `enum` | One of `visual`, `audio`, `text`, `behavior`, `interaction`. |
| `mature` | `bool` | Whether this flag triggers the age gate (see §5). |
| `reason` | `string` | Free-text justification supplied by the flagger. |
| `source` | `enum` | One of `author`, `moderator`, `automated`, `community`. |
### 2.2 Canonical Flag Set
The platform defines a **canonical registry** of flags so that different
content types and different flaggers use the same vocabulary. Examples:
| id | category | severity | scope | mature |
|-----------------------------|------------|-----------|--------------|--------|
| `violence.none` | violence | none | visual | false |
| `violence.mild` | violence | mild | visual | false |
| `violence.gore` | violence | severe | visual | true |
| `sexual.suggestive` | sexual | mild | visual | true |
| `sexual.explicit` | sexual | severe | visual | true |
| `language.mild` | language | mild | text | false |
| `language.profanity` | language | moderate | text | false |
| `substance.references` | substance | mild | text | false |
| `gambling.simulated` | gambling | moderate | behavior | true |
| `horror.jumpscare` | horror | mild | audio/visual | false |
| `hate.hateful` | hate | severe | text | true |
The registry is **data-driven** (a JSON/YAML manifest shipped with the server)
so new flags can be added without code changes.
---
## 3. Flag Propagation Model
Flags propagate through the system along the **content dependency graph**. The
three content types form a hierarchy:
```
World
└── Room
├── Asset (mesh, texture, audio, animation, material)
└── Script (behavior, dialogue, event logic)
```
### 3.1 Propagation Rules
1. **Explicit flags** are set directly on a content item by its author or a
moderator.
2. **Inherited flags** flow upward: a room's effective flag set is the **union**
of its own explicit flags and the flags of every asset and script it
references.
3. **Aggregation** uses the **maximum severity** per category across all
contributing items. A room containing one `violence.gore` asset and one
`violence.none` asset is `violence.severe`.
4. **Maturity is monotonic:** if any contributing item is `mature`, the
containing room and world are `mature`.
5. **Cycles are impossible** by construction: content references form a DAG
(an asset cannot contain a room; a room cannot contain itself).
### 3.2 Propagation Through the Pipeline
Flags are attached at **authoring time** and carried through every stage:
```
Authoring ──► Upload ──► Validation ──► Storage ──► Distribution ──► Runtime
│ │ │ │ │ │
│ flag │ flag │ automated │ flag │ flag │ flag
│ declared │ preserved │ scan adds │ indexed │ re-checked │ enforced
│ │ │ flags │ │ │
```
| Stage | Responsibility |
|--------------|-------------------------------------------------------------------------|
| **Authoring**| Author declares flags in the editor/upload UI. |
| **Upload** | Flags are validated against the canonical registry; unknown flags rejected. |
| **Validation**| Automated scanner (hash-based + heuristic) adds flags for detected content. |
| **Storage** | Flags are stored alongside the content record and indexed for queries. |
| **Distribution**| Flags are re-checked against the current registry before serving. |
| **Runtime** | The client enforces flags before loading content (see §4). |
### 3.3 Data Model
Flags are stored as a **normalized join table** so they can be queried and
aggregated efficiently:
```
content_item (id, type, owner_id, ...)
content_flag (content_id, flag_id, source, reason, created_at, actor_id)
flag_registry (id, category, severity, scope, mature, ...)
```
Aggregation is computed by a SQL view / query that joins a content item's
transitive dependencies. Because the graph is a DAG, this is a bounded
recursive query (PostgreSQL `WITH RECURSIVE`).
---
## 4. Enforcement
Enforcement happens at three layers, in order:
### 4.1 Load-Time Enforcement (Server)
Before a client is allowed to **request** content, the server evaluates the
content's effective flag set against the requesting account's **content
preferences** and **age verification status**:
- If the content is `mature` and the account is **not age-verified**, the
request is **rejected** with a `403 MATURE_CONTENT_REQUIRES_VERIFICATION`.
- If the content's flags exceed the account's **content filter** (a per-account
severity threshold per category), the request is rejected or the offending
content is **stripped** (e.g., audio muted, mesh hidden, script not loaded).
### 4.2 Runtime Enforcement (Client)
The client receives a **manifest** of allowed content from the server. It never
loads content that is not in the manifest. This prevents a malicious or buggy
client from bypassing the server's filter by loading content directly.
- Assets not in the manifest are **not instantiated**.
- Scripts not in the manifest are **not executed**.
- Rooms not in the manifest are **not entered**.
### 4.3 Network Boundary Enforcement
The server **filters replication** based on flags:
- Mature or filtered content is **not replicated** to clients that cannot
receive it.
- A room's flag set is broadcast in the room metadata so clients can show a
warning before entry.
### 4.4 Enforcement Decision Flow
```
Client requests content C for account A
│
▼
Compute effective flags F(C) [union + max-severity aggregation]
│
▼
Is A age-verified? ──no──► Is F(C).mature? ──yes──► REJECT (403)
│ yes │ no
▼ ▼
Does F(C) exceed A's filter? ──► yes ──► REJECT or STRIP
│ no
▼
Serve content + manifest
```
---
## 5. Age Verification Flow
Age verification is a **two-tier** system:
- **Tier 1 — Self-declaration:** The user asserts their date of birth. This is
fast, requires no PII beyond a birthdate, and is sufficient for most content.
- **Tier 2 — ID check (optional hook):** A pluggable identity-verification
provider confirms the user is an adult. This raises the account's assurance
level and unlocks the highest maturity tier.
### 5.1 Verification States
An account has an **age verification status**:
| Status | Meaning |
|-------------------|----------------------------------------------------------------------|
| `unverified` | No declaration on file. |
| `self_declared` | User declared a birthdate; **not** independently confirmed. |
| `id_verified` | User passed the ID check hook; independently confirmed adult. |
| `rejected` | ID check failed or declaration was inconsistent. |
### 5.2 Tier 1 — Self-Declaration Flow
```
User submits { date_of_birth }
│
▼
Server validates format & plausibility (age 13–120, not in future)
│
▼
Compute age from DOB
│
▼
age >= 18 ──► status = self_declared (adult)
age >= 13 ──► status = self_declared (minor, restricted)
age < 13 ──► status = rejected (below minimum platform age)
```
- The DOB is stored **encrypted at rest** and is **never exposed** to other
players.
- A user may **upgrade** from `self_declared` to `id_verified` at any time via
Tier 2.
### 5.3 Tier 2 — ID Check Hook
The ID check is an **optional, pluggable hook**. The platform defines an
interface; concrete providers (e.g., a third-party KYC service) implement it.
```
User requests ID verification
│
▼
Server creates a verification session (idempotent, expiring)
│
▼
Server calls provider hook: verify(identity_token) -> { adult: bool, ref: string }
│
▼
adult == true ──► status = id_verified, store provider ref (not raw doc)
adult == false ─► status = rejected
provider error ──► status unchanged, retry allowed
```
**Privacy guarantees:**
- The platform **never stores** the raw identity document or image.
- Only the provider's **opaque reference** and the boolean `adult` result are
persisted.
- The hook is **asynchronous and idempotent**; a session can be retried without
double-charging or double-verifying.
### 5.4 Hook Interface
The ID check hook is defined as a backend interface (FastAPI dependency /
abstract service):
```python
class IdentityVerificationProvider(Protocol):
def create_session(self, user_id: str) -> VerificationSession: ...
def verify(self, session_id: str, identity_token: str) -> VerificationResult: ...
class VerificationResult:
adult: bool
provider_ref: str | None
error: str | None
```
A default **no-op provider** returns `adult=False` with a clear error, so the
system runs without any third-party integration until one is configured.
### 5.5 Age Gate Enforcement
The age gate is enforced at **load time** (see §4.1):
| Verification status | Can access `mature` content? |
|---------------------|------------------------------|
| `unverified` | No |
| `self_declared` | Yes (adult) / No (minor) |
| `id_verified` | Yes |
| `rejected` | No |
---
## 6. Moderation Workflow
### 6.1 Flagging Sources
Content can be flagged by:
- **Author** (self-declared at upload).
- **Automated scanner** (hash-based detection, heuristic analysis).
- **Community** (players report content; reports become flags after review).
- **Moderator** (manual review and override).
### 6.2 Review Queue
Community and automated flags enter a **review queue**. A moderator either:
- **Approves** the flag (it becomes authoritative), or
- **Rejects** the flag (it is discarded), or
- **Escalates** to a senior moderator for severe/hate flags.
### 6.3 Actions
Once a flag is authoritative, a moderator can take actions:
| Action | Effect |
|-----------------|---------------------------------------------------------------|
| `warn` | Notify the author; content remains. |
| `restrict` | Content hidden from users below a filter threshold. |
| `age_gate` | Content marked `mature`; requires age verification. |
| `remove` | Content taken down; not served. |
| `ban_author` | Author's upload privileges suspended. |
### 6.4 Audit Log
Every flag and action is written to an **audit log**:
```
audit_event (id, content_id, actor_id, action, flag_id, reason, created_at)
```
This log is append-only and used for transparency and dispute resolution.
---
## 7. Client-Side Integration (Godot)
### 7.1 Content Manifest
The server sends each client a **content manifest** — the set of content the
client is permitted to load, derived from the account's verification status and
filter. The Godot client:
- Loads only manifest-listed assets.
- Executes only manifest-listed scripts.
- Shows a **maturity warning** dialog before entering a room whose effective
flags include `mature` content.
### 7.2 Flag Metadata on Content
Each content resource carries a `content_flags` metadata field (a Godot
`Dictionary` or custom `Resource`). The client uses this for local UI decisions
(e.g., showing a warning icon) but **never** as the authority for whether to
load — the server manifest is authoritative.
### 7.3 Age Verification UI
The client exposes an **age verification screen**:
- Tier 1: a date-of-birth picker.
- Tier 2: a button that opens the ID check provider flow (deep link / webview).
The client calls the backend endpoints (§8) and reflects the resulting status.
---
## 8. Backend API Surface
| Method | Path | Description |
|--------|-----------------------------------|------------------------------------------------|
| `POST` | `/v1/age/declare` | Submit self-declared DOB (Tier 1). |
| `POST` | `/v1/age/verify/session` | Create an ID-check session (Tier 2). |
| `POST` | `/v1/age/verify/confirm` | Confirm an ID-check session with a token. |
| `GET` | `/v1/age/status` | Return the account's verification status. |
| `GET` | `/v1/content/{id}/flags` | Return a content item's effective flags. |
| `POST` | `/v1/content/{id}/flags` | Add a flag (author/community). |
| `POST` | `/v1/moderation/review/{flag_id}` | Moderator approve/reject/escalate. |
| `POST` | `/v1/moderation/action` | Apply a moderation action. |
| `GET` | `/v1/manifest` | Return the client's permitted content manifest.|
All endpoints require a valid **JWT** (matching the existing auth stack). The
age endpoints additionally require the account to be in a non-`rejected` state.
---
## 9. Security & Privacy Considerations
- **DOB encryption:** Self-declared birthdates are encrypted at rest; only the
derived age and verification status are used in authorization decisions.
- **No raw identity storage:** The ID check hook persists only a provider
reference and a boolean result.
- **Server authority:** The client manifest prevents bypass; the client never
trusts its own flag metadata for access decisions.
- **Rate limiting:** Age-declaration and ID-check endpoints are rate-limited to
prevent abuse and enumeration.
- **Auditability:** All flag and verification events are logged append-only.
---
## 10. Open Questions / Future Work
- **Regional age thresholds:** Different jurisdictions may require different
minimum ages; the threshold should be configurable per region.
- **Parental controls:** A future feature may let a parent set a content filter
on a minor's account.
- **Automated scanner accuracy:** The heuristic scanner may produce false
positives; a confidence score should be attached to automated flags.
- **Offline / local content:** How flags apply to locally-authored content that
is never uploaded remains to be defined.
+683
View File
@@ -0,0 +1,683 @@
# World Chunk Data Format & Persistence Architecture
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Server authority + client prediction, avatar component system, content moderation & age verification, backend auth (JWT)
## 1. Overview
This document specifies the **world chunk data format** and **persistence
schema** for **agp-game**, a Godot-based multiplayer game built by autonomous
agents. It defines:
- The **chunk data format** — both a human-readable **JSON** form (for
authoring, tooling, and debugging) and a compact **binary** form (for
runtime loading and network transfer).
- The **SQLite schema** that persists world state on the server, including
chunk metadata, chunk payloads, and the world/room hierarchy.
- The **read/write API** that the server and client use to load, save, and
stream chunks.
The goal is a **single, versioned chunk contract** that is shared across the
Godot client, the Python backend, and the persistence layer. A chunk is the
atomic unit of world geometry, gameplay objects, and persistence — the server
loads and saves whole chunks, and clients stream the chunks around them.
### 1.1 Design Goals
- **Versioned & forward-compatible:** Every chunk carries a format version so
the loader can migrate old data and reject unknown future data gracefully.
- **Dual representation:** JSON for authoring/debugging, binary for
runtime/network. Both encode the *same* logical model so there is exactly one
source of truth for the schema.
- **Server-authoritative persistence:** The server is the only writer to the
SQLite store. Clients submit *edits* (deltas) and the server applies and
persists them, mirroring the server-authoritative networking model.
- **Chunk-granular persistence:** Persistence happens at chunk granularity so
the server can save a dirty chunk without rewriting the whole world, and can
stream only the chunks a client needs.
- **Deterministic & testable:** The binary format is byte-stable for a given
logical payload, enabling checksums, caching, and reproducible tests.
- **Moderation-aware:** Chunks reference content (assets, scripts) that carry
content flags (see `content_moderation.md`); the persistence layer stores the
moderation state alongside chunk data so enforcement can happen at load time.
---
## 2. World Model & Chunking
### 2.1 World Hierarchy
The world is organized as a hierarchy that matches the moderation content graph:
```
World
└── Room
└── Chunk (the atomic persistence & streaming unit)
├── Tiles / Terrain
├── Static objects (props, geometry)
├── Dynamic objects (spawnable entities, interactables)
└── Metadata (flags, version, timestamps)
```
- A **World** is the top-level container (a named, versioned space).
- A **Room** is a bounded region within a world (a level, a building, a zone).
- A **Chunk** is a fixed-size, axis-aligned region of a room. It is the unit of
load, save, and stream.
### 2.2 Chunk Dimensions
Chunks are **fixed-size, axis-aligned boxes** in world space. The default
dimensions are a configuration constant so they can be tuned per world:
| Constant | Default | Description |
|---------------------|---------|----------------------------------------------------|
| `CHUNK_SIZE_X` | 32 | Chunk width in world units (tiles). |
| `CHUNK_SIZE_Y` | 32 | Chunk height in world units (tiles). |
| `CHUNK_SIZE_Z` | 1 | Chunk depth (1 = flat 2D world; >1 = volumetric). |
| `CHUNK_ORIGIN` | `(0,0)` | World-space origin of chunk coordinate `(0,0)`. |
A chunk is identified by its **integer chunk coordinate** `(cx, cy, cz)`,
computed from a world-space position `p` as:
```
cx = floor((p.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)
cy = floor((p.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)
cz = floor((p.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)
```
The chunk's world-space bounding box is therefore:
```
min = (CHUNK_ORIGIN.x + cx*CHUNK_SIZE_X, ...)
max = min + (CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z)
```
### 2.3 Chunk Coordinate Key
For storage and lookup, a chunk is addressed by a **canonical string key**:
```
"{world_id}:{room_id}:{cx}:{cy}:{cz}"
```
Example: `"w_0001:r_0001:12:-4:0"`. This key is the primary lookup key in the
SQLite `chunks` table and is used in the read/write API.
---
## 3. Chunk Data Format
### 3.1 Logical Model
Both the JSON and binary encodings serialize the same logical chunk model. The
logical model is defined here once; the two encodings are projections of it.
```
Chunk
├── version : uint (format version, currently 1)
├── key : string (canonical chunk key, see §2.3)
├── world_id : string
├── room_id : string
├── coord : { cx, cy, cz } (int)
├── bounds : { min_x, min_y, min_z, max_x, max_y, max_z } (float)
├── tiles : TileLayer[] (terrain / tile data)
├── objects : WorldObject[] (static + dynamic objects)
├── flags : ContentFlags (moderation flags, see §3.5)
├── meta : { created_at, updated_at, revision, author }
└── checksum : string (SHA-256 of canonical payload)
```
### 3.2 Tile Layer
A chunk's terrain is stored as one or more **tile layers**. Each layer is a
dense grid of tile IDs of size `CHUNK_SIZE_X × CHUNK_SIZE_Y` (× `CHUNK_SIZE_Z`
for volumetric worlds).
```
TileLayer
├── name : string (e.g. "ground", "walls", "decor")
├── layer_id : uint
├── width : uint (= CHUNK_SIZE_X)
├── height : uint (= CHUNK_SIZE_Y)
├── depth : uint (= CHUNK_SIZE_Z)
├── tiles : uint[] (flattened tile IDs, row-major, length = w*h*d)
└── palette : TileDef[] (tile ID → tile definition, shared per layer)
```
**Tile encoding:** A tile ID is a `uint16` index into the layer's `palette`.
`0` is reserved for "empty/air". The palette maps each tile ID to a `TileDef`:
```
TileDef
├── id : uint16
├── asset_id : string (reference to a tile/terrain asset)
├── solid : bool (collision)
├── flags : uint32 (bitmask: walkable, climbable, liquid, ...)
└── variants : uint8 (number of visual variants for this tile)
```
**Rationale for palette + indices:** Storing dense `uint16` indices rather than
full asset strings per cell keeps the binary payload small (2 bytes/cell) while
the palette (shared across the layer) carries the rich per-tile metadata.
### 3.3 World Objects
Objects are the non-tile entities in a chunk: static props, interactables,
spawn points, and dynamic-object templates.
```
WorldObject
├── id : string (stable object id, unique within the chunk)
├── type : string (e.g. "prop", "interactable", "spawn_point", "door")
├── asset_id : string (reference to the object's mesh/glTF asset)
├── transform : { pos: [x,y,z], rot: [x,y,z,w], scale: [x,y,z] }
├── properties : object (type-specific key/value map, JSON-typed)
├── dynamic : bool (true if this is a spawnable dynamic entity)
└── flags : ContentFlags (per-object moderation flags)
```
**Dynamic objects** are stored as *templates* in the chunk. Their *runtime
state* (position, health, inventory) is **not** stored in the chunk; it lives
in the runtime entity store and is persisted separately (see §4.4). This keeps
chunks stable and diffable — a chunk only changes when its *static* content
changes.
### 3.4 Content Flags
Each chunk (and each object) carries a **content flag set** that is the
aggregate of the moderation flags of the assets and scripts it references. This
mirrors the aggregation rules in `content_moderation.md`:
```
ContentFlags
├── categories : { violence, sexual, language, substance, gambling, horror, hate, custom }
│ (each → severity: none|mild|moderate|severe)
├── mature : bool (true if any contributing item is mature)
└── sources : string[] (flag ids that contributed, for audit)
```
The server computes and persists these flags at write time so that enforcement
(age gating, content filtering) can happen at load time without re-scanning
assets.
### 3.5 JSON Encoding
The JSON encoding is the **authoring and debugging** representation. It is
human-readable, diffable, and used by tools and tests. It is the canonical
logical model serialized directly.
```json
{
"version": 1,
"key": "w_0001:r_0001:12:-4:0",
"world_id": "w_0001",
"room_id": "r_0001",
"coord": { "cx": 12, "cy": -4, "cz": 0 },
"bounds": { "min_x": 384.0, "min_y": -128.0, "min_z": 0.0,
"max_x": 416.0, "max_y": -96.0, "max_z": 1.0 },
"tiles": [
{
"name": "ground",
"layer_id": 0,
"width": 32,
"height": 32,
"depth": 1,
"palette": [
{ "id": 0, "asset_id": "", "solid": false, "flags": 0, "variants": 1 },
{ "id": 1, "asset_id": "tiles/grass", "solid": false, "flags": 1, "variants": 4 }
],
"tiles": [0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1]
}
],
"objects": [
{
"id": "obj_0001",
"type": "interactable",
"asset_id": "props/chest",
"transform": { "pos": [400.0, -110.0, 0.0],
"rot": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0] },
"properties": { "loot_table": "chest_basic", "locked": false },
"dynamic": false,
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] }
}
],
"flags": { "categories": { "violence": "none" }, "mature": false, "sources": [] },
"meta": { "created_at": "2026-08-17T00:00:00Z",
"updated_at": "2026-08-17T00:00:00Z",
"revision": 3,
"author": "agent-bot" },
"checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
```
### 3.6 Binary Encoding
The binary encoding is the **runtime and network** representation. It is compact
and byte-stable. It uses a simple, explicitly-specified layout (no external
serialization library) so it can be produced and consumed identically by the
Godot client (GDScript) and the Python backend.
#### 3.6.1 Header
| Offset | Size | Field | Type / Encoding |
|--------|------|--------------|------------------------------------------|
| 0 | 4 | magic | ASCII `"AGPC"` (0x41 0x47 0x50 0x43) |
| 4 | 2 | version | `uint16` LE (currently `1`) |
| 6 | 2 | header_len | `uint16` LE (bytes of header after this) |
| 8 | 4 | flags | `uint32` LE (bitmask, reserved) |
| 12 | 4 | payload_len | `uint32` LE (bytes of payload body) |
| 16 | 32 | checksum | SHA-256 of payload body (hex, 32 bytes) |
Total header size: **48 bytes**.
#### 3.6.2 Payload Body
The payload body is a sequence of typed sections. Each section has a small
header:
| Offset | Size | Field | Type / Encoding |
|--------|------|------------|----------------------------|
| 0 | 1 | section_id | `uint8` (see table below) |
| 1 | 4 | section_len| `uint32` LE (bytes of section data) |
| 5 | ... | data | section-specific |
Section IDs:
| section_id | Section | Data layout |
|------------|----------------|-------------------------------------------------------------------|
| `0x01` | metadata | world_id, room_id (length-prefixed strings), coord (3×int32), bounds (6×float32) |
| `0x02` | tile layer | name, layer_id, dims (3×uint16), palette, dense tile indices |
| `0x03` | object | one `WorldObject` (repeated; one section per object) |
| `0x04` | content flags | category severities (8×uint8), mature (uint8), sources (string list) |
| `0x05` | meta | created_at, updated_at (int64 unix), revision (uint32), author (string) |
**String encoding:** length-prefixed UTF-8 — `uint16` LE byte length followed by
the UTF-8 bytes.
**Tile layer data layout (section `0x02`):**
```
name : string
layer_id : uint16 LE
width : uint16 LE
height : uint16 LE
depth : uint16 LE
palette_count : uint16 LE
palette : palette_count × TileDef
tile_count : uint32 LE
tiles : tile_count × uint16 LE (dense, row-major)
```
```
TileDef:
id : uint16 LE
asset_id : string
solid : uint8
flags : uint32 LE
variants : uint8
```
**Object data layout (section `0x03`):**
```
id : string
type : string
asset_id : string
pos : 3 × float32
rot : 4 × float32
scale : 3 × float32
dynamic : uint8
properties : JSON string (length-prefixed)
flags : ContentFlags (see 0x04 layout)
```
**Content flags layout (section `0x04`):**
```
violence, sexual, language, substance, gambling, horror, hate, custom
: 8 × uint8 (severity enum: 0=none,1=mild,2=moderate,3=severe)
mature : uint8
source_count: uint16 LE
sources : source_count × string
```
#### 3.6.3 Byte-Stability
For a given logical payload, the binary encoding is **deterministic**:
- All integers are little-endian.
- All strings are UTF-8, length-prefixed, and never null-terminated.
- Maps (e.g. `properties`) are serialized as a **canonical JSON string** with
keys sorted lexicographically.
- The `checksum` is the SHA-256 of the payload body bytes.
This guarantees that two encoders produce identical bytes, enabling content
addressing, caching, and reproducible tests.
---
## 4. SQLite Persistence Schema
The server persists world state in a **SQLite** database. SQLite is chosen for
its zero-configuration, single-file, transactional, and embedded nature — ideal
for a server that owns the world store and needs ACID guarantees without a
separate database process.
### 4.1 Database Layout
The database file is `world.db` (path configurable). It contains the following
tables:
```
worlds
rooms
chunks
chunk_objects
content_flags
revisions
```
### 4.2 `worlds`
Top-level world containers.
```sql
CREATE TABLE worlds (
id TEXT PRIMARY KEY, -- e.g. 'w_0001'
name TEXT NOT NULL,
description TEXT,
chunk_size_x INTEGER NOT NULL DEFAULT 32,
chunk_size_y INTEGER NOT NULL DEFAULT 32,
chunk_size_z INTEGER NOT NULL DEFAULT 1,
origin_x REAL NOT NULL DEFAULT 0,
origin_y REAL NOT NULL DEFAULT 0,
origin_z REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, -- ISO-8601 UTC
updated_at TEXT NOT NULL
);
```
### 4.3 `rooms`
Rooms within a world.
```sql
CREATE TABLE rooms (
id TEXT PRIMARY KEY, -- e.g. 'r_0001'
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
name TEXT NOT NULL,
bounds_min_x REAL NOT NULL,
bounds_min_y REAL NOT NULL,
bounds_min_z REAL NOT NULL DEFAULT 0,
bounds_max_x REAL NOT NULL,
bounds_max_y REAL NOT NULL,
bounds_max_z REAL NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (world_id, name)
);
CREATE INDEX idx_rooms_world ON rooms(world_id);
```
### 4.4 `chunks`
The core persistence table. One row per chunk. The **payload** is stored as a
BLOB in the **binary format** (§3.6); the JSON form is derived on demand for
authoring/debugging and is not stored (to avoid dual-write drift).
```sql
CREATE TABLE chunks (
key TEXT PRIMARY KEY, -- canonical key 'world:room:cx:cy:cz'
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
cx INTEGER NOT NULL,
cy INTEGER NOT NULL,
cz INTEGER NOT NULL DEFAULT 0,
format_version INTEGER NOT NULL DEFAULT 1,
payload BLOB NOT NULL, -- binary chunk encoding (§3.6)
payload_len INTEGER NOT NULL,
checksum TEXT NOT NULL, -- SHA-256 of payload
revision INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0, -- 1 = needs flush to disk
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (world_id, room_id, cx, cy, cz)
);
CREATE INDEX idx_chunks_world_room ON chunks(world_id, room_id);
CREATE INDEX idx_chunks_coord ON chunks(cx, cy, cz);
```
**Design notes:**
- The `payload` BLOB is the single source of truth for chunk content. The
`checksum` guards against corruption and enables content addressing.
- `revision` increments on every write; it is used for optimistic concurrency
and change detection (see §5.4).
- `dirty` marks chunks that have been modified in memory but not yet flushed,
enabling a write-behind persistence strategy (see §5.5).
### 4.5 `chunk_objects`
A denormalized, queryable index of the objects inside each chunk. This is
**derived** from the chunk payload and kept in sync by the write path. It exists
so the server can answer queries like "which chunks contain a door?" without
parsing every payload BLOB.
```sql
CREATE TABLE chunk_objects (
id TEXT NOT NULL, -- object id (unique within chunk)
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'prop', 'interactable', ...
asset_id TEXT,
dynamic INTEGER NOT NULL DEFAULT 0,
pos_x REAL NOT NULL,
pos_y REAL NOT NULL,
pos_z REAL NOT NULL DEFAULT 0,
PRIMARY KEY (chunk_key, id)
);
CREATE INDEX idx_chunk_objects_type ON chunk_objects(type);
CREATE INDEX idx_chunk_objects_asset ON chunk_objects(asset_id);
```
### 4.6 `content_flags`
Persists the aggregated moderation flags per chunk (and optionally per object).
This lets the server enforce age/content gates at load time without parsing
payloads.
```sql
CREATE TABLE content_flags (
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
object_id TEXT, -- NULL = chunk-level flag
violence INTEGER NOT NULL DEFAULT 0,
sexual INTEGER NOT NULL DEFAULT 0,
language INTEGER NOT NULL DEFAULT 0,
substance INTEGER NOT NULL DEFAULT 0,
gambling INTEGER NOT NULL DEFAULT 0,
horror INTEGER NOT NULL DEFAULT 0,
hate INTEGER NOT NULL DEFAULT 0,
custom INTEGER NOT NULL DEFAULT 0,
mature INTEGER NOT NULL DEFAULT 0,
sources TEXT NOT NULL DEFAULT '[]', -- JSON array of flag ids
PRIMARY KEY (chunk_key, object_id)
);
CREATE INDEX idx_content_flags_mature ON content_flags(mature);
```
Severity is stored as `0=none, 1=mild, 2=moderate, 3=severe`.
### 4.7 `revisions`
An append-only audit log of chunk writes. Enables rollback, auditing, and
replication of changes.
```sql
CREATE TABLE revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chunk_key TEXT NOT NULL REFERENCES chunks(key) ON DELETE CASCADE,
revision INTEGER NOT NULL,
actor TEXT NOT NULL, -- user / agent id
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
checksum TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_revisions_chunk ON revisions(chunk_key);
```
### 4.8 Schema Versioning & Migrations
The database carries a schema version in a `meta` table:
```sql
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- seed: INSERT INTO meta(key, value) VALUES ('schema_version', '1');
```
Migrations are applied in order at startup. Each migration is a numbered SQL
script; the server tracks the applied version in `meta` and applies any pending
migrations transactionally. This keeps the schema forward-compatible as the
chunk format evolves.
---
## 5. Read/Write API
The API is exposed as a **Python service layer** on the backend (the same
process that owns the SQLite store) and consumed by the Godot client over the
network transport (see `networking.md`). The service layer is the **only**
writer to the database; clients never touch SQLite directly.
### 5.1 Service Interface (Python)
```python
class WorldStore:
"""Owns the SQLite connection and chunk read/write operations."""
def get_chunk(self, key: str) -> Chunk | None: ...
def get_chunk_binary(self, key: str) -> bytes | None: ...
def put_chunk(self, chunk: Chunk, actor: str) -> Chunk: ...
def delete_chunk(self, key: str, actor: str) -> None: ...
def list_chunks(self, world_id: str, room_id: str | None = None,
bounds: Bounds | None = None) -> list[str]: ...
def get_chunk_flags(self, key: str) -> ContentFlags: ...
def flush_dirty(self) -> int: ...
def migrate(self) -> None: ...
```
### 5.2 Encoding Helpers
The service layer provides symmetric encode/decode functions shared by the
client and server:
```python
def encode_binary(chunk: Chunk) -> bytes: ...
def decode_binary(data: bytes) -> Chunk: ...
def encode_json(chunk: Chunk) -> str: ...
def decode_json(text: str) -> Chunk: ...
def checksum(payload: bytes) -> str: ...
```
These are the single implementation of the format spec (§3), so the Godot
client and Python backend never drift.
### 5.3 Network RPCs
The chunk API is exposed to the client via RPCs (matching the networking
architecture). All chunk traffic is **reliable** (chunks are discrete,
must-arrive data).
| RPC | Direction | Payload | Notes |
|----------------------------|-----------|-------------------------------------------|-------|
| `request_chunk` | C → S | `{ key }` | Client asks for a chunk |
| `chunk_data` | S → C | `{ key, binary_payload, checksum }` | Server streams a chunk |
| `request_chunk_edit` | C → S | `{ key, base_revision, delta }` | Client submits an edit |
| `chunk_edit_result` | S → C | `{ key, success, new_revision, error? }` | Acknowledges an edit |
| `chunk_invalidated` | S → C | `{ key, new_revision }` | Server pushes a change |
| `request_chunk_flags` | C → S | `{ key }` | Age/content gate check |
### 5.4 Edit & Concurrency Model
- **Read:** Client requests a chunk; server returns the binary payload and its
`revision`.
- **Edit:** Client submits a **delta** against a known `base_revision`. The
server applies the delta, increments `revision`, persists, and returns the new
revision.
- **Optimistic concurrency:** If the chunk's current revision differs from the
client's `base_revision`, the server rejects the edit with a conflict error
and the client re-fetches and re-applies. This prevents lost updates when two
clients edit the same chunk.
- **Deltas:** A delta is a JSON patch (RFC 6902 style) applied to the chunk's
logical model. The server validates the patched chunk (schema, bounds,
moderation) before persisting.
### 5.5 Write-Behind Persistence
To amortize disk I/O, the server keeps a **dirty set** of chunks modified in
memory and flushes them to SQLite in batches:
1. `put_chunk` updates the in-memory chunk and marks it `dirty`.
2. A background flusher periodically calls `flush_dirty()`, which writes all
dirty chunks in a single transaction and clears the `dirty` flag.
3. On graceful shutdown, `flush_dirty()` is called to ensure durability.
This keeps the hot path (in-memory chunk access) fast while guaranteeing
durability at shutdown and on a configurable interval.
### 5.6 Streaming & Caching
- The server streams only the chunks a client needs (those within the client's
view radius), using `list_chunks` with a bounds query.
- Chunks are cached in memory (LRU) keyed by `key`; the cache is invalidated on
write.
- The binary payload is content-addressed by `checksum`, so unchanged chunks can
be served from cache without re-reading SQLite.
---
## 6. Format Versioning & Migration
- The chunk format `version` field (currently `1`) is checked on decode. A
decoder that encounters a higher version than it supports **rejects** the
chunk with a clear error rather than misreading it.
- The SQLite `schema_version` in `meta` drives database migrations (§4.8).
- Chunk payloads are migrated **lazily**: when a chunk is read and its
`format_version` is older than the current one, the server migrates it to the
current version in memory and marks it dirty so it is rewritten on the next
flush. This avoids a costly full-world migration at startup.
---
## 7. Security & Integrity
- **Server-authoritative writes:** Only the `WorldStore` writes to SQLite;
clients submit deltas that are validated before application.
- **Checksum verification:** Every chunk payload is checksummed (SHA-256) on
write and verified on read. Corruption is detected and the chunk is re-fetched
or flagged.
- **Bounds validation:** Chunk coordinates and object positions are validated
against the world's chunk dimensions and room bounds before persistence.
- **Moderation enforcement:** `content_flags` are checked at load time; mature
chunks are gated behind the age-verification flow (see `content_moderation.md`).
- **Input validation:** All string fields are length-limited and validated
against allowed character sets to prevent injection and oversized payloads.
---
## 8. Open Questions / Future Work
- **Volumetric worlds:** The schema and format already support `CHUNK_SIZE_Z >
1`, but the tooling and streaming for 3D chunks are not yet specified.
- **Chunk compression:** For very large worlds, the binary payload could be
compressed (e.g. zstd) before storage/transfer. The header reserves a `flags`
bitmask for a compression flag.
- **Multi-server sharding:** If a single SQLite file becomes a bottleneck, the
`chunks` table could be sharded by `world_id` across multiple databases. The
key design already supports this (world_id is a leading key component).
- **Delta compression for streaming:** Instead of sending full chunks, the
server could send only the delta between revisions to reduce bandwidth for
frequently-updated chunks.