[GH-ISSUE #6849] [Bug] Authored id fields redirect keyed-nib hierarchy identities #4189

Closed
opened 2026-10-31 00:47:34 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6849

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

The production analyzeInterfaceBuilderBundle boundary emits a child-to-itself hierarchy edge for a synthetic keyed nib whose root view contains ordinary application data id: "2". The object inventory correctly retains root UID 1 and child UID 2, but the hierarchy uses UID 2 for both. Every coverage facet reports complete with zero omissions, hierarchy_complete is true, and graph.truncated is false.

This reproduced with XML and binary-plist UID archives. Renaming only the authored field from id to recordID restores document → 1 → 2. An additional id: "record-17" control instead invents an unknown hierarchy parent even though the real root remains object 1. A supported objectID: "record-17" control consistently uses that identity in both inventory and hierarchy.

Reproduction

Actually executed from the source checkout, with this script saved outside it as hierarchy-id.mts and an existing scratch directory passed as probe_dir:

node --import ./node_modules/tsx/dist/loader.mjs "$probe_dir/hierarchy-id.mts" "$PWD" "$probe_dir"
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
const checkout = process.argv[2]!;
const out = process.argv[3]!;
const { analyzeInterfaceBuilderBundle } = await import(pathToFileURL(join(checkout, 'src/artifacts/apple/InterfaceBuilderAnalysis.ts')).href);
const { parseInterfaceBuilderRecords } = await import(pathToFileURL(join(checkout, 'src/domain/apple/interfaceBuilderKeyedArchive.ts')).href);
const { decodeKeyedArchiveBytes } = await import(pathToFileURL(join(checkout, 'src/artifacts/apple/KeyedArchiveReader.ts')).href);
const uid = (n: number) => ({ 'CF$UID': n });
const esc = (s: string) => s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
function xml(v: unknown): string {
  if (typeof v === 'number') return `<integer>${v}</integer>`;
  if (typeof v === 'string') return `<string>${esc(v)}</string>`;
  if (Array.isArray(v)) return `<array>${v.map(xml).join('')}</array>`;
  return `<dict>${Object.entries(v as object).map(([k,c]) => `<key>${esc(k)}</key>${xml(c)}`).join('')}</dict>`;
}
const work = await mkdtemp(join(tmpdir(), 'rea-apple-id-'));
const rows = [];
try {
  for (const [field, value] of [['recordID','2'], ['id','2'], ['id','record-17'], ['objectID','record-17']]) {
    const archive = { $archiver:'NSKeyedArchiver', $version:100000,
      $objects: ['$null',
        {$class:uid(4), title:'Root', [field]:value, NSSubviews:[uid(2)]},
        {$class:uid(4), title:'Child'},
        {$class:uid(5), NSSource:uid(2), NSDestination:uid(1), NSLabel:'parent'},
        {$classname:'ArchiveProbeView', $classes:['ArchiveProbeView','NSView','NSObject']},
        {$classname:'NSNibOutletConnector', $classes:['NSNibOutletConnector','NSNibConnector','NSObject']}],
      $top:{root:uid(1)} };
    const bytes = Buffer.from('<?xml version="1.0"?><plist version="1.0">'+xml(archive)+'</plist>');
    const tag = field+'-'+value;
    await writeFile(join(out, tag+'.nib'), bytes);
    const bundlePath=join(work,tag+'.app');
    await mkdir(bundlePath);
    await writeFile(join(bundlePath,'Main.nib'),bytes);
    const before = performance.now();
    const result = await analyzeInterfaceBuilderBundle({bundlePath,targetSha256:'a'.repeat(64)});
    const elapsedMs=performance.now()-before;
    const parsed=parseInterfaceBuilderRecords(archive);
    const generic=decodeKeyedArchiveBytes(bytes,{offset:0,limit:20});
    const node = (id: string | null) => result.graph.nodes.find((n: any) => n.id===id);
    const hierarchy = result.graph.edges.filter((e: any) => e.id.includes(':hierarchy:')).map((e: any) => ({from:node(e.from)?.attributes.interface_builder_object_id ?? 'document',to:node(e.to)?.attributes.interface_builder_object_id ?? null,resolution:e.resolution}));
    const row={field,value,elapsedMs,sha256:result.documents[0].archive_sha256,parsedIds:parsed.objects.map((o: any) => o.id),parsedHierarchy:parsed.hierarchy,document:result.documents[0],nodes:result.graph.nodes.map((n: any)=>({kind:n.kind,name:n.name,objectID:n.attributes.interface_builder_object_id,authoredId:n.attributes.id})),hierarchy,coverage:result.graph.coverage,truncated:result.graph.truncated,keyedObjects:generic.objects.filter((o: any)=>o.id===1||o.id===2)};
    rows.push(row);
    console.log(JSON.stringify(row));
    assert.equal(result.documents[0].hierarchy_complete,true);
    assert.equal(result.graph.truncated,false);
    if(field==='recordID') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','1'],['1','2']]);
    if(field==='id'&&value==='2') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','2'],['2','2']]);
    if(field==='id'&&value==='record-17') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','record-17'],['record-17','2']]);
    if(field==='objectID') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','record-17'],['record-17','2']]);
  }
  await writeFile(join(out,'hierarchy-id-results.json'),JSON.stringify({runtime:process.version,platform:process.platform,arch:process.arch,rows},null,2)+'\n');
} finally { await rm(work,{recursive:true,force:true}); }

All assertions passed. The failing XML analyzer call took 7.028 ms; its binary counterpart took 5.857 ms. These are single-call wall-clock observations, not performance claims. The four XML cases and two binary identity controls ran once in their saved final executions. Temporary bundle directories were removed.

Expected behavior

Without an explicit objectID or object-id, hierarchy references and graph nodes must use the same original object-table index. A custom object's serialized field named id must remain application data and cannot turn a parent-child link into a self-loop or an invented parent.

The documented identity rule reserves only objectID and object-id as authored identity overrides. The object inventory and reference stubs already follow it.

Impact and scope

A bounded static-analysis correctness defect: the returned hierarchy asserts a nonexistent relationship and marks the evidence complete. Real-app frequency is unknown. The two observed archived views and their raw fields remain available; the executed generic keyed-archive decoder preserved IDs 1 and 2 and NSSubviews → UID 2, allowing manual reconstruction. This workaround does not repair the automatic UI graph. No security impact or modification of the inspected bytes is involved.

REA version

rea-agents 6.7.0, source revision 9066444f6b8e9ffe1607a9b02cde3895a37db431. This is a source-checkout reproduction with locked dependencies; it does not claim execution of the published tarball. The research assignment confirmed 6.7.0 as npm latest.

Runtime and platform

Node.js v24.22.1; Linux x64; source tsx loader. No build, install, full suite, Apple compiler, native provider, target process, or CLI/MCP adapter was run. Binary controls used Python plistlib to encode the same synthetic archive dictionaries with actual plist UID records.

Target and artifact context

Small inert Main.nib XML/binary NSKeyedArchiver archives with two synthetic ArchiveProbeView objects declaring NSView ancestry, one outlet connector, and in-bounds class/reference records. These are synthetic fixtures, not Apple compiler output. The source-boundary target digest is a placeholder and does not establish public target admission.

Interface

Other

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Reduced from the saved executed output:

{
  "field": "id", "value": "2", "parsedIds": ["1", "2", "3"],
  "hierarchy": [
    {"from":"document","to":"2","resolution":"observed"},
    {"from":"2","to":"2","resolution":"observed"}
  ],
  "document": {"object_count":3,"connection_count":1,"hierarchy_complete":true},
  "hierarchyCoverage": {"status":"complete","reason":null,"examined":2,"omitted":0},
  "truncated": false
}

The recordID: "2" control instead has edges document → 1 and 1 → 2. Both formats give the same differing semantics. Generic decoding retains root object 1 with its authored id and child reference, and distinct child object 2.

Failing archive SHA-256: XML b2523f0a0a973fd76efc5e8bd035d56f97ace3712d139ab1e8434ab7788639ee; binary 561877d0b983aed54c60dc33f4d7c228e638907afc5e149ce98a972f134fbe2d. Source revision is the one above. No native Apple consumer or public adapter behavior was measured.

Typed outcome or recovery expected

Observed hierarchy edges document → object 1 → object 2, matching the retained nodes and raw UID references, with the authored id preserved as data.

Analysis or suspected cause

Source-derived: hierarchy appendNode consults text(item.id) before its UID fallback. The object inventory and reference-stub paths accept only objectID/object-id before using the table UID. These separate identity rules disagree on the same archived object. The field-name control isolates that disagreement; there is no missing object, invalid UID, or reached limit.

Additional context

Preliminary read-only open/closed searches covered authored identity, id field, hierarchy, objectID, and UIKit terms. Related closed #6662 concerns archiveUID changing inventory identities and collapsing distinct nodes; this reproduction keeps those identities correct and fails only in hierarchy selection via id, after that repair. Closed #6776 concerns className provenance. Open #5693 concerns compiled AppKit object-parent registries, not this keyed-archive identity mismatch.

Evidence provenance: executed production parser, bundle analyzer, and generic keyed-archive decoder; local verification: reproduced; reproduction completeness: complete for the supplied synthetic XML and binary fixtures. Real-app prevalence and native compiler compatibility remain unmeasured. No implementation changes were made.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Valid in-bounds synthetic archives deterministically produce a false self-parent relationship, or an invented unknown parent, while hierarchy coverage claims completeness. The defect is bounded to the demonstrated authored field and preserves raw evidence.
  • Workaround status: available
  • Workaround basis: The executed generic keyed-archive decoder preserves separate table identities and the original NSSubviews UID reference for the same failing bytes, allowing manual reconstruction without altering the archive. It does not restore the automated graph.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6849 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior The production `analyzeInterfaceBuilderBundle` boundary emits a child-to-itself hierarchy edge for a synthetic keyed nib whose root view contains ordinary application data `id: "2"`. The object inventory correctly retains root UID 1 and child UID 2, but the hierarchy uses UID 2 for both. Every coverage facet reports complete with zero omissions, `hierarchy_complete` is true, and `graph.truncated` is false. This reproduced with XML and binary-plist UID archives. Renaming only the authored field from `id` to `recordID` restores `document → 1 → 2`. An additional `id: "record-17"` control instead invents an unknown hierarchy parent even though the real root remains object 1. A supported `objectID: "record-17"` control consistently uses that identity in both inventory and hierarchy. ### Reproduction Actually executed from the source checkout, with this script saved outside it as `hierarchy-id.mts` and an existing scratch directory passed as `probe_dir`: ```sh node --import ./node_modules/tsx/dist/loader.mjs "$probe_dir/hierarchy-id.mts" "$PWD" "$probe_dir" ``` ```ts import assert from 'node:assert/strict'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; const checkout = process.argv[2]!; const out = process.argv[3]!; const { analyzeInterfaceBuilderBundle } = await import(pathToFileURL(join(checkout, 'src/artifacts/apple/InterfaceBuilderAnalysis.ts')).href); const { parseInterfaceBuilderRecords } = await import(pathToFileURL(join(checkout, 'src/domain/apple/interfaceBuilderKeyedArchive.ts')).href); const { decodeKeyedArchiveBytes } = await import(pathToFileURL(join(checkout, 'src/artifacts/apple/KeyedArchiveReader.ts')).href); const uid = (n: number) => ({ 'CF$UID': n }); const esc = (s: string) => s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); function xml(v: unknown): string { if (typeof v === 'number') return `<integer>${v}</integer>`; if (typeof v === 'string') return `<string>${esc(v)}</string>`; if (Array.isArray(v)) return `<array>${v.map(xml).join('')}</array>`; return `<dict>${Object.entries(v as object).map(([k,c]) => `<key>${esc(k)}</key>${xml(c)}`).join('')}</dict>`; } const work = await mkdtemp(join(tmpdir(), 'rea-apple-id-')); const rows = []; try { for (const [field, value] of [['recordID','2'], ['id','2'], ['id','record-17'], ['objectID','record-17']]) { const archive = { $archiver:'NSKeyedArchiver', $version:100000, $objects: ['$null', {$class:uid(4), title:'Root', [field]:value, NSSubviews:[uid(2)]}, {$class:uid(4), title:'Child'}, {$class:uid(5), NSSource:uid(2), NSDestination:uid(1), NSLabel:'parent'}, {$classname:'ArchiveProbeView', $classes:['ArchiveProbeView','NSView','NSObject']}, {$classname:'NSNibOutletConnector', $classes:['NSNibOutletConnector','NSNibConnector','NSObject']}], $top:{root:uid(1)} }; const bytes = Buffer.from('<?xml version="1.0"?><plist version="1.0">'+xml(archive)+'</plist>'); const tag = field+'-'+value; await writeFile(join(out, tag+'.nib'), bytes); const bundlePath=join(work,tag+'.app'); await mkdir(bundlePath); await writeFile(join(bundlePath,'Main.nib'),bytes); const before = performance.now(); const result = await analyzeInterfaceBuilderBundle({bundlePath,targetSha256:'a'.repeat(64)}); const elapsedMs=performance.now()-before; const parsed=parseInterfaceBuilderRecords(archive); const generic=decodeKeyedArchiveBytes(bytes,{offset:0,limit:20}); const node = (id: string | null) => result.graph.nodes.find((n: any) => n.id===id); const hierarchy = result.graph.edges.filter((e: any) => e.id.includes(':hierarchy:')).map((e: any) => ({from:node(e.from)?.attributes.interface_builder_object_id ?? 'document',to:node(e.to)?.attributes.interface_builder_object_id ?? null,resolution:e.resolution})); const row={field,value,elapsedMs,sha256:result.documents[0].archive_sha256,parsedIds:parsed.objects.map((o: any) => o.id),parsedHierarchy:parsed.hierarchy,document:result.documents[0],nodes:result.graph.nodes.map((n: any)=>({kind:n.kind,name:n.name,objectID:n.attributes.interface_builder_object_id,authoredId:n.attributes.id})),hierarchy,coverage:result.graph.coverage,truncated:result.graph.truncated,keyedObjects:generic.objects.filter((o: any)=>o.id===1||o.id===2)}; rows.push(row); console.log(JSON.stringify(row)); assert.equal(result.documents[0].hierarchy_complete,true); assert.equal(result.graph.truncated,false); if(field==='recordID') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','1'],['1','2']]); if(field==='id'&&value==='2') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','2'],['2','2']]); if(field==='id'&&value==='record-17') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','record-17'],['record-17','2']]); if(field==='objectID') assert.deepEqual(hierarchy.map((e: any)=>[e.from,e.to]),[['document','record-17'],['record-17','2']]); } await writeFile(join(out,'hierarchy-id-results.json'),JSON.stringify({runtime:process.version,platform:process.platform,arch:process.arch,rows},null,2)+'\n'); } finally { await rm(work,{recursive:true,force:true}); } ``` All assertions passed. The failing XML analyzer call took 7.028 ms; its binary counterpart took 5.857 ms. These are single-call wall-clock observations, not performance claims. The four XML cases and two binary identity controls ran once in their saved final executions. Temporary bundle directories were removed. ### Expected behavior Without an explicit `objectID` or `object-id`, hierarchy references and graph nodes must use the same original object-table index. A custom object's serialized field named `id` must remain application data and cannot turn a parent-child link into a self-loop or an invented parent. The [documented identity rule](https://github.com/morluto/rea/blob/9066444f6b8e9ffe1607a9b02cde3895a37db431/docs/native-investigation.md#L817-L820) reserves only `objectID` and `object-id` as authored identity overrides. The [object inventory](https://github.com/morluto/rea/blob/9066444f6b8e9ffe1607a9b02cde3895a37db431/src/domain/apple/interfaceBuilderKeyedArchive.ts#L419-L423) and [reference stubs](https://github.com/morluto/rea/blob/9066444f6b8e9ffe1607a9b02cde3895a37db431/src/domain/apple/interfaceBuilderKeyedArchive.ts#L254-L268) already follow it. ### Impact and scope A bounded static-analysis correctness defect: the returned hierarchy asserts a nonexistent relationship and marks the evidence complete. Real-app frequency is unknown. The two observed archived views and their raw fields remain available; the executed generic keyed-archive decoder preserved IDs 1 and 2 and `NSSubviews → UID 2`, allowing manual reconstruction. This workaround does not repair the automatic UI graph. No security impact or modification of the inspected bytes is involved. ### REA version `rea-agents` 6.7.0, source revision `9066444f6b8e9ffe1607a9b02cde3895a37db431`. This is a source-checkout reproduction with locked dependencies; it does not claim execution of the published tarball. The research assignment confirmed 6.7.0 as npm latest. ### Runtime and platform Node.js v24.22.1; Linux x64; source `tsx` loader. No build, install, full suite, Apple compiler, native provider, target process, or CLI/MCP adapter was run. Binary controls used Python `plistlib` to encode the same synthetic archive dictionaries with actual plist UID records. ### Target and artifact context Small inert `Main.nib` XML/binary NSKeyedArchiver archives with two synthetic `ArchiveProbeView` objects declaring NSView ancestry, one outlet connector, and in-bounds class/reference records. These are synthetic fixtures, not Apple compiler output. The source-boundary target digest is a placeholder and does not establish public target admission. ### Interface Other ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Reduced from the saved executed output: ```json { "field": "id", "value": "2", "parsedIds": ["1", "2", "3"], "hierarchy": [ {"from":"document","to":"2","resolution":"observed"}, {"from":"2","to":"2","resolution":"observed"} ], "document": {"object_count":3,"connection_count":1,"hierarchy_complete":true}, "hierarchyCoverage": {"status":"complete","reason":null,"examined":2,"omitted":0}, "truncated": false } ``` The `recordID: "2"` control instead has edges `document → 1` and `1 → 2`. Both formats give the same differing semantics. Generic decoding retains root object 1 with its authored `id` and child reference, and distinct child object 2. Failing archive SHA-256: XML `b2523f0a0a973fd76efc5e8bd035d56f97ace3712d139ab1e8434ab7788639ee`; binary `561877d0b983aed54c60dc33f4d7c228e638907afc5e149ce98a972f134fbe2d`. Source revision is the one above. No native Apple consumer or public adapter behavior was measured. ### Typed outcome or recovery expected Observed hierarchy edges `document → object 1 → object 2`, matching the retained nodes and raw UID references, with the authored `id` preserved as data. ### Analysis or suspected cause Source-derived: [hierarchy `appendNode`](https://github.com/morluto/rea/blob/9066444f6b8e9ffe1607a9b02cde3895a37db431/src/domain/apple/interfaceBuilderKeyedArchive.ts#L292-L311) consults `text(item.id)` before its UID fallback. The object inventory and reference-stub paths accept only `objectID`/`object-id` before using the table UID. These separate identity rules disagree on the same archived object. The field-name control isolates that disagreement; there is no missing object, invalid UID, or reached limit. ### Additional context Preliminary read-only open/closed searches covered authored identity, `id field`, hierarchy, `objectID`, and UIKit terms. Related closed [#6662](https://github.com/morluto/rea/issues/6662) concerns `archiveUID` changing inventory identities and collapsing distinct nodes; this reproduction keeps those identities correct and fails only in hierarchy selection via `id`, after that repair. Closed [#6776](https://github.com/morluto/rea/issues/6776) concerns `className` provenance. Open [#5693](https://github.com/morluto/rea/issues/5693) concerns compiled AppKit object-parent registries, not this keyed-archive identity mismatch. Evidence provenance: executed production parser, bundle analyzer, and generic keyed-archive decoder; local verification: reproduced; reproduction completeness: complete for the supplied synthetic XML and binary fixtures. Real-app prevalence and native compiler compatibility remain unmeasured. No implementation changes were made. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Valid in-bounds synthetic archives deterministically produce a false self-parent relationship, or an invented unknown parent, while hierarchy coverage claims completeness. The defect is bounded to the demonstrated authored field and preserves raw evidence. - Workaround status: available - Workaround basis: The executed generic keyed-archive decoder preserves separate table identities and the original NSSubviews UID reference for the same failing bytes, allowing manual reconstruction without altering the archive. It does not restore the automated graph.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4189
No description provided.