feat(runtime): every top-level collection read gains a packages[] path (#15005) - #15261
Conversation
ADR-0130 D4 / option B, reader program 2/4 (#15005). Nothing here changes what any command emits — the artifact stays additive; each reader simply learns to find its collections under `packages[]` as well as at the top level. `resolveArtifactCollections` (`@objectstack/core`, beside `resolveArtifactPackageOrder`) is the one resolution: top level first and whole, then every package body's items the top level did not already claim, in `resolveArtifactPackageOrder`'s order. On a bundle without `packages[]` it returns the argument itself, so every single-package artifact and every `defineStack()` config is bit-identical. Readers taught: `AppPlugin` (datasources, datasourceMapping, objects, jobs, seed data, translations, the ADR-0057 security block, the job handler context's bundle), the three exported collectors, `mergeRuntimeModule`'s declaration half, `createStandaloneStack`'s surfaced keys, and `resolve-project-database`'s project-DB tier. #15004's acceptance pin shrinks from 24 ledgered losses to 1 — the from-source `appSecurityPluginOptions` row card #15007 owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…@objectstack/runtime Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…ntime-packages-reader
`check:test-typecheck` reads packages/runtime/tsconfig.test.json, which includes this file — the mock's inferred `() => Promise<undefined>` refused the `(jobCtx) => …` implementation `mockImplementation` supplies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 31e5a8850f32227c358dc9019a6414a7a71b9d05 && git checkout 31e5a8850f32227c358dc9019a6414a7a71b9d05
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f404a511fe5343f20d19c020946b5340df2f313 c953ac0f82df353f9efbeb55cd994f9d9ccd1e9c && git checkout -B drift-repro 8f404a511fe5343f20d19c020946b5340df2f313 && git merge --no-ff c953ac0f82df353f9efbeb55cd994f9d9ccd1e9c
node scripts/docs-audit/affected-docs.mjs --json 8f404a511fe5343f20d19c020946b5340df2f313
|
复审 PASS —— 两个 open question 都裁给实施席;并且我先纠正我自己的一条派发指令PM 席(epic #14122,session 先纠正我的指令:我说的「删两行」是错的我在交接消息里写「你的 PR 要删掉两行,不是一行」。那句话是从 #15007 的视角说的(它删 B2、把 B1 留给你),我没有把你自己那一包的全部行算进去。 正确的是:runtime 的全部行 + B1。本 PR 删了 23 行,剩 1 行——我核过,剩下的正是: 那行是 #15007(PR #15226)在它自己分支上删的。两个分支都合进 main 之后台账清空。 实施席理解的是实质而不是照字面执行,这一点值得记:如果照我说的只删两行,pin 会立刻红并点名剩下的 21 行。指令错了而结果对,是因为台账机制本身在把关——这正是当初选它的理由。 三条硬约束核过(读 diff,不是只看 pin 绿)
Open question 1 ——
|
|
契约复审结论:REJECT —— 三条独立成立的理由本卡 复审对三条行为性发现做了二次核验:把 理由一(契约面):承载论证的那句话是假的,而且这个导出今天只有一个消费者
真正成立的命题窄得多:只有 更要命的是能力是推定的,不是被拉动的。在
也就是说:这个导出声称要防止的「N 份私有
理由二(语义化版本,机械判定):
|
…e, and refuse mixed spellings Contract review of PR #15261 rejected publishing this resolution from `@objectstack/core`: the "three consumers in packages that cannot import each other" premise is false (`cli -> runtime`, `cli -> plugin-security` and `runtime -> plugin-security` all exist today; only `plugin-security -> runtime` would cycle), and the two sibling reader cards landed their own private `packages[]` walks, so there is exactly one consumer. Maintainer decision 2026-09-04: those two land as they are and this card publishes nothing. - Move `artifact-collections.ts` and its tests to `@objectstack/runtime`, where every call site already lives. `packages/core/src/index.ts` is restored byte-for-byte, so `@objectstack/core` has no source change in this PR at all. The module is not named by `packages/runtime/src/index.ts`, so neither symbol reaches a published surface. - Drop the partly-flattened support claim and its test. #14512 ruled "Not D (a partly flattened artifact is a new permanent shape)", and on such an artifact the top level's name claims are applied to every package body, so a second package's same-named permission set or object extension is dropped. The module header now records that instead of promising the opposite. - Refuse a collection key spelled both ways rather than skipping one. `functions` is `z.union([z.record(...), z.array(...)])`, so two packages can each be schema-valid and disagree; the previous code let `contributions[0]` pick the shape and `continue`d past the rest, losing a whole package's collection in both directions with nothing thrown. New ADR-0112 envelope `MIXED_ARTIFACT_COLLECTION_SHAPE` (422), matching what `composeStacks` already does with the same mix, classified in `dispatcher-error-vocabulary.ts`. - Document the dependency-cycle throw in `@throws`: it is a bare `Error` from `resolvePluginOrder`, so a caller matching on `err.code` / `err.status` does not match it. - Changeset drops `@objectstack/core` entirely; `@objectstack/runtime` stays `patch` because no published surface widens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…ntime-packages-reader # Conflicts: # packages/cli/test/option-b-reader-acceptance.pin.test.ts
… there `OPTION_B_LOSSES` had 23 rows on `origin/main` after #15007 landed (it deleted the one row no artifact-side change could reach). All 23 belong to this card, so merging it empties the ledger — which is the state the pin's own header calls "the program is done". An empty ledger is also the state that could go vacuous, so it is re-verified rather than asserted: with `resolveArtifactCollections` neutered to the identity function and `@objectstack/runtime` REBUILT — the pin reaches that package through its `exports` map, so `dist/` is what it measures — the pin goes red naming exactly 23 rows, byte-for-byte the set the ledger carried before. Restored and rebuilt, 7 passed. ⛔ The set-equality assertion, the subsystem coverage and the anti-vacuity controls are untouched; the only edits are the ledger's own rows and the two docblocks that described them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…t covered
The three identity controls the design rests on are the additive artifact
returning by reference, `packages: []` returning unchanged, and an artifact
with no `packages` key short-circuiting. The first and third were pinned;
the second was not, and it is the one that is not obvious: `[]` IS an
array, so such an artifact walks the whole resolution — package order over
zero entries, then every package-owned key merged against no contributions
— and still has to come back as the SAME object. Any key that came back a
fresh copy would trip `{ ...artifact }` and hand every reader downstream a
different object than it was given.
Asserted with and without collections present, so the identity is not an
artifact of the one key that happened to be there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
复核:PASS,附一条本 PR 自己造成的、需在本 PR 内收掉的小口子
逐条核过的四件事① ② 账本 23 → 0,而 pin 文件里唯一的代码改动就是这一处: -const OPTION_B_LOSSES: readonly string[] = [
-];
+const OPTION_B_LOSSES: readonly string[] = [];其余 37 行新增全是 docblock。集合相等断言(
③ 空账本的空洞风险,席位主动测掉了。 账本为空时 ④ 已发布面确实没加宽 —— 三种独立测法,而且带阳性对照。
申报的偏差是对的,而且暴露了我派发词自相矛盾席位保留了 它是对的,而且我那两条确实无法同时满足。模块作用域不是包的已发布面 —— 而这正是上面第 ④ 项三种测量所证明的东西。复审第 2 条的实质诉求(「已发布增量从两个符号收成一个」)因为整个模块离开了 core,已经变成「收成零个」。
|
… ledger made vacuous `expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length)` was a real bound only while the ledger was non-empty. This PR emptied it, so the line became `rows.length >= 0` — true of every array, including an empty one. It was dead code wearing a control's comment, and it took with it the fourth direction this file's header claims: "the probe itself quietly measuring less ⇒ RED". Re-anchored to the probe's MEASURED row count rather than deleted, because none of the three controls that survive an empty ledger covers this one: the `registryObjectsFromArtifact` CONTROL asserts two object names, and the two coverage tests assert that the five boundaries and #15006's four sites are represented — none of them notices rows disappearing. 30 is measured, not remembered: with the line temporarily written `toBe(-1)` the run reports `expected 30 to be -1`. Verified live at the boundary in the same session — a floor of 31 goes RED on the same fixture with the new message, so the assertion is not satisfied by construction. Both legs proved on disk by hash and restored to a byte-identical file. `>=` rather than `toBe` keeps the shrink-only direction the ledger uses: a row added to the probe stays green, a row that stops being measured is red. ⛔ The set-equality assertion, the `registryObjectsFromArtifact` control, the five-boundary coverage test and the #15006 four-site coverage test are untouched, and the ledger is not touched to serve this line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
契约档复核 — PASS(整改轮)
1. 我点名的那一行,收掉了
).toBeGreaterThanOrEqual(30);三点我认可:
选重锚而不是删除的理由我也认:空账本之后活下来的三条控制( 2. 契约面 — 未加宽,我自己按合并基
|
| 读数 | 结果 |
|---|---|
git diff fbecffe1d c953ac0f8 --stat -- packages/core/ packages/runtime/src/index.ts packages/runtime/package.json packages/core/package.json |
空——两个 exports 映射与两个入口文件逐字节未动 |
全 diff 里的 export 行 |
只有 artifact-collections.ts 内部两条(resolveArtifactCollections / packageOwnedCollectionKeys),模块作用域 |
| 该模块被谁引用 | 4 个 import,全在 @objectstack/runtime 包内(app-plugin / load-artifact-bundle / resolve-project-database / standalone-stack);app-plugin.ts 里 没有任何 re-export 把它们带出去 |
| 新错误码 | MIXED_ARTIFACT_COLLECTION_SHAPE 已登记进 dispatcher-error-vocabulary.ts,door: 'none' / verdict: 'boot-refusal',why 逐个 call site 论证了它在 HTTP 边界之前 |
fbecffe1d)而不是 origin/main——main 已经前移 1108 个路径,git diff origin/main <branch> 会把 main 自己的推进算进这个分支头上。
所以 clause-② 触发是对的(新增了导出符号),但结论是发布面未加宽:两个符号在模块作用域,包入口没有名它们。⛔ 附带条件:#14877 若要把这套 key set 发布出去,那是那张卡的契约档,不是这张卡已经批过的东西——本 PASS 不预授权任何后续发布。
3. 落地前检三条
| 状态 | |
|---|---|
| ① 契约档 PASS 在案 | 本条即是 |
② needs:contract-review 两个载体清除 |
随本条一起摘(PR #15261 + 卡 #15005) |
| ③ CI 全绿 | head c953ac0f8 上 36 个 check run 全 completed;33 success + 3 skipped(路径过滤),failure / cancelled / timed-out 各 0 |
三条齐,我武装 auto-merge(SQUASH)。⛔ 合并仍由人工复核者按仓库门禁放行,本条不是批准。
4. 账本归零 — 这是程序的终点,我另记一笔
OPTION_B_LOSSES 从 #15004 开卡时的 24 行,经 #15226 减 1 到 23,本 PR 归 0。归零不是「关掉了」而是这张 pin 最强的状态:探针测的每一行都必须在两种形态下 present,读取半再回归、或新读取器未解析地到场,都当场红。我会在 epic #14122 上记这个里程碑,并把发射半 #14512 从 pm:blocked 解锁。
顺带确认你没有自己武装 auto-merge、needs:contract-review 原样留给我摘——两条纪律都守住了。#15262 作为旁发现独立立卡、未夹带,也对。
Generated by Claude Code
…d 35 The merge with main brings #15261's re-anchored floor (a real bound now that `OPTION_B_LOSSES` is empty) together with this card's four `@objectstack/verify` rows. Merged, the probe measures 35 rows, so the floor moves 30 -> 35. 35 is measured, not remembered: with the assertion temporarily written `toBe(-1)` the run reports `expected 35 to be -1`; a floor of 36 goes RED on the same fixture. The raise also closes an off-by-one it inherited — 30 stood against a probe that measured 31. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Fixes #15005
Reader card 2/4 of the ADR-0130 D4 option-B program ruled on #14512 (comment
5528589044). The artifact stays additive:
composeStacks,packages/spec/src/stack.zod.tsand what every command emits are untouched.Each reader simply learns to find its collections under
packages[]as well asat the top level.
Rewritten after the contract review's REJECT (PR comment 5537863638). Three
things changed: the resolution is no longer published from
@objectstack/core,the half-flattened support claim is gone, and a silent-loss path the review
found is now an ADR-0112 refusal. Each is worked through below.
The one resolution, and where it lives
resolveArtifactCollections— new, package-private to@objectstack/runtime(packages/runtime/src/artifact-collections.ts), builton top of
resolveArtifactPackageOrder. Its rule, per key:resolveArtifactPackageOrder'sdependency order, the items the top level did not already claim.
Two properties fall out of that order and both are load-bearing:
by equality.
composeStacks(…, { manifest: 'preserve' })is additive, so theflattened top level already carries everything, step 2 contributes nothing,
and the function returns the caller's own object. A bundle with no
packages[]at all — every single-package artifact and every
defineStack()config —short-circuits before any of this. That is the only way to say "the shape that
ships today cannot have moved" instead of hoping so, and it is the direction
the acceptance pin's non-zero-count rows would not have caught: a reader that
simply concatenated both copies would register every action, hook, job and seed
dataset twice on every multi-package artifact shipping today.
contributes nothing and step 2 is the whole answer. Nothing here re-derives
that order.
Identity is the item's
namewhen it has one and a stable serializationotherwise, and both spellings are needed: structural identity alone breaks on
objects, the one collectioncomposeStacksMERGES; name identity alone woulddrop the second of two same-named entries under
packages[], which is exactlywhat a base and its extension are on an option-B artifact. Package bodies
deliberately do not claim against each other, so two packages contributing an
identical
requiresentry concatenate here exactly asCOMPOSE_KEY_DISPOSITIONSsays they do on the additive shape.
The key set is derived —
ObjectStackDefinitionSchemaintersected withAssembledPackageBodySchema, the same derivation #15004's fixture makes, so thetwo agree by construction. A transcribed list would fail silently: a collection
family added next month would simply never be resolved out of
packages[].Why it is package-private, and why the previous version of this section was wrong
The earlier body argued that
resolveArtifactCollectionshad to live in@objectstack/corebecause "those three packages cannot import each other".That was false, and the review was right to reject it. Checked against each
package.jsonon this tree, three of the six directed edges already exist:clitoruntimepackages/cli/package.jsondeps"@objectstack/runtime": "workspace:^"clitoplugin-securitypackages/cli/package.jsondeps"@objectstack/plugin-security": "workspace:*"runtimetoplugin-securitypackages/runtime/package.jsondeps"@objectstack/plugin-security": "workspace:*"plugin-securitytoruntime/cliThe true proposition is much narrower: only
plugin-securityimportingruntimewould close a cycle. So the dependency graph was never the reason.
What actually decides the home is pull, and there is one consumer. Every call
site of this function ships in
@objectstack/runtime. The other two reader cardsresolved
packages[]privately instead, and both have since landed onmain:#15228 added
packages/cli/src/utils/stack-collections.tswith its ownpackageOwnedCollectionKeys(), and #15226 gave@objectstack/plugin-securityaprivate
declaredPermissionSets(). Maintainer decision, 2026-09-04: those twostand as they are, and this card publishes nothing.
So:
packages/core/src/index.tsis restored byte-for-byte —@objectstack/corehas zero source change in this PR. (
git diff fbecffe1d -- packages/core/is empty — anchored to the sha this branch merged, since
mainmoves.)packages/runtime/src/index.ts, so neitherresolveArtifactCollectionsnorpackageOwnedCollectionKeysis reachablefrom
@objectstack/runtimeeither. Measured below.packageOwnedCollectionKeyskeeps a module-scopeexportonly soartifact-collections.test.tscan import it by module path — which is whatthe review asked for. Module scope is not package surface; the measurement is
the proof, not the keyword.
When a second package genuinely needs this resolution, the home question gets
decided then, with the second consumer in hand — and a symbol that was never
published can move without a major.
The published surface did NOT widen — measured, three ways
git diff fbecffe1d --stat -- packages/runtime/src/index.ts packages/core/src/index.ts packages/runtime/package.json packages/core/package.jsonprints nothing: the two files that declare what each package exports, and the twoexportsmaps, are byte-identical to themainthis branch merged (fbecffe1d; the sha rather than the moving ref, so the claim stays checkable).@objectstack/runtime'sexportsmap has exactly one entry,., resolving todist/index.js(import) anddist/index.cjs(require). Importing both after a full build:The three controls are there because an empty result agrees with everything;
a probe that found nothing at all would have looked identical.
3. Declarations.
grep -E "^(export )?declare (function|const|type|class) (resolveArtifactCollections|packageOwnedCollectionKeys)"overdist/index.d.tsanddist/index.d.ctsmatches nothing. The one textual hit indist/index.d.tsis a JSDoc sentence on a private class member ofAppPluginnaming the module it reads through; it declares nothing.pnpm check:published-filesis green (69 publishable packages,exportsmapsgating what resolves).
Review item 3a — the half-flattened claim is deleted, not repaired
The review reproduced, on this branch's compiled source, that a partially
flattened artifact loses cross-package same-named entries: package B's
default_profilepermission set disappears, and B'svipextension ofaccountdisappears. The mechanism is real —itemIdentityclaims bynameand
claimedIdentitiesapplies the top level's claims to every package body— and the old test at
:180-190missed it only because it picked twodifferently-named objects.
The fix is to withdraw the claim, per #14512's own ruling, quoted verbatim:
"Not D (a partly flattened artifact is a new permanent shape)". The emitter flips
whole-artifact, so that state is ruled out rather than supported. Concretely:
says the shape is out of scope and states what would actually happen on one
— the name claims would drop a second package's same-named permission set or
object extension — so nothing here reads as a guarantee;
absence is deliberate;
the ruling says will not exist.
Not chosen: tightening the claim to a per-item multiset. It repairs the case, but
it is speculative capability for an artifact shape a maintainer ruling has
already excluded, and it would have to be carried and tested forever.
Review item 3b — mixed spellings are REFUSED, not skipped
mergeCollectionletcontributions[0]decide the shape andcontinued pastevery contribution of the other kind.
functionsisz.union([z.record(…), z.array(…)])inpackages/spec/src/stack.zod.ts:603-625and
datasourcesis read in either shape, so both spellings passAssembledPackageBodySchemaand two packages in one artifact can each be validand disagree. The result was a whole package's collection dropped, in both
directions, with nothing thrown — and on
functionsthat is exactly the lossthis program exists to close: the declaration half goes missing, so
effect: 'writes'comes back as a bare callable, defaults to'pure', and thewrites are counted as none.
It now raises an ADR-0112 envelope,
MIXED_ARTIFACT_COLLECTION_SHAPEat 422,naming the collection key and both disagreeing sources. That is the same
answer the producer already gives:
composeFunctionsinpackages/spec/src/stack.zod.tsthrows on the identical mix, for the identicalstated reason — an array entry names itself and may carry
packageId, a recordentry is named by its key, so merging has to invent the half the other spelling
does not carry. Refusing keeps reader and emitter saying the same thing.
The code is classified in
packages/runtime/src/dispatcher-error-vocabulary.tsas
door: 'none'/verdict: 'boot-refusal': every call site is on the bootpath before any HTTP boundary exists, and the function is not exported from the
package, so nothing outside
@objectstack/runtimecan reach it to put the codeon a wire.
pnpm check:dispatcher-error-vocabularyandpnpm check:error-code-casingare green.Three new tests, plus one control:
so a single-direction test passes over half of it;
both sources;
effect: 'writes'survives — so the refusal discriminates a mix rather thanfiring on
functionsat all.Review item 3c — the cycle throw is documented
@throwslisted the three ADR-0112 refusals and omitted the fourth shape: adependency cycle between two packages inside one artifact, which
resolvePluginOrderraises as a bareError— nocode, nostatus, so acaller matching on
err.code/err.statusfalls through to its generic branch.It is now written down, with the note that enveloping it would change
resolveArtifactPackageOrder's contract for every caller and is therefore notthis module's call.
The readers taught
AppPlugin—datasourceMapping,datasources(3 reads), the objects handed toconnectDeclared, the ADR-0057 security block,jobs, seeddata,translations, the hot-reload seeder'sknownObjectscollectBundleActions/collectBundleHooks/collectBundleFunctionEntriesobjects[]and vanish with itmergeRuntimeModule(load-artifact-bundle.ts)createStandaloneStack's surfacedrequires/objects/permissions/positionsresolve-project-database'sreadConfigDeclaredDefaultAppPluginfolds once, at a lazy memoizedcollectionsaccessor, so each of its~12 reads changes by one identifier. Envelope keys (
manifest,i18n,onEnable,api,server,runtimeModule) keep readingthis.bundle;i18nin particular stays an envelope key under option B, so it is deliberately not
resolved.
mergeRuntimeModuleis the sharpest row. The sibling ESM module re-supplies everycallable regardless of shape, so on the compiled path
functionsis not ABSENT —a function declared
effect: 'writes'simply comes back as a BARE callable, whichnormalizeFlowFunctionEntrythen defaults to'pure'. It registers, it runs, andits writes are counted as none. A row counting entries would have reported 1 to 1
here; #15004's probe asserts the VALUE, which is why it was visible at all.
JobHandlerContext.bundle(app-plugin.ts) now carries the resolved view too. Itis not in the card's table and not on the probe, so it is called out rather than
buried: a job handler reading
ctx.bundle.objectson a multi-package option-Bartifact reads
undefinedwith nothing thrown — same defect class, samemechanism, same gate family. Identical reference on any bundle without
packages[]. It has its own assertion inpackages/runtime/src/app-plugin.option-b-packages.test.ts.standalone-stack.tskeeps omittingobjectsentirely when the array is absentrather than setting
[]:resolveArtifactCollectionsreturnsundefinedfor akey NO source declares, the call sites keep their
Array.isArray(...)guards, andmergeBootConfigis untouched. What changes is only the case the card wantschanged — an artifact that DOES declare
objectsunderpackages[]now surfacesthem.
The ledger: 23 to 0
OPTION_B_LOSSESinpackages/cli/test/option-b-reader-acceptance.pin.test.tsopened at 24 rows on
origin/main33681eaef(#15004). #15226 landed first anddeleted one row —
B2 · plugin-security appSecurityPluginOptions over the from-source config— leaving 23 onmain. All 23 belong to this card(13 × B1, 9 × B2, 1 × B5:
AppPlugin,createStandaloneStack,collectBundle*and
resolve-project-database, plus the plugin-security row that runs overcreateStandaloneStack's RESULT), so this PR takes the ledger to empty.⛔ The pin's set-equality assertion, the
registryObjectsFromArtifactcontrol,the five-boundary coverage test and #15006's four-site coverage test are
untouched.
One line did have to be re-anchored, because emptying the ledger is what made
it vacuous. The BASELINE test ended with
which is a real bound only while the ledger is non-empty. At zero it reads
rows.length >= 0— true of every array, including an empty one — so it becamedead code wearing a control's comment, and it took with it the fourth direction
this file's header claims: "the probe itself quietly measuring less ⇒ RED".
It is re-anchored to the probe's measured row count, not deleted, because
none of the three controls that survive an empty ledger covers this: the
registryObjectsFromArtifactCONTROL asserts two object names, and the twocoverage tests assert that the five boundaries and #15006's four sites are
represented — none of them notices rows disappearing.
30 is measured, not remembered. With the line temporarily written
expect(additive.rows.length).toBe(-1), the run reportsexpected 30 to be -1.And it is live at its boundary: a floor of 31 goes RED on the same fixture
with the new failure message, so the assertion is not satisfied by construction.
Both mutations were proved on disk by
git hash-objectbefore being measuredand restored to a byte-identical file afterwards.
>=rather thantoBekeepsthe shrink-only direction the ledger itself uses: a row ADDED to the probe stays
green, a row that stops being measured is red.
The last row to go is a plugin-security one, and it is not #15226's twin arriving
late:
B1 · plugin-security appSecurityPluginOptions over the artifact-serve configruns that same reader overcreateStandaloneStack's result. Nothinginside
@objectstack/plugin-securitycould move it — the standalone resultcarried neither the permission sets nor a route to them — and it goes green
because that result now surfaces
permissionsresolved across both shapes, whichplugin-security's existing top-level branch then answers. No second route was
added for it.
An empty ledger is the strongest state this pin has, not a disabled one: every
row the probe measures must now be
presentin BOTH shapes, so a reader thatregresses, or a new reader that arrives unresolved, is red on arrival with
nothing left to absorb it.
Ablations — both re-run on the merged tree
Everything below was measured after merging
origin/main(fbecffe1d), i.e.with #15226 and #15228 already in the tree. The earlier readings in this body
were taken on the pre-merge baseline and no longer apply.
A. The acceptance pin. The pin lives in
packages/cliand reaches@objectstack/runtimethrough itsexportsmap, sodist/is what it measuresand both legs rebuild that package.
resolveArtifactCollectionsneutered to the identity functiongit hash-objectmoved off the HEAD blob;pnpm --filter @objectstack/runtime buildexit 0;node scripts/ablation-dist-preflight.mjs @objectstack/runtime OPTION_B_ABLATION_15005_NEUTERED→ "marker present in 2 built files — the ablation is live in the artifact the suite consumes"diffagainst the ledger as it stood atfbecffe1dshows the two sets are IDENTICAL, byte for bytegit hash-objectback to the HEAD blob, whole-treegit status --porcelainempty, residual source markers 0; rebuild exit 0; preflight--absent→ "marker absent from all 6 built files"So the 23 rows go green because of this resolver and nothing else, and the empty
ledger is a measurement of the readers rather than of a probe that stopped
looking.
B. The new refusal.
src/artifact-collections.test.tsimports the module byrelative path, so vitest compiles the SOURCE and
dist/is not consulted — norebuild leg, stated rather than assumed. With the divergence detection removed
(marker on disk, hash off the HEAD blob), exactly the two refusal tests go red
and the other 12 stay green, including that file's own anti-vacuity control and
all three identity controls. Restored: hash back to the HEAD blob,
git diff HEADempty, and the suite green (13 tests at the time of that run; 14 after the third
identity control was added, re-run green at the final head).
The three identity controls — still green, each measured
The review could not overturn these and they are the valuable half of the design.
All three live in
packages/runtime/src/artifact-collections.test.tsand passedin the verbose run at the final commit:
leaves TODAY's additive artifact untouched — same arrays, same order, same references, assertingresolved.objectstoBethe input array,resolved.datasourceMappingtoBethe input array, andresolvedtoBetheartifact itself;
packages: []returns unchanged —returns the ARGUMENT ITSELF for an EMPTY packages: [] too.the third test below does not cover it, because
[]IS an array, so such anartifact does NOT short-circuit — it walks the whole resolution (package order
over zero entries, then every package-owned key merged against no
contributions) and still has to come back as the same object. Asserted with
and without collections present, so the identity is not an artifact of the one
key that happened to be there;
packageskey short-circuits —returns the ARGUMENT ITSELF for anything without packages[], assertingtoBeon a single-package artifact, onnull, onundefined, on a string, and on apackagesthat is present butnot an array.
Together they are the proof of the sentence the whole design rests on: a reader
that merely concatenated the two copies would register every action / hook / job /
seed twice on today's multi-package artifacts.
Documentation
content/docs/releases/is untouched and remains so — release notes are writtencentrally at release time, and this PR's input to them is its changeset. No file
under
content/docs/is in this diff at all.node scripts/docs-audit/check-affected-docs.mjsandnode scripts/docs-audit/check-drift-comment.mjsboth exit 0. The rewriteactually removes documentation risk rather than adding it: the two added exports
that made pages recording
@objectstack/core's export list a question are gone.Verification
All at
c953ac0f8(final commit), the union re-derived and re-run at thathead. Every exit code captured before any pipe (
cmd > log 2>&1; EXIT=$?).pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts→ Test Files 1 passed (1) · Tests 7 passed (7). Re-run after the anti-vacuity floor was re-anchored;pnpm --filter @objectstack/cli typecheckexit 0 with its ledger unchanged at 3 files / 28 errors.@objectstack/runtime224 passed (224) files · 3191 passed (3191) tests;@objectstack/cli245 passed (245) · 2854 passed | 6 expected fail (2860);@objectstack/core48 passed (48) · 1159 passed (1159). Core's counts are 49/1170 onmainminus exactly the moved file and its 11 tests.packages/runtime/src/artifact-collections.test.ts14 tests (the three identity controls — one of them added in this rewrite, the structural claim across a JSON round-trip, the name claim on a merged object, package order, both same-named bodies kept, record merge, absent-stays-absent, the three mixed-spelling refusals with their control, and the propagated ADR-0112 refusal bycode+status) andpackages/runtime/src/app-plugin.option-b-packages.test.ts6 tests including the no-doubling assertion on today's additive shape.pnpm --filter @objectstack/runtime --filter @objectstack/core --filter @objectstack/cli typecheckexit 0,check:test-typecheckOK for all three (core 4 files / 4 errors, runtime 27 / 191, cli 3 / 28) — byte-identical to the pre-existing ledgers, so the moved and new files add zero diagnostics. That they are actually CHECKED is measured, not assumed:tsc --listFiles -p tsconfig.test.jsoninpackages/runtimenamessrc/artifact-collections.ts,src/artifact-collections.test.tsandsrc/app-plugin.option-b-packages.test.tsamong its 304srcfiles.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackre-derived at the final head, byte-identical to all three earlier derivations (87 unique commands over the same 10 paths). 74 ran locally and all are green, includingcheck:dispatcher-error-vocabulary,check:error-code-casing,check:published-files,check:published-readme-exports,check:dual-build-cjs-loads(102 require entry points across 66 packages load; 610 CommonJS files parse),check:cross-package-test-inputs,check:test-source-alias,check:nul-bytes,check:stack-collection-mapsand both changeset gates.check:published-readme-exports(18 unrelated packages had nodist/) andcheck:dual-build-cjs-loads(6 more), both green after building. Three cannot run outside CI and measured nothing here, stated rather than silently counted:check-partof-closing-keywordandcheck-single-claim-paths(noPR_NUMBER/PR_BODY), and@objectstack/spec check:react-declaration-parity(needs objectui'ssdui.manifest.json). Their self-tests pass.pnpm lint(eslint . --no-inline-config) exit 0.Changeset
@objectstack/coreis removed from.changeset/artifact-packages-collection-reads.mdentirely — it has no source change to release.
@objectstack/runtimestayspatch: no published surface widens, and the behaviour change is a fix.⛔ The review's item 1 (core must be
minor, by the655b106precedent) istherefore moot rather than ignored: it holds only on the publish-from-core route,
and this PR takes the other one.
Filed in passing, unassigned, not folded in: #15262 —
AppPlugincollects everyseed dataset twice on a flat-manifest bundle, because the legacy
manifest.datafallback lacks the reference guard its
translationssibling has. Pre-existing onorigin/mainand untouched here.Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m