DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0010: Inventory Replication — CARRY from Outcomes, SharedStorage and Node Lists on the Wire, Equipment, and the Id Codecs

Status

Accepted

Accepted 2026-09-11. Gate 0 confirmed GO the same day by the two-machine friend playtest (production/playtests/gate-0-2026-09-11/, record production/gate-checks/gate-0-provisional-pass-2026-09-11.md); the provisional note and revisit clause this ADR was written under are discharged. Playtest observation carried to the register: occasional remote stutter at ≈ 150–180 ms RTT (interpolation tuning, ADR-0009 §6).

Date

2026-09-11

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3.18f1) · NGO 2.13.2 (NetworkList<T>, NetworkVariable<T>, spawn-time field sync)
DomainNetworking (Core data replication)
Knowledge RiskHIGH for NetworkList semantics (initial sync with the spawn message, indexed set, event kinds, capacity) — read from the 2.13.2 source 2026-09-11 (NetworkVariable/Collections/NetworkList.cs, Core/NetworkBehaviour.cs), facts marked (src). LOW for everything else (plain C# tables that already ship).
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md; ADR-0008 §4–§6 (representation rule, encoding, snapshot), ADR-0002 (InventoryTransfer, ctx.Persist/Broadcast/QueueSpawn), ADR-0003 (IInventoryTx, ItemStack, worked PlaceStructureCommand with SpawnRequest.SharedStorage), ADR-0004 (carries/sharedStorage/harvestDiffs tables, stage 7, tag-without-row), ADR-0005 (LandNet, list-before-spawn, INodeSeeder seam), ADR-0015 (PlayerActor.Equipped, IGearHooks); design/quick-specs/inventory-hauling-2026-08-26.md, harvesting-system.md (Rule 4, EC1, EC16, replication note), crafting-system.md (Rules 4/5/7/9, EC21), base-building.md (Rule 12, storageEmpty, demolish gate); shipped code: Rules/Inventory/ (ICarryInventory, ISharedStorage, StackBag, InventoryTransferCommand), Runtime/Inventory/ (CarryInventory, SharedStorage, InventoryTx, CarriesSaveTable), Runtime/Harvest/* (NodeTable, LandStateAdapter), Rules/Harvest/HarvestResolveCommand.cs, Runtime/Land/LandSeams.cs (INodeSeeder), Data/Tables/Ids.cs (ItemId/ContainerId are strings, NodeId is an int)
Post-Cutoff APIs UsedNetworkList<T>(IEnumerable<T> values, readPerm, writePerm) where T : unmanaged, IEquatable<T> (src 14/39); backing NativeList<T>(64, Persistent) — grows (src 16); OnListChanged + NetworkListEvent<T>{EventType, Index, Value} (src 23–28, 197…); Add, Remove, Clear, Contains, IndexOf, Count, indexer set (src 433–534); WriteField/ReadField (full contents — the spawn-time sync path, src 150/160) and WriteDelta/ReadDelta (src 85/171); NetworkBehaviour.NetworkVariableFields initialised on spawn (src NetworkBehaviour.cs 838); NetworkVariable<uint> server-write (ADR-0008)
Verification Required(V1) Confirmed from source 2026-09-11 (specialist): spawn-time sync calls WriteFieldSynchronization → WriteField for every NetworkVariableFields entry (NetworkBehaviour.cs ~1384–1400, NetworkVariableBase.cs ~496), so a NetworkList populated before Spawn() arrives in full with the spawn message and is readable in OnNetworkSpawn. ADR-0005's NodesReady contingency is not needed; story 1 keeps a PlayMode assertion (Nodes.Count == instance.Nodes.Count in OnNetworkSpawn) so a future NGO change is caught, not a runtime gate. (V2) The indexer set Rows[i] = row on the host emits one Value event and one delta; a client's OnListChanged sees Index == i. (V3) Confirmed from source (specialist): NetworkList<T>'s constraint is exactly where T : unmanaged, IEquatable<T> (NetworkList.cs:14); INetworkSerializeByMemcpy belongs to NetworkVariable<T>/RPC serializer selection (FallbackSerializer.cs) and is inert for lists — the row structs may declare it (harmless) but need not. Indexer set routes to Set(int, T) → one Value event + one delta (NetworkList.cs ~617–659) — V2 also confirmed; NativeList grows on demand (no hard cap). (V4) A list of storageSlots (40; ≤ 80) rows and a land's ~40 node rows stay under one MaxPayloadSize (6 144 B) in the spawn message: 40 × 8 B = 320 B — trivially; asserted so growth is noticed. (V5) CarrySync (≤ 90 B) fits the raised 128 B outcome payload and reaches only the owner's view (others ignore by OwnerClientId).

Revisit clause (Gate 0)

No section is at risk from a feel verdict. The two provisional items are ADR-0008's: the one-tick state lag (affects when a StorageNet row visibly changes relative to its InventoryTransferred outcome — presenters act on the outcome, §2) and the snapshot chunk size.

Documents Amended by This ADR

Document Amendment
ADR-0008§4 OutcomeEnvelope payload becomes 128 B (fixed byte Payload[128], MaxOutcomePayloadBytes ≤ 128) so CarrySync (10 slots + 2 equipment rows ≈ 90 B) fits; OutcomeKind gains CarrySync (owner-addressed), EquipmentChanged, StorageSpawned/StorageDespawned are not outcomes (spawn/despawn is state); §6 SnapshotDocument drops the joiner's own CARRY and the sharedStorage and harvestDiffs tables — CARRY arrives as CarrySync from SessionReadyCommand.Execute; storage and nodes have live replicated forms; §5 table rows for CARRY, SharedStorage, NodeState are now decided (this ADR); the snapshot header gains itemIndexHash (§1).
ADR-0002CommandKind gains Equip, Unequip (§5) — extended by ADR as ADR-0002 requires; SpawnRequest (a placeholder struct in Rules.Commands) gets its shape (§3): { SpawnKind Kind; uint StructureInstance; Cell Origin; int Slots } plus a DespawnRequest for demolish.
ADR-0003HostContext gains QueueDespawn(DespawnRequest) — 19 members; HostContextMemberCountTests 18 → 19 in the same story; IInventoryTx unchanged; SpawnRequest.SharedStorage(instance, slots) in the worked PlaceStructureCommand is now a real factory; new Runtime types StorageNet, StorageReplicator, CarryView, ItemIndexTable, TagCodec, NodeListSeeder : INodeSeeder; Rules.Inventory gains EquipCommand/UnequipCommand, EquipmentSlot, CarryDelta.
ADR-0005INodeSeeder (the shipped seam) is implemented by NodeListSeeder: writes LandNet.Nodes from LandInstance.Nodes + kept diff rows before Spawn(); the NodesReady contingency clause is closed — spawn-time sync sends full list contents (V1, source-confirmed).
ADR-0015PlayerActor.Equipped{Weapon, Armor} is written only by EquipCommand/UnequipCommand.Execute through a new ICombatState.SetEquipped(key, slot, instanceId?); IGearHooks reads are unchanged.
ADR-0004carries table's reserved equipment array (CarriesSaveTable._equipmentPassThrough) becomes live: two instanceId strings (weapon, armor) — the persisted half of §5.
design/quick-specs/inventory-hauling-2026-08-26.mdRule 4: equip moves the tagged stack out of the bag into the slot (freeing a bag slot); unequip needs a free bag slot unless it is a swap — the "never requires bag slots" wording is narrowed to swaps (§5); Rule 7's "prediction only on own CARRY" is made concrete: transfers only, never harvest yields (§2).
design/gdd/harvesting-system.mdEC16's ⚑ closes: swingsRemaining is a NetworkList row on LandNet, delivered with the land's spawn (§4).
design/gdd/crafting-system.mdRule 7's ⚑ closes: instanceId is issued host-side, rides stacks as the decimal-string instanceTag, and crosses the wire as uint (§1 TagCodec); InstanceMetadata sync is ADR-0008's snapshot + CraftCommitted row.
architecture.mdRequired-ADR row 10 ✅; §2 Harvesting/Inventory rows lose their ⚠️; QQ-13 closed (NetworkList<NodeState>).

ADR Dependencies

Field Value
Depends OnADR-0008 (representation rule, outcomes, snapshot, SessionReady), ADR-0002 (InventoryTransfer, QueueSpawn, drain phases), ADR-0003 (IInventoryTx, ItemStack, purity), ADR-0004 (tables), ADR-0005 (LandNet + seeder seam), ADR-0015 (Equipped, gear hooks)
EnablesADR-0011 (a storage-category structure spawns a StorageNet through this ADR's SpawnRequest; demolish gates on StorageNet emptiness), ADR-0012 (nothing — enemies carry no items), presentation-ui-hud-screens (inventory + storage screens read CarryView/StorageView), crafting runtime epic (CraftCommitted deltas on CarryView)
Blocksinventory replication stories (TR-inv-002/004/007), harvesting's two declared limbs (TR-harv-001 replication; TR-harv-007's slack is ADR-0008's), land story 009's node list, HUD inventory/storage screens
Ordering NoteThird of the five. ADR-0011 must use SpawnRequest.SharedStorage and StorageNet as defined here; it decides only the structure registry's own representation.

Context

Problem Statement

The host already owns every bag and every chest: InventoryTx holds the per-PlayerKey carries and per-ContainerId storages, and InventoryTransferCommand/HarvestResolveCommand/PlaceStructureCommand mutate them inside the drain. What is undecided is how any of that reaches a client: the spec calls CARRY "owner-authoritative and client-predicted" while every mutation is a host command; SharedStorage must render host-confirmed state only; harvesting wants swingsRemaining "continuously replicated" and its EC16 wants that bounded; crafting's instanceTag and every ItemId/ContainerId are strings, and NetworkList<T> accepts only unmanaged rows. TR-inv-007 says the CARRY representation is "undefined; must be decided by the inventory replication ADR". This is that ADR.

Constraints

Requirements

TR-inv-002 (CARRY owner-side + predicted; SharedStorage host-auth, N instances), TR-inv-004 (no world items; two equipment slots outside the bag), TR-inv-007 (CARRY representation), TR-inv-003 (atomic transfers — already met by InventoryTransferCommand; this ADR adds the client half), TR-harv-001 (swingsRemaining replicated), TR-harv-007's crack-stage half (host-confirmed), TR-craft-004/008 (instance ids on the wire; craft commit deltas reach the bag view), TR-bb-006 (storage placement instantiates a container).

Decision

Alternative A: the host's tables are the only truth. A player's CARRY never rides a NetworkVariable: the owner's CarryView is seeded by a CarrySync outcome when its SessionReady executes and thereafter applies the outcomes the host already broadcasts (HarvestResolved, InventoryTransferred, CraftCommitted, StructurePlaced/Demolished, EquipmentChanged), with a predicted overlay for the owner's own transfer clicks only; each SharedStorage is a StorageNet NetworkObject spawned by the placement command carrying a slot-indexed NetworkList<StackNetRow> mirrored from the host table after every drain; each docked land's nodes are a NetworkList<NodeNetRow> on LandNet, written before Spawn() and on every Decrement; string ids cross the wire through two deterministic codecs (ItemIndexTable, TagCodec); equipment is two commands and one outcome.

1. Ids on the wire — the two codecs

Rules/Data type Wire form Codec Why deterministic
ItemId (string)ushort ItemIndexItemIndexTable (Runtime): built once at data load as the ordinal-sorted key order of IGameData.Items; 0xFFFF = empty slotevery machine loads the same configVersion (ADR-0008 §6 disconnects on mismatch); the table's FNV-1a hash rides the snapshot header as itemIndexHash and a mismatch is a ConfigVersion disconnect — the codec can never silently mis-map
InstanceTag (string, null = untagged)uint InstanceId; 0 = untaggedTagCodec: a tag is the decimal rendering of crafting's host-monotonic instanceId (Rule 7 — issued from 1); a tag that does not parse as a positive uint is a data error: EditMode-fenced on fixtures; at runtime the shipped CarriesSaveTable.Reconcile posture wins — the row is kept with its tag and reported KeptOrphan (crafting Rule 9), the tag is not stripped; on the wire such a row encodes as InstanceId = 0 (untagged) — the wire form is lossy for orphans, so an orphan can never round-trip through a client view, which is fine because views never write back (TD C6)crafting issues the ids; nothing else creates a tag
NodeId (int)ushort NodeIndex = NodeId.Valuenone neededLayout.Build numbers nodes 0..n-1 in a fixed order on every machine
ContainerId (string)uint StructureInstanceContainerId.Value == "s" + structureInstanceId — the s prefix keeps the two decimal id spaces (containers, gear instances) distinguishable in reconcile findings and makes a cross-table mix-up a parse failure, not a silent hit (TD C6). Handed to ADR-0011 as a constraint it may reject: if it does, this row and StorageNet.StructureInstance change together and nothing else does (TD C7)the structure instance id is host-monotonic and persisted (ADR-0004)
PlayerKey16 BADR-0008—

2. CARRY — host truth, owner view from outcomes, prediction for transfers only

Outcome (ADR-0008 OutcomeRpc) Delta the view applies Where the numbers come from
CarrySync{Player, rows[10]{ItemIndex, Count, InstanceId}, equipment[2]}replace Confirmed wholesale, clear Pendinghost table at SessionReadyCommand.Execute (owner-addressed: OwnerClientId set, others ignore)
HarvestResolved{Player, Socket, Node, Item, Yield}+Yield × Itemthe outcome (shipped record; Item crosses as ItemIndex)
InventoryTransferred{Player, Container, Direction, Item, Count, InstanceTag}∓ on CARRY per direction; consumes the matching Pending entry (same direction/item/count/tag, FIFO)the outcome
CommandRejected{ClientSeq, Reason} (ADR-0008)drop the Pending entry stamped with that ClientSeq—
CraftCommitted{Player, RecipeId, InstanceId, mark row} (ADR-0008)− the recipe's inputs, + its output tagged InstanceIdIGameData.Recipes[RecipeId] — data, identical everywhere
StructurePlaced{Instance, Structure, Origin, Placer} / StructureDemolished{…, Demolisher} (ADR-0011 fixes fields)− StructureDef.Cost for the placer / + the refund for the demolisherIGameData.Structures + BB's refund rule (data)
EquipmentChanged{Player, Slot, InstanceId?, BagSlotFreed/Used}move the tagged stack between bag and slotthe outcome

Two shipped commands mutate without a bag-delta outcome (TD C1). (i) HarvestResolveCommand's post-Decrement BagFull path spends the node's swing and adds nothing: the node row moves (§4) and the owner receives CommandRejected(BagFull) with a ClientSeq that matches no Pending entry (harvests are never predicted). The fold treats a CommandRejected with no matching pending entry as a no-op on Confirmed, never an error — by rule, not by luck. (ii) InventoryTransferCommand's restore path (Add fails, the source takes its stack back, Fail(BagFull)) leaves both tables net-unchanged and dirty-marks neither; StorageReplicator's dirty set is therefore correct by construction. Both are asserted (Inv_FoldHandlesUnmatchedRejection; a replicator test over the restore path). A future bag-touching command must broadcast a delta-bearing outcome — the total-switch fence catches a declared kind without a fold case, but it cannot catch a mutation with no outcome at all, which is why this rule is written down and reviewed per command. The view is therefore a pure fold over an ordered stream (Rules.Inventory.CarryFold.Apply(confirmed, pending, outcome, self, data) — engine-free, EditMode-tested), and OutcomeRpc is reliable and per-object ordered (ADR-0008 §3), so Confirmed equals the host table after every outcome by construction. No NetworkVariable, no per-tick lag (ADR-0008 §2's lag is for state; outcomes leave the same tick).

3. SharedStorage — one StorageNet per container, slot-indexed list

4. Nodes — NetworkList<NodeNetRow> on LandNet, written before Spawn()

5. Equipment — two slots outside the bag, two commands, one outcome

6. What is deliberately not replicated

Other players' CARRY; enemy inventories (none); harvestDiffs; InstanceMetadata beyond ADR-0008's snapshot + CraftCommitted row; any count of anything for HUD purposes other than through the structures above.

7. Fences (EditMode, every CI pass)

Fence Asserts
Inv_WireRowsAreUnmanagedStackNetRow (8 B: ushort ItemIndex, ushort Count, uint InstanceId) and NodeNetRow (3 B) are blittable, declare IEquatable<T>, contain no reference field (reflection)
Inv_ItemIndexIsSortedOrdinalItemIndexTable.Build(data) equals Items.Keys.OrderBy(ordinal); hash stable across two builds; a fixture with a changed key set changes the hash
Inv_TagCodecRoundTripevery fixture instanceTag round-trips through uint; a non-numeric tag is a fixture failure
Inv_StorageRowsSingleWriterRows[ assignment / Rows.Add( / .Clear( appear only in StorageReplicator and StorageNet.Initialise (raw-text)
Inv_NodesSingleWriterNodes[ assignment appears only in NodeListSeeder and LandStateAdapter.Decrement (raw-text)
Inv_NoCarryOnWireno NetworkVariable<>/NetworkList<> field whose type argument names ItemStack, StackBag, CarryRow or CarryInventory (reflection); CarryView derives from no NetworkBehaviour
Inv_CarryFoldIsPureCarryFold.Apply lives in Driftlands.Rules (engine-free by ADR-0003's assembly fence) and is total over every OutcomeKind the fold claims (a switch with no default fall-through — a new bag-touching outcome without a fold case fails a test)
Inv_FoldHandlesUnmatchedRejectiona CommandRejected whose ClientSeq matches no pending entry leaves Confirmed and Pending unchanged; the harvest BagFull-after-Decrement fixture (node row moves, no HarvestResolved) reproduces the host table
Inv_StorageRowsFixedLengthRows.Count == storageSlots after Initialise and after every replicator pass (PlayMode, host only)

Architecture Diagram

HOST (drain)                                                              CLIENTS
InventoryTx tables ◄── commands: HarvestResolve · InventoryTransfer · CraftCommit · PlaceStructure/Demolish · Equip/Unequip
   │ phase 4 flush ── OutcomeRpc ──────────────────────────────────────► owner: CarryView.Confirmed = CarryFold(prev, outcome, data); Pending consumed/dropped
   │                  CarrySync (owner-addressed, from SessionReady)  ─► seeds the view on join/rejoin; no CARRY in the snapshot
   │ after Drain: StorageReplicator diffs Snapshot → StorageNet.Rows[i] ═══ NetworkList deltas ═══► StorageView (grid = rows; flyout = InventoryTransferred)
   │ QueueSpawn(SharedStorage) / QueueDespawn ── after the loop ── spawn/despawn StorageNet (rows in the spawn message)
LandActivated: NodeTable → NodeListSeeder → LandNet.Nodes (before Spawn) ═══ spawn message ═══► NodeView crack stages
Decrement: NodeTable + diff row + Nodes[i] ═══ delta ═══► NodeView
ids: ItemId ⇄ ItemIndexTable (hash in snapshot header) · InstanceTag ⇄ TagCodec(uint) · NodeId.Value = NodeIndex · ContainerId = structureInstanceId

Key Interfaces

namespace Driftlands.Rules.Inventory                    // engine-free
{
    public enum EquipmentSlot : byte { Weapon, Armor }
    public readonly record struct CarryDelta(uint ClientSeq, TransferDirection Direction, ItemId Item, int Count, string InstanceTag);
    public static class CarryFold { public static (StackBag confirmed, IReadOnlyList<CarryDelta> pending) Apply(StackBag confirmed, IReadOnlyList<CarryDelta> pending, IOutcome o, PlayerKey self, IGameData data); }
    public sealed record EquipCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, EquipmentSlot Slot, string InstanceTag) : ICommand;
    public sealed record UnequipCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Player, EquipmentSlot Slot) : ICommand;
    public sealed record EquipmentChanged(PlayerKey Player, EquipmentSlot Slot, string InstanceTag /* null = emptied */) : IOutcome;
    public sealed record CarrySync(PlayerKey Player, IReadOnlyList<ItemStack> Slots, ItemStack Weapon, ItemStack Armor) : IOutcome;   // owner-addressed; ≤ 128 B on the wire
}

namespace Driftlands.Rules.Commands
{
    public enum SpawnKind : byte { SharedStorage }
    public readonly record struct SpawnRequest(SpawnKind Kind, uint StructureInstance, Cell Origin, int Slots) { public static SpawnRequest SharedStorage(uint instance, Cell origin, int slots) => new(SpawnKind.SharedStorage, instance, origin, slots); }
    public readonly record struct DespawnRequest(uint StructureInstance);
    // HostContext.QueueSpawn(SpawnRequest) exists (ADR-0003); QueueDespawn(DespawnRequest) is added as its sibling. ADR-0003's member list is reviewed at each new ADR (its §Risks mitigation);
    // this ADR exercises that review and takes HostContext to NINETEEN members — Tests/EditMode/HostContextMemberCountTests' constant moves 18 → 19 in the same story (TD C2).
}

namespace Driftlands.Runtime.Inventory
{
    public struct StackNetRow : IEquatable<StackNetRow>   // unmanaged + IEquatable is the whole NetworkList constraint (src) { public ushort ItemIndex; public ushort Count; public uint InstanceId; }   // 8 B; ItemIndex 0xFFFF = empty
    public sealed class ItemIndexTable { public ushort Of(ItemId id); public ItemId At(ushort i); public uint Hash { get; } public static ItemIndexTable Build(IGameData data); }
    public static class TagCodec { public static uint ToWire(string tag); public static string FromWire(uint id); }   // 0 ⇄ null
    public sealed class StorageNet : NetworkBehaviour {                                    // one per SharedStorage; prefab in NetworkPrefabsList
        public NetworkVariable<uint> StructureInstance = new(writePerm: NetworkVariableWritePermission.Server);
        public NetworkList<StackNetRow> Rows = new(writePerm: NetworkVariableWritePermission.Server);   // fixed length = storageSlots
        internal void Initialise(uint instance, StackBag snapshot, ItemIndexTable idx);   // host, before Spawn()
    }
    public sealed class StorageReplicator { public void AfterDrain(IReadOnlyCollection<ContainerId> dirty); }   // the ONLY runtime writer of Rows
    public sealed class CarryView : ICarryView { public StackBag Confirmed { get; } public StackBag Visible { get; } public void Predict(CarryDelta d); public void OnOutcome(IOutcome o); }
    public sealed class StorageView { public StackBag Rows { get; } public event Action<int> SlotChanged; }
}

namespace Driftlands.Runtime.Harvest
{
    public struct NodeNetRow : IEquatable<NodeNetRow> { public ushort NodeIndex; public byte SwingsRemaining; }
    public sealed class NodeListSeeder : INodeSeeder { public void Seed(LandInstance instance); }        // writes LandNet.Nodes before Spawn (ADR-0005 step 2→3)
    // LandNet (ADR-0005/story 009) gains: public NetworkList<NodeNetRow> Nodes = new(writePerm: Server);  [+ NetworkVariable<bool> NodesReady iff V1 fails]
}

namespace Driftlands.Rules.Combat { public partial interface ICombatState { void SetEquipped(PlayerKey key, EquipmentSlot slot, InstanceId? instance); } }   // ADR-0015 amended; written only by Equip/Unequip

Alternatives Considered

Alternative B: an owner-read NetworkVariable<CarryNet> for the bag

Alternative C: everything outcome-driven (no NetworkList for storage or nodes)

Alternative D: one NetworkVariable<byte> per node

Alternative E: ContainerId as an arbitrary string with a runtime id map

Alternative F: own CARRY inside the snapshot (ADR-0008 as first written)

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD / spec Requirement How This ADR Addresses It
inventory Rule 1, 2 (TR-inv-001/002)two components; stack interface with opaque tag§2/§3; TagCodec carries the tag verbatim as the id it is
inventory Rule 6, 7 (TR-inv-003)atomic host transfers; race = honest no-op; prediction on own CARRY only; storage host-confirmedshipped command + §2 Pending/CommandRejected; §3 StorageView renders rows only
inventory Rule 4, 5 (TR-inv-004)equipment outside the bag; no world items§5; nothing here creates a world item
inventory AC "own bag within one frame" / "storage on host confirmation"§2 prediction; §3 rows
TR-inv-007CARRY representation§2 — outcomes + CarrySync, no variable
harvesting Rule 4, EC16, replication note (TR-harv-001)swingsRemaining replicated; staleness bounded§4
harvesting EC1in-flight swing lands in a departed player's CARRYhost table (shipped) + CarrySync on rejoin
crafting Rules 4/7 (TR-craft-004)instanceId monotonic, rides as a tag§1 TagCodec
crafting commit deltas (TR-craft-008)inputs consumed, output produced§2 fold over CraftCommitted + recipe data
base-building Rule 12, storageEmpty (TR-bb-006)placement instantiates a container; demolish only when empty§3 SpawnRequest.SharedStorage, QueueDespawn after ChestNotEmpty
save Rule 2 (TR-inv-005)carries/sharedStorage persistunchanged; equipment array becomes live

Performance Implications

Migration Plan

Validation Criteria

Related Decisions

Amendments — 2026-09-11 (ADR-0011 structure registry replication)

Editorial amendments — 2026-09-11 (TD-MANIFEST C2)

Amendments — 2026-09-13 (NetcodeSession story-006b — SpawnRequest union)