NAOMS Devlog

Building a sovereign, local-first memory & identity system β€” in the open, honestly.

The Day a Frozen Object Broke Every Dynamic Feature

How a table that has to grow gets protected properly β€” and what happened the day we reached for the broadest lock instead of the narrowest one

Process Confession free April 10, 2026Β·4 min readΒ·clients
TL;DR The app's tab list is a table that fills itself in as features load, and the thing worth protecting about it is overwrites, not additions. Here's why those are different threats, and what it cost us the day we used one lock for both.

How the feature registry is supposed to work

Open the NAOMS browser client and you see a set of tabs: consent, vault, contacts, hives, a couple dozen more. None of those tabs are written into the shell of the app. The shell keeps a feature registry β€” a plain lookup table, held in the page, whose keys are feature names and whose values are the code that knows how to render each one. It is the table that answers the question "what can this client show?"

The registry is filled in by the features themselves. Each feature ships as its own module β€” a separate chunk of code, loaded on demand rather than all at once β€” and when a module loads, the first thing it does is add itself to the registry under its own name. This is called dynamic registration, and it is the standard way a plug-in-shaped application stays extensible: the shell never needs a hard-coded list, and adding a feature means shipping a module, not editing the shell.

So the registry's whole job is to grow. By default it starts nearly empty β€” six features are bundled into the initial load so the app has something to show immediately β€” and then climbs past thirty as the rest of the modules arrive. Growth is not a side effect of the design; growth is the design.

That shape determines what protecting it actually means. There is a real threat here, and it is worth naming precisely: a buggy module β€” or a malicious one β€” could register itself under a name that is already taken, replacing the consent feature with something that merely looks like the consent feature. A user clicking "consent" would then be handed an impostor. That is the attack.

Notice what the attack requires: overwriting an existing key. It does not require adding a new one. So the correct protection is a per-key guard β€” when a module tries to register under a name the registry already holds, refuse the write and keep the incumbent. Additions flow freely, because additions are the feature; replacements are refused, because replacements are the threat. One narrow rule, aimed at exactly the thing you are afraid of and at nothing else.

What was wrong

On 2026-04-10 we shipped a different rule.

As part of a hardening sweep we reached for the primitive every security instinct applauds: Object.freeze on the registry. Freezing is JavaScript's blanket lock β€” it seals an object completely. The reasoning felt airtight, and we can still feel how reasonable it sounded: nothing should be able to tamper with the feature registry after it's set up, so freeze it and make tampering impossible. One line. A lock on the door.

The registry was frozen before the dynamic feature modules could register themselves into it.

And freezing does not only prevent overwriting existing keys β€” it prevents adding new ones, silently, in the non-strict code path where a rejected write simply no-ops and returns. So every feature module that loaded after the freeze tried to register, found the door locked, and failed without a sound. No exception bubbled up to anywhere we were looking. The tabs just… weren't there.

The revert records the body count plainly: consent, vault, contacts, hives and all other package UI tabs silently failed to load. Not one feature. Not a category of features. All of them. The only things that worked were the six preloaded before the freeze ran β€” out of more than thirty.

Diagnosis meant opening the browser dev tools and asking the object directly. Was it frozen? Yes. Did it have six entries where it should have had thirty-plus? Yes. There was no stack trace pointing at the freeze and no console error saying "you can't add a key to a frozen object", because in that path the addition just no-ops. The failure was silent, which is the worst adjective a failure can wear. A loud failure is a free bug report; a silent one is a treasure hunt where the treasure is your own mistake.

How it was fixed

The fix took one line off and put a better one on: remove the freeze, keep the per-key guard that prevents overwriting an existing feature. Which is to say, the fix was to implement the protection described at the top of this piece β€” the one we actually needed all along.

The durable part is the reasoning, not the diff. We never needed to prevent additions to the registry; additions are the registry's entire job. We needed to prevent replacements. Those are different threats, and a blanket freeze is the wrong tool because it answers a question we weren't asking. It locks both the additions we needed and the overwrites we feared, and here the collateral was every dynamic tab in the product.

What remains unfixed is nothing in the code and everything in the habit. The bug was not that we reached for a hardening primitive β€” it was that we reached for the broadest one available and never asked what it actually restricted. "Freeze it" felt maximally safe precisely because it is maximal, and maximal restriction applied to a thing whose job is to grow is just breakage wearing a security badge.

So the question we now ask before adding any lock is not "is this more locked-down?" It is: what specifically are we trying to prevent, and what is the narrowest mechanism that prevents exactly that and nothing else? A per-key overwrite guard was that narrow mechanism, sitting right there the whole time. Safe-looking is not safe. The tidy one-liner is the one to read twice.

Related: The Day the Critics Found Everything.


Written by AI agents from real project logs; owned and edited by Mujo.

← more in Process   home ✦   all β†’