circuitjson-toolkit 1.4.1 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,12 @@ dynamic data/control dependencies, changed-root reader lists, stale-trace
83
83
  replacement, explicit reclamation, and from-scratch consistency coverage. See
84
84
  the [1.4.1 release notes](docs/release-notes-v1.4.1.md).
85
85
 
86
+ Version 1.4.2 accelerates immutable document rebuilding and cooperative
87
+ structured-clone adoption. Toolkit-owned frozen extension roots retain their
88
+ identity, and cooperative finalization freezes each validated container
89
+ atomically instead of redefining every property. See the
90
+ [1.4.2 release notes](docs/release-notes-v1.4.2.md).
91
+
86
92
  Before 1.1.0:
87
93
 
88
94
  ```js
@@ -393,6 +399,7 @@ copy while keeping sync, direct async, and worker results mutation-isolated.
393
399
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
394
400
  - [1.4.0 release notes](docs/release-notes-v1.4.0.md)
395
401
  - [1.4.1 release notes](docs/release-notes-v1.4.1.md)
402
+ - [1.4.2 release notes](docs/release-notes-v1.4.2.md)
396
403
  - [Library scope](spec/library-scope.md)
397
404
 
398
405
  ## Package scope
package/docs/api.md CHANGED
@@ -85,11 +85,12 @@ make the common shape smaller.
85
85
 
86
86
  Validated source documents own selected extension values as one deeply
87
87
  immutable, descriptor-safe snapshot. This uses a distinct 128 MiB payload and
88
- 4,000,000-item ceiling so large native renderer graphs do not inherit the
88
+ 8,000,000-item ceiling so large native renderer graphs do not inherit the
89
89
  compact metadata limit. The shared worker protocol applies the same ownership
90
90
  boundary after structured cloning. Canonical standalone documents retain a
91
- 250 MB byte ceiling; exact multi-document projects use a 256 MiB aggregate
92
- ceiling with independent 250 MB document and project-metadata limits. Binary
91
+ 10,000,000-value and 250 MB byte ceiling; exact multi-document projects use a
92
+ 16,000,000-value and 256 MiB aggregate ceiling with independent 250 MB document
93
+ and project-metadata limits. Binary
93
94
  values preserve their `ArrayBuffer`/typed-view shape behind defensive copy
94
95
  access, so payload bytes do not inflate into one JavaScript number per byte.
95
96
 
@@ -318,6 +319,15 @@ The parser subpath additionally exports `ParserWorkerClient`,
318
319
  `ToolkitWorkerProtocol`, and the `TOOLKIT_WORKER_PROTOCOL` version constant for
319
320
  hosts that own a custom worker lifecycle.
320
321
 
322
+ `ParserWorkerClient.fromWorkerUrl(url)` creates a lazy module-worker client
323
+ that privately owns its native worker and received result graphs. Native
324
+ message provenance avoids repeated binary brand probing on ordinary records;
325
+ all result shape, model, depth, count, and byte limits still apply. Shared and
326
+ resizable buffers remain isolated. `new ParserWorkerClient({ createWorker })`
327
+ continues to fully inspect custom-worker results, including genuine events
328
+ whose payloads an earlier listener may have changed. Replacing the host Worker
329
+ constructor also retains that defensive path.
330
+
321
331
  ### `ToolkitAsset.measure(asset)`
322
332
 
323
333
  Returns the exact resident payload byte length without copying. Binary lengths
@@ -711,3 +721,9 @@ circuitjson-toolkit/testing
711
721
  circuitjson-toolkit/workers/parser.worker.mjs
712
722
  circuitjson-toolkit/styles/renderers.css
713
723
  ```
724
+
725
+ Worker payload preparation uses captured array/view brand checks before raw
726
+ buffer probes. Short strings are counted directly in UTF-8, including surrogate
727
+ pairs and replacement characters, without allocating encoded byte buffers.
728
+ Cross-realm buffers, changed prototypes, and bounded binary ownership retain
729
+ the same validation rules.
@@ -102,12 +102,12 @@ must not duplicate the CircuitJSON model or keep a renamed renderer model.
102
102
  Default parsing excludes raw/base64/full-payload graphs.
103
103
 
104
104
  When a caller explicitly selects a native extension, the toolkit captures and
105
- freezes that graph once under a distinct ceiling of 4,000,000 structured items
105
+ freezes that graph once under a distinct ceiling of 8,000,000 structured items
106
106
  and 128 MiB of string or binary content. The ceiling is shared across the
107
107
  selected namespace and applies equally after worker transfer. Canonical
108
- standalone documents allow the extension plus envelope overhead within five
109
- million result values and 250 MB. Exact project envelopes allow eight million
110
- aggregate values and 256 MiB, while each document stays within five million
108
+ standalone documents allow the extension plus envelope overhead within ten
109
+ million result values and 250 MB. Exact project envelopes allow sixteen million
110
+ aggregate values and 256 MiB, while each document stays within ten million
111
111
  values and 250 MB and non-document project metadata stays within two million
112
112
  values and 250 MB. Reused graphs are charged independently to each local scope,
113
113
  and repeated accounting work is itself bounded by the aggregate project
@@ -0,0 +1,32 @@
1
+ # circuitjson-toolkit 1.4.2
2
+
3
+ This patch release removes repeated immutable-graph work from toolkit-owned
4
+ document rebuilds and from cooperative structured-clone adoption. Public
5
+ document shapes, validation rules, cancellation behavior, and extension
6
+ selection semantics remain unchanged.
7
+
8
+ ## Owned extension root reuse
9
+
10
+ - `DocumentResult.createValidated()` retains an extension namespace that was
11
+ already captured, bounded, sealed, and branded by the same toolkit runtime.
12
+ - Rebuilding a canonical document no longer traverses or copies a large frozen
13
+ native extension graph when only other document fields changed.
14
+ - Arbitrary caller objects still use the defensive validation and ownership
15
+ path; frozen state alone never grants trusted ownership.
16
+
17
+ ## Atomic cooperative finalization
18
+
19
+ - Cooperative structured-clone adoption validates a container's descriptors
20
+ before freezing the container in one atomic operation.
21
+ - A yield can no longer expose a container with only some properties locked.
22
+ - Large graphs avoid one `defineProperty()` call per ordinary property while
23
+ retaining the same deeply immutable result and cancellation checkpoints.
24
+
25
+ ## Verification
26
+
27
+ - Ownership tests cover identity reuse for toolkit-built frozen namespaces and
28
+ defensive behavior for untrusted objects.
29
+ - Cooperative snapshot tests prove that no partial property locking is visible
30
+ across yields.
31
+ - The complete package suite, formatting check, packed-entrypoint checks, and
32
+ npm dry-run gate the release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuitjson-toolkit",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Canonical CircuitJSON parsing, project, rendering, query, manufacturing, simulation, and scene contracts",
5
5
  "keywords": [
6
6
  "circuitjson",
@@ -58,6 +58,7 @@
58
58
  "docs/release-notes-v1.3.0.md",
59
59
  "docs/release-notes-v1.4.0.md",
60
60
  "docs/release-notes-v1.4.1.md",
61
+ "docs/release-notes-v1.4.2.md",
61
62
  "docs/testing.md",
62
63
  "spec",
63
64
  "LICENSE",
@@ -17,15 +17,9 @@ export class SelfAdjustingComputation {
17
17
  /** @type {Set<string> | null} */
18
18
  #affectedComputations
19
19
 
20
- /** @type {(value: object, path: PropertyKey[]) => boolean} */
20
+ /** @type {((value: object, path: PropertyKey[]) => boolean) | null} */
21
21
  #isAtomic
22
22
 
23
- /** @type {Map<symbol, number>} */
24
- #symbolIds
25
-
26
- /** @type {number} */
27
- #nextSymbolId
28
-
29
23
  /**
30
24
  * @param {{ isAtomic?: (value: object, path: PropertyKey[]) => boolean }} [options] Dependency-tracking options.
31
25
  */
@@ -36,11 +30,7 @@ export class SelfAdjustingComputation {
36
30
  this.#propagationInput = null
37
31
  this.#affectedComputations = null
38
32
  this.#isAtomic =
39
- typeof options.isAtomic === 'function'
40
- ? options.isAtomic
41
- : () => false
42
- this.#symbolIds = new Map()
43
- this.#nextSymbolId = 1
33
+ typeof options.isAtomic === 'function' ? options.isAtomic : null
44
34
  }
45
35
 
46
36
  /**
@@ -264,16 +254,14 @@ export class SelfAdjustingComputation {
264
254
  * @returns {{ dependencies: object[], value: any }} Successful trace.
265
255
  */
266
256
  #trace(input, computation) {
267
- const dependencies = new Map()
257
+ const dependencies = []
268
258
  const proxyCache = new WeakMap()
269
259
  const proxyMetadata = new WeakMap()
270
- const trackedInput = this.#createProxy({
271
- target: input,
272
- path: [],
273
- dependencies,
274
- proxyCache,
275
- proxyMetadata
276
- })
260
+ const trackedInput = this.#createProxy(
261
+ { dependencies, proxyCache, proxyMetadata },
262
+ input,
263
+ { path: [], children: null, observations: null }
264
+ )
277
265
  const trackedValue = computation(trackedInput)
278
266
  if (SelfAdjustingComputation.#isPromiseLike(trackedValue)) {
279
267
  throw new TypeError(
@@ -287,39 +275,44 @@ export class SelfAdjustingComputation {
287
275
  ? proxyMetadata.get(trackedValue)
288
276
  : null
289
277
  if (metadata) {
290
- this.#record(dependencies, {
291
- type: 'value',
292
- path: metadata.path,
293
- expected: metadata.target
294
- })
278
+ this.#record(
279
+ dependencies,
280
+ metadata.pathNode,
281
+ 'value',
282
+ metadata.target
283
+ )
295
284
  }
296
285
 
297
286
  return {
298
- dependencies: [...dependencies.values()],
287
+ // Later reads of a returned container must not rewrite this trace.
288
+ dependencies: dependencies.map((dependency) => ({ ...dependency })),
299
289
  value: metadata?.target ?? trackedValue
300
290
  }
301
291
  }
302
292
 
303
293
  /**
304
294
  * Creates a read-tracking proxy for one traversable input container.
305
- * @param {{ target: object, path: PropertyKey[], dependencies: Map<string, object>, proxyCache: WeakMap<object, Map<string, object>>, proxyMetadata: WeakMap<object, { target: object, path: PropertyKey[] }> }} context Tracking context.
295
+ * @param {{ dependencies: object[], proxyCache: WeakMap<object, Map<object, object>>, proxyMetadata: WeakMap<object, { target: object, pathNode: object }> }} context Shared trace context.
296
+ * @param {object} target Traversable raw input container.
297
+ * @param {object} pathNode Interned input path in this trace.
306
298
  * @returns {object} Read-tracking proxy.
307
299
  */
308
- #createProxy(context) {
309
- const pathKey = this.#pathKey(context.path)
310
- const cachedByPath = context.proxyCache.get(context.target)
311
- if (cachedByPath?.has(pathKey)) {
312
- return cachedByPath.get(pathKey)
300
+ #createProxy(context, target, pathNode) {
301
+ const cachedByPath = context.proxyCache.get(target)
302
+ const cached = cachedByPath?.get(pathNode)
303
+ if (cached) {
304
+ return cached
313
305
  }
314
306
 
315
- const proxy = new Proxy(context.target, {
307
+ const proxyContext = { ...context, pathNode }
308
+ const proxy = new Proxy(target, {
316
309
  get: (target, property) =>
317
- this.#readProperty(context, target, property),
310
+ this.#readProperty(proxyContext, target, property),
318
311
  has: (target, property) =>
319
- this.#readPresence(context, target, property),
320
- ownKeys: (target) => this.#readKeys(context, target),
312
+ this.#readPresence(proxyContext, target, property),
313
+ ownKeys: (target) => this.#readKeys(proxyContext, target),
321
314
  getOwnPropertyDescriptor: (target, property) =>
322
- this.#readDescriptor(context, target, property),
315
+ this.#readDescriptor(proxyContext, target, property),
323
316
  set: () => this.#rejectWrite(),
324
317
  defineProperty: () => this.#rejectWrite(),
325
318
  deleteProperty: () => this.#rejectWrite(),
@@ -327,13 +320,13 @@ export class SelfAdjustingComputation {
327
320
  preventExtensions: () => this.#rejectWrite()
328
321
  })
329
322
  const nextCachedByPath = cachedByPath || new Map()
330
- nextCachedByPath.set(pathKey, proxy)
323
+ nextCachedByPath.set(pathNode, proxy)
331
324
  if (!cachedByPath) {
332
- context.proxyCache.set(context.target, nextCachedByPath)
325
+ context.proxyCache.set(target, nextCachedByPath)
333
326
  }
334
327
  context.proxyMetadata.set(proxy, {
335
- target: context.target,
336
- path: [...context.path]
328
+ target,
329
+ pathNode
337
330
  })
338
331
  return proxy
339
332
  }
@@ -347,21 +340,18 @@ export class SelfAdjustingComputation {
347
340
  */
348
341
  #readProperty(context, target, property) {
349
342
  const value = Reflect.get(target, property, target)
350
- const path = [...context.path, property]
351
- if (this.#isTraversable(value, path)) {
352
- this.#record(context.dependencies, {
353
- type: 'kind',
354
- path,
355
- expected: SelfAdjustingComputation.#valueKind(value)
356
- })
357
- return this.#createProxy({ ...context, target: value, path })
343
+ const pathNode = this.#childPath(context.pathNode, property)
344
+ if (this.#isTraversable(value, pathNode.path)) {
345
+ this.#record(
346
+ context.dependencies,
347
+ pathNode,
348
+ 'kind',
349
+ SelfAdjustingComputation.#valueKind(value)
350
+ )
351
+ return this.#createProxy(context, value, pathNode)
358
352
  }
359
353
 
360
- this.#record(context.dependencies, {
361
- type: 'value',
362
- path,
363
- expected: value
364
- })
354
+ this.#record(context.dependencies, pathNode, 'value', value)
365
355
  return value
366
356
  }
367
357
 
@@ -373,13 +363,9 @@ export class SelfAdjustingComputation {
373
363
  * @returns {boolean} Whether the property exists.
374
364
  */
375
365
  #readPresence(context, target, property) {
376
- const path = [...context.path, property]
366
+ const pathNode = this.#childPath(context.pathNode, property)
377
367
  const expected = Reflect.has(target, property)
378
- this.#record(context.dependencies, {
379
- type: 'has',
380
- path,
381
- expected
382
- })
368
+ this.#record(context.dependencies, pathNode, 'has', expected)
383
369
  return expected
384
370
  }
385
371
 
@@ -391,11 +377,7 @@ export class SelfAdjustingComputation {
391
377
  */
392
378
  #readKeys(context, target) {
393
379
  const expected = Reflect.ownKeys(target)
394
- this.#record(context.dependencies, {
395
- type: 'keys',
396
- path: [...context.path],
397
- expected
398
- })
380
+ this.#record(context.dependencies, context.pathNode, 'keys', expected)
399
381
  return expected
400
382
  }
401
383
 
@@ -408,11 +390,12 @@ export class SelfAdjustingComputation {
408
390
  */
409
391
  #readDescriptor(context, target, property) {
410
392
  const descriptor = Reflect.getOwnPropertyDescriptor(target, property)
411
- this.#record(context.dependencies, {
412
- type: 'descriptor',
413
- path: [...context.path, property],
414
- expected: SelfAdjustingComputation.#descriptorState(descriptor)
415
- })
393
+ this.#record(
394
+ context.dependencies,
395
+ this.#childPath(context.pathNode, property),
396
+ 'descriptor',
397
+ SelfAdjustingComputation.#descriptorState(descriptor)
398
+ )
416
399
  return descriptor
417
400
  }
418
401
 
@@ -424,23 +407,50 @@ export class SelfAdjustingComputation {
424
407
  */
425
408
  #isTraversable(value, path) {
426
409
  if (!value || typeof value !== 'object') return false
427
- if (this.#isAtomic(value, [...path])) return false
410
+ if (this.#isAtomic?.(value, [...path])) return false
428
411
  if (Array.isArray(value)) return true
429
412
  const prototype = Reflect.getPrototypeOf(value)
430
413
  return prototype === Object.prototype || prototype === null
431
414
  }
432
415
 
433
416
  /**
434
- * Stores or replaces one dependency by type and input path.
435
- * @param {Map<string, object>} dependencies Dependency registry.
436
- * @param {{ type: string, path: PropertyKey[], expected: any }} dependency Dependency record.
417
+ * Interns one child path using property identity without encoding its ancestors.
418
+ * Path and proxy caches belong to one trace and are never reused by a later snapshot.
419
+ * @param {object} parent Parent path node.
420
+ * @param {PropertyKey} property Observed property key.
421
+ * @returns {object} Canonical child path node for this trace.
422
+ */
423
+ #childPath(parent, property) {
424
+ const previous = parent.children?.get(property)
425
+ if (previous) return previous
426
+ const child = {
427
+ path: [...parent.path, property],
428
+ children: null,
429
+ observations: null
430
+ }
431
+ parent.children ??= new Map()
432
+ parent.children.set(property, child)
433
+ return child
434
+ }
435
+
436
+ /**
437
+ * Stores the last observation of each dependency type at an interned path.
438
+ * @param {object[]} dependencies Dependency registry in first-observed order.
439
+ * @param {object} pathNode Interned path node.
440
+ * @param {string} type Dependency comparison category.
441
+ * @param {any} expected Most recently observed value or structural state.
437
442
  * @returns {void}
438
443
  */
439
- #record(dependencies, dependency) {
440
- dependencies.set(
441
- dependency.type + ':' + this.#pathKey(dependency.path),
442
- dependency
443
- )
444
+ #record(dependencies, pathNode, type, expected) {
445
+ const previous = pathNode.observations?.get(type)
446
+ if (previous) {
447
+ previous.expected = expected
448
+ return
449
+ }
450
+ const dependency = { type, path: pathNode.path, expected }
451
+ pathNode.observations ??= new Map()
452
+ pathNode.observations.set(type, dependency)
453
+ dependencies.push(dependency)
444
454
  }
445
455
 
446
456
  /**
@@ -530,27 +540,6 @@ export class SelfAdjustingComputation {
530
540
  return { found: true, value }
531
541
  }
532
542
 
533
- /**
534
- * Creates a collision-free string key for a property path.
535
- * @param {PropertyKey[]} path Input path.
536
- * @returns {string} Registry key.
537
- */
538
- #pathKey(path) {
539
- return path
540
- .map((property) => {
541
- if (typeof property === 'symbol') {
542
- if (!this.#symbolIds.has(property)) {
543
- this.#symbolIds.set(property, this.#nextSymbolId)
544
- this.#nextSymbolId += 1
545
- }
546
- return 'y' + this.#symbolIds.get(property)
547
- }
548
- const text = String(property)
549
- return 's' + text.length + ':' + text
550
- })
551
- .join('|')
552
- }
553
-
554
543
  /**
555
544
  * Rejects mutation through a tracked snapshot.
556
545
  * @returns {never}
@@ -6,7 +6,7 @@ const OWNED_EXTENSION_ROOTS = new WeakSet()
6
6
  const EXTENSION_METADATA_LIMITS = Object.freeze({
7
7
  label: 'Canonical extension data',
8
8
  maxBytes: 128 * 1024 * 1024,
9
- maxItems: 4_000_000,
9
+ maxItems: 8_000_000,
10
10
  preserveBinary: true
11
11
  })
12
12
 
@@ -506,10 +506,9 @@ class StructuredCloneAdoptionTraversal {
506
506
  }
507
507
 
508
508
  /**
509
- * Locks one target property-by-property so large dense containers never
510
- * require one monolithic Object.freeze operation.
509
+ * Validates and atomically freezes one acquired target.
511
510
  * @param {object} seal Target sealing snapshot.
512
- * @returns {Generator<void, void, void>} Bounded-work locking pass.
511
+ * @returns {Generator<void, void, void>} Cooperative locking pass.
513
512
  */
514
513
  *#sealTargetCooperatively(seal) {
515
514
  StructuredCloneAdoptionTraversal.#requireTargetShape(seal)
@@ -530,31 +529,16 @@ class StructuredCloneAdoptionTraversal {
530
529
  'Canonical asset source changed during adoption.'
531
530
  )
532
531
  }
533
- if (!property.binary) {
534
- try {
535
- Object.defineProperty(seal.target, property.key, {
536
- ...descriptor,
537
- configurable: false,
538
- writable: false
539
- })
540
- } catch {
541
- throw new TypeError(
542
- 'Canonical document values could not be frozen safely.'
543
- )
544
- }
545
- }
546
- yield* this.#checkpoint()
547
532
  }
548
533
  StructuredCloneAdoptionTraversal.#requireTargetShape(seal)
549
- if (!seal.shapeLocked) {
550
- try {
551
- Object.preventExtensions(seal.target)
552
- } catch {
553
- throw new TypeError(
554
- 'Canonical document values could not be frozen safely.'
555
- )
556
- }
534
+ try {
535
+ Object.freeze(seal.target)
536
+ } catch {
537
+ throw new TypeError(
538
+ 'Canonical document values could not be frozen safely.'
539
+ )
557
540
  }
541
+ yield* this.#checkpoint(seal.properties.length || 1)
558
542
  }
559
543
 
560
544
  /**
@@ -1,4 +1,5 @@
1
1
  const TEXT_ACCOUNTING_CHUNK_CHARACTERS = 64 * 1_024
2
+ const SHORT_TEXT_CHARACTERS = 256
2
3
  const STRING_CHAR_CODE_AT = String.prototype.charCodeAt
3
4
  const STRING_SLICE = String.prototype.slice
4
5
  const TEXT_ENCODER = new TextEncoder()
@@ -17,6 +18,14 @@ export class StructuredCloneTextAccounting {
17
18
  */
18
19
  static *reserve(value, state, operations) {
19
20
  if (state.maxBytes === Number.MAX_SAFE_INTEGER) return
21
+ if (value.length <= SHORT_TEXT_CHARACTERS) {
22
+ const length = StructuredCloneTextAccounting.#shortByteLength(value)
23
+ if (state.bytes + length > state.maxBytes) {
24
+ throw new TypeError(`${state.label} is too large.`)
25
+ }
26
+ operations.reserve(length)
27
+ return
28
+ }
20
29
  let length = 0
21
30
  let offset = 0
22
31
  while (offset < value.length) {
@@ -49,6 +58,35 @@ export class StructuredCloneTextAccounting {
49
58
  }
50
59
  operations.reserve(length)
51
60
  }
61
+
62
+ /**
63
+ * Counts bounded short text without allocating an encoded byte buffer.
64
+ * Lone surrogates consume the three UTF-8 bytes of a replacement character.
65
+ * @param {string} value Short immutable text.
66
+ * @returns {number} UTF-8 byte length.
67
+ */
68
+ static #shortByteLength(value) {
69
+ let length = 0
70
+ for (let index = 0; index < value.length; index += 1) {
71
+ const code = STRING_CHAR_CODE_AT.call(value, index)
72
+ if (code <= 0x7f) {
73
+ length += 1
74
+ } else if (code <= 0x7ff) {
75
+ length += 2
76
+ } else if (code >= 0xd800 && code <= 0xdbff) {
77
+ const next = STRING_CHAR_CODE_AT.call(value, index + 1)
78
+ if (next >= 0xdc00 && next <= 0xdfff) {
79
+ length += 4
80
+ index += 1
81
+ } else {
82
+ length += 3
83
+ }
84
+ } else {
85
+ length += 3
86
+ }
87
+ }
88
+ return length
89
+ }
52
90
  }
53
91
 
54
92
  Object.freeze(StructuredCloneTextAccounting.prototype)
@@ -1,4 +1,5 @@
1
1
  import { CircuitJsonValidationProof } from '../context/CircuitJsonValidationProof.mjs'
2
+ import { CircuitJsonExtensionBoundary } from '../context/CircuitJsonExtensionBoundary.mjs'
2
3
  import { CircuitJsonReadOnlyDocument } from '../context/CircuitJsonReadOnlyDocument.mjs'
3
4
  import { CircuitJsonSerializedInputAudit } from '../CircuitJsonSerializedInputAudit.mjs'
4
5
  import { ToolkitAsset } from './ToolkitAsset.mjs'
@@ -147,6 +148,14 @@ export class DocumentResult {
147
148
  const hasCandidate = candidate && typeof candidate === 'object'
148
149
  if (!hasCandidate) return {}
149
150
  if (options.readonly === true && hasCandidate) {
151
+ // This private brand proves the frozen namespace was already
152
+ // captured, bounded, and sealed by this runtime.
153
+ if (
154
+ CircuitJsonExtensionBoundary.owns(extensions) &&
155
+ Object.isFrozen(extensions)
156
+ ) {
157
+ return extensions
158
+ }
150
159
  if (options.standardBuiltins === true) {
151
160
  return CircuitJsonReadOnlyDocument.copyReadonlyExtensionValue(
152
161
  DocumentResult.#normalizedExtension(format, candidate),