Frontend developer reviewing JavaScript Object.freeze and Map mutation behavior on a large monitor in a professional workspace.

A Frozen JavaScript Map Can Still Change Its Entries

Sat, Oct 10, 2026

A frontend registry starts with two approved settings: theme=light and density=comfortable. Its owner calls Object.freeze(registry), confirms that Object.isFrozen(registry) returns true, and publishes the object as stable configuration. Later, a consumer reads theme=dark. Nothing “thawed” the object. A normal Map.set() call changed the collection entries while the object remained formally frozen.

That result is surprising only when two contracts have been collapsed into one. Object.freeze() governs extensibility and own-property descriptors. A Map stores its entries in collection state described by the ECMAScript specification as [[MapData]]. The set, delete, and clear algorithms operate on that state; they do not ask whether the receiver passes the frozen-property predicate.

The operational question is therefore narrower than “Is this object frozen?” It is: Can any published capability or retained alias change the values that consumers were promised?

This article answers that question with a nine-case Node.js ES-module experiment. The negative controls prove that a frozen Map can still change. The property control proves that freezing itself is working as specified. The repair creates an owned primitive snapshot and publishes only get, has, and detached entries results. The outcome is deliberately bounded: REFACTOR an exposed mutable design, ACCEPT_BOUNDED the tested snapshot interface, reject unsupported object-valued input, and HOLD when evidence is incomplete or contradictory.

Define what the published configuration must preserve

The authored baseline is ordered and literal:

[['theme', 'light'], ['density', 'comfortable']]

Order matters because the experiment compares entry arrays, not just key membership. After publication, a consumer must observe light from get('theme'), true from has('density'), and those two pairs in that order from entries(). An absent key must still return undefined, and an unapproved debug key must remain absent.

The contract does not require every array returned to a caller to be frozen. A caller may edit a temporary exported array, including a nested pair, provided a later entries() call still reports the owned baseline. That is different from editing the collection that supplies future reads.

This distinction establishes two roles. The configuration producer may hold a mutable working Map while assembling settings. Consumers receive a stable snapshot interface after publication. The producer must not publish the working collection itself, and a snapshot must not continue reading through a retained source alias.

The choice is intentional: this article validates a stable consumer snapshot, not a universally immutable Map. It complements broader frontend engineering foundations by focusing on one runtime ownership boundary rather than frameworks, rendering, or state-management architecture.

A publication review should write the promise before selecting an implementation. Here the promise is value stability through three named reads, not object identity, serializability, deep immutability, or freedom from all internal mutation. That wording determines the evidence: exact entries before and after source edits, method-surface inspection, mutation probes, and rejected inputs. Without the written promise, a true frozen predicate can easily be promoted into a conclusion it never tested.

Record the runtime and the language contract

The supplied commissioning run used revision map-freeze-admission-v1. It started at 2026-10-10T06:29:20.769Z and finished at 2026-10-10T06:29:20.772Z, reporting PASS_9_CASES. Its recorded environment was Node v24.19.0, V8 13.6.233.17-node.51, Linux x64, OS release 6.18.44. The source SHA-256 was:

148a615667bddcd38dd73a06b2c169f8eca7d8937bf4c6cf31734da0b09ae88b

Those facts identify one dated execution. They do not establish that this Node release is latest, that every browser behaves identically, or that the code has been benchmarked. No browser run was supplied. A new qualification should retain its own printed run-directory name, start and finish timestamps, runtime versions, platform, operating-system release, source digest, exit status, and results.json file.

The test assumes one JavaScript realm, ordinary same-realm Map instances, unmodified built-ins, synchronous execution, and primitive string keys and values. The .mjs extension makes the driver an ES module, so its assignment control executes in strict mode. No DOM, framework, package installation, account, or remote service is involved.

Separate established semantics from document version labels

The ECMAScript SetIntegrityLevel and TestIntegrityLevel algorithms prevent extensions and process the object’s own property keys and descriptors. TestIntegrityLevel likewise decides whether that property set is fixed. Neither algorithm enumerates Map entries.

By contrast, the specified Map methods require a receiver with the [[MapData]] internal slot and then read or alter records in that slot. [[MapData]] is specification machinery, not an enumerable field that application code can inspect and not a promise about an engine’s memory layout.

The pages inspected for this article displayed the draft label “ECMAScript 2027.” That label identifies the current specification draft; it does not mean this behavior was introduced as a new 2027 feature. MDN’s Object.freeze() documentation describes non-extensibility, descriptor changes, same-object return, strict-mode failures, and shallow behavior. Its broad introductory wording must be read together with the normative property algorithm and the collection algorithms. Object.isFrozen() tests non-extensibility and property descriptors; it does not compare current configuration entries with an approved revision.

Run the complete nine-case experiment

Use a writable disposable directory. Save the exact driver below as map_freeze_fixture.mjs, then run it with the Node executable being qualified:

node map_freeze_fixture.mjs

The program creates a uniquely named adjacent directory such as map-freeze-run-..., writes results.json there with exclusive creation, and prints a compact JSON object containing the overall result and path. It accepts no endpoint, credentials, browser state, or production configuration.

Before inspecting any implementation output, record the literal oracle. M1 expects theme=dark plus unchanged density; M2 expects the baseline plus debug=on; M3 expects only density; M4 expects no entries. M5 expects the baseline because it tests an ordinary property write. M6 expects theme=dark through an aliasing facade. M7 and M8 expect the original two-entry snapshot. M9 expects rejection rather than entries. These arrays are authored expectations, not values copied from the candidate after it runs.

The report deliberately records more than a single Boolean. before and after preserve the frozen predicate, extensibility, own keys, size, and ordered entries around each raw-Map mutation. The alias and snapshot cases retain their own threat-specific controls: source edits that must be visible, facade keys that must be exact, exported arrays that must actually be changed, and rejection attempts that must publish nothing. A candidate can therefore fail for the right reason without being mistaken for an accepted design.

Read the driver as an executable decision record. The helper named require is not a general assertion library; each call encodes one fact needed by this narrow contract. The same helper uses JSON text only because the expected values are deterministic arrays of strings or null in this fixture. The article does not generalize that comparison to arbitrary JavaScript values.

// Local ECMAScript experiment. No browser, DOM, HTTP, or third-party packages.
import {readFileSync, mkdtempSync, writeFileSync} from 'node:fs';
import {createHash} from 'node:crypto';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {release} from 'node:os';

const revision = 'map-freeze-admission-v1';
const sourceFile = fileURLToPath(import.meta.url);
const runDir = mkdtempSync(join(dirname(sourceFile), 'map-freeze-run-'));
const report = {
  revision, run_id: runDir.split('/').at(-1), started_utc: new Date().toISOString(),
  source_sha256: createHash('sha256').update(readFileSync(sourceFile)).digest('hex'),
  runtime: {node: process.version, v8: process.versions.v8, platform: process.platform,
    arch: process.arch, os_release: release()},
  result: 'FAIL', stage: 'initialization', cases: []
};
const expected = [
  ['M1', [['theme','dark'],['density','comfortable']], 'REFACTOR'],
  ['M2', [['theme','light'],['density','comfortable'],['debug','on']], 'REFACTOR'],
  ['M3', [['density','comfortable']], 'REFACTOR'],
  ['M4', [], 'REFACTOR'],
  ['M5', [['theme','light'],['density','comfortable']], 'PROPERTY_CONTROL'],
  ['M6', [['theme','dark'],['density','comfortable']], 'REFACTOR'],
  ['M7', [['theme','light'],['density','comfortable']], 'ACCEPT_BOUNDED'],
  ['M8', [['theme','light'],['density','comfortable']], 'ACCEPT_BOUNDED'],
  ['M9', null, 'REJECT_UNSUPPORTED_INPUT']
];
function require(ok, message) { if (!ok) throw new Error(message); }
function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
function baseline() { return new Map([['theme','light'],['density','comfortable']]); }
function rows(map) { return Array.from(map, ([key,value]) => [key,value]); }
function state(map) {
  return {frozen: Object.isFrozen(map), extensible: Object.isExtensible(map),
    own_keys: Reflect.ownKeys(map), size: map.size, entries: rows(map)};
}
function exceptionName(operation) {
  try { operation(); return null; } catch (error) { return error.name; }
}
function facade(map) {
  return Object.freeze({get: key => map.get(key), has: key => map.has(key),
    entries: () => rows(map)});
}
function snapshot(source) {
  // Contract: ordinary same-realm Maps with primitive string keys and values.
  // Object-valued input, subclasses, proxies and modified built-ins are outside it.
  require(Object.getPrototypeOf(source) === Map.prototype, 'Expected ordinary Map');
  const owned = new Map();
  for (const [key,value] of source) {
    if (typeof key !== 'string' || typeof value !== 'string') {
      throw new TypeError('Only string keys and values are admitted');
    }
    owned.set(key, value);
  }
  return facade(owned);
}
function conduct(id, operation) {
  const row = {id, result: 'FAIL'};
  report.stage = id;
  report.cases.push(row); // Preserve even a partially failed case.
  operation(row);
  const specification = expected.find(([key]) => key === id);
  require(same(row.observed_entries, specification[1]), ${id}: wrong entries);
  row.decision = specification[2];
  row.result = 'PASS';
}
try {
  for (const id of ['M1','M2','M3','M4']) {
    conduct(id, row => {
      const map = baseline();
      row.freeze_return_same = Object.freeze(map) === map;
      row.before = state(map);
      if (id === 'M1') row.return_same = map.set('theme','dark') === map;
      if (id === 'M2') row.return_same = map.set('debug','on') === map;
      if (id === 'M3') row.delete_return = map.delete('theme');
      if (id === 'M4') row.clear_return_undefined = map.clear() === undefined;
      row.after = state(map);
      row.observed_entries = row.after.entries;
      require(row.freeze_return_same && row.before.frozen && row.after.frozen, 'Freeze state mismatch');
      require(!row.after.extensible && same(row.after.own_keys, []), 'Own property mismatch');
      if (id === 'M1' || id === 'M2') require(row.return_same, 'Map.set return mismatch');
      if (id === 'M3') require(row.delete_return === true, 'Delete return mismatch');
      if (id === 'M4') require(row.clear_return_undefined, 'Clear return mismatch');
    });
  }
  conduct('M5', row => {
    const map = baseline();
    map.label = 'candidate-v1';
    Object.freeze(map);
    row.before = state(map);
    row.error_type = exceptionName(() => { map.label = 'changed'; });
    row.descriptor = Object.getOwnPropertyDescriptor(map, 'label');
    row.after = state(map);
    row.observed_entries = row.after.entries;
    require(row.error_type === 'TypeError', 'Strict assignment was not rejected');
    require(map.label === 'candidate-v1' && !row.descriptor.writable &&
      !row.descriptor.configurable && row.after.frozen, 'Property control failed');
  });
  conduct('M6', row => {
    const source = baseline();
    const view = facade(source); // Deliberately retains a writable source alias.
    row.before = view.entries();
    row.facade_frozen = Object.isFrozen(view);
    source.set('theme','dark');
    row.observed_entries = view.entries();
    require(row.facade_frozen && view.get('theme') === 'dark', 'Alias control failed');
  });
  conduct('M7', row => {
    const source = baseline();
    const view = snapshot(source);
    row.before = view.entries();
    source.set('theme','dark'); source.delete('density'); source.set('debug','on');
    row.source_after_edits = rows(source);
    require(same(row.source_after_edits, [['theme','dark'],['debug','on']]), 'Source mutation missing');
    source.clear(); row.source_after_clear = rows(source);
    row.observed_entries = view.entries();
    row.facade_keys = Reflect.ownKeys(view);
    require(source.size === 0 && Object.isFrozen(view), 'Isolation setup failed');
    require(same(row.facade_keys, ['get','has','entries']), 'Unexpected facade surface');
    require(view.get('theme') === 'light' && view.has('density') &&
      !view.has('debug') && view.get('absent') === undefined, 'Read contract failed');
  });
  conduct('M8', row => {
    const view = snapshot(baseline());
    row.errors = {
      replace_get: exceptionName(() => { view.get = () => 'dark'; }),
      borrowed_set: exceptionName(() => { Map.prototype.set.call(view,'theme','dark'); })
    };
    const exported = view.entries();
    exported[0][1] = 'dark'; exported.push(['debug','on']);
    row.edited_export = exported;
    row.observed_entries = view.entries();
    require(row.errors.replace_get === 'TypeError' && row.errors.borrowed_set === 'TypeError', 'Mutation controls failed');
    require(same(row.edited_export, [['theme','dark'],['density','comfortable'],['debug','on']]), 'Export edit missing');
    require(Object.isFrozen(view) && view.get('theme') === 'light', 'Facade reads changed');
  });
  conduct('M9', row => {
    row.rejections = [];
    for (const source of [new Map([['theme',{mode:'light'}]]), new Map([[{},'light']])]) {
      let published = null;
      const error_type = exceptionName(() => { published = snapshot(source); });
      row.rejections.push({error_type, published: published !== null});
      require(error_type === 'TypeError' && published === null, 'Unsupported input escaped');
    }
    row.observed_entries = null;
  });
  require(report.cases.length === expected.length && new Set(report.cases.map(row => row.id)).size === 9,
    'Incomplete or duplicate case ledger');
  report.result = 'PASS_9_CASES'; report.stage = 'complete';
} catch (error) {
  report.error = {name: error.name, message: error.message, stack: error.stack};
  process.exitCode = 1;
} finally {
  report.finished_utc = new Date().toISOString();
  const path = join(runDir, 'results.json');
  writeFileSync(path, JSON.stringify(report, null, 2) + '\n', {flag:'wx'});
  console.log(JSON.stringify({result: report.result, path}));
}

The supplied October 10, 2026 commissioning execution matched all nine authored rows and reported PASS_9_CASES on the recorded Node environment. That is historical evidence for this exact source identity. It is not a newly executed result from this writing session, and it is not browser evidence.

Preserve incomplete evidence before declaring success

conduct() appends a row with result: 'FAIL' before invoking the case operation. If an assertion throws, the outer catch records the error name, message, and stack, keeps the partially populated case, and sets a nonzero exit status. The finally block still attempts to write the current report. This ordering matters: a missing observation should remain visible instead of disappearing from an apparently clean ledger.

Success also requires exactly nine cases with unique IDs. A report that silently omits M4 or duplicates M7 cannot become PASS_9_CASES. Storage is part of the evidence path too. flag: 'wx' prevents replacing an existing report; if writing fails, there is no durable result file to review, so the qualification is HOLD, regardless of what appeared briefly in a terminal.

Most importantly, PASS_9_CASES means that the implementation agreed with the diagnostic manifest. It does not convert M1–M4 or M6 into acceptable immutable designs. Those cases pass because the expected defect was observed and correctly classified REFACTOR. M5 passes as a property control. Only M7 and M8 reach ACCEPT_BOUNDED, and only for the stated interface and input domain.

A rerun should produce new timestamps, a new uniquely named directory, and a digest calculated from the source actually executed. Do not copy the commissioning PASS_9_CASES value into a report for edited code, and do not reuse its SHA-256 after changing even a comment.

Change an existing entry and add a new one

M1 and M2 make Map.set() answer two different mutation questions.

M1 starts from the two-entry baseline, freezes the Map, then calls map.set('theme', 'dark'). The method returns the same receiver. The final entries are:

[['theme', 'dark'], ['density', 'comfortable']]

The size remains two. That is the critical counterexample to a size-only monitor: a value changed without any change in entry count. Object.isFrozen(map) remains true before and after the call.

M2 instead calls map.set('debug', 'on'). The method again returns the same receiver, and the final ordered entries become:

[['theme', 'light'], ['density', 'comfortable'], ['debug', 'on']]

The size rises from two to three. The specified set algorithm searches [[MapData]]; it updates the matching record or appends a new one, then returns the Map. It does not consult TestIntegrityLevel.

Both cases are REFACTOR for a stable publication contract. A successful return is not evidence of immutability, and a still-true frozen predicate does not compare the entries with the approved baseline. The ordered arrays also show that replacing theme keeps it in the first position, whereas adding debug appends a third pair. That difference helps detect a mistaken delete-and-reinsert repair or an oracle that checks only key/value membership while ignoring the declared iteration order.

Inspect entries separately from own properties

The ordinary Map instances in M1 and M2 have Reflect.ownKeys(map) equal to [] while containing two or three entries. They are also non-extensible after freezing. Those facts are compatible because entries are not represented as own string or symbol properties.

The integrity algorithms fix the own-property set and descriptors; the Map algorithms operate on [[MapData]]. This is an inference from the two specified paths, corroborated by the dated local execution. Application code should not try to read map.[[MapData]]: the notation names an internal slot in the specification, not a public JavaScript property.

Delete and clear entries without thawing the object

M3 and M4 use fresh baselines rather than inheriting state from M1 or M2. Isolation prevents one negative control from changing the preconditions of the next.

In M3, the frozen Map receives delete('theme'). The method returns true, showing that the key existed and was removed. The final state is:

[['density', 'comfortable']]

The size is one, the object remains non-extensible, and Object.isFrozen(map) remains true.

In M4, clear() returns undefined, as specified, and the final entries are []. The size is zero. Frozen status is still true; there is no “unfreeze” transition in the test or in the collection algorithm. The ECMAScript collection algorithms describe delete and clear as changing records in [[MapData]], with deleted records represented by the specification value empty.

These return values alone are not sufficient oracles. A reviewer should reconcile the method result, the final size, the exact ordered entries, the non-extensible state, and the frozen predicate. For example, delete() returning false could mean the test began from the wrong fixture. An empty result after clear() is meaningful only if the baseline was first established.

Strict mode does not disable Map methods. The methods remain callable because freezing has not removed them from the prototype or changed the receiver’s [[MapData]] slot. M3 and M4 therefore receive REFACTOR: a raw frozen Map does not meet the chosen stable-entry publication contract.

Demonstrate the property restriction that freeze actually enforces

M5 is the control that keeps this diagnosis honest. Before freezing, the case adds the own data property label with value candidate-v1. After Object.freeze(map), it attempts:

map.label = 'changed';

Because the driver is an ES module, the assignment runs in strict mode and throws TypeError. The value remains candidate-v1. Its descriptor reports writable: false and configurable: false, and the object remains frozen. Meanwhile, the collection entries stay exactly at the baseline:

[['theme', 'light'], ['density', 'comfortable']]

That result is the documented property behavior, not a workaround or engine accident. MDN’s Object.freeze() documentation explains that freezing prevents extensions and makes existing data properties non-writable and properties non-configurable; it also notes that strict-mode writes commonly throw.

M5 is classified PROPERTY_CONTROL, not ACCEPT_BOUNDED. It proves that this Map is frozen according to the relevant property rules. It does not test whether the collection entries are stable under set, delete, or clear.

Keep strict assignment failure distinct from collection mutation

Property assignment and Map.set() take different specified paths. The assignment targets the own property label; the frozen descriptor causes the strict-mode failure. Map.set() calls the collection algorithm, checks for [[MapData]], and changes an entry record.

The same object can therefore reject map.label = 'changed' and still accept map.set('theme', 'dark'). There is no contradiction. Nor should the M5 exception be generalized to every non-strict script: failed writes may be silent in other assignment contexts. The reliable conclusion is narrower: freezing enforced the own-property restriction observed under this ES-module execution.

Expose the writable alias behind a read-only facade

A common repair removes set, delete, and clear from the public surface:

function facade(map) {
  return Object.freeze({
    get: key => map.get(key),
    has: key => map.has(key),
    entries: () => rows(map)
  });
}

M6 shows why that change is incomplete when the facade closes over the caller’s original Map. The wrapper itself is frozen and exposes only reads. Nevertheless, the producer still holds source, calls source.set('theme', 'dark'), and the facade immediately returns dark. Its final entries are:

[['theme', 'dark'], ['density', 'comfortable']]

The public absence of a set method did not revoke the producer’s authority over the same collection. Object.isFrozen(view) says that the wrapper’s own properties are fixed; it says nothing about the mutable object captured by those functions. The design is therefore REFACTOR.

This is an ownership defect, not merely a method-list defect. The closest comparison is the separate problem of shared mutable input and ownership, where independent-looking consumers can still reach common mutable state. The acceptance target there is test isolation; here it is a published JavaScript configuration snapshot. The reusable reasoning is to trace aliases, not to copy that article’s fixture or deep-copy recipe.

A reviewer should ask: Which object supplies future reads, who still holds it, and through what capabilities can it change? A frozen wrapper is insufficient if its closures continue to consult owner-controlled mutable state.

Draw the alias graph explicitly. In M6, both the producer variable source and the three facade closures point to the same collection. Freezing the facade changes only the descriptors of the wrapper’s get, has, and entries properties. It neither replaces the captured collection nor removes the producer’s reference. A code review that inspects only the exported object will therefore miss the authority retained outside it.

The required repair is not “freeze more wrappers.” It is to allocate state whose ownership begins at publication, populate it from admitted values, and ensure that every future read reaches that owned state. Only then does the small method surface become meaningful. This is why M7 must prove both sides: the source really changes, and the published values do not.

Publish an owned snapshot with a deliberately small interface

snapshot(source) changes ownership before it changes presentation. It first admits only an ordinary same-realm Map whose direct prototype is Map.prototype. It allocates a new internal Map, iterates the source, rejects any non-string key or value, and copies each admitted pair. Only then does it create the frozen read facade.

M7 proves independence rather than assuming it. After publication, the case changes the source theme to dark, deletes density, and adds debug=on. It records:

source_after_edits = [['theme', 'dark'], ['debug', 'on']]

It then clears the source and records source_after_clear = []. Those controls show that the mutation attempts actually occurred. The snapshot still returns the original ordered entries, reports light for theme, contains density, excludes debug, and returns undefined for an absent key.

The Map() constructor can create a collection from key/value pairs, and MDN’s Map cloning discussion makes the important limit explicit: the new collection is separate, but the data copy is shallow. That is why this acceptance proof admits strings only rather than claiming independence for referenced objects.

State exactly what the facade owns and exposes

The returned object has exactly three own keys: get, has, and entries. Consumer code receives no raw Map, mutation method, callback that receives the owner, or public iterator over owner-controlled mutable state. Each method closes over the newly allocated collection, not the source supplied by the caller.

The private collection is still a normal mutable Map; the design succeeds by not publishing a mutation capability through the declared interface. Calling it a built-in immutable Map would overstate the evidence. It is an authored read facade around an owned primitive snapshot.

This boundary assumes ordinary same-realm input and unmodified built-ins. It is not a hostile-realm membrane, an answer to arbitrary prototype replacement, or a security boundary against code with broader authority in the same process. Within the admitted model, however, M7 supports ACCEPT_BOUNDED because source aliases no longer control future consumer reads.

Test consumer access and exported entry arrays

M8 attacks two parts of the public surface and one returned value.

First, it tries to replace view.get. The frozen facade has a non-writable own data property for that function, so strict assignment throws TypeError. Second, it borrows the built-in mutator:

Map.prototype.set.call(view, 'theme', 'dark')

That call also throws TypeError, but for a different reason: the facade is not a Map and lacks the required [[MapData]] internal slot. Freezing is not what makes the borrowed mutator fail. The Map.prototype.set receiver requirement does.

Next, the case edits the array returned by entries():

[['theme', 'dark'], ['density', 'comfortable'], ['debug', 'on']]

The edited export proves that no immutable-array promise is being made. Yet a fresh entries() call still returns the two-entry baseline, and get('theme') still returns light. rows() creates a new outer array and new two-element pair arrays on every call. Because both elements are strings, those detached pairs carry no mutable reference back into the owned collection.

M8 therefore reaches ACCEPT_BOUNDED. Its evidence is not merely “the facade is frozen” or “borrowed set threw.” Acceptance depends on all controls together: fixed methods, no [[MapData]] receiver, visibly edited exports, and unchanged subsequent reads.

This capability view prevents two false positives. First, a method name such as entries is not automatically safe; it is safe here because it creates detached arrays rather than returning the private Map iterator. Second, a thrown borrowed mutator does not prove that all future surface additions will be safe. A later method that returns the owner, accepts a callback over it, or exposes a live iterator would change the result even if the facade itself remained frozen.

Reject input outside the snapshot contract

M9 tests admission rather than post-publication mutation. One source contains an object value:

new Map([['theme', {mode: 'light'}]])

The other contains an object key:

new Map([[{}, 'light']])

Each call to snapshot() must throw TypeError, and in each attempt published must remain null. That ordering matters: the function validates while building the private collection and returns no facade after encountering an unsupported pair.

A separate Map would not make either object independent. As the MDN Map reference explains, collection copying is shallow, so the source and snapshot would still share referenced key or value objects. The current proof does not freeze those objects, clone them, validate their shapes, or control other aliases. Accepting them would silently enlarge the claim beyond the evidence.

The supported domain is deliberately exact: ordinary same-realm Map instances containing string keys and string values. Numbers, booleans, bigints, symbols, functions, dates, arrays, records, and arbitrary iterables are not implicitly admitted merely because some are primitive or iterable. M9 assigns REJECT_UNSUPPORTED_INPUT, which is a controlled boundary decision rather than an implementation failure.

Keep shape admission separate from deep immutability

Object.getPrototypeOf(source) === Map.prototype is a small fixture guard for this laboratory. It excludes subclasses and many unusual inputs, but it is not a universal brand test or a security membrane. Proxies, alternate realms, changed built-ins, getters introduced through other structures, and hostile execution are outside the experiment.

A requirement for object-valued configuration needs a new contract: define ownership, alias treatment, admissible prototypes, validation timing, and what each read method returns. It also needs new negative controls. Dropping in a generic recursive freeze helper would not validate those questions and would violate this article’s bounded purpose.

Reconcile the expected manifest with observed evidence

The matrix below distinguishes the authored oracle from the supplied October 10, 2026 commissioning observation. “Matched” means the recorded run agreed with the exact expected row; it is not a new run performed while writing this article.

Case

Attempted change

Authored expected contract

Supplied commissioning observation

Decision

M1

Replace theme through set

Frozen remains true; final [['theme','dark'],['density','comfortable']]

Matched; size stayed 2

REFACTOR

M2

Add debug=on through set

Frozen remains true; final baseline plus ['debug','on']

Matched; size became 3

REFACTOR

M3

delete('theme')

Frozen remains true; final [['density','comfortable']]

Matched; delete returned true

REFACTOR

M4

clear()

Frozen remains true; final []

Matched; clear returned undefined

REFACTOR

M5

Replace own label property

TypeError; baseline entries; non-writable and non-configurable descriptor

Matched under ES-module strict mode

PROPERTY_CONTROL

M6

Mutate retained source alias

Frozen facade; final [['theme','dark'],['density','comfortable']]

Matched; facade read dark

REFACTOR

M7

Edit, delete, add, then clear source

Source controls prove changes; snapshot stays baseline

Matched; source reached [['theme','dark'],['debug','on']], then []

ACCEPT_BOUNDED

M8

Replace method, borrow set, edit export

Two TypeErrors; edited export changes; fresh snapshot stays baseline

Matched; get('theme') stayed light

ACCEPT_BOUNDED

M9

Object value; object key

Both throw TypeError; no facade published

Matched both rejection attempts

REJECT_
UNSUPPORTED_INPUT

This is the kind of inventory discipline expected from repeatable QA evidence: the oracle is independent, negative controls remain visible, and the report states its boundary. Here, the evidence also checks M7’s known source mutations and M8’s known exported-array edits. Without those controls, an unchanged snapshot could be a test that never exercised its threat.

For a new implementation, any missing case, duplicate ID, unexpected exception type, contradictory final entries, absent digest, or unwritten result file produces HOLD. Do not drop the inconvenient row and preserve the overall pass label.

Recover from drift without inventing the original configuration

Suppose a raw published Map has already changed from theme=light to theme=dark. Freezing it now prevents new own properties; it does not reverse the entry. Copying it now creates an independent collection containing dark. Neither operation reconstructs the previously approved value.

Recovery requires an independent known-good source: a reviewed literal fixture, an approved configuration revision, or another authority whose values did not derive from the already changed Map. Preserve the diagnostic record first, including current entries, relevant alias paths, runtime identity, and the evidence showing how the publication boundary failed. Then replace the flawed publication with a snapshot built from the independently known baseline.

The same temporal distinction appears in the separate discussion of capturing independently selected state: a reference that survived independently can identify earlier approved state, whereas a late copy of already changed state cannot. That article concerns tensor checkpoints and persistence; the limited comparison here is only the difference between an independent reference and a late copy.

No production rollback was executed in this laboratory. The experiment proves mutation and snapshot isolation with synthetic strings. Operational recovery still needs the system owner to identify the authoritative configuration revision rather than infer it from what happens to remain in memory.

A disciplined replacement sequence is: preserve the changed-state evidence, identify the independent approved literal, construct a candidate snapshot from that literal, run the complete manifest against the exact candidate source, and publish only after the result is durable and reviewable. If the independent baseline cannot be established, the correct state is HOLD; choosing whichever values look plausible would manufacture history rather than recover it.

Set the acceptance gate and assign the configuration owner

Use four operational outcomes:

  • REFACTOR: A supposedly stable registry changes through a published raw Map, a visible mutator, or a retained source alias. M1–M4 and M6 demonstrate qualifying reasons.

  • ACCEPT_BOUNDED: The owned snapshot passes the exact source-alias, consumer-method, detached-export, read-surface, and primitive-domain checks. M7 and M8 support this decision for the declared interface only.

  • REJECT_UNSUPPORTED_INPUT: A candidate contains keys or values outside the admitted string/string schema. M9 confirms rejection before publication.

  • HOLD: Evidence is incomplete, missing, contradictory, attached to different source, or not durably written. An unknown result is not acceptance.

The configuration producer owns the schema, independent-copy step, and publication surface. Component consumers own using only get, has, and entries as documented rather than depending on an unavailable raw Map. QA owns the independent manifest, all nine IDs, negative controls, source digest, environment record, and result-file integrity.

Any surface expansion reopens qualification. Adding forEach could leak the owner if its callback contract is careless. Returning the raw Map, exposing its iterator directly, or admitting object values changes the alias model. Even a seemingly convenient diagnostic callback can restore mutation authority.

The owner should version the contract in review terms, even if no public version number is exposed: admitted key/value types, method names, return shapes, absence of raw-owner access, and the exact manifest that qualifies them. A change to any one of those items is not a maintenance detail; it is a new publication interface requiring renewed evidence.

This ownership gate is synchronous. It does not decide which request may publish a newer snapshot. That is the distinct problem of asynchronous UI ownership, where a current intent must authorize a later commit. A stable snapshot can still be the wrong snapshot if an obsolete request is allowed to replace it.

The accountable acceptance statement is therefore narrow: accept the supplied snapshot() facade for ordinary same-realm string/string Maps under unmodified built-ins, provided its complete evidence matches; refactor any interface that exposes or aliases mutable owner state.

Build stronger JavaScript reasoning through structured practice

The practical lesson is not to distrust Object.freeze(). It is to specify what must remain stable, identify where that state actually lives, trace every authority that can change it, and test the promised read interface against an independent oracle. For this fixture, the raw frozen Map and aliasing facade are REFACTOR; the owned string/string snapshot is ACCEPT_BOUNDED; richer input is rejected until it has its own verified contract.

Refonte Learning’s Frontend Development programme provides a broader setting for developing that discipline through HTML5/CSS3, JavaScript ES6+, responsive design, React component architecture, Redux, APIs/AJAX, Git, accessibility, performance optimization, and a capstone. The page lists a dedication of 10–12 hours per week, names Git, npm, React, and Visual Studio Code among its tools, and states that successful completion provides a Training Certificate and a Certificate of Internship. It also describes a potential internship and identifies Frontend Developer, UI Developer, and Web Developer as career directions; those statements are not guarantees of individual placement or employment.

The programme link is an invitation to strengthen verified frontend foundations and project evidence. It should not be read as a claim that this exact Map laboratory, Node version, or internal-slot analysis appears in the curriculum.