~/blognode-id-new-document.md
cchu@nycu:~/blog$ cat node-id-new-document.md
2026.10.306 min[agents][browser-automation][state][testing]

A Node ID Across Two Documents

An Ego Lite registry replay checks frame-scoped element identity, document invalidation, persistent reference counters, and the limits of stale-reference protection.

In a synthetic registry replay, two frames both contained backend node 42. They received separate public references, @1 and @2. Changing the child frame's document identity invalidated @2 while leaving @1 active. Registering node 42 in the new child document allocated @3.

The useful detail is the namespace. An agent's printed element handle cannot safely be just the renderer's node number. Ego Lite's open harness keeps a separate reference registry whose identity includes the frame and document. Native snapshot numbering can restart without redirecting an old public reference to another node.

I examined revision aecc5831fc5a9ea01a078b632185992a3a7ac448 and ran the registry and filesystem ledger modules with Node 24.19.0 on Linux. All page, frame, document, and browser identifiers below were synthetic. No browser, CDP connection, navigation, or click was executed.

A synthetic main-frame node keeps reference 1. A child-frame node with the same backend ID has reference 2 in document A, becomes stale, and receives reference 3 in document B.

Public References Have Their Own Allocator

The repository contains an agent-facing browser harness: CDP transport, snapshot processing, element resolution, task spaces, drivers, and persisted page state. The browser application itself is closed source and supplies the native runtime. The package configuration declares ego-browser-v2 version 0.1.0, Node >=22, and a build pipeline using esbuild and Rollup. That source-package version is the reproduction metadata here, not a claim about the installed desktop application.

The small PageRefRegistry module maintains state per target. Each stored entry has a public ref ID, backend node ID, frame metadata, document ID, and active flag. Its identity key is a JSON array:

[frameId ?? null, backendNodeId, documentId ?? null]

replace() treats a snapshot as a complete active set. merge() adds or updates entries without expiring omitted ones. Both rewrite each incoming record's refId to the public ID selected by the registry. Consumers must use the rewritten snapshot, rather than continuing with its original numbering.

The first synthetic snapshot supplied:

[
  { refId: 1, backendNodeId: 42, frameId: 'main',
    documentId: 'doc-main', frameProvenance: 'page' },
  { refId: 2, backendNodeId: 42, frameId: 'child',
    documentId: 'doc-child-a', frameProvenance: 'frame' }
]

A subsequent partial snapshot called the child node 1. The registry rewrote it to 2, preserving the public identity. Changing only its accessible name also retained 2. Frame and document identity control this mapping; a name is descriptive metadata.

Invalidation Is Scoped to the Document

I passed invalidateChangedDocuments() a map in which only the child's document changed. The observed transitions were:

OperationMain referenceChild reference
Initial full snapshot@1 active@2 active in document A
Partial snapshot restarts numbering@1 activeStill @2
Child document map changes@1 active@2 inactive
Same backend ID registered in document B@1 activeNew @3; @2 remains inactive
Full snapshot contains only the new child node@1 inactive@3 active

This checks the registry's response to supplied document identities. It does not verify that a real browser reports them correctly.

The inspected Page model builds those identities from frame trees. Each value includes the root frame ID and loader ID, plus the particular frame's ID and loader ID. It also reads out-of-process frame sessions. The registry's opaque documentId is therefore supplied by a concrete browser adapter rather than inferred from the element's label.

The snapshot path compares document maps before and after collection, rejecting a changed document with a transient error. Before resolving public refs, the Page model checks document changes again and rejects stale or unknown handles. These adapter paths were inspected, not run in this replay.

Unknown frame provenance takes a conservative path. Two registrations of an otherwise identical unknown-provenance fixture received refs 4 and 5. A ref without a document ID also became inactive when I ran the document check. Neither condition established a reusable cross-snapshot identity.

Persistence Includes the Counter

Saving only active mappings would lose part of the contract. The allocator's nextRef and inactive entries also matter: a caller may still hold an old printed handle after its node disappears.

The replay exported the registry, passed it through JSON, restored it into a new registry, and registered the document-B node again. It retained @3. A malformed persisted state with nextRef: 1 and an existing public ref 1 was rejected by validatePageRefState().

I also used the unchanged PageLedgerStore against a temporary directory. One store wrote the page and its reference state; a new store with the same synthetic browser identity read back the JSON-equivalent state. A store configured with a different browser identity could not retrieve the prior p1 label.

That is actual filesystem I/O with reconstructed store objects, not an actual browser or process restart. The ledger writes a complete temporary document and renames it into place. This replay does not establish concurrent-writer safety or crash durability.

Inactive entries are bounded separately from the allocator. After invalidating 10,001 synthetic refs, the oldest tombstone was absent from isInvalidated(), but lookup still returned no entry. Registering a new node allocated 10002, not 1. Trimming the diagnostic history did not recycle the old public number. The 10,000-entry limit applies to inactive entries, not all live references.

Replay and Verification Boundary

The probe checks the SHA-256 digests of three source files. It strips TypeScript annotations using Node's built-in stripTypeScriptTypes(), writes temporary ESM modules, and runs the original implementation without replacement business logic. Node emits an experimental-feature warning for that API.

With a checkout at the pinned revision, run:

node ref-probe.mjs /path/to/ego-lite

My actual command was:

node public/blog/node-id-new-document/ref-probe.mjs /tmp/ego-note30

It exited 0. Selected output:

same backend id, two frames: public refs=1,2
native numbering restarts: child retains ref=2
same identity, changed accessible name: ref=2 retained
child document changes: ref=1 active; ref=2 stale
same backend id, new document: replacement ref=3
JSON restore, same identity: replacement retains ref=3
10001 stale refs: oldest tombstone absent; next ref=10002
new ledger instance, same browser: refs restored from real file
different browser identity: prior page label unavailable
PASS: 13 synthetic cases; no browser, CDP, or click executed

The probe also stages the unchanged registry tests and ledger tests. Running both with node --test --test-isolation=none against the emitted modules passed all 30 tests, exit 0. I did not run the package's complete build/typecheck suite or real-browser E2E suite.

Document identity protects against one class of stale target. It does not freeze the meaning of an element. The same-identity name-change case kept its handle, so a still-valid ref is not evidence that an action still means what the agent previously read. The registry preserves node identity; the agent still needs current page evidence before acting.