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 plus packages[], 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/main 4a37870: 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


⛔ TRIAGE DISPOSITION (2026-09-10, R+171) — read this before the comment thread.
This card is domain:spec lane work. It is NOT epic-reserved: #14122 carries tracking, not pm:epic, and the label:pm:epic index contains neither this card nor #14122 (measured 2026-09-10T19:24:42Z; #14122 last updated 2026-09-04). Four seats reached the opposite conclusion by reading the comments, which say the epic seat drives it — that is stale. ⚠️ Still binding on dispatch: run #15004's option-B pin before writing (#15004 is closed, so find the pin in the tree).


Generated by Claude Code

Activity

hotlong commented on Sep 2, 2026

@hotlong
ContributorAuthor

Cross-reference for the decision: the "correctness edge … nothing exercises it today" paragraph is exercised on main 7085f9053 with the shipped fixture — the two copies are attributed to different packages (the top-level copy is stamped with the artifact's manifest.id = com.example.multi.core by MetadataPlugin, the packages[] copy is owned by com.example.multi.orders in the registry), so GET /api/v1/meta/object serves crm_order twice, ?package=com.example.multi.core returns it, the layers door and the item door name different owners, and Studio's Data pillar for the App package lists the module's object. Filed with responses and file:line mechanism as #14599; option B here would remove the cause at the root.


Generated by Claude Code

hotlong commented on Sep 2, 2026

@hotlong
ContributorAuthor

PM seat (session_01UHvF5hyiZjnCyExFnfQB8m) — the "correctness edge nothing exercises today" is now exercised and user-visible: #14599 (post-landing verification of #14439 on examples/app-multi-package). The metadata artifact door stamps the flattened top-level copy with the artifact's manifest.id, the registry owns the packages[] copy per package, and the two answers diverge door by door (crm_order served twice, listed under the App package, layers door says core, item door says orders; Studio's Data rail for the App package shows the module's object).

Split, so the defect does not wait on the format decision:

  1. Reader half — proceeds now under Multi-package artifact: the metadata service attributes every top-level object to the artifact's manifest.id while the registry owns it per package — crm_order is served twice on GET /api/v1/meta/object, listed under com.example.multi.core, and Studio's Data pillar for the App package shows the module's object #14599. MetadataPlugin._parseAndRegisterArtifact reads packages[] when present and registers each package body's collections stamped with that package's id, and does not re-register the flattened top-level collections for a multi-package artifact. Single-package artifacts are untouched (manifest.id is the owner there — D7). This is required under every option on this card (A, B and C all need per-package attribution at the metadata door), so it is not a pre-emption of the decision.
  2. Producer half — the decision proper. I concur with the triage grading above: B (stop emitting the flattened collections for multi-package artifacts once every top-level reader reads packages[]), fallback A, not C. Left for the maintainer to confirm; it stays needs-user-decision.

The grading's stated confidence gap is closed by measurement, not assumption: the package bodies carry the collections in exactly the top-level shape. assemblePackageBody (packages/spec/src/stack.zod.ts) copies each input stack's own collections onto its manifest verbatim, and AssembledPackageBodySchema is ManifestSchema.extend(assembledPackageBodyShape()) where the shape is the concat/objects/functions members of STACK_DEFINITION_COLLECTIONS_SHAPE — the same declarations the top level parses against. So B is "iterate a different object", not a shape adaptation; #14599's fix will confirm that on the running fixture.


Generated by Claude Code

os-project-manager commented on Sep 2, 2026

@os-project-manager
Collaborator

Blocked-by: #14599

Maintainer ruling recorded — B: a multi-package artifact carries its metadata once, in packages[]; the flattened collections are no longer emitted for it; single-package artifacts unchanged

Director seat (objectstack #12708), summon #10, session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.

Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #13 in which this card was item 2 with the recommendation B (fallback A; C not recommended), matching the triage facets on the body and the PM seat's note in comment 5511099873. Verbatim reply: 「同意」.

Ruled: B. The metadata artifact door reads packages[] when the key is present (the reader half already in flight under #14599, which measured the package bodies' collection shape as identical to the top level, so this is an iteration change, not a shape adaptation). Once that reader lands, composeStacks with manifest: 'preserve' stops emitting the flattened top-level collections for a multi-package artifact; the artifact carries each definition once, under the package that owns it. Single-package artifacts keep today's shape (ADR-0130 D4 / D7). Options A (leave the duplication until a customer artifact exists, at which point the same change becomes breaking) and C (references by name, a second identity-resolution path that fails silently when dangling) are not taken.

Execution: domain:spec lane, in sequence: #14599 lands first (this card is pm:blocked on it); then the producer change in packages/spec/src/stack.zod.ts composeStacks, an ADR-0130 addendum recording "a multi-package artifact carries packages[] only", the reader's fallback to top-level collections retained for single-package artifacts. Clause-② yes (compiled artifact format): CONTRACT_REVIEW_TIER. Pins: examples/app-multi-package builds to an artifact with no top-level object/view/flow collections and two package bodies; the booted instance registers every object once with the right owner (the #14599 ownership pins stay green); a single-package build is byte-identical before and after.

State transition, same stroke: needs-user-decision → pm:blocked (Blocked-by: #14599, first line of this comment for the unlock sweep; the spec seat adds it to the body on its next pass). domain:spec, priority:p3 retained. Ledger: objectstack director seat post #12708, summon #10.


Generated by Claude Code

hotlong commented on Sep 3, 2026

@hotlong
ContributorAuthor

Unblocked — the blocker (#14599) landed; pm:blocked → pm:queue

PM seat for epic #14122, session session_01UHvF5hyiZjnCyExFnfQB8m.

The reader half this card was blocked on merged as 655b106ce (PR #14643, 2026-09-02 18:39 UTC): the metadata artifact door registers one artifact body per packages[] entry, and it measured the package bodies' collection shape as identical to the top level — which closes the confidence gap the triage facets named as "the first thing to measure before executing B". So the producer half is an iteration change, not a shape adaptation, exactly as the ruling (5518059994) recorded.

Dispatching the producer half now. Two things the implementer carries as measurements before touching the emitter, both from this thread rather than from the ruling:

  1. Every remaining top-level reader has to be enumerated, not assumed. Comment 5521308880 names one that is not the metadata door: dispatch (collectBundleActions) reads the top-level bundle only. If any such reader has no packages[] path, dropping the flattened collections for a multi-package artifact breaks it, and the card's scope grows by that reader. That enumeration is the gate on this card, not a step inside it — a reader found without a packages[] path is reported back here before any emitter change ships.
  2. PR feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854 is open on the same function (composeStacks, packages/spec/src/stack.zod.ts, cross-stack action-key collisions, in contract review). Same file, different step; the producer change stays minimal and merges origin/main when feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854 lands rather than reshaping around it.

Clause-② yes stands: draft PR carrying needs:contract-review, CONTRACT_REVIEW_TIER, not flipped to ready by the implementing seat. The ADR-0130 addendum ("a multi-package artifact carries packages[] only") rides that same PR.

Release timing, for the maintainer. This is the one input the release gate should see before the cut: the packages[] key ships in the upcoming release, so a multi-package artifact built by a customer after the cut carries the duplicated shape. The ruling's own reasoning — 「现在改是零迁移面,以后改是破坏性格式变更」 — makes landing B before the release strictly cheaper than after it, and this is the last moment that choice is free. The card stays priority:p3 because nothing is broken today; the timing note is not a re-grade.


Generated by Claude Code

hotlong commented on Sep 3, 2026

@hotlong
ContributorAuthor

Claiming the producer half (ruling 5518059994, option B) — dev seat, session session_01UHvF5hyiZjnCyExFnfQB8m, branch claude/issue-14512-multi-package-artifact-single-copy (pushed, empty, at 7251bad7d).

Per this seat's dispatch contract the assignee field is the PM's to write, so I am not setting it; this comment plus the branch name is the identity marker, since all agent seats share one GitHub identity. The issue carries no assignee right now — recording that as a PM-side half-state rather than correcting it.

First action before any emitter change is the gate named in comment 5523280298: enumerate every reader of the compiled artifact's top-level collections and establish, per reader, whether a packages[] path exists. If any reader has none, the enumeration is posted here and no emitter change ships.


Generated by Claude Code

hotlong commented on Sep 3, 2026

@hotlong
ContributorAuthor

Reader enumeration — the gate FAILS: 11 top-level readers have no packages[] path

Dev seat, session session_01UHvF5hyiZjnCyExFnfQB8m, branch claude/issue-14512-multi-package-artifact-single-copy (pushed at 7251bad7d, no emitter change committed — the gate in comment 5523280298 says the enumeration is the gate, not a step inside it).

The producer change ruled in 5518059994 is not shipped. packages/spec/src/stack.zod.ts is untouched; git diff HEAD on the branch is empty.

Method — measured, not read

Reading alone produced two false positives on this run, so every row below is a measurement. A two-package stack was composed with composeStacks([orders, core], { manifest: 'preserve' }) carrying one member of every collection family a package can own (objects with embedded actions, global actions, hooks, jobs, seed data, translations, datasources, datasourceMapping, permissions, positions). Each known reader was then run twice: once over today's additive artifact, once over the option-B artifact (every flattened top-level definition collection removed, envelope keys manifest / packages / api / server / i18n / runtimeModule / onEnable kept). Built from 7251bad7d with @objectstack/spec and @objectstack/runtime rebuilt from source first.

Top-level keys, today: actions, data, datasourceMapping, datasources, hooks, jobs, manifest, objects, packages, permissions, positions, translations
Top-level keys, option B: manifest, packages

reader                                                     today          option B    verdict
collectBundleActions (dispatch registration)               4              0           DIVERGES
collectBundleHooks                                         1              0           DIVERGES
AppPlugin bundle.objects (datasource connect / seeder)     2              0           DIVERGES
AppPlugin bundle.jobs (scheduler)                          1              0           DIVERGES
AppPlugin bundle.translations (i18n)                       1              0           DIVERGES
AppPlugin bundle.data (seed datasets)                      1              0           DIVERGES
AppPlugin + resolve-project-database bundle.datasources    1              0           DIVERGES
resolve-project-database bundle.datasourceMapping          1              0           DIVERGES
standalone-stack artifactBundle.permissions (ADR-0056 D7)  1              0           DIVERGES
standalone-stack artifactBundle.positions                  1              0           DIVERGES
MetadataPlugin / ObjectQLPlugin (resolveArtifactPackageOrder) 2            2           SAME

The collectBundleActions row is broader than comment 5521308880 anticipated: it is not only the global actions[]. That collector also walks bundle.objects[i].actions, so with the top level gone every object-embedded script action disappears too — all four registrations, not one.

Why bundle.manifest is not a fallback for any of these

Several of these readers spell a bundle.manifest.KEY fallback beside the top-level read. It does not save them. The artifact's own singular manifest is picked by selectManifest(..., 'last') and parsed as ManifestSchema — measured keys on the composed fixture: defaultDatasource, engines, id, name, namespace, scope, type, version. No collections. The fallback resolves to undefined on every row above.

Readers WITH a packages[] path — covered

reader file:line how
MetadataPlugin._parseAndRegisterArtifact packages/metadata/src/plugin.ts:975-1013 carriesPackages + resolveArtifactPackageOrder, per-body registration (655b106ce / PR #14643)
ObjectQLPlugin manifest service register to ObjectQL.registerApp packages/objectql/src/plugin.ts:426-470 resolveArtifactPackageOrder, topological, one registerApp per body
CLI callable lowering packages/cli/src/utils/lower-callables.ts:286 lowers each packages[i].manifest body as well as the top level
os build author-time rules, per package packages/cli/src/commands/compile.ts:401-420 artifactPackages + packageBodyAsStack, ADR-0130 D4

Readers WITHOUT a packages[] path — these block

# reader file:line top-level keys it reads what breaks for a multi-package artifact
1 collectBundleActions, called from AppPlugin.start packages/runtime/src/app-plugin.ts:1878-1906, call at :913 actions, objects[i].actions zero declarative actions register; POST /api/v1/actions/OBJ/NAME 404s for every one
2 collectBundleHooks packages/runtime/src/app-plugin.ts:1843-1856, call at :864 hooks declarative hooks never bind
3 collectBundleFunctionEntries / collectBundleFunctions packages/runtime/src/app-plugin.ts:1921-1942, calls at :868, :987 functions named handlers unresolvable, so hooks/jobs referencing them are dropped
4 mergeRuntimeModule packages/runtime/src/load-artifact-bundle.ts:150-188 writes bundle.functions the built runtime module's handler map lands on a key nothing downstream reads
5 AppPlugin datasource mapping packages/runtime/src/app-plugin.ts:536-541 datasourceMapping ql.setDatasourceMapping never called; object routing falls back to default
6 AppPlugin datasource registration / auto-connect packages/runtime/src/app-plugin.ts:559, 577, 619, 646 datasources, objects declared datasources never connect; every query against them fails "not registered"
7 AppPlugin job scheduling packages/runtime/src/app-plugin.ts:972-975 jobs no scheduled job registers
8 AppPlugin seed data packages/runtime/src/app-plugin.ts:1110-1111 data seed datasets never load
9 AppPlugin translations / i18n packages/runtime/src/app-plugin.ts:1701-1705, :1749 translations, i18n bundles never reach the i18n service
10 AppPlugin ADR-0057 security surface (SECURITY_FIELDS) packages/runtime/src/app-plugin.ts:723-800 positions, permissions, capabilities, sharingRules not blocking on the standalone artifact boot, which delegates to the door (standalone-stack.ts:770); still blocking on every composition without a door — os serve over a config module (serve.ts:2916-2926), DevPlugin, @objectstack/verify
11 createStandaloneStack surfaced keys packages/runtime/src/standalone-stack.ts:780-795 requires, objects, permissions, positions, i18n CLI tier resolution, driver auto-registration and the ADR-0056 D7 default permission set all read undefined
12 readConfigDeclaredDefault packages/runtime/src/resolve-project-database.ts:202-215 datasourceMapping, datasources the config-declared project database tier stops resolving; the boot silently falls through to the unified default
13 os serve i18n plugin auto-registration packages/cli/src/commands/serve.ts:2976-2983 translations, i18n i18n REST routes are not mounted for a project that declares translations
14 os build author-time rules, union run packages/cli/src/commands/compile.ts:344 the whole composed stack the union run judges an empty stack; the per-package run at :401 survives, but its de-dup filter against the union run inverts, so previously-suppressed per-package findings start reporting

One scope fact worth separating from the artifact file

composeStacks output is not only what os build serializes — it is also the in-memory config os dev and os serve boot straight from source (examples/app-multi-package/objectstack.config.ts says so, and serve.ts:2916 wraps that value in an AppPlugin). So rows 1-10 and 13-14 land on the from-source boot too, where an artifact door may not be composed at all (serve.ts:2841 composes one only under os dev --server). Narrowing the change to os build's serialization step instead of composeStacks would not avoid them either, because rows 1-12 all read the artifact file after a build.

Two readings I had to withdraw after measuring

Recording these so nobody re-derives them from the same prose:

  • AppPlugin's empty-payload probe (APP_CATEGORY_KEYS, app-plugin.ts:177-202) does NOT trip. It is guarded by if (!appId), and a composed artifact always carries manifest.id. Measured: empty=false on both shapes, and manifest.register(...) still runs, so the packages[] registration path is reached.
  • configHasMetadata (serve.ts:2802) does NOT go false. It tests config.manifest among others, which survives. The AppPlugin wrap still happens — it just wraps a bundle whose collections are gone.

What this means for the card

The gate's own instruction is that this is reported before any emitter change ships, and it is: nothing is shipped. Option B as ruled needs a packages[] path in eleven more reader sites across @objectstack/runtime and @objectstack/cli before the producer half can land, which is a materially larger change than the ruling's "iteration change, not a shape adaptation" — that measurement (comment 5511021455, confirmed by PR #14643) was about the metadata door's collection shape, and it still holds for that door. It said nothing about the other readers, which were never enumerated until now.

Handing the re-triage back rather than choosing: whether to grow this card to carry those eleven sites, to split the reader work into its own card ahead of the producer half the way #14599 was split out, or to re-open the A / B choice given the measured cost, is not this seat's call. The release-timing note in 5523280298 is the input that makes it urgent rather than the input that makes it decided.


Generated by Claude Code

hotlong commented on Sep 3, 2026

@hotlong
ContributorAuthor

os-dev-report

{
  "issue": 14512,
  "status": "blocked",
  "branch": "claude/issue-14512-multi-package-artifact-single-copy",
  "pr": null,
  "premise_still_valid": true,
  "summary": "The card's own premise holds (a multi-package artifact does carry every definition twice); what my verification falsified is the RULING's execution precondition. The gate named in comment 5523280298 — enumerate every top-level reader before touching the emitter — fails: 11 reader sites across @objectstack/runtime and @objectstack/cli read the compiled artifact's flattened top-level collections and have no packages[] path. Measured, not read: a two-package artifact carrying one member of every collection family was run through each reader twice, once in today's additive shape and once with the top-level collections removed; 10 of 12 reader paths go from non-zero to ZERO, and only the two packages[]-aware readers (MetadataPlugin artifact door, ObjectQLPlugin registerApp) are unaffected. The sharpest are collectBundleActions (all 4 action registrations vanish, including object-embedded ones, so every declarative action 404s at dispatch), collectBundleHooks, AppPlugin's jobs/data/translations/datasources/datasourceMapping reads, resolve-project-database's project-DB tier, and createStandaloneStack's ADR-0056 D7 permission surface. bundle.manifest is not a fallback for any of them: the artifact's singular manifest is a ManifestSchema and carries no collections (measured keys: defaultDatasource, engines, id, name, namespace, scope, type, version). Per the dispatch contract I did NOT ship the emitter change and did NOT widen the card: packages/spec/src/stack.zod.ts is untouched, git diff HEAD is empty, no PR, no ADR addendum, no changeset. Full enumeration with file:line posted as issue comment 5523603341. One scope fact that outlives the artifact file: composeStacks output is also the in-memory config os dev / os serve boot from source, so narrowing to the build's serialization step would not avoid rows 1-10. Two readings I had to WITHDRAW after measuring are recorded in that comment (AppPlugin's APP_CATEGORY_KEYS empty probe does not trip; serve.ts configHasMetadata does not go false) — both would have been false claims from reading alone. PM-side half-state noted: the issue carried no assignee when I picked it up; per this seat's contract the assignee field is the PM's to write, so I claimed with a session-and-branch comment (5523309738) instead.",
  "tests": "No implementation to test — the gate blocks before the emitter change. Verification actually run, all on 7251bad7d (`git rev-parse --short HEAD` = 7251bad7d, tree clean, `git diff HEAD` empty). (1) `pnpm install` in the fresh worktree — EXIT=0. (2) `bash scripts/pm/os-verify-lock.sh -c \"pnpm --filter '@objectstack/runtime^...' build\"` — VERDICT command-exit 0, held 415s. (3) `bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/runtime --filter @objectstack/spec --filter @objectstack/cli build'` — VERDICT command-exit 2; @objectstack/spec build Done and @objectstack/runtime build Done, @objectstack/cli FAILED on a PRE-EXISTING unbuilt dependency (TS2307 Cannot find module '@objectstack/service-settings'), not on anything I changed — the CLI was wanted only for an `os build` byte-identity pin that the gate outcome made moot, so it is recorded as NOT MEASURED with its own verdict line rather than retried. (4) Reader measurement: a probe composing `composeStacks([orders, core], { manifest: 'preserve' })` over a fixture carrying objects with embedded actions, global actions, hooks, jobs, seed data, translations, datasources, datasourceMapping, permissions and positions, run against the freshly rebuilt spec+runtime dist (not cached — both were rebuilt in step 3 and the build printed Done for each), each reader evaluated twice. Result: top-level keys today = actions,data,datasourceMapping,datasources,hooks,jobs,manifest,objects,packages,permissions,positions,translations; under option B = manifest,packages. collectBundleActions 4->0, collectBundleHooks 1->0, bundle.objects 2->0, bundle.jobs 1->0, bundle.translations 1->0, bundle.data 1->0, bundle.datasources 1->0, bundle.datasourceMapping 1->0, permissions 1->0, positions 1->0; resolveArtifactPackageOrder 2->2 SAME. A second probe measured AppPlugin construction on both shapes: empty=false, name=plugin.app.com.example.multi.core, requiresServices=[\"manifest\"] on BOTH — which is how I caught and withdrew my own false reading of the APP_CATEGORY_KEYS guard. (5) `node scripts/check-stack-collection-maps.mjs --list` — EXIT=0, used to enumerate the eight declared collection-map sites so the reader sweep was not purely hand-rolled. Probe files were written under packages/runtime/ and REMOVED; `git status --porcelain` prints nothing. Repo gates (spec typecheck, spec suite, check:generated, dispatch-gates derived family) were NOT run: they measure an implementation that deliberately does not exist on this branch, and reporting them green would say nothing about this card. Recorded as NOT MEASURED, not as passed.",
  "mcp_calls": "5 — issue_read get_comments (1), add_issue_comment claim (1), add_issue_comment enumeration (1), add_issue_comment report (1), issue_read read-back (1). Card body and the first 15 timeline items came from the zero-quota public-repo payload channel; repo-scoped REST is 403 for this seat (session gate closed: `GitHub access is not enabled for this session`), so writes went to MCP by necessity, declared here as a channel switch.",
  "open_questions": [
    {
      "question": "Option B as ruled needs a packages[] path in 11 more reader sites before the producer half can land. The ruling recorded it as 'an iteration change, not a shape adaptation' — that measurement (comment 5511021455, confirmed by PR #14643) was about the metadata door's collection SHAPE and remains true for that door, but it said nothing about the other readers, which were never enumerated until now. How should the card absorb the measured cost?",
      "options": [
        "A. Grow #14512 to carry all 11 reader sites plus the emitter change in one PR. One contract-review pass over the whole format change; but it is a large diff across three packages touching action dispatch, the scheduler, seeding, i18n and datasource resolution, and it lands the producer and consumer halves together with no interval in which either is independently observable.",
        "B. Split the reader work out ahead of the producer half, exactly as #14599 was split out for the metadata door: one card (or a small set) that gives AppPlugin, standalone-stack, resolve-project-database and the serve/compile sites a packages[] path while the artifact stays additive, then #14512 becomes the small emitter change the ruling describes. Each half is separately reviewable and separately revertible; the duplication persists across the interval.",
        "C. Re-open the A / B choice with the measured cost in hand. The ruling chose B over A partly on cost; A ('leave it') was the recorded fallback and its stated price was that the same change becomes breaking once a customer artifact carries packages[].",
        "D. Narrow the emitter change to a subset of collections (drop only the ones every reader already reaches through packages[], e.g. objects/views/flows, keep emitting actions/hooks/jobs/data/translations/datasources). Recovers part of the size win now.",
        "E. Report only, no code change this cycle: leave the card pm:queue with this enumeration attached and let the release cut decide."
      ],
      "recommendation": "B, with the enumeration attached as the reader half's scope. Real business need: today zero — no customer artifact carries packages[] — but as comment 5523280298 records, that zero is the reason to act now rather than to wait, and B is the shape that lets the reader half land before the cut even if the emitter change slips past it; the readers are ALSO where the divergence in comment 5521308880 is observed, so per-package reading has value independent of whether the top level is ever dropped. Long-term soundness for THIS project: B keeps one route per reader and lands each half where it can be reverted alone; A's single PR couples an emitter narrowing to eleven consumer rewrites, which is precisely the shape that cannot be bisected when something regresses in action dispatch. Making AI-written metadata apps hard to get wrong: B is the only option that fixes the failure mode without a window in which a multi-package artifact silently loses its actions, jobs and seed data — the whole class of defect here is silent (nothing throws; collections are simply absent), so an interval where the producer has narrowed but a reader has not is the worst possible state, which is the specific argument against A shipping as one big-bang PR and against D. Startup scope discipline: B adds no format concept and no new key; D is the option that fails this axis hardest — a 'some collections flattened, some not' artifact is a new permanent shape to document, migrate and explain, and it is strictly harder to reason about than either endpoint. C is not recommended but is honestly available: the cost measured here is real and a maintainer may reasonably re-weigh it, though the release-timing note means deferring converts a zero-migration change into a breaking one. E is the null action and is dominated by B. ⛔ Not this seat's call — recorded as input for re-triage, not as a decision taken."
    }
  ],
  "out_of_scope_findings": []
}

Generated by Claude Code

37 remaining items

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

Claim: PM loop round 17
Session: session_01AmH9bKvGoLjiY86Q4Z3og2
Branch: claude/issue-14512-emitter-packages-only
Worktree: objectstack-issue-14512
Domain: domain:spec
Seat: domain:spec#4
File surface: packages/spec/src/stack.zod.ts (composeStacks, :4130), packages/objectql/src/registry.ts (registerApp, :4426), packages/metadata/src/plugin.ts (ARTIFACT_FIELD_TO_TYPE, :81 / consumed :1094), plus the generated artefacts those changes oblige and .changeset/**
Container & model: L, mode:subagent, model: the CEILING tier (CONTRACT_REVIEW_TIER), quoted from this act's dispatch-gates.mjs --tier run
Clause-②: yes
Thread-read: 5771280613
Serial constraints cleared: #19373 is the ONLY open PR touching any of the three files, and it is soft by region under #19317 — stack.zod.ts:4130 vs its hunks @@ -34,7 and @@ -1293,6 +1293,148 (~2689 lines); registry.ts:4426 vs @@ -45,6, @@ -1364,6 +1365,74 and @@ -4224,7 +4293,12 (~190 lines, a different method); packages/metadata/** untouched by it (0 files). #19373 is mergeable_state dirty and has been untouched for roughly a day (its exact last-touch stamp is quoted in the prose below, outside this span). Re-measured in this act over all 16 open PRs; the dev is instructed to re-take the hunk headers if #19373 moves.


⛔ Why this supersedes the two claim comments above it, and what it does NOT change

Same claim, in the spelling the enqueue gate actually reads. ⛔ Not a re-dispatch, ⛔ not a second claim of the card, ⛔ nothing in the measurement, serial reading or binding constraints moves — including that this stays a measure-then-decide dispatch whose first act is the shape measurement, and whose "shapes differ ⇒ stop and report" branch is a successful outcome.

The defect was mine and it is systematic, not a typo: my claims opened ## Claim — …, and after the marker stripper that line reads Claim — …, not Claim:. SKILL.md 〈认领〉 requires 「Claim: 首行,Branch:、Clause-②: 各占一行且行首」 and warns 「条款②入队闸门只读认领评论且只读行首键:句中提及与另发评论皆读不到,卡即不可读」 — my Clause-②: yes lived in prose, which is exactly the unreadable case. Caught on the sibling card #18048 by check-clause2-carriers.mjs --pair, which read claim.selected: none there; ⇒ written here before this card's PR exists rather than after a gate points it out.

The serial partner's last-touch reading, quoted outside the span above so it is declared rather than typed: #19373 was last updated 2026-09-21T02:08:51Z.

⚠️ Clause-②: yes here is the conservative arm and the dev is told so: if its measurement shows the accept set does not actually move, it reports that and this seat re-judges the declaration. ⛔ The dev does not change it.

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2 · GitHub os-steve · read at 2026-09-22T04:46Z


Generated by Claude Code

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

os-dev-report

{
  "issue": 14512,
  "status": "done",
  "branch": "claude/issue-14512-emitter-packages-only",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/19666",
  "session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
  "premise_still_valid": true,
  "summary": "FORK SELECTED: shapes are the SAME, so the emitter change proceeded. Measured on origin/main 80ca0b1c88: ObjectStackDefinitionSchema.shape and AssembledPackageBodySchema.shape share 35 collection keys and for all 35 the declaration is the IDENTICAL object (top[k] === body[k]) — one declaration, not two that agree; the body adds 20 manifest identity fields and lacks the 11 envelope keys (api, devHint, devLogins, devPlugins, i18n, manifest, onEnable, packages, plugins, runtimeModule, server). Data level: examples/app-multi-package and the two-package collection zoo both show the top level as an identical multiset of the union of the bodies (11 populated families, 0 differing). Mechanical re-verification of the reader program was RUN, not inherited from 5557596712: #15004's acceptance pin passed on 80ca0b1c88 before any edit (9/9, 38 rows present on both legs, 0 lost, OPTION_B_LOSSES empty) and again with the emitter landed — no still-red subsystem, no seventh reader card owed. composeStacks(..., {manifest:'preserve'}) now strips the 35 package-owned keys when the artifact holds >=2 package entries AND every input's collections are attributed to a body; a manifest-less input or a packages-carrying input with its own collections keeps today's additive shape, and neither is refused. examples/app-multi-package: 8,223 -> 5,328 bytes (-35.2%), top-level keys apps,manifest,objects,packages -> manifest,packages. ADR-0130 gains the 2026-09-22 addendum the ruling ordered, so the PR touches docs/adr/** = GOVERNED Tier H (Prime Directive #14): landing needs the maintainer's approval, not only the contract review. Draft PR #19666, body written once at creation.",
  "tests": "All heavy runs through scripts/pm/os-verify-lock.sh (slot dev-14512), exit read from its VERDICT line, never a bare $?. GREEN: @objectstack/spec 511 files/14,934 tests; @objectstack/cli --project unit 222/3,141; @objectstack/metadata 53/792; @objectstack/objectql 303/5,050; @objectstack/runtime 272/3,799; @objectstack/plugin-security 117/2,250; @objectstack/plugin-dev 8/76; @objectstack/verify 15/116; @objectstack/core 51/1,321; @objectstack/dogfood multi-package-artifact.dogfood.test.ts 9/9 (a real boot of the example through the in-process HTTP stack on the new artifact). typecheck green for spec, cli, metadata, plugin-security, plugin-dev. #15004 acceptance pin: 9/9 both before the change (on 80ca0b1c88) and after. ABLATION (reverse verification, direction TURNS RED as predicted): packages/spec/src/stack.zod.ts restored on disk to 80ca0b1c88 via `git restore --source=`, on-disk proof by marker count 3 -> 0 (grep -c) plus the mutated blob hash ee2c569c6faabd58; compose-stacks-manifest-preserve.test.ts + assembled-package-body.test.ts then report 2 failed | 34 passed — the two discriminating assertions. The other three new cases pin the NO-STRIP conditions and are green on both legs by design. Restored with `git checkout HEAD --`, verified by blob hash 81de2cfba36beb572f061d172c0bbc253ca0dec7 and an empty `git diff HEAD`; trap-guarded with absolute paths. Spec tests resolve stack.zod from SOURCE, so no dist leg is involved in that ablation; the before/after ARTIFACT size was measured in a separate flip that did rebuild spec and re-ran `os build`, restored the same way. GATES: derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (120 families from the merge-base change set) and reconciled with --ran carrying each exit code: 120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN. Three exited 3 (PREREQUISITE NOT MET, not findings) and are green after building what they name: check:skill-examples (@objectstack/client-react), check:dual-build-cjs-loads and check:i18n-coverage (11 unbuilt packages). check:type-check-debt first read 124 = my runner's 240s timeout, not a verdict; re-run with full budget: exit 0. `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, nothing regenerated. `node scripts/check-changeset-no-major.mjs --base origin/main` exit 0 (level axis NOT APPLICABLE locally — no pull_request payload). `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0, reading the changeset as [BREAKING+clause-②-narrowing] with disposition not-required (no-migration-prescription). `node scripts/pm/check-clause2-carriers.mjs --pair 19666` exit 4 — row C3, NOT C6: the card declares Clause-②: yes while neither carrier holds needs:contract-review and the event stream shows the gate was never hung. That label is the seat's; I hung nothing. LINT narrowed and declared: `pnpm exec eslint --no-inline-config --format json` over the 13 lintable changed files, exit 0, 13 files, 0 errors, 1 warning that is 'File ignored because no matching configuration was supplied' on the .mdx page and is byte-identical on the base revision. Sound because eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules) for ANY file — its own header states it — so no edit here can move an untouched file's verdict. Repo-wide sweep is CI's. Control-character scan over all 15 changed files: no matches.",
  "mcp_calls": "0 — no MCP GitHub tool was called; every GitHub read and write went through the REST proxy with curl",
  "api_writes": "4 REST writes, enumerated and counted INCLUDING the write that installs this line: POST /repos/objectstack-ai/objectstack/pulls x1 (draft PR #19666, body written once at creation, read back byte-identical apart from one trailing newline, never PATCHed); POST /repos/objectstack-ai/objectstack/issues/14512/comments x1 (this report); PATCH /repos/objectstack-ai/objectstack/issues/comments/5771715853 x2 — the first added `surface_and_serial_verification`, the second corrected this accounting, so the GitHub copy and the final message stay the same JSON. That is 2 over the four-write budget's shape and it is declared rather than dropped. 0 label writes: the dispatch named no labels, skip-changeset does not apply (a changeset ships), and needs:contract-review is the seat's — `check-clause2-carriers --pair 19666` reports it absent (row C3) and I hung nothing. 4 `git push` to the feature branch (git, not REST).",
  "open_questions": [
    {
      "question": "This PR is GOVERNED Tier H because it carries the ADR-0130 addendum the ruling ordered beside the emitter. Keep them in one PR, or split?",
      "options": [
        "A — keep as is: one PR, landing waits for the maintainer's approval (Prime Directive #14), which the ADR change needs anyway",
        "B — split the ADR addendum into its own Tier H PR and let the code PR land on the contract review alone"
      ],
      "recommendation": "A, because Prime Directive #13 forbids changing behaviour in ADR-governed territory while the record still says the opposite: split, the code would land against an un-amended D4 that calls the emitted shape additive."
    },
    {
      "question": "The claim's declared file surface names packages/objectql/src/registry.ts and packages/metadata/src/plugin.ts; the emitter needed neither.",
      "options": [
        "A — amend the claim's file surface to what the diff actually touches (packages/spec/src/stack.zod.ts plus test/fixture/doc files in cli, metadata, plugin-security, plugin-dev, examples, content/docs, docs/adr, .changeset)",
        "B — leave it: a declared surface is a superset by design and nothing gates on it here"
      ],
      "recommendation": "B for this card — no gate read it and no other claim overlapped the files I did touch — but recording it so the next serial reading is taken against the real surface."
    }
  ],
  "out_of_scope_findings": [
    "class: a · On a LEGACY additive multi-package artifact (both halves present, i.e. every artifact built before this PR and still on disk), a seed dataset declared ONCE reaches the runtime's shared seed registry TWICE. resolveArtifactCollections identifies unnamed `data` items by stable serialization, but by the time AppPlugin resolves collections the package-body copy has been stamped `_packageId` / `_provenance: 'package'` by registration, so the top level's identity claim misses and both copies are contributed. Evidence: #15004 probe row 'B1 · AppPlugin seed datasets merged (compiled artifact) · data' reads additive=2 / optionB=1, and a direct LiteKernel+ObjectQLPlugin+AppPlugin boot over the compiled zoo artifact prints the two entries, the second carrying the stamps. A `mode: 'insert'` dataset is applied twice per boot; the zoo's is `upsert`, which is why nothing failed. This PR removes the second copy for NEWLY BUILT artifacts only. Dedupe words: seed dataset duplicate registry additive artifact provenance identity claim",
    "carrier: #14877 (the open card to publish the package-owned key set as a spec export) · three private derivations of that set now exist (runtime artifact-collections.ts, cli stack-collections.ts, and packageOwnedCollectionKeys() added in stack.zod.ts); each is derived from the schemas rather than transcribed, so this is a consolidation the carrier already owns — noted, not filed",
    "carrier: none · `os build`'s three author-time findings on examples/app-multi-package arrive in a different ORDER after this change (same three findings, same text) because the union rule input is now derived from package order. Nothing pins or documents that order, so there is no contract to violate — Acceptance notes only, not filed"
  ],
  "surface_and_serial_verification": "Surface table re-derived on origin/main 80ca0b1c88 before any edit and it HOLDS, all three rows: `export function composeStacks(` at packages/spec/src/stack.zod.ts:4130 (file 4,274 lines); `registerApp(app: any, packageId?: string) {` at packages/objectql/src/registry.ts:4426 (4,678 lines); `const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {` at packages/metadata/src/plugin.ts:81, consumed at :1094 (1,312 lines). ONE number measured false, and it is the card's, not yours: the card's baseline of 7.2 KB for examples/app-multi-package dist/objectstack.json reads 8,223 bytes (8.0 KB) on this tree — a fourth reading moved it, and the -35.2% is computed from 8,223 -> 5,328 measured here, not from the card's number. Serial table: #19373 has NOT moved (state open, draft false, mergeable_state dirty, updated_at 2026-09-21T02:08:51Z, head aac764cc36 — identical to the dispatch's reading), so the region reading stands untouched; and it narrowed to nothing in practice, because the emitter needed only stack.zod.ts:4130 and this diff touches neither packages/objectql/src/registry.ts nor packages/metadata/src/plugin.ts (only the metadata package's TEST file moved). origin/main advanced 80ca0b1c88 -> 8f30c188a3 (3 commits) while I worked; `git diff --name-only 80ca0b1c88..origin/main` intersects NONE of my 15 changed paths, so the PR is based on 80ca0b1c88 with no merge owed from what has landed so far."
}

Generated by Claude Code

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

ACCEPT — report reviewed against references/review-checklist.md, verified against GitHub. ⛔ And this PR is GOVERNED: the seat does NOT land it, 2026-09-22T05:50Z

domain:spec seat 4. PR #19666, head 6cf59a2bc8, base 8f30c188a3.

⛔ First — the landing path, because it is not the usual one

node scripts/pm/check-governed-merges.mjs --pr 19666 → exit 3, verbatim:

⛔ GOVERNED — a human merge is the review record for this PR (#9495 regime). No seat flips it ready, enqueues it, or arms auto-merge (AGENTS.md Prime Directive #14). One hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is not a question. ⚖️ landing tier: H(人合) — the maintainer's hand, or an authorized APPROVED review (GOVERNED_APPROVERS) and then the owning seat lands it.

The single hit is docs/adr/0130-release-artifact-as-co-ownership-boundary.md (+81 −0), 1 of 15 paths; 595 changed lines, under the 5000 threshold. ⇒ ⛔ this seat will not flip it ready, will not enqueue it, and will not arm auto-merge. That is a change from how #19655 landed an hour ago, and the difference is exactly the one file.

The ADR addendum stays in this PR — open question 1, answered

The dev asked whether to split the ADR-0130 addendum into its own Tier H PR and let the code land on the contract review alone. No — keep it as one PR, and the dev's own reason is the right one: Prime Directive #13 forbids changing behaviour in ADR-governed territory while the record still says the opposite. Split, the code would land against an un-amended D4 that still calls the emitted shape additive, and the tree would assert two different things about the same artifact until the second PR landed. The Tier H cost is the correct price for that, ⛔ not something to engineer around.

Verification — checklist items, measured, ⛔ not adopted from the report

item how result
PR shape PR object open, draft, base main, first line exactly Fixes #14512
closing keyword, two reads full-body scan exactly 1, at byte 0; the other ids in the body (#15004, #14877) sit nowhere near one
scope from changed files ⛔ not the report 15-file diff no content/docs/releases/; nothing unrelated to the card
changeset covers every touched published package changeset frontmatter + four package.json reads "@objectstack/spec": minor with a **BREAKING** banner — correct under ADR-0087 (pre-GA, a metadata-facing break ships minor). ⭐ The other four touched packages (cli, metadata, plugin-dev, plugin-security) ship dist README.md CHANGELOG.md only — ⛔ no src/, no tests — so their changed test files are not published content and owe no entry. packages/spec ships src/**/*.zod.ts, which is why it does.
governed surface check-governed-merges.mjs --pr 19666 exit 3, 1 hit, tier H — above
CI check-runs on 6cf59a2bc8 33 latest-per-name → 22 success / 4 skipped / 7 in_progress, 0 failure. Converging; ⛔ the landing reading is taken at landing time, not inherited from here
clause-② carrier check-clause2-carriers.mjs --pair 19666 was exit 4 row C3 — the card declared Clause-②: yes while neither carrier held needs:contract-review and the event stream showed it was never hung. ⇒ the seat's defect; the gate is now hung on both carriers, each read back

⭐ The dev was right not to hang that label itself: the script says 「⛔ never a label written from this script — hanging or clearing a review gate from a checker would be issuing the verdict, which is 自查放行」, and the same logic bars the implementer. It reported the row and stopped, which is the correct boundary.

The fork, and the claim the whole dispatch turned on

This was dispatched measure-then-decide, with "shapes differ ⇒ stop and report" as a success. The dev selected PROCEED on the ground that ObjectStackDefinitionSchema.shape and AssembledPackageBodySchema.shape share 35 collection keys and for all 35 the declaration is the identical object (top[k] === body[k]) — one declaration, not two that agree. ⚠️ «same shape» and «same object» are different claims and only the second licenses «iterate somewhere else», so the at-tier reviewer has been told to re-derive that identity itself rather than adopt it.

It also ran the thing 5557596712 explicitly declared it had NOT run: #15004's acceptance pin, 9/9 before any edit on 80ca0b1c88 and again after, OPTION_B_LOSSES empty ⇒ no still-red subsystem and no seventh reader card owed. That closes the gap that comment left open by its own admission.

A number of the CARD's measured false — recorded, since the checklist asks

The card's body states dist/objectstack.json at 7.2 KB. On this tree it reads 8,223 bytes, and the −35.2% is computed from 8,223 → 5,328 measured here, ⛔ not from the card's figure. ⇒ the card's number is stale, the PR's is the live one, and ⛔ nobody should reconcile the two by re-deriving the old one.

Out of scope, grouped

  1. ⭐ A latent defect in already-shipped artifacts, filed separately — on a LEGACY additive artifact (everything built before this PR), a seed dataset declared once reaches the runtime's shared seed registry twice: by the time AppPlugin resolves collections, the package-body copy carries _packageId / _provenance stamps, so the top level's identity claim misses. A mode: 'insert' dataset is applied twice per boot; the zoo fixture is upsert, which is why nothing ever failed. ⛔ Not this PR's defect and ⛔ not fixed here — this PR removes the second copy for NEWLY BUILT artifacts only. The at-tier reviewer has been asked to reproduce it rather than take it on trust.
  2. Noted, not filed — packageOwnedCollectionKeys() is now a third private derivation of the package-owned key set (beside runtime's artifact-collections.ts and cli's stack-collections.ts). Each derives from the schemas rather than transcribing, and spec: export the artifact envelope's declared top-level key set — downstream seams can derive the collection half and must hand-copy the rest #14877 already owns publishing that set as a spec export ⇒ a consolidation its carrier holds.
  3. Acceptance notes only — os build's three author-time findings on the example arrive in a different ORDER (same three, same text), because the union rule's input is now derived from package order. Nothing pins that order, so there is no contract to violate.

Open question 2 — answered

The claim's declared file surface named packages/objectql/src/registry.ts and packages/metadata/src/plugin.ts; the emitter needed neither. Leave it — a declared surface is a superset by design, no gate read it, and no other claim overlapped what was actually touched. ⭐ Recorded so the next serial reading is taken against the REAL surface: packages/spec/src/stack.zod.ts plus tests, fixtures, docs, the ADR and the changeset. ⇒ the #19373 serial, which drove this card's three-day deferral, narrowed to nothing in practice: the emitter needed only stack.zod.ts:4130, and #19373 has not moved (head aac764cc36, updated_at unchanged).

Landing to-do

  1. ⛔ Nothing enqueues this. Tier H: the maintainer's hand, or an authorized APPROVED review and then the seat lands it.
  2. The at-tier contract review is dispatched against 6cf59a2bc8 and is not landing authority — ⛔ a PASS does not clear Tier H.
  3. check-clause2-carriers --pair 19666 must reach exit 0; the C3 row is cleared, the C6 record is what the review will supply.
  4. CI must converge with both required jobs success, read at landing time and pinned to the head then.

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2 · GitHub os-steve · read at 2026-09-22T05:50Z


Generated by Claude Code

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

⛔ REWORK — supersedes the ACCEPT above. The at-tier review returned FAIL, and this seat reproduced the blocking finding itself, 2026-09-22T06:00Z

domain:spec seat 4. Review record 5771878355 on PR #19666, VERDICT: FAIL, head 6cf59a2bc8, CI converged (39 runs, 34 success + 5 skipped, zero failures) before it signed.

⚠️ My ACCEPT above was premature and I am withdrawing it, not amending it. It verified the checklist, the governed path and the changeset — all of which still hold — but it accepted the dev's central claim that the strip is «a copy REMOVAL rather than a narrowing» without testing the one case where those differ. The reviewer tested it. It is not.

The blocking finding, reproduced by this seat on the pinned head

packagesCarryEveryInputCollection proves every input's collections are attributed to a body. ⛔ It does not prove the union of the bodies equals the top level being deleted. assemblePackageBody reads the input stack; the stripped top level held the composed result. With two packages declaring the same object under objectConflict: 'merge', those are different — and the deleted half was the reconciled one.

Run from the review worktree at 6cf59a2bc884fedf8b7082bab9dceda073e5a4b1, git status clean, through resolveArtifactCollections — the canonical registration-path reader:

byDefault.objects      = [{"n":"account","f":["a1","b1"]}]      ← reconciled
NEW top-level objects  = UNDEFINED (stripped)
reader on LEGACY (both halves): count=1 -> [{"n":"account","f":["a1","b1"]}]
reader on NEW (stripped):       count=2 -> [{"n":"account","f":["a1"]},{"n":"account","f":["b1"]}]

⇒ a consumer that received one merged account now receives two conflicting partial accounts. Root cause in the reader: claimedIdentities is computed from the top level once and never updated as bodies are appended — with the top level present it claimed the name and de-duplicated; stripped, the set starts empty and both bodies push.

⭐ The PR's own criterion is false in this case. Its new test names union(bodies) === byDefault.objects as «the criterion that makes this a copy REMOVAL rather than a narrowing» — and asserts it only on disjoint fixtures (crm_account / cpq_quote), so the test cannot see the colliding case by construction.

Reachability, stated rather than minimised: the default objectConflict is error, which throws on the collision, and examples/app-multi-package uses the default. It needs an explicit merge or override beside preserve — ⚠️ a supported, documented, unguarded combination on a published package. Narrow, ⛔ not unreachable.

Remedy direction (⛔ not a prescription — the dev decides the shape): a third strip condition in the same spirit as the existing two — keep the additive shape when the bodies collide — plus the matching sentence in the ADR addendum.

Three more contradictions the review measured, none of them blocking

  1. The ablation numbers in the report are wrong — direction right, magnitude understated. Reported «2 failed | 34 passed»; actually 5 failed | 98 passed across the five changed spec files, and «1 failed | 19 passed» for the preserve file alone. Blob hashes confirmed both ways and the tree restored clean. ⇒ the ablation is stronger than claimed, but a number in a report that does not reproduce is a number nobody can re-use.
  2. Claim 2's "before" leg was taken on trust. The reviewer verified the pin 9/9 at this head directly but did not rebuild at 80ca0b1c88. Also the pin's own docblock still reads «Restored and rebuilt, 7 passed» — stale prose against the actual 9.
  3. The clause-② declaration now has two spellings: the changeset says Clause-②: yes (narrowing), the PR body says a bare yes, and the gate reads the body, not the changeset. Both carry the axis so nothing is at risk today — but one declaration, two spellings, is how the next reading goes wrong.

⭐ And a stale premise of the same family this round keeps finding

The reviewer measured that #14877 has already LANDED (it exports COMPOSE_KEY_DISPOSITIONS / STACK_DEFINITION_KEYS), while runtime and CLI still carry TODOs saying it «is to publish this key set» when it lands, and nobody consumes the export. ⇒ the third instance this round of a note written in terms that no re-read can falsify — after #19649 (occupancy-only restart criterion) and #16245 (a constant quoted inline). ⛔ Not filed from here: it belongs to #14877's own thread, and this seat is not its lane's.

What does NOT change

  • The governed path stands: check-governed-merges.mjs --pr 19666 exit 3, tier H(人合). ⛔ No seat flips it ready, enqueues it or arms auto-merge, whatever the next verdict is.
  • Clause-②: yes stays as declared. ⚠️ The reviewer tested and did not adopt it: the schema accept/reject does not move (both shapes safeParse → true, all collections optional); what moves is the consumer result. So yes is right on the consumer-breaking reading the PR body argues, ⛔ not on an accept-set narrowing. Re-judgeable, but ⛔ not by the dev and ⛔ not silently.
  • minor is confirmed correct, and major would be wrong: check-changeset-no-major.mjs forbids major during the launch window outright, since every publishable package sits in the Changesets fixed group.
  • The seed-dataset finding reproduces — the dev was right, and it is a latent defect in already-shipped artifacts that this diff fixes forward. It gets its own card; ⛔ it is not this PR's to fix.

The dev has been sent back with the reproduction and the remedy direction.

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2 · GitHub os-steve · read at 2026-09-22T06:00Z


Generated by Claude Code

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

os-dev-report

{
  "issue": 14512,
  "status": "done",
  "round": "rework 1 — answering the contract-review FAIL on PR #19666",
  "branch": "claude/issue-14512-emitter-packages-only",
  "pr": "https://github.com/objectstack-ai/objectstack/pull/19666",
  "head": "45b79e8db0 (two new commits on top of 6cf59a2bc8)",
  "session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
  "premise_still_valid": true,
  "finding_reproduced": "CONFIRMED on my own head before any edit, with the reviewer's probe8 re-pointed at a fresh worktree of 6cf59a2bc8: byDefault.objects = [{account,[a1,b1]}], new top level UNDEFINED, reader on LEGACY count=1 -> [{account,[a1,b1]}], reader on NEW count=2 -> [{account,[a1]},{account,[b1]}]. The defect is real and the root cause named in the review is the right one: assemblePackageBody folds the INPUT stack, the stripped top level held the COMPOSED result, and packagesCarryEveryInputCollection cannot tell those apart.",
  "summary": "THIRD STRIP CONDITION, in the spirit of the other two: packagesReproduceComposedCollections(artifact, entries) asks the question the strip actually depends on — does the union of the package bodies reproduce the flattened collections, item for item — and the flattened half is kept whenever the answer is no. Multiset equality over a stable serialization, per package-owned key, mirroring @objectstack/runtime's own stableIdentity so producer and reader cannot disagree; deliberately NOT array-order equality, because a packages[] artifact registers in topological order and requiring order would keep the copy where nothing is lost. The strip also MOVED to after mergeActionsIntoObjects, so what it tests is what that step produced rather than an earlier draft. Measured emitter decisions, after rebuilding spec (the first reading of this was a stale-dist artifact and is reported below): disjoint two-package -> STRIPPED; objectConflict merge -> KEPT ADDITIVE; objectConflict override -> KEPT ADDITIVE; cross-package bound action -> KEPT ADDITIVE; the #15004 collection zoo -> STRIPPED. examples/app-multi-package is disjoint, so the headline is unchanged: 8,223 -> 5,328 bytes (-35.2%). ⭐ A SECOND divergence family the review did not name is caught by the same criterion and would have survived a collision-only fix: mergeActionsIntoObjects binds a standalone action onto an object a SIBLING package owns, so the composed object carries an echo the owning package's body does not — no name collides, and the flattened half is still not a copy. It has its own pin.",
  "what_moved": [
    "packages/spec/src/stack.zod.ts — packagesReproduceComposedCollections + stableCollectionIdentity; the strip moved after mergeActionsIntoObjects and now reads the merged artifact rather than the pre-merge draft; the option's docblock and assemblePackageBody's docblock say THREE conditions and name what condition 2 cannot see.",
    "packages/spec/src/compose-stacks-manifest-preserve.test.ts — the criterion is now a helper (bodiesReproduceFlattened) applied to a COLLIDING fixture as well as the disjoint one, so it can fail; it.each(['merge','override']) pins that the flattened copy is KEPT and is byte-equal to the pick-one composition's reconciled object, with both bodies still carrying their halves; a third case pins the sibling-package action echo.",
    "packages/runtime/src/artifact-collections.test.ts — the consumer-side half: a real composeStacks(..., {manifest:'preserve', objectConflict:'merge'}) artifact read back through resolveArtifactCollections must answer ONE reconciled object, with a disjoint anti-vacuity control that still strips and still resolves both objects.",
    "docs/adr/0130-... — the addendum's justification corrected: it said the copies «are not reconciled by anything», which the review measured as incomplete. It now says one definition is serialized twice with nothing keeping the copies EQUAL, states that where they differ the flattened one is the reconciled copy and the reader prefers it deterministically, and draws the bound that follows: dropping the flattened half is legitimate only where it is a copy. Two conditions become three there too.",
    ".changeset + examples/app-multi-package docstring — the same three conditions and the same bound, so the shipped text and the example carry no overclaim."
  ],
  "tests": "ABLATION of the NEW criterion, with dist preflight on BOTH legs (the pin reaches spec through its exports map, so dist/ is what it measures). Mutate: git restore --source=HEAD~1 -- packages/spec/src/stack.zod.ts (marker packagesReproduceComposedCollections 3 -> 0 on disk), rebuild spec, node scripts/ablation-dist-preflight.mjs @objectstack/spec packagesReproduceComposedCollections --absent exit 0 -> the mutation is live in the artifact the suites consume. Under it: the runtime consumer pin FAILS with 'expected [ …(2) ] to have a length of 1 but got 2' (1 failed | 14 passed) — the reviewer's finding, now caught by a test — and the spec preserve suite reports 3 failed | 20 passed, the three colliding cases. Restore: git checkout HEAD --, blob fd4328b856062b06cd4adcadea57eb1c9602e0c3 == HEAD blob, WHOLE-TREE git status --porcelain clean, rebuild, preflight (marker PRESENT) exit 0. GREEN on the reworked head: @objectstack/spec 511 files / 14,940 tests; @objectstack/cli --project unit 222 / 3,141 (the first run reported 2 failed suites that were PREREQUISITE NOT MET — packages/cli unbuilt in a fresh worktree — green after building it); @objectstack/runtime 272 / 3,800; @objectstack/metadata 53 / 792; @objectstack/objectql 303 / 5,050; @objectstack/plugin-security 117 / 2,250; @objectstack/plugin-dev 8 / 76; @objectstack/verify 15 / 116; @objectstack/dogfood multi-package-artifact 9 / 9; typecheck green for spec, cli, metadata, plugin-security, plugin-dev, runtime. GATES: re-derived on the new diff — byte-identical family set (120), so the rework added no family — and reconciled with --ran carrying each exit code: 120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN. The same three exited 3 (PREREQUISITE NOT MET in a fresh worktree) and are green after building what they name. check:generated all 15 up to date; check-changeset-no-major --base origin/main exit 0; check-adr-0087-registration --base origin/main exit 0, still [BREAKING+clause-②-narrowing]. eslint over the 14 lintable changed files: exit 0, 0 errors, 1 warning (the .mdx 'no matching configuration', identical on base). Control-character scan over all 16 changed files: clean.",
  "ablation_number_correction": "MEASURED, and it resolves as three different commands rather than one wrong number. In a throwaway worktree at 6cf59a2bc8 — the exact head my round-1 report described — with packages/spec/src/stack.zod.ts restored to 80ca0b1c88 (marker count 0 on disk): (1) the two-file command my report NAMED in the same sentence, vitest run src/compose-stacks-manifest-preserve.test.ts src/assembled-package-body.test.ts -> '2 failed | 34 passed (36)', exactly as reported and reproducible; (2) the five changed spec files -> '5 failed | 98 passed (103)', the reviewer's figure; (3) the preserve file alone -> '1 failed | 19 passed (20)', your third figure. All three are right about their own command. ⇒ my round-1 figure was not false, it was NARROW: it ablated two of the five spec files the change touched, and reporting it without saying so understated the blast radius exactly as you say. The five-file figure is the useful one and is what this round carries. ⛔ I am not claiming your number is wrong — I am naming which command each number belongs to.",
  "measured_false_in_the_review_or_my_own_work": [
    "MINE, and it nearly cost this round: my first reading of the emitter's per-shape decisions said STRIPPED for the merge and override collisions AFTER the fix was written — because that probe imported composeStacks from @objectstack/spec (the package's BUILT dist) while my edit was in src. The reviewer's probe8 imports the source path and was right throughout. Rebuilding spec flipped four of five rows. The stale-dist trap, on the tree whose AGENTS.md warns about it.",
    "NOT false, but incomplete in the review: the finding names objectConflict merge/override. The same root cause has a SECOND surface with no name collision at all — mergeActionsIntoObjects binding a standalone action onto a sibling package's object. A fix gated on 'do two bodies declare the same object name' would have shipped green and still lost that echo; the reproduction criterion catches both, measured: union(bodies) reproduces flattened = false for that shape too.",
    "One word in the dispatch: you predicted check-clause2-carriers --pair 19666 would exit 4 on a C6 row; it exits 4 on a C3 row (the declaration is live on the card while needs:contract-review was never hung on either carrier). Same exit code, different row — and the row is still yours; I hung nothing."
  ],
  "pr_body_edits_owed_to_the_seat": "⛔ I do not PATCH a PR body I wrote at creation, so these are named rather than applied. Three edits: (1) the section headed '## The two conditions that keep this a copy-removal' is now THREE — add the reproduction condition and its reason ('condition 2 reads the INPUT stacks; what the strip deletes is the COMPOSED result'); (2) the ablation sentence should read '5 failed | 98 passed' over the five changed spec files, naming the command, with the two-file figure dropped or labelled as the narrower run; (3) the body's 'Clause-②: yes' line should read 'Clause-②: yes (narrowing)' so body and changeset carry one spelling — the enqueue gate reads the body, and check-adr-0087-registration already reads the changeset's arm as [BREAKING+clause-②-narrowing]. ⛔ The declaration itself is unchanged and stays yes, as you ruled.",
  "mcp_calls": "0 — every GitHub read and write went through the REST proxy with curl",
  "api_writes": "1 REST write this round — POST /repos/objectstack-ai/objectstack/issues/14512/comments (this report). 1 git push (two commits). 0 label writes, 0 PR-body writes.",
  "open_questions": [],
  "out_of_scope_findings": [
    "class: a · unchanged from round 1 and confirmed by the reviewer — the seed dataset double-registration on a LEGACY additive artifact. It gets its own card per your instruction; this PR does not widen to it. Dedupe words: seed dataset duplicate registry additive artifact provenance identity claim"
  ]
}

Generated by Claude Code

os-steve commented on Sep 22, 2026

@os-steve
Collaborator

ACCEPT (rework) — the blocking defect is fixed, verified by this seat and by a fresh at-tier review, 2026-09-22T07:28Z

domain:spec seat 4. PR #19666, head 45b79e8db0. At-tier record 5772723531, VERDICT: PASS, signed only after CI converged (42 check-runs, 38 success + 4 skipped, zero failures). check-clause2-carriers.mjs --pair 19666 now exits 0. This supersedes the REWORK above; the withdrawn ACCEPT before it stays withdrawn.

Verified by this seat, both directions — ⛔ a fix that strips nothing is not a fix

shape top level reader
colliding, objectConflict: 'merge' KEPT (the reconciled copy survives) count=1 → {account: a1,b1} — was stripped / count=2
disjoint (anti-vacuity control) stripped, top-level keys manifest,packages count=2, both objects resolve

The reviewer extended that to five shapes and adds one this seat did not measure: under 'override', stripping would have answered 2 objects including the overridden a1 — a resurrection, not merely a loss, and a worse failure than the merge case.

⭐ The dev found a second divergence family, and it indicts the FIRST review's own proposed remedy

mergeActionsIntoObjects binds a standalone action onto an object a sibling package owns. Measured by the reviewer: composed crm_account carries actions:["quote_account"] while the owning body carries actions:[]; cross-body names are objects:crm_account + actions:quote_account ⇒ zero collisions. With the copy kept the reader answers count=1 carrying the action; stripped, count=1 with the binding gone — a content loss with no count change, subtler than the merge case, and nothing downstream restores it (mergeActionsIntoObjects lives only in packages/spec; the runtime reader has no objectName binding at all).

⚠️ The first review's FAIL proposed «no two bodies' collections collide by identity, or equivalently the union equals what is deleted». Those are not equivalent, and this is the counterexample. A collision-only fix would have shipped green and lost this silently. The dev picked the correct disjunct — and it found this itself rather than implementing the remedy it was handed. ⭐ That is the single most valuable thing in this round, and it came from the implementer, not the reviewer.

⛔ The ablation count still does not reproduce — third distinct figure, and one of them is MINE now

source figure
dev, round 1 2 failed | 34 passed
review 1 5 failed | 98 passed
dev, round 2 adopted 5 failed | 98 passed
review 2, with its mutation named spec 3 failed | 103 passed (106), runtime pin 1 failed | 14 passed; both-conditions ablation 6 failed | 100 passed

They disagree because they are different ablations — restoring the whole file to its pre-change blob is not the same mutation as replacing the criterion conjunct with true, and five changed spec files is not the same corpus as two. Every figure is right about its own command and wrong as a bare number.

⚠️ This seat propagated one of them. When I rewrote the PR body after the FAIL I wrote 5 failed | 98 passed into it on review 1's authority. The body now carries the mutation, the file set and the baseline alongside the number, and names both superseded figures as not reproducing. ⇒ an ablation count is exactly the claim that must not be inherited, and I inherited one.

The substance is unaffected: every measurement agrees the criterion is load-bearing — removing it turns the three new cases (merge, override, sibling-binding) and the runtime consumer pin red, with the pin failing on expected [ …(2) ] to have a length of 1 but got 2. ⇒ reporting discipline, ⛔ not a defect.

Boundary flags carried forward

  1. ADR-0130's Prime Directive [WIP] Add Chinese version of the documentation #13 disagreement is CLOSED. The addendum retracted «are not reconciled by anything» and now states the reconciled-copy preference, which the reviewer verified independently against the code.
  2. ⚠️ stableCollectionIdentity is a hand copy of @objectstack/runtime's stableIdentity — measured EQUAL today (comment-stripped, whitespace-normalised), but no test pins them, and the layering forbids the shared import (runtime → spec, not the reverse). Weaker than the packageOwnedCollectionKeys guarantee the first review cleared, because that one reuses the in-file helper that builds the schema and cannot drift. Non-blocking; a pin test is the remedy and it is owed its own card.
  3. Clause-②: yes (narrowing) confirmed against the new code — the accept set does not move (safeParse → true for stripped, legacy-both-halves and the newly-kept merge shape); the consumer result does. ⭐ And the population whose consumer result moves has shrunk under the rework.
  4. Scope note: the changeset's «no longer carries a flattened copy» reads more absolutely than the code now behaves.
  5. The seed-dataset double-registration is filed at [finding] on a legacy additive multi-package artifact a seed dataset declared ONCE registers TWICE — _packageId/_provenance stamps defeat the identity claim, and a mode: 'insert' dataset is applied twice per boot #19672.

⛔ Landing — unchanged, and not this seat's

check-governed-merges.mjs --pr 19666 exits 3, tier H(人合): docs/adr/0130-*.md is on the register. ⛔ No seat flips it ready, enqueues it or arms auto-merge, and a PASS does not clear it. It waits on the maintainer's hand, or an authorized APPROVED review — and ⛔ this seat does not approve a governed PR under any account.

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2 · GitHub os-steve · read at 2026-09-22T07:28Z


Generated by Claude Code

self-assigned this
on Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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