Through nothing but public API, the entity index can be corrupted in several related ways: claiming the next generation of a live id silently kills the live entity and hands its components to the claimer; world:entity() can issue the same id to two different logical entities (with one's component data bleeding into the other); world:entity() can return an id that world:contains() rejects and world:add() silently ignores; deleting one entity can make a never-created id fully alive; and a desired-id claim can permanently destroy a recycle-queue entry. Two smaller invariant bugs (an exported allocator returning ids from the wrong range, and unbounded map growth in cached queries) round out the cluster.
Affects: current main (3087601). Found during a systematic audit (context: #317); reproduces identically with and without that PR's changes.
Most of these share one theme: the desired-id branch of world_entity (src/jecs.luau:3366-3413) makes no liveness or occupancy checks — it never asks whether the slot's current generation is still alive, whether the dense cell it appends into is already occupied by a parked id, or whether the claimed id itself is sitting parked in the dead zone. They can likely be fixed together, so they are grouped here for triage. The critical bugs from the same audit are filed separately.
All repros: save as repro.luau in the repo root, run luau repro.luau. Output shown is verbatim from real runs against main and is deterministic across runs.
1. Claiming gen+1 of a live slot silently kills the live entity and steals its components (HIGH)
Root cause. In world_entity's desired-id branch (src/jecs.luau:3383-3391), when sparse_array[index] exists and dense_array[r.dense] holds a different id, the code unconditionally appends the claimed id at alive_count + 1 and repoints r.dense there — without checking whether the current occupant is alive (dense <= alive_count). The slot's single record object is reused as-is (archetype/row preserved), so the claimed id inherits every component of the live entity. The old id is left behind in dense_array inside the alive region and in its archetype's entities row, so queries still yield it — while entity_index_try_get (line 511, backing world:contains) now rejects it because dense_array[r.dense] ~= entity.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local C = world:component()
local e = world:entity() -- alive
world:set(e, C, "old-data")
print("[1] e alive before claim:", world:contains(e))
-- claim generation+1 of the SAME, still-alive slot
local claimed = world:entity(jecs.ECS_GENERATION_INC(e))
print("[2] old live entity still contained (expect true, or claim rejected):", world:contains(e))
print("[3] claimed is contained:", world:contains(claimed))
print("[4] claimed inherits old entity's component (expect nil):", world:get(claimed, C))
for ent, v in world:query(C) do
print("[5] query yields:", ent, v, "== old e:", ent == e, "contains(yielded):", world:contains(ent))
end
Observed:
[1] e alive before claim: true
[2] old live entity still contained (expect true, or claim rejected): false
[3] claimed is contained: true
[4] claimed inherits old entity's component (expect nil): old-data
[5] query yields: 272 old-data == old e: true contains(yielded): false
Expected: claiming a higher generation of a slot whose current generation is still alive should be rejected (id conflict) — or at minimum must not corrupt the index. Instead the live entity is silently de-listed, the claimed id takes over its record and components, and queries yield an id that contains() declares dead — a direct query/liveness invariant violation, with the dead id stuck in the alive dense region permanently.
2. Claiming the parked next-generation id is a silent no-op; the allocator then double-issues the id (HIGH)
Root cause. After world:delete(e), the slot's next-generation id is parked at dense_array[i_swap] with record.dense = i_swap > alive_count (world_delete tail, src/jecs.luau:3693-3705). When that exact id is then claimed, world_entity's desired-id branch finds dense_array[r.dense] == entity, so the existing_entity ~= entity promote path (3385) is skipped and the function falls through to return entity at line 3393 — without checking r.dense > alive_count and without promoting the id into the alive region. The claim is a no-op: contains() stays false, but set/get go through the non-strict entity_index_try_get_unsafe (2860, alive-count check commented out), so the user can attach data to the still-parked record. The next plain world:entity() (3418-3423) then pops dense_array[alive_count + 1] — the very same id — and returns it as a fresh entity sharing the same record, pre-loaded with the earlier data.
Found independently via two flows with identical results: the parked id obtained as jecs.ECS_GENERATION_INC(e) after a local delete (below), and as jecs.ECS_COMBINE(jecs.ECS_ID(e), 1) mirroring an id replicated from an authority world that already recycled the slot.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local C = world:component()
local e = world:entity()
world:delete(e)
-- the next-generation id of e's slot: exactly what world:delete parked for recycling,
-- and exactly what a mirrored authority world would replicate after delete + respawn
local parked = jecs.ECS_GENERATION_INC(e)
local claimed = world:entity(parked)
print("[1] claim returned the requested id:", claimed == parked)
print("[2] contains(claimed) right after claiming it (expect true):", world:contains(claimed))
world:set(claimed, C, "payload")
print("[3] set+get on claimed id works:", world:get(claimed, C))
local fresh = world:entity()
print("[4] next world:entity() returns the SAME id (expect false):", fresh == claimed)
print("[5] 'fresh' entity already has data (expect nil):", world:get(fresh, C))
Observed:
[1] claim returned the requested id: true
[2] contains(claimed) right after claiming it (expect true): false
[3] set+get on claimed id works: payload
[4] next world:entity() returns the SAME id (expect false): true
[5] 'fresh' entity already has data (expect nil): payload
Expected: world:entity(id) with a desired id claims it — afterwards contains(id) is true and no future world:entity() call returns the same id for a different logical entity. Instead one id is handed to two logical entities, and components set through the first handle silently appear on the second.
3. Reviving a deep-parked stale handle corrupts the recycle queue: the next world:entity() returns an unusable id (HIGH)
Root cause. The promote branch of world_entity (src/jecs.luau:3383-3391) (a) never clears the claimer's old dense cell — which still holds the claimer slot's parked next-generation id with no sparse backpointer — and (b) blindly overwrites dense_array[alive_count], which when the revived slot was parked deeper than position alive_count + 1 is the parked dead id of a different slot; that slot's record now aliases a dense entry owned by the revived entity. The subsequent plain world:entity() recycle path (3418-3423) returns the orphaned dense entry — gen 1 of the revived slot — without verifying that sparse_array[ECS_ID(id)].dense points back at it. The returned id fails the dense identity check in both entity_index_try_get (511) and entity_index_try_get_unsafe (2860), so the world has just issued an id that contains() rejects and add/set silently drop.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local T = world:component()
local a = world:entity()
local b = world:entity()
-- delete in creation order: 'a' parks deep in the dead zone (below 'b')
world:delete(a)
world:delete(b)
-- revive the stale handle (documented desired-id revive flow)
local revived = world:entity(a)
print("[1] revived == a:", revived == a, " contains:", world:contains(revived))
-- now create a brand-new entity the normal way
local f = world:entity()
print("[2] f is gen1 of a's slot:", jecs.ECS_ID(f) == jecs.ECS_ID(a), jecs.ECS_GENERATION(f) == 1)
print("[3] contains(f) (freshly returned by world:entity()):", world:contains(f))
world:add(f, T)
print("[4] has(f, T) after world:add(f, T):", world:has(f, T))
local n = 0
for _ in world:query(T) do
n += 1
end
print("[5] entities matching T in query:", n)
Observed:
[1] revived == a: true contains: true
[2] f is gen1 of a's slot: true true
[3] contains(f) (freshly returned by world:entity()): false
[4] has(f, T) after world:add(f, T): false
[5] entities matching T in query: 0
Expected: after reviving a stale handle, a plain world:entity() returns a distinct, contained, usable entity. Instead it returns gen 1 of the just-revived slot (two "alive" dense entries for one slot), contains() is false on an id the world just handed out, add is a silent no-op, and the entity never appears in queries. Slot b is also bricked: its sparse record aliases the revived entity's dense entry.
4. Deleting a pre-range entity makes a never-created range placeholder fully alive (HIGH)
Root cause. world_range (src/jecs.luau:1046-1069) prefills dense_array[i] = i and sparse_array[i] = { dense = 0, ... } for i = max_id + 1 .. range_begin and sets alive_count = range_begin — so placeholder ids sit inside the alive range while their sparse records say dense == 0 ("not alive"). world_delete's swap (3693-3705) picks i_swap = alive_count, reads e_swap = dense_array[i_swap] (a placeholder id, e.g. 1000), fetches its record via the world-local entity_index_try_get_any closure (2772-2775) — which, unlike the top-level variant at 498, does not reject dense == 0 — then assigns r_swap.dense = record.dense and dense_array[record.dense] = e_swap. The placeholder record now has a valid dense slot inside the alive range pointing at its own id: the never-created entity is alive.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local A = world:component()
local e = world:entity()
world:set(e, A, "real")
world:range(1000, 2000)
local phantom = 1000 -- never created by anyone
print("[1] contains(1000) before delete:", world:contains(phantom))
world:delete(e) -- delete an unrelated pre-range entity
print("[2] contains(1000) after delete:", world:contains(phantom))
-- the phantom is fully usable now
world:set(phantom, A, "ghost")
print("[3] get(1000, A):", world:get(phantom, A))
local claimed = world:entity(phantom)
print("[4] world:entity(1000) returns:", claimed)
local n = 0
for _ in world:query(A) do
n += 1
end
print("[5] entities with A in query:", n)
Observed:
[1] contains(1000) before delete: false
[2] contains(1000) after delete: true
[3] get(1000, A): ghost
[4] world:entity(1000) returns: 1000
[5] entities with A in query: 1
Expected: deleting entity e affects only e; id 1000 stays non-contained until someone actually claims it via world:entity(1000). Instead contains(1000) flips true as a side effect of deleting an unrelated entity, and the phantom accepts set/get and appears in queries. The same swap would also corrupt a real claimed range entity whenever it happens to occupy the alive_count cell; the placeholder case fires deterministically.
5. Claiming a brand-new desired id overwrites a parked recycle entry — the deleted slot leaks permanently (MEDIUM)
Root cause. world_entity's desired-id else-branch for an unseen slot (src/jecs.luau:3394-3411) does alive_count += 1; dense_array[alive_count] = entity (3404-3406) without first relocating whatever occupies that cell. After a delete, dense_array[alive_count + 1] is exactly where world_delete parked the next-generation id (3702-3705), so the claim clobbers it. Because the recycle queue is purely positional (dense cells above alive_count, read at 3418-3423), the deleted slot can never be recycled again, and its sparse record is left pointing into a dense cell owned by the claimed id. The no-arg fresh path only consumes a dense cell when it is non-nil; the desired-id path has no such guard.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local a = world:entity()
world:delete(a)
local parked = jecs.ECS_GENERATION_INC(a) -- what the recycle queue now holds
-- claim an unrelated brand-new id (e.g. mirroring a foreign/high id)
local claimed = world:entity(5000)
print("[1] claimed 5000:", claimed == 5000, " contains:", world:contains(claimed))
local nxt = world:entity()
print("[2] next world:entity() (expect recycled", parked, "):", nxt)
print(" recycled? (expect true):", nxt == parked)
-- the deleted slot is now unreachable through normal recycling:
local seen = false
for _ = 1, 100 do
if world:entity() == parked then
seen = true
end
end
print("[3] parked id reissued within next 100 spawns (expect true):", seen)
Observed:
[1] claimed 5000: true contains: true
[2] next world:entity() (expect recycled 16777488 ): 5001
recycled? (expect true): false
[3] parked id reissued within next 100 spawns (expect true): false
Expected: the existing "Recycling" test pins that after a delete, the next world:entity() returns the recycled generation of the deleted slot; an unrelated desired-id claim should not destroy the recycle queue. Instead the parked entry is overwritten, the next spawn allocates a fresh id, and the deleted slot leaks permanently with its sparse record dangling into a dense cell owned by another entity.
6. jecs.new_low_id() can never return a low id and desyncs world.max_component_id (LOW)
Root cause. new_low_id (src/jecs.luau:4011-4030) probes for a free low id with entity_index_try_get_any(entity_index, e) ~= nil, but world construction unconditionally calls ENTITY_INDEX_NEW_ID for i = 1 .. EcsRest (3883-3885), creating sparse records for every slot up to 271 — so the probe sees every id in 1..HI_COMPONENT_ID as taken on every world, even though only the ids actually used by world:component() are. The while-loop walks world.max_component_id up to 257, the e >= HI_COMPONENT_ID fallback returns an ordinary high entity id, and entity_index_ensure (3951, the only other consumer) is unreachable. world:component() (3761-3771) uses a closure-local counter, so the two counters silently diverge.
Repro
local jecs = require("@jecs")
local world = jecs.world()
print("[1] first component id:", world:component()) -- low ids 2..256 are all free
local low = jecs.new_low_id(world)
print("[2] new_low_id returned:", low, "(expected: a free low id < 257)")
print("[3] world.max_component_id after new_low_id:", world.max_component_id)
print("[4] next world:component():", world:component())
Observed:
[1] first component id: 1
[2] new_low_id returned: 272 (expected: a free low id < 257)
[3] world.max_component_id after new_low_id: 257
[4] next world:component(): 2
Expected: new_low_id returns the next free id in the low component range (here 2..256 are entirely free), mirroring world:component() for addon use. Instead it returns a fresh high entity id (without EcsComponent) on every world, and leaves world.max_component_id at 257 while world:component() continues handing out 2, 3, ... from its own counter. Exported but undocumented API, hence low severity.
7. Cached query archetypes_map re-inserts destroyed archetype ids — unbounded growth under churn (LOW)
Root cause. on_delete_callback in query_cached (src/jecs.luau:1932-1943) swap-removes the destroyed archetype: archetypes[i] = lastarchetype; archetypes[n] = nil; archetypes_map[archetypeid] = nil; archetypes_map[lastarchetype.id] = i. When the destroyed archetype is the last element (i == n — the common case for a query matching one archetype), lastarchetype.id == archetypeid, so the final line re-inserts the dead archetype id into the map immediately after removing it. Archetype ids are never reused (max_archetype_id is monotonic), so the entry can never be cleaned up — one permanently dead map entry per archetype create/destroy cycle.
Repro
local jecs = require("@jecs")
local world = jecs.world()
local Foo = world:component()
local q = world:query(Foo):cached()
-- churn: create an entity with Foo (creates the archetype), delete it,
-- cleanup (destroys the now-empty archetype). Repeat.
for i = 1, 100 do
local e = world:entity()
world:set(e, Foo, i)
world:delete(e)
world:cleanup()
end
local n = 0
for _ in q.archetypes_map do
n += 1
end
print("live archetypes in cached query list:", #q.compatible_archetypes)
print("entries in archetypes_map:", n)
print("(expected: map entries == live archetypes; actual grows by 1 per churn cycle)")
Observed:
live archetypes in cached query list: 0
entries in archetypes_map: 100
(expected: map entries == live archetypes; actual grows by 1 per churn cycle)
Expected: archetypes_map mirrors the live compatible-archetypes list. No wrong query results today (ids are never recycled, so stale entries can't be hit), but in a long-running server with cached queries and archetype churn this is unbounded memory growth.
Impact
The four HIGH entries break core identity invariants through documented public API, in exactly the flows the desired-id API exists for (replication mirrors applying authority-assigned ids, stale-handle revival, partitioned ranges): a live entity silently dying with its components stolen, one id serving two logical entities with data bleed, never-created ids coming alive, and world:entity() returning ids the world itself then refuses. At game runtime these surface as despawned-but-still-rendered mirrors, components appearing on the wrong entity, and adds/sets that silently do nothing — corruption that persists in the index and is extremely hard to trace back. The MEDIUM/LOW entries are permanent slot/memory leaks and a wrong-range allocator, relevant for long-running servers and addons.
These bugs were found, diagnosed, and reproduced by Claude (Fable 5, via Claude Code) under my direction, as part of the same audited setup as #317. The output above is from a real run against main.
Through nothing but public API, the entity index can be corrupted in several related ways: claiming the next generation of a live id silently kills the live entity and hands its components to the claimer;
world:entity()can issue the same id to two different logical entities (with one's component data bleeding into the other);world:entity()can return an id thatworld:contains()rejects andworld:add()silently ignores; deleting one entity can make a never-created id fully alive; and a desired-id claim can permanently destroy a recycle-queue entry. Two smaller invariant bugs (an exported allocator returning ids from the wrong range, and unbounded map growth in cached queries) round out the cluster.Affects: current
main(3087601). Found during a systematic audit (context: #317); reproduces identically with and without that PR's changes.Most of these share one theme: the desired-id branch of
world_entity(src/jecs.luau:3366-3413) makes no liveness or occupancy checks — it never asks whether the slot's current generation is still alive, whether the dense cell it appends into is already occupied by a parked id, or whether the claimed id itself is sitting parked in the dead zone. They can likely be fixed together, so they are grouped here for triage. The critical bugs from the same audit are filed separately.All repros: save as
repro.luauin the repo root, runluau repro.luau. Output shown is verbatim from real runs againstmainand is deterministic across runs.1. Claiming gen+1 of a live slot silently kills the live entity and steals its components (HIGH)
Root cause. In
world_entity's desired-id branch (src/jecs.luau:3383-3391), whensparse_array[index]exists anddense_array[r.dense]holds a different id, the code unconditionally appends the claimed id atalive_count + 1and repointsr.densethere — without checking whether the current occupant is alive (dense <= alive_count). The slot's singlerecordobject is reused as-is (archetype/row preserved), so the claimed id inherits every component of the live entity. The old id is left behind indense_arrayinside the alive region and in its archetype'sentitiesrow, so queries still yield it — whileentity_index_try_get(line 511, backingworld:contains) now rejects it becausedense_array[r.dense] ~= entity.Repro
Observed:
Expected: claiming a higher generation of a slot whose current generation is still alive should be rejected (id conflict) — or at minimum must not corrupt the index. Instead the live entity is silently de-listed, the claimed id takes over its record and components, and queries yield an id that
contains()declares dead — a direct query/liveness invariant violation, with the dead id stuck in the alive dense region permanently.2. Claiming the parked next-generation id is a silent no-op; the allocator then double-issues the id (HIGH)
Root cause. After
world:delete(e), the slot's next-generation id is parked atdense_array[i_swap]withrecord.dense = i_swap > alive_count(world_delete tail, src/jecs.luau:3693-3705). When that exact id is then claimed,world_entity's desired-id branch findsdense_array[r.dense] == entity, so theexisting_entity ~= entitypromote path (3385) is skipped and the function falls through toreturn entityat line 3393 — without checkingr.dense > alive_countand without promoting the id into the alive region. The claim is a no-op:contains()staysfalse, butset/getgo through the non-strictentity_index_try_get_unsafe(2860, alive-count check commented out), so the user can attach data to the still-parked record. The next plainworld:entity()(3418-3423) then popsdense_array[alive_count + 1]— the very same id — and returns it as a fresh entity sharing the same record, pre-loaded with the earlier data.Found independently via two flows with identical results: the parked id obtained as
jecs.ECS_GENERATION_INC(e)after a local delete (below), and asjecs.ECS_COMBINE(jecs.ECS_ID(e), 1)mirroring an id replicated from an authority world that already recycled the slot.Repro
Observed:
Expected:
world:entity(id)with a desired id claims it — afterwardscontains(id)istrueand no futureworld:entity()call returns the same id for a different logical entity. Instead one id is handed to two logical entities, and components set through the first handle silently appear on the second.3. Reviving a deep-parked stale handle corrupts the recycle queue: the next
world:entity()returns an unusable id (HIGH)Root cause. The promote branch of
world_entity(src/jecs.luau:3383-3391) (a) never clears the claimer's old dense cell — which still holds the claimer slot's parked next-generation id with no sparse backpointer — and (b) blindly overwritesdense_array[alive_count], which when the revived slot was parked deeper than positionalive_count + 1is the parked dead id of a different slot; that slot's record now aliases a dense entry owned by the revived entity. The subsequent plainworld:entity()recycle path (3418-3423) returns the orphaned dense entry — gen 1 of the revived slot — without verifying thatsparse_array[ECS_ID(id)].densepoints back at it. The returned id fails the dense identity check in bothentity_index_try_get(511) andentity_index_try_get_unsafe(2860), so the world has just issued an id thatcontains()rejects andadd/setsilently drop.Repro
Observed:
Expected: after reviving a stale handle, a plain
world:entity()returns a distinct, contained, usable entity. Instead it returns gen 1 of the just-revived slot (two "alive" dense entries for one slot),contains()isfalseon an id the world just handed out,addis a silent no-op, and the entity never appears in queries. Slotbis also bricked: its sparse record aliases the revived entity's dense entry.4. Deleting a pre-range entity makes a never-created range placeholder fully alive (HIGH)
Root cause.
world_range(src/jecs.luau:1046-1069) prefillsdense_array[i] = iandsparse_array[i] = { dense = 0, ... }fori = max_id + 1 .. range_beginand setsalive_count = range_begin— so placeholder ids sit inside the alive range while their sparse records saydense == 0("not alive").world_delete's swap (3693-3705) picksi_swap = alive_count, readse_swap = dense_array[i_swap](a placeholder id, e.g. 1000), fetches its record via the world-localentity_index_try_get_anyclosure (2772-2775) — which, unlike the top-level variant at 498, does not rejectdense == 0— then assignsr_swap.dense = record.denseanddense_array[record.dense] = e_swap. The placeholder record now has a valid dense slot inside the alive range pointing at its own id: the never-created entity is alive.Repro
Observed:
Expected: deleting entity
eaffects onlye; id 1000 stays non-contained until someone actually claims it viaworld:entity(1000). Insteadcontains(1000)flipstrueas a side effect of deleting an unrelated entity, and the phantom acceptsset/getand appears in queries. The same swap would also corrupt a real claimed range entity whenever it happens to occupy thealive_countcell; the placeholder case fires deterministically.5. Claiming a brand-new desired id overwrites a parked recycle entry — the deleted slot leaks permanently (MEDIUM)
Root cause.
world_entity's desired-id else-branch for an unseen slot (src/jecs.luau:3394-3411) doesalive_count += 1; dense_array[alive_count] = entity(3404-3406) without first relocating whatever occupies that cell. After a delete,dense_array[alive_count + 1]is exactly whereworld_deleteparked the next-generation id (3702-3705), so the claim clobbers it. Because the recycle queue is purely positional (dense cells abovealive_count, read at 3418-3423), the deleted slot can never be recycled again, and its sparse record is left pointing into a dense cell owned by the claimed id. The no-arg fresh path only consumes a dense cell when it is non-nil; the desired-id path has no such guard.Repro
Observed:
Expected: the existing "Recycling" test pins that after a delete, the next
world:entity()returns the recycled generation of the deleted slot; an unrelated desired-id claim should not destroy the recycle queue. Instead the parked entry is overwritten, the next spawn allocates a fresh id, and the deleted slot leaks permanently with its sparse record dangling into a dense cell owned by another entity.6.
jecs.new_low_id()can never return a low id and desyncsworld.max_component_id(LOW)Root cause.
new_low_id(src/jecs.luau:4011-4030) probes for a free low id withentity_index_try_get_any(entity_index, e) ~= nil, but world construction unconditionally callsENTITY_INDEX_NEW_IDfori = 1 .. EcsRest(3883-3885), creating sparse records for every slot up to 271 — so the probe sees every id in 1..HI_COMPONENT_IDas taken on every world, even though only the ids actually used byworld:component()are. The while-loop walksworld.max_component_idup to 257, thee >= HI_COMPONENT_IDfallback returns an ordinary high entity id, andentity_index_ensure(3951, the only other consumer) is unreachable.world:component()(3761-3771) uses a closure-local counter, so the two counters silently diverge.Repro
Observed:
Expected:
new_low_idreturns the next free id in the low component range (here 2..256 are entirely free), mirroringworld:component()for addon use. Instead it returns a fresh high entity id (withoutEcsComponent) on every world, and leavesworld.max_component_idat 257 whileworld:component()continues handing out 2, 3, ... from its own counter. Exported but undocumented API, hence low severity.7. Cached query
archetypes_mapre-inserts destroyed archetype ids — unbounded growth under churn (LOW)Root cause.
on_delete_callbackinquery_cached(src/jecs.luau:1932-1943) swap-removes the destroyed archetype:archetypes[i] = lastarchetype; archetypes[n] = nil; archetypes_map[archetypeid] = nil; archetypes_map[lastarchetype.id] = i. When the destroyed archetype is the last element (i == n— the common case for a query matching one archetype),lastarchetype.id == archetypeid, so the final line re-inserts the dead archetype id into the map immediately after removing it. Archetype ids are never reused (max_archetype_idis monotonic), so the entry can never be cleaned up — one permanently dead map entry per archetype create/destroy cycle.Repro
Observed:
Expected:
archetypes_mapmirrors the live compatible-archetypes list. No wrong query results today (ids are never recycled, so stale entries can't be hit), but in a long-running server with cached queries and archetype churn this is unbounded memory growth.Impact
The four HIGH entries break core identity invariants through documented public API, in exactly the flows the desired-id API exists for (replication mirrors applying authority-assigned ids, stale-handle revival, partitioned ranges): a live entity silently dying with its components stolen, one id serving two logical entities with data bleed, never-created ids coming alive, and
world:entity()returning ids the world itself then refuses. At game runtime these surface as despawned-but-still-rendered mirrors, components appearing on the wrong entity, and adds/sets that silently do nothing — corruption that persists in the index and is extremely hard to trace back. The MEDIUM/LOW entries are permanent slot/memory leaks and a wrong-range allocator, relevant for long-running servers and addons.These bugs were found, diagnosed, and reproduced by Claude (Fable 5, via Claude Code) under my direction, as part of the same audited setup as #317. The output above is from a real run against main.