A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

Description

@hotlong

Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

The fact

composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

Why both copies exist today

They feed two different readers, and each reader ignores the other's copy:

  • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
  • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

Why it matters

ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

What the decision is

  • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
  • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
  • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


Triage — graded

Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

<!-- os-decision-facets -->

  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
  • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
  • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
  • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

      Description

      @hotlong

      Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

      The fact

      composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

      Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

      Why both copies exist today

      They feed two different readers, and each reader ignores the other's copy:

      • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
      • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

      D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

      Why it matters

      ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

      There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

      What the decision is

      • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
      • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
      • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

      Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


      Triage — graded

      Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

      The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

      <!-- os-decision-facets -->

      • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
      • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
      • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
      • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

      推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
      回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
      不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
      置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

      Generated by Claude Code

      Metadata

      Metadata

      Assignees

      No one assigned

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

          Description

          @hotlong

          Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

          The fact

          composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

          Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

          Why both copies exist today

          They feed two different readers, and each reader ignores the other's copy:

          • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
          • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

          D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

          Why it matters

          ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

          There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

          What the decision is

          • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
          • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
          • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

          Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


          Triage — graded

          Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

          The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

          <!-- os-decision-facets -->

          • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
          • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
          • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
          • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

          推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
          回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
          不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
          置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

          Generated by Claude Code

          Metadata

          Metadata

          Assignees

          No one assigned

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

              Description

              @hotlong

              Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

              The fact

              composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

              Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

              Why both copies exist today

              They feed two different readers, and each reader ignores the other's copy:

              • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
              • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

              D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

              Why it matters

              ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

              There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

              What the decision is

              • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
              • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
              • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

              Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


              Triage — graded

              Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

              The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

              <!-- os-decision-facets -->

              • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
              • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
              • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
              • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

              推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
              回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
              不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
              置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

              Generated by Claude Code

              Metadata

              Metadata

              Assignees

              No one assigned

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

                  Description

                  @hotlong

                  Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

                  The fact

                  composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

                  Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

                  Why both copies exist today

                  They feed two different readers, and each reader ignores the other's copy:

                  • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
                  • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

                  D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

                  Why it matters

                  ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

                  There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

                  What the decision is

                  • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
                  • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
                  • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

                  Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


                  Triage — graded

                  Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

                  The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

                  <!-- os-decision-facets -->

                  • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
                  • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
                  • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
                  • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

                  推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
                  回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
                  不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
                  置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

                  Generated by Claude Code

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

                      Description

                      @hotlong

                      Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

                      The fact

                      composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

                      Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

                      Why both copies exist today

                      They feed two different readers, and each reader ignores the other's copy:

                      • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
                      • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

                      D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

                      Why it matters

                      ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

                      There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

                      What the decision is

                      • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
                      • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
                      • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

                      Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


                      Triage — graded

                      Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

                      The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

                      <!-- os-decision-facets -->

                      • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
                      • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
                      • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
                      • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

                      推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
                      回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
                      不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
                      置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

                      Generated by Claude Code

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

                          Description

                          @hotlong

                          Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

                          The fact

                          composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

                          Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

                          Why both copies exist today

                          They feed two different readers, and each reader ignores the other's copy:

                          • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
                          • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

                          D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

                          Why it matters

                          ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

                          There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

                          What the decision is

                          • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
                          • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
                          • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

                          Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


                          Triage — graded

                          Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

                          The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

                          <!-- os-decision-facets -->

                          • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
                          • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
                          • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
                          • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

                          推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
                          回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
                          不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
                          置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

                          Generated by Claude Code

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512

                              Description

                              @hotlong

                              Found while implementing #14439 (the ADR-0130 D4 producer). Filed rather than solved there: removing either copy changes what a booted instance can see, which is a design decision, not a compiler's.

                              The fact

                              composeStacks([...], { manifest: 'preserve' }) is deliberately additive: it produces the flattened stack the platform has always produced pluspackages[], whose entries are the same metadata assembled per package. So a compiled multi-package artifact carries every object, view, flow and permission set twice.

                              Measured on examples/app-multi-package (two packages, one object each): dist/objectstack.json is 7.2 KB, of which the packages[] half is a near-copy of the top-level half. The ratio does not improve with size — it is one extra copy of everything a package owns.

                              Why both copies exist today

                              They feed two different readers, and each reader ignores the other's copy:

                              • The flattened top level is what MetadataPlugin._parseAndRegisterArtifact iterates (ARTIFACT_FIELD_TO_TYPE over the parsed definition, packages/metadata/src/plugin.ts). It never looks at packages. Drop the top level and a booted instance has no views, flows, permission sets or datasets in the metadata service at all.
                              • packages[] is what ObjectQL.registerApp iterates, one manifest at a time, and it is the only thing that can stamp per-package ownership (_packageId, the namespace-owner set). Drop it and the artifact installs N package records owning nothing — which is the state ADR-0130 D4 existed to leave behind.

                              D4's read-both rule is what makes the second point sharp: when packages is present the load path registers through it, so the top-level collections reach registerApp not at all.

                              Why it matters

                              ADR-0130 D4 reserves the { ref, integrity } external-segment position specifically because artifact size is a real constraint — 黑猫's single-package artifact is already 2.6 MB, and both marketplace transfer and startup parse scale with it. Doubling the payload is the opposite direction, and it arrives at the same moment the format gains the key that was meant to help.

                              There is a correctness edge too, though nothing exercises it today: two copies of one definition can be edited apart. Nothing reconciles them, and which one a consumer sees depends on which reader it went through.

                              What the decision is

                              • A. Leave it. The duplication is honest (both readers are real) and the cost is bounded by the artifact staying one file. Revisit when a real artifact gets large.
                              • B. Teach the metadata artifact door to read packages[] — iterate the package bodies when the key is present, exactly as the ObjectQL side does — and stop emitting the flattened collections for multi-package artifacts. Single-package artifacts keep today's shape untouched (D4/D7). This is the one that removes the copy rather than compressing it.
                              • C. Keep both readers but make the artifact carry each definition once, with packages[i] referencing top-level items by name. Cheapest on bytes, and the one that most deserves scrutiny: a reference that resolves at load is a second lookup path for metadata identity, and a dangling one fails silently.

                              Not urgent: no customer artifact carries packages[] yet, so this can be decided before it is expensive rather than after.


                              Triage — graded

                              Located at origin/main4a37870: composeStacks is packages/spec/src/stack.zod.ts:2451; the metadata-side reader is packages/metadata/src/plugin.ts:72 (ARTIFACT_FIELD_TO_TYPE) consumed at :935. The artifact shape is a packages/spec contract, which is what sets the lane.

                              The card needs no correction — it states its own three options fairly, names the correctness edge without overclaiming it ("nothing exercises it today"), and is explicit that no customer artifact carries the key yet. Graded as a decision because every option changes the compiled artifact format, which is a published contract with an ADR (ADR-0130 D4/D7) on it.

                              <!-- os-decision-facets -->

                              • ① 项目长远合理性(权重 ≥50%,领起推荐) —— 一份定义存两份、两个读取方各读各的,是标准的双真相来源。今天两份一致纯粹因为它们由同一次编译同时生成 —— 没有任何东西在核对它们。而 ADR-0130 D4 专门留出「外部分段引用」的位置,理由正是产物体积是真实约束;在这个当口把载荷翻倍,方向恰好相反。①指向「一份定义只存一次」,并且在 B 与 C 之间明确偏 B:C 引入的是「按名字引用」这条新的元数据身份解析路径 —— 那是新增特例,不是缩小特例。
                              • ② 实际业务拉动 —— 今天为零,卡面自己说得很清楚:没有任何客户产物带 packages[]⚠️ 但零拉动在这里的读法要反过来:产物格式一旦有客户落地就固化了。现在改是零迁移面,以后改是破坏性格式变更。 所以「零拉动」是「趁现在便宜」的理由,不是「先放着」的理由 —— 这是本卡与一般零拉动卡最大的不同。
                              • ③ 防 AI 犯错 —— 出错时谁看到什么:两份副本被改岔,消费者看到哪一份取决于它走了哪个读取方。不报错,只是两个不一致的答案。 卡面说今天没有东西会造成分歧,这是对的;但「一份定义两处存储」本身就是等着分歧的结构。B 让这种分歧在结构上不可能;C 把它换成「悬空引用静默失败」—— 卡面自己就点了这一条,是三个方案里最该被审视的。
                              • ④ 创业阶段不扩散 —— 每一种写进产物格式的形状都是永久义务:向后兼容、迁移工具、文档。packages[] 目前没有任何客户产物,这是唯一一次可以零成本收敛的窗口,过了就得带着两份副本做兼容。

                              推荐:A = 卡面的方案 B。 教元数据产物读取方在 packages[] 存在时按包体遍历,多包产物不再输出扁平集合;单包产物形状原样不动(D4/D7)。①领起 —— 它删掉一份,而不是新增一条查找路径;③让改岔在结构上不可能;④不新增格式概念;②的零拉动不反对它,反而正是它现在做最便宜的理由。
                              回退:B' = 卡面的方案 A(先不动)。 若维护者认为 D4 的外部分段位置本就是为体积准备的、届时一并处理更省事,走这条 —— 代价写明:届时已有客户产物,同一个变更从「零迁移」变成「破坏性」。
                              不荐方案 C。 按名字引用会新增第二条元数据身份解析路径,悬空时静默失败,③④都反对;卡面把它列为「最该被审视的一个」,本席同意并且把它排在最后。
                              置信缺口(本分析看不见什么): 没有量方案 B 的真实成本。卡面说 MetadataPlugin._parseAndRegisterArtifact 今天完全不看 packages 键,但没有说它要读的那些集合(views / flows / permission sets / datasets)在包体里的形状是否与顶层一致。若不一致,B 就不是「换个地方遍历」而是一次形状适配,成本和推荐都要重排。这是执行 B 之前必须先量的第一件事,不是执行中顺手确认的事。

                              Generated by Claude Code

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions