Internal IDs
Any complex engineering project comprises of multiple elements. It could consists of billions of steel plates, bolts, wires and so on. When we want to represent these elements in computer, and more importantly, we want to mimic the real world relationship between various elements, we need to identify each element with a unique IDs. As you might be aware, computers have a “finite” amount of memory ( called RAM ). So the question arises, how long should the IDs be ? Example: Mobile numbers in India (excluding country codes) have 10 digits, i.e. can represent ~10 Billion unique numbers. That is well above the population of 1.4 Billion. Well within the same order of magnitude. So now all the forms got 10 boxes for mobile number. Now let’s come back to creating IDs for things inside computer memory. How many digits for everything?
Computers memory are measured in number of bits/bytes. 1 Decimal digits is approximately 3.1 bits. So mobile number are already 10 digit = ~33 bits. Fortunately every time we increment the number of bits, the number of unique elements we can identify doubles. So if our mega refinery had total of 100 Billion unique things, we will need approx. LogBase2(100,000,000,000) = 37 bits. That’s how much we genuinely need.
Now let’s see the state of art in computer science. Since computers prefer working in multiple of 2, the choices are 32 bits, 64 bits, 128 bits, 256 bits and so on. 32 bits is obviously less than our minimum requirement. Perhaps 64 or 128. 256 is so obviously super-duper over designed. The field of computer science have mostly decided on 128 bits by default. The reason for choosing 128 is mostly about letting everyone (all computers) assign his own unique IDs for the elements they generate, without a chance of 2 different things assigned same IDs on different computer. These are called UUID ( Universally Unique Identifier ). UUID has multiple versions, v1 / v2 / v3 / v4 / v5 / v6 / v7. Some other variants are ULIDs and so on. All with various trade-offs. AWS has 256 bit ids for some reason. Now some smart peoples in the computers science ! those at Facebook / Instagram are able to manage the entire website with 64 bits IDs. The downside? They need to maintain a loosely coupled central authority assigning new IDs to every post/messages/phots/comments/likes and so on.
As we saw before, we need nearly 37 bits minimum, and every new bits doubles the number of IDs available, even 64 bit is more than sufficient. We just have to do some upfront book-keeping engineering.
So I have made the decision to go ahead with 64 bits for Mission Vishwakarma. 64 Bits = 8 Bytes = Approx. 10 to the power 19 unique IDs. Just to give some sense of scale, the largest memory super computer in the world, Fugaku, has got 32 petabytes RAM = 2^55 Bytes. It is still well less than 2^64. So here we go.
Our design goal is that 1000s of engineers should be able to work parallel in a project. All creating new thing ( with new IDs) and so no. So who gets to assign what IDs ? While we do want the IDs to be sequential, we can’t want every new IDs to be generated by a central authority. If we did that, people will not be able to work when their internet connections disconnects. We want people to keep working on their laptops, even when they are offline. To address this, I have decided upon some conventions for book-keeping of IDs. We will take inspirations from excellent concept of IP-Address management followed across the world, called CIDR ( Classless Inter-Domain Range ). So we declare upfront, how these 64 bits shall be used. Here we go.
-
Out of 64 bits, top 16 bits are reserved. Always zero. This is more of a temporary measure to future proof ourselves. This leaves us with 64-16 = 48 bits. 2^48 is still plenty. In CIDR lingo, it’s 0:0/16. Our choose 0:0/16 (=2^48) IDs consists of 256 Nos. /24 (=2^40) IDs.
-
The first 2^40 IDs (0:0/24) are reserved for use by Mission Vishwakarma Software developer’s assigned items/catalogue items.
-
Next 2^40 IDs are assigned to be local use IDs. I.e. Whenever a computer assigns new IDs, it will assign in range 2^40+1 to 2^41. However, when they save / sync their work to the central computer/server, server will assign them new IDs and inform the computer to updates it’s memory. So multiple computers can have duplicate IDs in this range, until they save their work.
-
Central computer/server assigns IDs starting with 2^42. Increasing sequentially. That’s it. Initially we are using sequential increment, however in future, we may improve our algorithms/implementations to reuse deleted IDs. Perhaps after 2035! Remember, this auto incrementing IDs can never cross 2^48 since all IDs more than 2^48 are reserved. This gives us ~280,000 Billion unique things / IDs. plenty huh !
Now let’s calculate how many IDs a reasonable workstation computer can use simultaneously. All elements/entities are expected to have some extra information in addition to IDs. After all, IDs themselves are just dumb numbers. For example, consider a 3D coordinate, It will have at minimum: A) 64 Bit (=8 Byte) ID, B) 4 Byte Element Type identifying it as co-ordinate, C) 3 Nos 8 Bytes Co-ordinates, D) Around 16 Bytes for Name, E) Time t’s created and so on. 8 + 4 + 3 x 8 + 8 = 44 Bytes. A line will have 2 co-ordinates. Conservatively consider on an average 64 Byte per entity. So a high end computer with 64 GB RAM will be able to hold 64 GB / 64 Bytes = ~ 1 Billion entity with unique IDs. You see, we run out of RAM much faster than running out of possible unique IDs. Remember, a project is expected to have much more data. The single computer will in general load a subset of data.
When one entity has a relationship to another, it will refer using other’s IDs. So having a shorter 8 Byte ID takes up half the RAM compared to 16 Byte ( = 128 bits ) IDs. We want to store more of engineering information, than just the IDs. Hence the emphasis on upfront engineering to go ahead with 64 bit ids.
Next comes the question, how shall we store these IDs on disc. Here we have to learn the behavior ( & constraints) of our database management system. I have decided to go for SQLite database. More on it latter in database section. One important information is that SQLite IDs are int64_t i.e. 64 bits but both plus and minus. So 1 bit is lost to store the sign. Hence, I have decided to utilize only the +ve numbers for IDs for simplicity. Hence 63rd (1st bit is 0th bit) bit is lost. This makes our our ID system limited to 2^63 only. Still plenty.
Next we need to decide how are we going to refer to information coming for different teams. I have decided that there could be multiple files, in which people could be working independently. The ID numbering system is for INSIDE one file only. So whenever an object in 1 file need to refer to object in another file, it has to store not just the IDs but also the fileID reference of the respective file. So whenever 1 file reference another, this reference itself shall be assigned a unique ID. And all foreign reference shall have to store this foreign file reference ID as well. Effectively making foreign file reference as 64 x 2 = 128 bits. Now we need a way to distinguish whether a reference is file-internal reference or going outside the file. We are going to store this information in our highest usable bit, i.e. 62nd bit ( remember, the 63rd bit is the sign bit, which is always 0 as decided above ). Hence one more bit lost. Now our IDs are limited up to 2^62 distinct values only. That’s it. No further reduction in ID space size.
Above discussion is for persisted IDs only. Notice that 2 different files coming from 2 different teams can have same IDs assigned for very different engineering data type. Hence if application needs to be able to distinguish between 2 objects ( in different file ) with same IDs than it must assign a temporary CPU Memory ID. This temporary ID is simply different 64 bit ID generated on the fly starting with zero and is not persisted across session. Our memory manager code will work on this temporary ID only. However whenever data is saved to disc or over the network, a translation from CPU Memory ID to persistence ID shall be performed.
In this article we learned about importance and planning of IDs in Mission Vishwakarma. This is also the 1st of the data structure we have designed ! Let’s get deeper in subsequent articles.
Update ( July 2026 ): While designing the catalogue of standard steel sections, I revisited this ID scheme. The following rules are now final:
-
The range [0, 2^32) is declared permanently invalid. No entity shall ever be assigned an ID below 2^32. So within the first 2^40 IDs reserved for developer’s catalogue items ( point 2 above ), actual allocation happens only in the [2^32, 2^40) band. Why throw away 4 Billion perfectly good IDs ? Defensive engineering ! Every valid ID now has at least one bit set above the 31st bit. So if any code accidentally truncates a 64 bit ID into a 32 bit integer, the result falls below 2^32 and immediately fails the validity check. A whole class of silent data corruption becomes a loud, immediate error. The cost is just 1/256th ( ~0.4% ) of the developer band. Cheap insurance.
-
The top 16 bits reserved as zero ( point 1 above ) remains as it is.
-
Sequential ID allocation in the local use range and by the central server ( points 3 and 4 above ) remains as it is.
-
The 63rd bit is ALWAYS 0. It is the sign bit of SQLite’s int64_t, and we use positive numbers only. The internal-vs-external file reference flag lives in the 62nd bit. Persisted IDs therefore remain limited to 2^62 distinct values.
-
Allocation strategy differs between the two bands, deliberately. Catalogue IDs are drawn RANDOMLY within the [2^32, 2^40) band ( checked against the existing catalogue, redrawn in the rare case of a collision ). Multiple developers keep adding catalogue items in parallel, each on their own version control branch. A shared next-ID counter would turn every concurrent addition into a merge conflict; random draws need no counter, so branches merge cleanly. User entity IDs are the opposite: the central authority assigns them SEQUENTIALLY ( point 4 above ). Entities created together receive adjacent IDs, hence sit adjacent on disc and in RAM, which is exactly what file caches and CPU caches love. In short: the band without a central authority gets randomness, the band with one gets locality.
Update ( August 2026 ): Designing inter-object references — a pipe pointing at the nozzle it connects to, a member pointing at the catalogue profile it is made from — forced a second revisit. The numbered sections below are the current specification. Two things in the text above are now superseded, and are kept only because this page is a record of how the design got here:
- The 62nd-bit internal-vs-external flag is retired ( July update point 4, and the paragraph above it ). Every reference field, in RAM and on disc, now holds a plain same-file id; a reference that leaves the file goes through a ForeignReference proxy object instead ( §5 ). No reference field is ever 128 bits, and no bit of an id carries meaning. Persisted ids lose only the sign bit, and remain capped at 2^48 by the top-16-reserved rule regardless.
- Foreign file references are not packed into the id. An earlier design in
storage.md§7.3 packed a 16-bit file alias and a 40-bit object id into one 64-bit word. That is withdrawn: a.zzzproject may reference more than 2^32 files, and permanent ids are no longer constrained to 40 bits. §4.4 and §9 carry the detail.
Scope of this page
This page is about ids and references, and nothing else. memoryID, persistedId, how one object refers to another, how that reference is resolved in RAM, how it is written to disc, and what happens when the thing it refers to is not there.
The object-model migration that this page used to carry — the 2D world’s move to META_DATA, Optional64, arena residency, Page2D paging and the sizing tables — has moved to 2Drendering.md, under “The 2D object model”. Anything that mentions a record type, a page, or a GPU buffer belongs there, not here.
The scale everything here is judged against, stated 2026-08-26:
“Expected scale is 10M objects on a regular PC / Workstation. 1 Billion Object on central Server.”
Both numbers, not just the first. An answer that is elegant at 10M and needs 50 GB of index at 1B is not an answer.
1. Two id spaces, and the rule that keeps them apart
Almost every confusion on this page came from letting one mechanism try to serve both. They are different in lifetime, in who assigns them, in what they are unique against, and in how they are resolved.
| memoryID | persistedId | |
|---|---|---|
| Lives | one process, one session | the file, forever |
| Assigned by | MemoryID::next(), a global atomic |
the authority — server, or the local .yyy virtual-server host |
| Unique against | every object loaded in this process | every object inside one file |
| Assigned to | every loaded object, always | only objects that have been committed |
| Banded | never ( §3.5 ) | yes ( §4 ) |
| Recycled | never | never |
| Resolved by | binary search of the owning tab’s directory ( §3.4 ) | translation maps at load and save ( §6 ) |
| Written to disc | never | that is its whole purpose |
The rule: no mechanism serves both spaces. §2 and §3 are entirely about memoryID and RAM. §4, §5 and §6 are entirely about persistedId and disc. The only place they meet is the load/save boundary, where a translation happens explicitly and in one direction at a time.
The reason this matters practically: an object loaded from a file has both — a memoryID because it is in RAM, and a persistedId because it came from a file. An object the user just drew has only a memoryID until it is committed. Code that reaches for “the id” without saying which one is a bug waiting for a save cycle.
2. memoryID — what exists today
Everything in this section was checked against the tree, not recalled.
2.1 3D — arena objects behind a directory vector
struct StoredGeometryObject3D { // 24 bytes, in a contiguous std::vector
VishwakarmaStorage::ObjectType objectType;
uint64_t memoryId;
META_DATA* object; // the payload, in the arena
};
META_DATA::operator new(size, memoryGroupNo) routes to cpu.Allocate, so every 3D object lands in
a per-tab arena group of 4 MB chunks, with 8 bytes of allocation header
(राम::POINTER_OVERHEAD_BYTES) and notifyTabClosed de-committing the group when the tab closes.
This is already the “vector of ids, objects in the arena” shape — and better than a bare id
vector, because the pointer is carried inline, so a scan is one contiguous walk plus a dereference
rather than a walk plus a map lookup. storageLogicalObjects has the same shape.
memoryGroupNo == tabNo is load-bearing, and it is why the directory is per tab and not per
world or per container. CPU_RAM_4MB::reset(tabNo) makes the group be the tab, which is what lets
राम::MemoryGroupOf(anyPointer) answer “which tab owns this address” from an address alone. Any
other partitioning of the arena breaks that. 2Drendering.md carries the consequence for optional
properties.
2.2 2D — value records in vectors
The nine Cad2D*RecordCPU types derive META_DATA, so they carry a memoryID like everything else
and there is one object model. They are still value types in std::vector on the CRT heap rather
than arena objects behind a directory, so they are not reachable by ResolveObject and the
2D world has no id directory yet.
The reasoning, the sizes, the residency migration and what it is waiting on are in 2Drendering.md.
The only thing this page needs from it: when 2D records move into the arena they adopt §3.4’s
directory and §3.5’s scope rule unchanged — one directory per tab, sorted by memoryID, binary
searched. There is no second id mechanism for 2D, and no separate 2D id space.
2.3 The identity fields matched, so they were merged — DONE
The 2D records were a structural clone of META_DATA under different names, drawing ids from the
same MemoryID::next() global atomic. That is why the merge was cheap: objectId → memoryID,
containerMemoryId → memoryIDContainer, parentObjectId → memoryIDGenerator, with
persistedId, persistedParentId, schemaVersion and isDeleted already identically named.
dataVersion and dataType arrived with the base — 2D had neither, and undo-redo.md needs the
first regardless.
One consequence that is pure id semantics and worth not re-deriving: “0 means unassigned” stopped
being expressible for memoryID, because META_DATA’s constructor issues one. Every objectId == 0
sentinel went unreachable and was retired. persistedId == 0 still means unassigned, and still
does — that asymmetry is correct, not an oversight: an object always has a memoryID and does not
always have a persistedId.
2.4 Container versus generator — SETTLED
memoryIDContainer is the container — where an object is shown — and memoryIDGenerator is
the owner, the asset instance or template it was stamped out of. Both are ordinary fields on
META_DATA. The two were never semantically in conflict: 3D had one slot with two meanings
competing for it while 2D had two slots under confusing names.
The dividing line is “lives inside a container” versus “does not” — a containment concern, not
a dimensionality one, which is why 3D templates will want the same pair and why a separate
META_DATA2D would have frozen the wrong axis into the type system.
There is no persistedGeneratorId. The file stores ONE parent id — the generator if there is
one, else the container — and the loader rebuilds the split by asking what TYPE the stored parent
turned out to be. This is the one place where two RAM fields collapse into one persisted field, and
it is deliberate.
2.5 memoryID resolution is an O(log N) binary search — DONE
FindObjectInStorage in डेटा.h is a std::lower_bound over any sorted directory of stored
objects; FindGeometryObject3D, FindLogicalObject and ResolveObject in विश्वकर्मा.h are the
entry points that use it. Resolution is O(log N) with no extra memory at all, on the sortedness
guarantee of §3.4. There is still no index, and there is not meant to be one — the directory’s own
order is the index.
Three mechanisms were built for this job before it, none of them was ever alive, and none of them survives as an index:
ReferenceIDinID.h— process-localmemoryID, persistentrealID,savedFileReference— survives, renamed and relocated. It is nowDataReferenceinडेटा.h, derivingMETA_DATAso thatmemoryIDandpersistedIdcome from the base, and declaring onlyrealID,savedFileReferenceandloadedFileReferenceof its own. The cacheddatapointer is gone ( §3.3 ). It moved because a base class must be a complete type andडेटा.hincludesID.h, never the reverse. It is still referenced nowhere: it is the declared shape of a reference, not a live one, and §5 is what actually carries references between files.
A declared-but-unpopulated index invites someone to trust it, and three of them invited someone to wire the wrong one. That is why two are deleted outright and the survivor is not an index — it holds ids, resolved by the search of §3.4, and no pointer at all.
The only live id map is InstanceRegistry::indexOfMemoryId, and it maps memoryID →
gpuInstanceIndex. That is copy-thread-owned GPU identity, not the CPU object, and it is not a
substitute. Page2D’s TabCad2DStorage::recordIndex is the same kind of thing one level over: an
index into a record vector, copy-path-owned.
The scan that remains is not a lookup. Twelve sites still walk storageObjects3D end to end —
persistedId assignment, save, zoom-to-fit, hide/move-selected, tree build, snap-candidate gathering
— and every one of them wants every object, so a scan is already optimal there. None of the twelve
is an id-to-object lookup. Those were converted, and there were more of them than the 3D side
suggested: nine call sites resolve by id today, and most are on storageLogicalObjects rather than
on the geometry directory, because the container lookups had been hiding inside one shared
FindLogicalObjectByIdLocked helper.
3. In-RAM references and directory scope
3.1 There are no inter-object references at all
Not merely no resolution mechanism — nothing in the object model refers to another object,
except containment. ELBOW, TEE, FLANGE and PIPE are pure geometry: centre, radii, sweep
angle, colours. The only reference fields anywhere are memoryIDContainer and memoryIDGenerator.
So a pipe cannot record which nozzle it connects to, and the lookup was never built because nothing ever needed it. That is a better position than having references that resolve badly, but it means the whole capability is greenfield. Everything in §3 through §6 is design, not description.
The one field that looks like an exception is LINE_MEMBER::profileId, and it is not one — see §8.
3.2 Representation and resolution are different decisions
Keeping these apart avoids a lot of confusion:
| Axis | Question | Decision |
|---|---|---|
| Representation | what the referring object stores | an id, never a raw pointer — §3.3 |
| Resolution | how an id becomes an object | binary search of the calling tab’s directory — §3.4 |
Storing the id is what makes a resolution mechanism necessary in the first place; store pointers and no lookup is needed. The two are complementary, not alternatives.
3.3 Why the reference stores an id, not a pointer
The routine reasons are good: 8 bytes rather than 24, meaningful on disc, no thread-lifetime rules for a value that crosses threads, and arena defragmentation only has to fix the directory entry rather than hunt down every reference in the model.
The decisive reason is the failure mode. MemoryID::next() is a monotonic counter that never
recycles, so a reference to a deleted object fails to resolve — a null, reportable as “connection
target missing”. A raw pointer into freed arena memory is a use-after-free that, once the arena
reuses the block, silently addresses a different live object, possibly of another type. Ids fail
safe; pointers fail dangerous. In a file where a mis-resolved nozzle is a fabrication error, that
difference decides it.
The same argument is why persistent ids are never recycled either ( storage.md §7.2 ): recycling
makes an old reference resolve to a wrong new object, which is worse than resolving to nothing.
This inverts the original ReferenceID design, which cached a data pointer alongside the id
— and that cached pointer is exactly why DefragmentRAMChunks carried the note that every move must
update the central map. The field is already gone: its successor DataReference ( डेटा.h, §2.5 )
holds ids only, and the DefragmentRAMChunks comment now points at the owning tab’s directory.
Dropping the field removed the coupling entirely.
A transient cache is still fine, and §5 uses one: the ForeignReference proxy caches its resolved target as a memoryID, never a pointer, and re-resolves per session. A cache that holds an id is still id-safe; a cache that holds a pointer is not.
3.4 The directory is already sorted — make that a guarantee
storageObjects3D is clear()ed only on teardown, and is never erased from or sorted. Ids come
from a monotonic counter assigned at construction, immediately before the append. So the vector is
already sorted by memoryID, and std::lower_bound resolves in O(log n) — 24 comparisons at 10M,
30 at 1B — with no extra memory at all. Soft-delete leaves tombstones in place, which preserves
the ordering.
That property would otherwise hold by accident. It has to be enforced, because a later “compact
the vector” or “sort by type for cache locality” would break it silently and the failure mode is a
wrong lookup rather than a crash. What guards it is a _DEBUG warning at the append site, not an
assert — an out-of-order append makes lookups wrong, but the application is still usable, and
killing a modelling session over it would cost the user more than the bad lookup does:
// At every append site, under _DEBUG. Turns an emergent property into a stated invariant.
if (!tab.storageObjects3D.empty() &&
tab.storageObjects3D.back().memoryId >= object->memoryID) {
std::cout << "[3d][warn] storageObjects3D out of memoryId order: ..." << std::endl;
}
Two gaps remain, both known. FlushGeneratedGeometryBatch bulk-inserts and carries no check.
It is correct today — one thread, ids issued in order — but it is the import path, so
it is the one most likely to acquire a second producer later. And storageLogicalObjects, which
§3.5 now binary searches on equal terms, has no check at either of its two append sites. Both want
the same guard the geometry directory already has.
Why binary search and not a hash map, at 1 Billion. A std::unordered_map costs ~50 bytes per
entry measured on this codebase’s own 2D record index: ~500 MB at 10M and ~50 GB at 1B, to
duplicate ordering information the vector already carries. Binary search costs zero bytes. The
O(1) alternatives were weighed and rejected:
| Option | Bytes at 1B | Verdict |
|---|---|---|
Sorted directory + lower_bound |
0 | Chosen |
| Per-tab hash map | ~50 GB | Rejected — memory |
Global sharded map ( MemoryIDMap ) |
~50 GB + a lock per lookup | Rejected — memory and contention |
Arena-owned global map ( id2MemoryMap ) |
~30 GB | Rejected — arena must not know object identity |
| Paged slot array over a partitioned id space | ~4 GB | Rejected — partitioning is FINAL no ( §3.5 ) |
If a lookup-heavy consumer ever appears and profiling shows resolution on a hot path,
interpolation search is the upgrade that costs nothing: ids are near-dense within a tab, so it
converges in ~4-6 probes instead of 24-30, with no new structure and no memory. Behind a
ResolveObject(memoryID) function this stays a one-function change with no call site touched.
3.5 Directory scope: the calling tab, and nothing else
The memoryID space is NOT partitioned. Decided 2026-08-26, FINAL. No range of it is reserved for
any tab, world, container or purpose; MemoryID::next() starts at 1 and increments, forever. No
code may infer anything from an id’s value except that it is not zero. Routing a lookup to a
directory is therefore never done by inspecting the id.
That leaves the question of which directory. The answer is now simple, and it got simpler because of §5.6’s decision to give each tab its own copy of a mounted catalog file:
ResolveObject(memoryID):
binary search each of the CALLING TAB's directories. That is the whole algorithm.
A tab has three directories, not one, and the resolver searches every one that exists. They partition by what an object is, not by id — nothing about a memoryID says which one holds it:
| Directory | Holds | Built |
|---|---|---|
storageObjects3D |
3D geometry | yes |
storageLogicalObjects |
containers — Scene3D, Page2D, Folder | yes |
storageLogicalData |
non-geometry, non-container data objects. ForeignReference proxies live here ( §5.3 ) | no — arrives with the proxies, and nothing needs it before them |
ResolveObject searches the two that exist, geometry first: hits short-circuit, and geometry is the
common case. Adding the third is one more call in the same function. Three binary searches come to
~72 comparisons at 10M, still zero extra memory. Each directory is sorted independently and each
carries the §3.4 invariant on its own — storageLogicalObjects is searched on those terms today
without yet being guarded on them, which is the second gap §3.4 names.
Every memoryID a tab’s objects can legally reference lives in one of that tab’s directories — its own objects, and the objects of any catalog file it has mounted, because under §5.6 option (i) a mounted catalog is loaded into the mounting tab. There is no fallback search, no tab-0 special case, no process-wide structure. A reference that does not resolve in the calling tab does not resolve at all, and that is a reportable state, not a reason to look elsewhere.
Tabs may not reference each other, and this makes it structural rather than policed. A
tab-3-object → tab-5-object reference cannot be resolved even in principle, so closing tab 3 can
never leave tab 5 holding a dangling pointer into freed arena memory. A _DEBUG assert at
reference-creation time catches an attempt at the source rather than at the failed lookup.
Two properties this rests on, both worth stating as requirements rather than observations:
- One writer per directory. Each tab has exactly one engineering thread, so append order is id
order and §3.4’s sortedness holds. A shared directory would destroy it — thread A takes id 100, B
takes 101, B appends first, and the vector is
[…, 101, 100]. This is the structural reason the directory is per tab, independent of the catalog question. - Tab close is O(1). Dropping a per-tab directory is a
clear(). Erasing one tab’s entries from a shared structure is O(closed tab size) under a lock every other engineering thread contends on.
What §5.6 option (ii) would cost. A process-wide shared catalog, loaded once and referenced by
many tabs, reintroduces exactly the cross-directory routing this section just removed: a second
directory to search, a lifetime that is not any tab’s, and an arena group that is not any tab’s
either — which collides with §2.1’s memoryGroupNo == tabNo invariant. None of that is
unsolvable, and none of it is free. It is the price of (ii), to be paid when the memory duplication
of (i) is measured and found to matter, not before.
3.6 What this still does not solve
Reverse lookup — DECIDED: linear scan, and nothing more. The pipe knows the nozzle; the nozzle does not know the pipe. Deleting or moving a nozzle needs its referrers, and forward ids cannot answer that below a full scan. No back-references, no mirror index, no referrer table. Referrer queries are O(n) over the tab’s directory, and that is accepted: they happen on delete and on “where is this used”, both user-initiated and both rare, and the alternative is a second index to keep consistent through every edit — the exact class of duplicated truth this page keeps removing. §5 improves the foreign half of the problem for free: “who references foreign object X” reduces to the local question “who references proxy P”.
One directory per world. A memoryID may live in storageLogicalObjects, in storageObjects3D,
or — once residency lands — in the 2D world’s directory. A pipe→nozzle reference stays inside
storageObjects3D and is fine, but a P&ID symbol referring to the 3D equipment it represents, an
ordinary CAD requirement, needs a resolver spanning all of them. ResolveObject must therefore be
specified from the start as searching the tab, not searching one vector, even while only one
vector is searchable.
3.7 Decisions
Decided, and not to be re-litigated:
- References store an id, never a raw pointer ( §3.3 ). A transient cache may hold an id.
- The memoryID space is never partitioned ( §3.5 ). FINAL.
ResolveObject(memoryID)binary searches the calling tab’s directory only. No fallback, no process-wide index.- The sortedness invariant is guarded at every append site, the bulk-insert import path
included. A
_DEBUGwarning, not anassert( §3.4 ). - Reverse lookup is a linear scan ( §3.6 ).
Built: 1, 2 and 3. Item 4 is guarded on the two single-object geometry appends and nowhere else —
FlushGeneratedGeometryBatch and both storageLogicalObjects appends are still open ( §3.4 ).
4. persistedId — the bands
memoryID has no bands. persistedId has them, they are the ones declared at the top of this page, and the August 2026 revisit left them intact. What changed is what they mean and what they are not allowed to imply.
4.1 The bands as they now stand
| Range | Band | Assigned by | Allocation |
|---|---|---|---|
| [0, 2^32) | permanently invalid | nobody | the truncation guard — §4.5 |
| [2^32, 2^40) | application catalogue | Mission Vishwakarma developers | random draw — §4.3 |
| [2^40, 2^41) | local | the client, offline or pre-commit | sequential |
| [2^41, 2^42) | reserved gap | nobody | — |
| [2^42, 2^48) | permanent | the authority | sequential |
| ≥ 2^48 | reserved ( top 16 bits zero ) | nobody | — |
Uniqueness is within one file. Two files may hold the same persistedId for entirely different objects — that is normal and expected, and it is precisely why memoryID exists ( §1, and the original essay’s closing paragraphs ).
Implemented 2026-08-27, and it was not before. Until then the .yyy writer assigned
persistedIds by counting up from 1, capped at 2^40 — so every id ever written sat below
MINIMUM_VALID_ID, and the truncation guard the whole [0, 2^32) sacrifice was made to buy was
disarmed for user data. ID.h’s floor was being enforced for catalogue ids only; the file
writer never consulted it. Now:
- assignment starts at
kFirstAssignableObjectId= 2^40, the local band ( §4.2 ), because a desktop client has no authority to mint permanent ids; kMaxLocalObjectIdis 2^48 − 1, the top of the usable space, not 2^40 − 1;object_store.object_idcarriesCHECK (object_id >= 2^32 AND object_id < 2^48)in both the application’s schema and the round-trip harness’s copy of it, so a truncated id is rejected by SQLite rather than silently stored;- an id outside the band is SILENTLY DROPPED on save.
BuildRowsFromTabzeroes any out-of-bandpersistedIdbefore the assignment sweep runs, so the object is written with a fresh in-band id instead. The object itself is never dropped — only the offending number.
Two things about that drop are worth not re-deriving. It tests the band, not the assignment
base: [2^32, 2^40) is the application catalogue ( §4.3 ) and is valid even though this writer
never mints one, so using 2^40 as the floor would silently reissue every catalogue id and rewrite
every reference to one. And it is safe for references because it runs before assignment —
memoryIdToPersistedId is built afterwards, and both parent ids and the Asset2DInsert
definition reference are translated through that map when rows are written, so they follow the new
ids with no extra machinery. This is enforcement at the one point that issues ids, and it is
unrelated to the authority renumbering of §4.2.
No migration path exists, deliberately. A file written before this date keeps its old
CHECK — EnsureSchema uses CREATE TABLE IF NOT EXISTS, which is a no-op on a table that
already exists — and SQLite enforces the constraint stored in the FILE, not the one in the
source. Since the old ceiling was 2^40 and that is exactly kFirstAssignableObjectId, the first
id assigned into such a file always fails, quoting a constraint no longer present anywhere in the
code. A rebuild-on-save migration was built and then removed: every .yyy in existence was a
regenerable fixture, so the decision is to delete such files rather than carry code that repairs
them. Decided 2026-08-27.
4.2 The local band and the renumbering dance — RETAINED
Points 3 and 4 of the original essay stand: a client working offline assigns ids from the local
band, and when the work reaches the authority — a central server, or the local .yyy
virtual-server host — the authority assigns permanent ids and tells the client to update its
memory. Local-band ids may be duplicated across machines until that happens.
This was reconsidered in August 2026 and deliberately kept. The alternative — the file’s own host assigns permanent ids immediately, so nothing is ever renumbered — is simpler, but it gives up the property that made the band scheme worth having: a client can create thousands of objects with no authority reachable, and the ids it hands out are unmistakably provisional by their value. A reader can tell a committed id from an uncommitted one without consulting anything.
The cost is real and must be designed for, not discovered: renumbering rewrites references. When the authority renumbers object 2^40+7 to 2^42+9000, every reference field in that file that named 2^40+7 has to be rewritten in the same transaction. Two things keep that bounded:
- It is file-internal. Nothing outside a file ever names that file’s renumberable ids, because a foreign reference goes through a proxy and §5.5 forbids pointing a proxy at a provisional id. So renumbering never reaches across a file boundary, and never invalidates anyone else’s data.
- The reference set is enumerable. Every reference is either a plain same-file id field or a proxy payload; both are ordinary object payloads reached by the same sweep that renumbers.
4.3 What the catalogue band is, and is not
Clarified 2026-08-26, and this correction matters. The [2^32, 2^40) catalogue band is for items shipped as part of the application itself — the standard steel section catalogue, and anything else the developers assign and version alongside the binary. Nothing else.
A company’s own catalog is not this. Company-specific catalogue items — a firm’s standard
nozzle library, its own bolt tables, its internal component families — are ordinary engineering
objects in an ordinary .yyy file, drawing ordinary ids from the local and permanent bands like
any 3D or 2D object. They are catalog-like in use, not in identity.
Two consequences follow, and neither is a problem once §5 is in place:
- A band no longer tells you what kind of thing an id names. A company catalog item and a piece of project geometry are numerically indistinguishable. Any design that wanted to route a lookup by testing “is this a catalog id” is therefore impossible — which is a second, independent reason for §3.5’s no-partitioning rule and for §5’s proxy: file identity, not id value, is what distinguishes a foreign target.
- Random allocation is a catalogue-band property only ( July update point 5 ). It exists to stop parallel developer branches conflicting over a shared counter. A company catalog file has an authority like any other file, so it gets sequential ids and the locality they buy.
4.4 Packing is gone
storage.md §7.3 specified a packed 64-bit reference: 8 bits reserved, 16 bits of file alias, 40
bits of local object id. Withdrawn, 2026-08-26. Three independent reasons, any one sufficient:
- A
.zzzproject may reference more than 2^32 files, so a 16-bit alias — and any fixed alias width — is a ceiling that will be hit. The 65,534-external-files-per-.yyylimit is lifted. - Permanent ids are no longer constrained to 2^40. The permanent band runs to 2^48, so 40 bits
cannot hold one.
storage.md’sCHECK (object_id < 1099511627776)constraints are superseded. - Packing forces every schema to understand aliasing. With the proxy, only one payload does.
The replacement is not a wider packed word. It is §5: the alias and the target id are two
separate uint64 fields in the proxy’s protobuf payload, varint-encoded, so a small alias still
costs one or two bytes on disc while nothing has a width ceiling at all.
4.5 What survives unchanged
- The invalid floor below 2^32, and the truncation guard it buys. Every valid id, in either
space, has a bit set above the 31st.
MINIMUM_VALID_IDinID.his the check. - The top 16 bits are zero.
- The 63rd bit is the SQLite sign bit and is always 0.
- Ids are never recycled, in either space ( §3.3 ).
- The 62nd bit is no longer a flag and returns to being an ordinary unusable-because-reserved bit. Nothing is gained by reclaiming it and nothing needs it.
5. Foreign references — the ForeignReference proxy
A reference that leaves the file needs three things a same-file reference does not: which file, a resolution state that can be something other than “resolved”, and somewhere to put both. The design adopted 2026-08-26 gives all three one home.
5.1 The shape
- Same-file reference: the field holds a plain 64-bit id. A memoryID in RAM; the target’s persistedId on disc. Nothing else. This is the overwhelmingly common case and it stays free.
- Foreign reference: the field holds the id of a ForeignReference proxy object living in this same file. The proxy’s payload names the foreign file and the object inside it.
So every reference field, everywhere, in RAM and on disc, is one 64-bit same-file id. The referring object never learns that foreign files exist. Only the proxy’s payload does.
How 8 bytes are enough — the discriminator lives outside the field. The obvious objection is that one 64-bit field cannot hold two kinds of number, and there is no spare bit to say which it is: §3.5 forbids giving any memoryID range a meaning, so no value can be recognised by inspection.
It does not need to be. The field always holds a memoryID of a live object in this tab’s
directory, and the resolved object’s dataType is the discriminator. Resolve it: an ordinary
object means the reference hit its target; a ForeignReference means one more hop. The
discriminator costs zero bits in the field because it lives in the object the field points at.
That gives the invariant everything else in §5 and §6 rests on:
Every reference field resolves to a live object, at all times, in every state.
There is no state in which a reference field holds a persistedId, a sentinel, or a zero it did not start with. What varies is only what kind of object it names:
| State | Field holds | Resolves to |
|---|---|---|
| Same-file target, resolved | the target’s memoryID | the object itself |
| Same-file target, missing or deleted | a repair proxy’s memoryID | a proxy carrying the original persistedId ( §5.3 ) |
| Foreign target, its file loaded | the authored proxy’s memoryID | the proxy, whose cache holds the target’s memoryID |
| Foreign target, its file not loaded | the authored proxy’s memoryID | the proxy, state file_not_loaded |
Rows three and four are the same field value. A foreign reference names its proxy permanently, so mounting, unmounting or updating a catalog changes only the proxy’s internal cache and never the referring object. That is what lets the same file open correctly in sessions where different files are available.
5.2 Why a proxy rather than an inline foreign reference
- Uniformity. One field shape, one resolution entry point, one thing for the properties pane, undo, and the copy threads to understand. This is what retires the 62nd-bit flag: there is no longer anything to flag.
- Fan-in dedup. Ten members made from the same catalogue profile store ten 8-byte local ids and share one proxy. An inline design repeats the full foreign identity ten times.
- One home for state. Resolution state, the cached resolved target, the expected type, and any future permission or version outcome live on the proxy — once per foreign target, not once per referrer. §6 is only writable because this place exists.
- Re-resolution touches proxies only. A catalog remounted at a new path, re-aliased, updated, or temporarily unavailable changes N-distinct-targets proxy objects. It never touches the model.
The cost is one indirection on a foreign hop, and it is paid against a cached memoryID, not a search. Accepted.
5.3 Two kinds of proxy, and the lifecycle rule
One type, one payload — but two lifecycles that must not be confused, because one is persisted
and the other must never be. fileAlias is what tells them apart, and that is the whole rule:
| Authored proxy | Repair proxy | |
|---|---|---|
fileAlias |
≥ 2 | 0 |
| Created by | the user, authoring a foreign reference | the loader, when a same-file id fails to resolve ( §6.2 ) |
| Created when | edit time | load time, phase 2 |
| Has a persistedId | yes — an ordinary object_store row |
no |
| Written to disc | yes, like any object | never |
| Survives the session | yes, it is in the file | no, rebuilt next load if the fault is still there |
| Expected frequency | common — every member→profile reference | rare — corruption, tampering, a partial write |
Authored proxies cost the loader nothing. They arrive as ordinary rows and are assigned a memoryID like every other object, so the common case creates nothing during load. Only the rare, broken case allocates.
Repair proxies must never be persisted, and this is the load-bearing half of the rule. Opening a file whose catalog is unmounted, or whose one corrupt row has not been repaired yet, must not add objects to that file. A repair proxy exists for exactly two reasons: to keep the referring field resolvable for the session ( §5.1’s invariant ), and to carry the original persistedId to the save path so it round-trips ( §6.3 ). Persist them and a transient condition becomes a permanent file change — the failure §6.3 exists to prevent, arriving from the other direction.
An authored proxy is an ordinary object: it derives META_DATA, lives in the arena, appears in
a tab directory, carries a dataType, and is stored as an ordinary object_store row with an
ordinary persistedId from its own file’s space. It therefore rides transactions, sync, change_seq,
tombstones and the two-phase loader for free, with no parallel machinery — consistent with
storage.md §14.6’s verdict that relationships are engineering data, not a database-layer table.
A repair proxy is the same C++ type with the same fields; it simply never reaches the save path.
Residency — decided 2026-08-26. A proxy is neither geometry nor a container, so it goes in
neither existing directory. It lives in the tab’s third directory, storageLogicalData ( §3.5 ),
which exists for exactly this: non-geometry, non-container data objects. It needs a new
ObjectType::ForeignReference, which must be added after LineMember and must stay outside
IsGeometry3DObjectType — whose range currently reads >= Elbow && <= LineMember, so anyone adding
a further 3D type later must not simply extend that upper bound past the proxy.
memoryIDContainer is 0 on a proxy. A proxy is not shown anywhere, and fan-in means no single
referrer owns it, so there is no container to name. Zero also keeps it out of the model tree, which
is the wanted behaviour: a proxy is plumbing, not something the user places.
Do NOT reuse META_DATA::persistedId to hold the target’s id. It is tempting — the field is
inherited and sits unused on a repair proxy — but C++ offers no zero-cost way to alias an
inherited member under a second name ( using renames types and functions, never data members; a
reference member costs its own 8 bytes and makes the type non-trivially-copyable; an anonymous union
cannot span a base class ). The only real mechanism is an accessor returning the inherited field,
and that is naming, not aliasing — which puts the question back where it belongs: should one field
mean two things? No. An authored proxy genuinely needs its own persistedId, because it is a
row; only the repair proxy’s is free, so reuse would make the field’s meaning depend on the proxy
kind. That is precisely the overloading §2.4 removed from memoryIDContainer, and the save path’s
assignment sweep already keys on persistedId == 0 to mean “needs an id”. targetObjectId stays a
field of its own: 8 bytes, on the rarest object type in the model.
Payload — protobuf, no packing, varint-encoded. Written to disc for authored proxies only:
fileAlias uint64 which external file. >= 2 authored; 0 = repair proxy (§6.2)
targetObjectId uint64 the target's persistedId INSIDE that file.
For a repair proxy: the ORIGINAL id that failed to resolve
expectedType uint32 what type the reference was created against
expectedType earns its place: a foreign resolution that lands on a different type than the
reference was created against is a replaced-or-corrupt outcome, not a valid target. Without it, a
recycled-by-file-rewrite id resolves silently to the wrong kind of object — the failure §3.3 exists
to prevent, arriving through the file boundary instead of through a pointer.
RAM-only on both kinds, never serialized, recomputed every session:
state the resolution outcome (storage.md §8's set)
resolvedMemoryId the target once found. An ID, never a pointer (§3.3)
resolutionEpoch which mount generation this answer belongs to
5.4 The alias table
The proxy stores an alias, not a file identity. The alias resolves through the file’s own
external_file table ( storage.md §14.3 ) to the file_uuid, canonical URI, last known path and
content hash.
That indirection is kept deliberately: file identity lives in exactly one row, so a catalog that
moved, was renamed, or was replaced by a newer revision is one table update, not a sweep over
every proxy. Aliases are per-file and never global — file A may know catalog C as alias 7 while file
B knows the same catalog as alias 12 — which is storage.md §8’s rule and stays true.
Two amendments to that table follow from §4.4: the alias is uint64 with no upper CHECK, and
alias values keep only their two reserved meanings — 0 = this same file, 1 = a
transaction-scoped temporary reference that must never appear in a committed payload.
5.5 Rules
- A foreign reference may target a mounted catalog
.yyy, never a sibling project. Decided 2026-08-26. It keeps authority boundaries clean and means the server’s permission model never has to answer for cross-project object visibility. - A foreign reference may only target a permanent id. Creating a proxy that points at an object whose id is still local-band, or at an alias-1 temporary, fossilizes a number that is about to change ( §4.2 ). Assert at proxy creation, not at resolution.
- One proxy per (alias, targetObjectId) per file. A unique index enforces it going forward; the loader merges duplicates found in files written before the rule. This holds for repair proxies too: a thousand referrers to one missing object share one repair proxy, keyed by the id that failed.
- A proxy is never shared between files. It belongs to the file that refers outward.
- Alias 1 never reaches disc. RAM and transaction scope only.
fileAlias == 0means RAM-only, always ( §5.3 ). A proxy with alias 0 is a repair proxy and is never written as a row. Assert it on the save path rather than trusting the loader.- Repair proxies are created EAGERLY, during load phase 2 — never lazily, never on first access. §5.1’s invariant is what makes the save path total, and it only holds if every failed reference has its proxy before the load returns.
5.6 Loading a shared catalog: one copy per tab now, a registry later
Multiple projects may share one catalog .yyy, and the same catalog file may be needed by several
independently-opened tabs. Two options, and the sequence is decided:
(i) Each tab loads its own copy — do this first. The catalog’s objects are loaded into the mounting tab’s own arena group and its own directory. Simple, obviously correct, no lifetime question, no cross-thread sharing, and it is what makes §3.5’s single-directory resolution possible. The cost is duplicated memory: a catalog mounted by four tabs is resident four times.
(ii) A process-wide FileRegistry keyed by file_uuid — later. First mount loads the file
read-only; later tabs reuse it. DATASETTAB already carries the fileID field this would key on.
It is the optimization the TODO on DataReference::loadedFileReference is reaching for — carried
over verbatim from ReferenceID — and it is worth doing when duplication is measured to matter —
a large shared catalog open in many tabs — not before.
The proxy design is indifferent to which is in force, which is the point: it resolves through
the alias and the registry either way, so (i) → (ii) changes no reference, no payload and no schema.
What (ii) does change is §3.5 — it reintroduces a second directory to route to, a lifetime owned
by no tab, and an arena group that breaks memoryGroupNo == tabNo. That is the bill for (ii), and
§3.5 states it so it is not a surprise later.
6. Loading, staleness and broken references
A persisted reference names an object that may not have been read yet, may never have existed, may have been deleted, may have been replaced, or may live in a file this session cannot open. All five must be survivable. None of them may lose data, and none of them may fail a load.
6.1 Two-phase load — ordering is not a state, it is a phase
A reference to an object further down the file is not a problem to record; it is a problem to sequence away:
Phase 1 read every row; construct every object; build persistedId -> memoryID for the file;
push each unresolved reference field onto a fixup list
Phase 2 sweep the fixup list, translating each persisted target id into a memoryID
Order-independence comes free, and it must, because SQLite promises no row order — a VACUUM can
reorder the table under a loader that assumed topological order. The save path’s mirror
(memoryIdToPersistedId) and the load path’s persistedIdToMemoryId already exist for
containment; a reference field carries both halves through the same maps.
6.2 When the target is not there
After phase 2, an unresolved same-file target is a real inconsistency — corruption, external tampering, a partial write, or a genuinely deleted object. The response is graded, not binary:
- Consult
object_tombstone. Found → the state is deleted, or replaced ifreplacement_object_idnames a successor. Follow a replacement chain with a depth cap and a cycle guard — a chain can dangle or loop, and neither may hang a load. - Not found → missing. Combined with the payload CRC and the §4.5 validity floor, a target id below 2^32 is reported as corrupt rather than missing, because it is diagnostic: it means something truncated a 64-bit id.
- Promote the dangling reference to a repair proxy. Build one with
fileAlias = 0, carrying the original target id intargetObjectIdand the outcome instate, and point the referring field at it. RAM-only — it is never written as a row ( §5.3 ). The referring field now holds a valid, resolvable local id; the properties pane can say “target #N missing”; and — the point — saving round-trips the original id unchanged ( §6.3 ).
That third step is why one object type serves foreign references, broken references and permission-denied references alike. The uniformity is not tidiness; it is what makes the recovery path exist at all — and it is what keeps §5.1’s invariant true, since after phase 2 there is no reference field left that resolves to nothing.
Two properties of repair proxies worth stating outright, both from §5.5:
- One per failed id, not one per referrer. A thousand objects that referenced one deleted nozzle share a single repair proxy. A corrupt file costs a handful of small RAM objects, not a parallel copy of the model.
- Created eagerly, in phase 2, never lazily. A field that could resolve to nothing at save time is a field whose original id has nowhere to come from, and that is exactly the data loss §6.3 forbids.
For a foreign target the same grading applies, driven by the proxy’s state field over
storage.md §8’s outcomes: resolved, missing, deleted, moved, replaced,
permission_denied, file_not_loaded, file_alias_unknown, schema_unsupported, corrupt_payload.
file_not_loaded is the ordinary case, not an error — an external_file row may carry
load_policy = load_on_demand or never_auto_load, so a proxy may sit unresolved for an entire
session and that is correct behaviour.
State is derived truth. It is recomputed per session and invalidated by bumping a per-mounted-file resolution epoch, so remounting or updating a catalog cannot leave a stale resolved answer standing. Only the target’s identity is ever persisted.
6.3 Saving a reference — one branch, and it cannot lose anything
Because §5.1’s invariant holds, saving a reference field never has to ask whether it resolved. It resolves the memoryID once and branches on what came back:
Save reference field holding memoryID M:
obj = ResolveObject(M)
ordinary object -> write obj.persistedId
AUTHORED proxy (alias>=2)-> write the PROXY's own persistedId
(the field genuinely references the proxy, which is a real row)
REPAIR proxy (alias==0)-> write the ORIGINAL id the proxy carries
(the proxy itself is never written)
Three cases, no side table, no second map, and nothing is lost — because eager promotion ( §6.2 )
guarantees ResolveObject(M) always returns something. That is the whole reason the invariant is
worth enforcing: it makes the save path total.
Note the asymmetry between the two proxy kinds, and that it is deliberate. An authored proxy is a real object in the file, so the reference to it is written as a reference to it. A repair proxy is a session-local repair standing in for something that should have been there, so what is written is what the file said in the first place. Load, fail to resolve, save, load again: the file is byte-identical in that field.
Two rules that follow, both about not converting a recoverable problem into a permanent one:
- A reference whose target was absent this session is never written back as 0. The original id round-trips. The file may be opened tomorrow with the catalog mounted, the network up, or the corruption repaired from backup — and it must then resolve. Zeroing it makes the loss permanent and silent, which is the worst outcome available.
- A bad reference never fails a load. One corrupt row degrading to a reported broken reference beats refusing a 10-million-object file. Loading reports counts by outcome at the end; the properties pane and the model tree show the state per object.
6.4 Lifecycle states
META_DATA::isDeleted is a bool today. storage.md §9 specifies four states, and they exist
precisely so that external references can be answered after a deletion:
0 live
1 soft_deleted hidden from the model, payload still present (undo, recovery, review)
2 tombstone_retained payload may be gone; identity and deletion metadata remain,
so a foreign reference can be answered "deleted" or "replaced"
3 purged / archive boundary only after explicit compaction policy. Never permits id reuse.
Adopted in the design now; implemented when delete ships. No delete operation exists anywhere in
the application yet ( undo-redo.md ), so there is nothing to set the states from. META_DATA and
the relevant code take the enum when the delete path is built — the bool is not widened
speculatively, but nothing may be designed that assumes a bool is enough.
7. Settled — do not re-litigate
- One object model, no
META_DATA2D( §2.4 ): the dividing line is containment, not dimensionality. memoryIDContaineris the container,memoryIDGeneratoris the owner, both ordinary fields onMETA_DATA. Not optional properties — a plain field is 8 bytes and one load.- References store ids, never raw pointers ( §3.3 ).
- The memoryID space is never partitioned ( §3.5 ). FINAL.
- Resolution searches the calling tab’s directory and nothing else ( §3.5 ).
- Reverse lookup is a linear scan ( §3.6 ).
- Foreign references go through a ForeignReference proxy, stored as an ordinary object row
( §5 ), with alias and target as separate unpacked
uint64fields. - No packing of file alias into an id, and no external-file count limit ( §4.4 ).
- The local band and the renumbering dance are retained ( §4.2 ).
- The application catalogue band is for application-shipped items only; company catalogs are ordinary objects in ordinary files ( §4.3 ).
- A foreign reference may target a mounted catalog
.yyy, never a sibling project ( §5.5 ). - Shared catalog: one copy per tab now, a
FileRegistrylater ( §5.6 ). - A broken reference is promoted to a proxy and round-trips its original id. Never zeroed, never fatal ( §6.2, §6.3 ).
- Every reference field resolves to a live object, always ( §5.1 ). A reference field never
holds a persistedId, a sentinel or a zero it did not start with; the discriminator is the
resolved object’s
dataType, not a bit in the field. - Two proxy kinds, told apart by
fileAlias( §5.3 ). Authored (>= 2) is persisted as an ordinary row; repair (== 0) is created eagerly by the loader and never written to disc. - Proxies live in a third directory,
storageLogicalData, withmemoryIDContainer = 0( §3.5, §5.3 ).ResolveObjectsearches every one of a tab’s directories — two exist today and the third is created with the proxies themselves. - No field on
META_DATAis ever reused to mean something else ( §5.3 ).targetObjectIdis its own field;persistedIdalways means “my own persisted identity”, on every object type. - An out-of-band persistedId is silently dropped and reissued on save, and there is no migration path for a file written under an older id range ( §4.1 ). Such files are deleted, not repaired.
8. Open, and deliberately deferred
Open — needs a decision before implementation:
- When is a file safe to reference? §4.3 made company catalogs ordinary files with ordinary ids, and §4.2 keeps local-band ids renumberable. A company catalog that has never reached an authority therefore holds provisional ids. §5.5 rule 2 forbids pointing a proxy at one, which makes the failure loud rather than silent — but it leaves a workflow question: a firm builds a catalog locally and wants to reference it before any server exists. The candidate rule is a file is mountable as a shared catalog only once it contains no local-band ids, checked at mount. Assertable, and it makes renumbering strictly file-internal ( §4.2 ). Not yet decided.
- Version skew on a mounted catalog.
external_filecarriescontent_hashandexpected_schema_catalog_hash. Decide whether a mismatch warns and re-resolves, or blocks the mount. Proxies re-resolve per session ( §6.2 ), so a stale cached resolution cannot survive an update — but a silently different catalog revision is a different problem. - Proxy orphan collection, and undoing a foreign reference. When the last referrer to a proxy
goes away the proxy is garbage; the choice is between never collecting, collecting at save, and
collecting during compaction. Undo reaches this before delete does — undoing the edit that
created a foreign reference should drop the proxy only if no other referrer remains. Tracked in
undo-redo.md§13, which owns the transaction semantics; this page owns only the rule that a proxy is an ordinary object and is therefore whatever the undo log says it is.
Deferred, deliberately — these are decisions, not gaps:
- Reverse lookup stays a linear scan ( §3.6 ). Revisit only if a real workflow makes referrer queries hot.
LINE_MEMBER::profileIdstays as it is. It names a row in the compile-time embedded steel catalogue (SteelProfileCatalog.h), which is application-shipped data in the [2^32, 2^40) band ( §4.3 ), version-locked to the binary, and needs no file identity and no proxy. If catalogues later become mountable.yyyfiles, it becomes an ordinary foreign reference through a proxy like anything else — decided then, not now.- The 62nd bit is not reclaimed ( §4.5 ).
- Optional-property back-references are not built. §3.6 chose the scan instead.
9. Relationship to storage.md
storage.md is the authority on the file format — schema, tables, transactions, sync,
permissions. This page is the authority on identity: what an id means, which band it comes from,
what a reference stores, and how it resolves. Where the two disagree, this page wins on identity and
storage.md wins on everything else.
Four parts of storage.md were superseded by the August 2026 decisions. All were amended
2026-08-26, so the two pages now agree:
storage.md |
Used to say | Amended to |
|---|---|---|
| §7.2 | persistent ids use the lower 40 bits only | the band table of §4.1, with the [2^32, 2^48) range and the company-catalog clarification of §4.3 |
| §7.3 | packed reference: 16-bit alias + 40-bit id | the ForeignReference proxy, alias and target as separate unpacked uint64 varints ( §4.4, §5.3 ) |
| §14 | CHECK (object_id < 1099511627776); CHECK (external_file_alias BETWEEN 2 AND 65535) |
CHECK (object_id >= 2^32 AND object_id < 2^48); alias >= 2 with no ceiling. §14.4 notes proxies are ordinary rows; §14.6’s discarded object_relation names the proxy as its replacement |
| §17 | resolution by unpacking a packed reference | resolution by branching on whether the id names a proxy, with the expectedType check and the never-zero / never-fatal rules of §6 |
What storage.md contributes to this page and keeps: the ten resolution outcomes ( §8 there → §6.2
here ), the four lifecycle states ( §9 there → §6.4 here ), the external_file alias table ( §14.3
there → §5.4 here ), the object_tombstone table with replacement_object_id ( §14.5 there → §6.2
here ), and the authority model that decides who may assign a permanent id at all ( §10.1 there →
§4.2 here ). Its §14.6 object_relation table stays discarded, as it already says — the proxy in
§5 is engineering data in an ordinary object row, which is exactly what that note asked for.