Document type: Normative.
Scope: iWorldService, fork semantics, destroy semantics, info-class downgrade. Append-only invariant.
1. Append-only is non-negotiable¶
Archetype's storage and audit layers are append-only. There is no method on iAsyncStore or iAuditLog that deletes data. Ever.
The verb "drop" does not appear in either protocol. The verb "delete" does not appear in either protocol. Implementation code MUST NOT issue DELETE statements against archetype tables or the audit table.
This invariant is load-bearing for time-travel queries, audit integrity, fork persistence, and the destroy_world semantics defined below. It is the second-most important invariant in the system, after single-gate enforcement.
(Caveat: AsyncCachedStore may evict from its in-memory cache. That is cache eviction, not data deletion. The persisted layer underneath is untouched.)
2. World lifecycle operations¶
Three operations on iWorldService, plus their gated proxies on iCommandService:
| Operation | What it does |
|---|---|
create_world |
Establish new world identity. Storage allocated. |
fork_world |
Create a derivative world from a snapshot of source's current state. |
destroy_world |
In-memory cleanup. Storage and audit rows are NEVER touched. |
3. create_world¶
# iWorldService (returns iWorld; internal use)
async def create_world(
config: WorldConfig,
storage_config: StorageConfig | None = None,
cache_config: CacheConfig | None = None,
system: iAsyncSystem | None = None,
) -> iWorld: ...
# iCommandService (returns WorldInfo; downgrade at gate boundary)
async def create_world(
ctx: ActorCtx,
config: WorldConfig,
storage_config: StorageConfig | None = None,
cache_config: CacheConfig | None = None,
) -> WorldInfo: ...
WorldConfig is serializable: world_id, run_id, name, tick, next_entity_id, dictionaries for entity2sig, spawn_cache, despawn_cache, plus lineage (fork ancestry read segments, see §4.6). NOTHING ELSE.
Processors, resources, and hooks are arbitrary Python objects; they do NOT go in WorldConfig. The runtime wires them at activation through dedicated gated paths:
iCommandService.add_processor(ctx, world_id, processor)iCommandService.add_resource(ctx, world_id, resource)iCommandService.add_hook(ctx, world_id, event_type, fn)
Each is one gated call, one audit row.
4. fork_world¶
async def fork_world(
source_world_id: UUID | str,
name: str | None = None,
storage_config: StorageConfig | None = None,
cache_config: CacheConfig | None = None,
) -> iWorld: ...
Fork creates a new world from a snapshot of the source's current state.
4.1 — Generated fresh in the fork¶
| Field | Value |
|---|---|
world_id |
new uuid7() |
name |
from caller |
run_id |
new uuid7() — fork starts a new run lineage |
4.2 — Copied from source (deep snapshot at fork time)¶
| Field | Reason |
|---|---|
tick |
fork inherits source's current tick — no temporal reset |
next_entity_id |
preserves entity-id space alignment for cross-fork comparison |
entity2sig (dict) |
the entities exist in the fork; mapping must be independent |
spawn_cache (dict) |
pending spawns at fork moment carry over to the fork |
despawn_cache (dict) |
same reason |
| hooks registry | hook registrations at fork moment carry over; new registrations on either side don't propagate |
Pending mutation transfer is the critical detail. If a user spawns an entity then immediately forks before the next step materializes the spawn, the fork inherits the pending spawn. Source and fork both see the entity on their next tick; the entity diverges from there.
4.3 — Shared between source and fork (same Python instance)¶
| Field | Reason |
|---|---|
resources |
resources are typically clients / connections / handles. Source and fork share the same Resources instance. |
processors (in system) |
the system holds processor instances. Forks share the same processor instances. |
4.4 — Implication of resource sharing¶
A NATSConnection in source.resources is the SAME NATSConnection in fork.resources. State mutations on the resource are visible to both. Stateful resources (live connections) need user-side awareness.
For users who want isolated resources per fork, they instantiate new resource instances explicitly. A future API may add fork_world(..., new_resources=[...]); not in v1 scope.
4.5 — Storage¶
The fork writes to the same physical store as the source by default, with rows partitioned by world_id. The optional storage_config argument allows the fork to write to a different store entirely.
Routing a fork to a different store severs read lineage (§ 4.6): ancestor
segments name rows in the source's store, and the fork's reads only see its
own store. A cross-store fork therefore starts from transferred pending
state (un-materialized spawns/despawns), not from the source's persisted
history. Forking with an explicit storage_config on a stepped source logs
a warning for this reason. Same-store forks — the default — get full
history continuation.
4.6 — Read lineage (copy-on-write history)¶
A fork does not copy the source's materialized rows. Instead it carries a lineage — an ascending list of (world_id, run_id, up_to_tick) segments: the source's own lineage plus, if the source has stepped, one segment covering the source's rows for ticks 0..tick-1.
Reads resolve per tick: a tick at or below a segment's up_to_tick reads from that ancestor's run; later ticks read from the fork's own run. Because the store is append-only, ancestor rows are immutable history — a parent that keeps running (or is destroyed) after the fork never affects the fork's view, and rows the parent writes after the fork point are excluded from the fork's history.
Consequences:
- Forking stays O(metadata) regardless of world size.
- A fork of a stepped world is immediately queryable, and its first step processes the parent's last tick as input — state continues across the fork point.
- Lineage flattens across generations: a fork of a fork reads base history, mid-fork history, and its own rows through one segment list.
- Lineage is durable. At fork time the full ancestor chain is appended to the store as
WorldLineagerows under the fork's(world_id, run_id)(negative entity ids — metadata, never live entities). The store is append-only, so provenance is never compromised: gated reads resolve ancestry for destroyed worlds by loading the persisted chain (QueryService.get_lineage). Live worlds resolve from memory; the persisted rows are the fallback and the system of record.
Contract tests: tests/integration/test_fork_destroy_contracts.py (§8 fork lineage, §9 persisted lineage / dead-world ancestry).
4.7 — Audit emission¶
iCommandService.fork_world emits one audit row with:
command_type = "fork_world"payload_json = {"source_world_id": ..., "fork_world_id": ..., "name": ..., "tick_at_fork": ...}
See Audit Log for the audit row schema.
5. destroy_world¶
In-memory cleanup. Drops the live iWorld instance from the registry. Persisted storage and audit rows are NEVER touched.
# iWorldService
async def destroy_world(world_id: str | UUID) -> None: ...
# iCommandService (orchestrates cross-service cleanup)
async def destroy_world(ctx: ActorCtx, world_id: str | UUID) -> None: ...
5.1 — iCommandService.destroy_world steps, in order¶
- Fire
OnDestroyhook on the world (typed event incore/hooks.py). Handlers may read final state but MUST NOT submit commands; the world is closing. - Flush any buffered audit rows for this world via
iAuditLog.flush(). (Flush ≠ delete — rows are written to permanent storage; nothing is dropped.) - Cancel pending in-memory broker commands for this world via
iCommandBroker.clear(world_id). - Take the world's
op_lock; await any in-flightstep()to complete (wait-then-destroy). - Call
iWorldService.destroy_world(world_id)— removes from registry. - Emit ONE audit row recording the destroy.
5.2 — iWorldService.destroy_world steps¶
- Resolve world from registry; return early if absent (idempotent).
- Fire
OnDestroyvia the world's hook bus. - Remove from
WorldRegistry.
iWorldService.destroy_world is the registry-level primitive. The cross-service cleanup (broker.clear, audit.flush) is iCommandService's concern; iWorldService doesn't have references to the broker or audit log.
5.3 — What destroy_world is NOT¶
- It does NOT delete the world's persisted rows from
iAsyncStore. Time-travel queries against the destroyed world remain valid forever. - It does NOT delete audit rows.
iAuditLog.query(world_id=...)returns the destroyed world's rows. - It does NOT delete storage files. The
StorageServicekeeps files associated with the destroyed world's data.
5.4 — OnDestroy event¶
Defined in core/hooks.py:
@dataclass(frozen=True, slots=True)
class OnDestroy(HookEvent):
"""Fires before in-memory world cleanup begins.
Handlers MAY read final world state but MUST NOT submit commands."""
world_id: UUID
Read-only by convention; no enforcement in v1.
5.5 — Idempotency¶
Destroying an unknown world_id is a no-op, not an error. Repeated calls to destroy_world(world_id) after the first succeed silently.
6. WorldInfo and the info-class downgrade¶
The gate downgrades live objects to immutable info classes before returning to user code. This is what enforces "the runtime never holds an iWorld."
6.1 — WorldInfo¶
class WorldInfo(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
world_id: UUID
name: str | None = None
tick: int
run_id: UUID | None = None
Returned by create_world, fork_world, get_world_info. Field access is sync; the round-trip to fetch is async (gated).
WorldInfo is NOT the same shape as WorldConfig. WorldConfig carries pending caches and entity mappings (large at scale); WorldInfo is identity + current tick only.
6.2 — Other info classes¶
Defined in app/models.py:
class ProcessorInfo(BaseModel):
model_config = ConfigDict(frozen=True)
qualname: str
priority: int
components: tuple[str, ...] # component class qualnames
class HookInfo(BaseModel):
model_config = ConfigDict(frozen=True)
event_type: str # HookEvent subclass qualname
handler_qualname: str
mode: str # "blocking" | "spawn"
handle_id: int # internal HookHandle id
class ResourceInfo(BaseModel):
model_config = ConfigDict(frozen=True)
qualname: str # resource class qualname
# Resources are type-keyed in the underlying container;
# qualname is sufficient identity. No second field.
6.3 — Downgrade points¶
| Internal return | Gate return |
|---|---|
iWorldService.create_world → iWorld |
iCommandService.create_world → WorldInfo |
iWorldService.fork_world → iWorld |
iCommandService.fork_world → WorldInfo |
iWorldService.list_processors → list[iAsyncProcessor] |
iCommandService.list_processors → list[ProcessorInfo] |
iWorldService.list_hooks → list[HookHandle] |
iCommandService.list_hooks → list[HookInfo] |
iWorldService.list_resources → list[object] |
iCommandService.list_resources → list[ResourceInfo] |
The downgrade happens at the gate's return statement. Live objects (iWorld, iAsyncProcessor instances, callables, user resources) never escape past the gate.
7. Permissions¶
| Method | viewer | player | operator | admin |
|---|---|---|---|---|
create_world |
— | — | — | ✓ |
fork_world |
— | — | ✓ | ✓ |
destroy_world |
— | — | ✓ | ✓ |
get_world_info |
✓ | ✓ | ✓ | ✓ |
list_processors |
✓ | ✓ | ✓ | ✓ |
list_hooks |
✓ | ✓ | ✓ | ✓ |
list_resources |
✓ | ✓ | ✓ | ✓ |
fork_world and destroy_world are operator-permitted because operators routinely fork (rollouts) and destroy (cleanup), and neither operation deletes persisted data.
8. Tests¶
Critical:
destroy_world: storage row count for the world is unchanged before/after (verify viaQueryService.query_archetype).destroy_world: audit row count for the world is unchanged before/after (verify viaAuditLog.query(world_id=...)).fork_world: spawn-then-fork → entity exists in BOTH source's next tick AND fork's first tick.fork_world: source.resources is fork.resources (identity check).fork_world: hook registered on source post-fork does NOT fire on fork.destroy_worldis idempotent on unknown ids.destroy_worldwaits onop_lockfor in-flightstep()to complete.iAsyncStoreprotocol has nodrop_*/delete_*method. (Reflective check.)iAuditLogprotocol has nodrop_*/delete_*method. (Reflective check.)
9. Out of scope¶
- Per-resource forking semantics (isolated resources per fork). v1: shared.
- Per-world storage migration after creation. v1: storage_config at creation time only.
- Snapshot export / world serialization to disk. v1: forks are the only supported "save" mechanism.