Uh oh!
There was an error while loading. Please reload this page.
ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240
Conversation
ADR-0130 D5 + D7. The `manifest` service load path reads both artifact shapes D4 declares and registers the packages inside one artifact in dependency-topological order, resolved by `resolvePluginOrder` — ADR-0116's single sorter, reused rather than re-implemented. A package that extends another package's object now registers after the package it extends, whatever slot the artifact's array put it in. The singular-`manifest` branch returns the caller's own object by reference, unvalidated and unrewritten, so an existing artifact registers exactly as before (D7, pinned against a direct `registerApp` on an identically booted kernel). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
`slot-lookup/no-any-assignment` (#4251): a service lookup already returns the slot's contract, and erasing it to `any` switches that checking off while looking identical to code that has it. The five lookups in the new pins resolve through their contracts instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…he dispatcher vocabulary `check:dispatcher-error-vocabulary` reads every code-stamping site under `packages/**` and refuses any it cannot classify, so the three ADR-0112 envelopes the ADR-0130 load path raises owe a verdict row each. All three are `boot-refusal` / `door: 'none'`, and each `why` records the measurement behind that: the `manifest` service has three callers, and none puts these codes on a wire — boot registration aborts pre-HTTP, the rehydrate loop catches per entry and logs, and the one HTTP install route catches and answers with its own registered `PLUGIN_REGISTER_FAILED`, carrying this refusal's message but not its code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 32 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 cd5aa4db67334da2cf539dce45244a631228da35 && git checkout cd5aa4db67334da2cf539dce45244a631228da35
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 07370fdb4c2762f9400473e8e0abe05f29a042ab && git checkout -B drift-repro a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 && git merge --no-ff 07370fdb4c2762f9400473e8e0abe05f29a042ab
node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8
|
hotlong
commented
Sep 1, 2026
文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见六页命中,全部经由 |
| 页 | 用法 | 本 diff 是否证伪 |
|---|---|---|
data-modeling/objects.mdx:134 | lifecycle 清扫默认开启 | ❌ 未触及 |
kernel/services-checklist.mdx:329 | 元数据协议的注册来源 | ❌ 未触及 |
kernel/services.mdx:130 | 服务表里 LifecycleService 的注册者一行 | ❌ 未触及 |
permissions/authentication.mdx:140,151 | new ObjectQLPlugin() 装配片段 | ❌ 未触及 |
plugins/packages.mdx:472,477 | 同上 | ❌ 未触及 |
protocol/kernel/index.mdx:257,267 | 同上 | ❌ 未触及 |
没有一页描述 manifest 服务 register() 的内部——调用几次 registerApp、按什么顺序。本 diff 全部动在那个抽象层之下,六页逐字仍然成立。第七页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。
结论:无文档改动可欠。
⚠️ 但请复审者注意 advisory 自己声明的盲区
advisory 原文:
⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.
index.ts 恰恰是本 PR 新增两个公开导出(resolveArtifactPackageOrder、ArtifactPackageError)的地方。所以:
advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。
这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。
Generated by Claude Code
hotlong
commented
Sep 1, 2026
CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳
复审者请优先看这三处① 新公开导出,零消费者(PR 正文自己标出的判断题)。 ② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效—— ③ 派生出的 spec 张力已单独立卡 #14242( ⛔ 不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。 Generated by Claude Code |
…isplaced JSDoc, align the id fallback with registerApp Two findings from the independent review of #14240, both local: - `plugin.ts`: `bridgeArtifactObjectsToMetadataService` had been inserted BETWEEN `bridgeManifestObjectsToMetadataService`'s doc block and its declaration, so that block (the `_packageId` same-name rule, the `notify: false` contract, "never throws") became an orphan and the method it described lost its documentation. The new method now sits ABOVE the original block; both signatures are once again preceded by their own `*/`. - `artifact-packages.ts`: the package id fell back with `??`, while `ObjectQL.registerApp` keys the installed package on `id || name` — the very rule the refusal message quotes. An empty-string `id` therefore registered under `name` in one place and was refused in the other. Now `||`, with a comment saying why, so a lint-driven "fix" back to `??` cannot reintroduce the split. Verified in the review worktree with the objectql dependency closure built: `pnpm --filter @objectstack/objectql typecheck` exit 0; the D5/D7 pin suite 12/12; eslint on the two files 0 errors / 0 warnings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
hotlong
commented
Sep 2, 2026
复审:PASS —— head |
| 断言 | 实测 |
|---|---|
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序 | ✅ plugin-order.ts:66 签名 Map<string,P>;:93if (plugins.has(dep)) visit(dep);visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序 |
registerApp 处理 manifest.objectExtensions | ✅ engine.ts:4797 |
ArtifactPackageEntrySchema.safeParse 可直接调 | ✅ lazySchema<T>(factory): T 返回 T 本身 |
| "扩展生效"半在读取期折叠下不具区分度 | ✅ resolveObject → foldExtenders,ADR-0029 D9.3 "ORDERING ONLY … selects its base layer by KIND"——已在 epic 上更正我自己 #14122 §七② 的夸大 |
两处判断题,裁定
① dependencies 归入 optionalDependencies——正确,维持。 物不是外部依赖的解析域;ManifestSchema.dependencies 自己的示例就是外部包。读成硬边会拒掉每一个真实产物,D7 直接禁止。选择排序器的既有桶而不是先过滤再重实现同一规则,是对的形状。
② index 导出 resolveArtifactPackageOrder / ArtifactPackageError——保留。 ADR-0130 点名的 CLI(os compile/os build)与市场安装路径是记录在案的消费者,不是猜的;不导出的代价是让每个门各自重新推导一遍"两种形状怎么读、怎么排"——正是 ADR-0116 以名点出要防的漂移。minor 定级正确(纯加法、零既有面移动),且两行随时可删。index.ts 未产出锚点),已在上一条评论说明。
两处发现,已修(07370fdb)
- JSDoc 孤儿。
bridgeArtifactObjectsToMetadataService被插在bridgeManifestObjectsToMetadataService的文档块与其声明之间,原文档(_packageId同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的*/。 ??vs||。 装载器用manifest.id ?? manifest.name,而ObjectQL.registerApp键在id || name上——恰是本文件报错信息里引用的那条规则。id: ''时一边回落到name、一边拒绝。已改||并加注释说明,防 lint 驱动的"修回??"。
两处都在复审工作树中以 objectql 依赖闭包已构建的状态验证:pnpm --filter @objectstack/objectql typecheck exit 0 · D5/D7 pin 12/12 · 两文件 eslint 0 错 0 警。
其余
- 消融纪律认可:A(卡面点名)红 2、B(空转探针)红 3、还原按字节证(blob
37ce69c5往返)。B 的存在本身就是正确反应——A 红在排序断言而"扩展生效"保持绿,若不探针就分不清是折叠语义还是断言空转。 - Clause-② 自评 NO 成立:不碰
packages/spec/src/**、不加可授权键、不移动既有输入的判定;三个新错误码不上线(词表三行已按boot-refusal分类,且是重派生门族后才进族的——check:dispatcher-error-vocabulary由红转绿 55/55)。 - 交办给 ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆) #14163 的读数已按本 PR 收窄:同物内重复
manifest.id在此被DUPLICATE_ARTIFACT_PACKAGE(422)拒绝,不再落到共有权门;该卡剩余维度与一条自重装豁免的负向断言已登记在卡上。 - 派生的 spec 张力 ArtifactPackageEntrySchema's body half cannot describe the payload the load path actually registers #14242 已立卡(
needs-user-decision)。
收口
07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163。
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14162
Implements ADR-0130 D5 — the packages inside one release artifact register in dependency-topological order, resolved by the platform's single sorter — and D7 — an existing single-
manifestartifact registers bit-identically through that new path.What changed
packages/objectql/src/plugin.ts— themanifestservice'sregister()no longer makes oneql.registerApp(manifest)call. It resolves the artifact into an ordered list of manifests, registers each in that order, and then bridges each package's objects into the metadata service in the same order.Registration of all packages completes before any bridging begins. The bridge resolves objects out of the registry, and an artifact's later packages contribute extensions onto the earlier ones' objects, so a register/bridge interleave would bridge a body that the very next package is about to change. Bridging is sequential rather than
Promise.allfor the same reason: the bridge reads an object out of the metadata service and decides whether the copy sitting there is its own before overwriting it, and two packages contributing to one object must not interleave that read-then-write. For a single-package artifact this is the same one register followed by the same one bridge as before.packages/objectql/src/artifact-packages.ts(new) —resolveArtifactPackageOrder, the one place that turns either artifact shape into the ordered manifest list:packagespresent → iterate it;packagesabsent → treat the caller's own object as a single-element list. The second branch returns the caller's original object by reference, unvalidated and unrewritten — that identity is what D7's compatibility claim rests on, and it is pinned.resolvePluginOrder(@objectstack/core, ADR-0116's single topological sorter). No second ordering mechanism, in any form. The cycle throw and the missing-hard-dependency behaviour are inherited, not re-adjudicated, and both are pinned.ArtifactPackageEntrySchemafrom@objectstack/specis imported and applied, rather than duck-typing{ manifest: … }a second time.Two decisions inside that module are worth a reviewer's eye, because each is a judgement rather than a transcription:
optionalDependencies, not as hard edges.manifest.dependenciesis a map of package ids to version ranges whose own schema example is an external package (@steedos/plugin-auth), resolved by the installer. The artifact is not the resolution scope for those, so a declared id naming a sibling in this artifact is a real edge and one that does not is simply not an edge here — which isresolvePluginOrder'soptionalDependenciessemantics verbatim, so the classification is expressed by choosing the sorter's existing bucket instead of pre-filtering the list and re-implementing the same rule. Reading every declared id as a hard edge would refuse, at load time, every artifact that depends on anything outside itself, which D7 forbids outright.registerAppis the caller's originalentry.manifest, never a parsed clone. Measured on this tree against the landed schema: a fullArtifactPackageEntrySchema.safeParseof an assembled package payload fails withmanifest.objects.0: expected string, received object, becauseManifestSchema.objectsisz.array(z.string())(glob patterns) while what reaches this load path is an assembled payload whoseobjectsare object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body —ManifestSchemaapplies defaults (defaultDatasource,scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifestbranch does for the same package, and D7 pins that those two branches do not disagree. That the entry schema's body half cannot describe the payload the load path registers is a real tension in the landed D4 surface; it is recorded here and on the card rather than papered over — widening it is a spec decision, not a loader's.packages/runtime/src/dispatcher-error-vocabulary.ts— the three ADR-0112 refusals the new module raises each get a verdict row, becausecheck:dispatcher-error-vocabularyrefuses any code-stamping site it cannot classify. All three areboot-refusal/door: 'none', and eachwhycarries the measurement: themanifestservice has three callers and none puts these codes on a wire — boot registration aborts pre-HTTP, the rehydrate loop catches per entry and logs, and the one HTTP install route catches and answers with its own registeredPLUGIN_REGISTER_FAILED, carrying this refusal's message but not its code.The D5 pin is behavioural, and here is what it actually discriminates
D5 forbids the pin that only asserts the sorter returned a permutation, because that one stays green on an implementation that computes the order and then never uses it. The pin here installs an artifact whose
packagesarray is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.objectExtensionsregister as contributors keyed by the target FQN and are folded at read time in priority order, so the fold does not care which contributor arrived first. Registering both orders and deep-diffing the whole resulting registry state produced exactly one difference: the order of the package records themselves.So the pin keeps both halves, for different jobs:
This is worth flagging to reviewers as a fact about the tree rather than about this patch: ADR-0130 D5 motivates itself with "get the order wrong and nothing throws — the extension simply does not take effect", and on today's read-time-folding registry the extension does still take effect. The ordering requirement is still right and still pinned; its observable consequence today is narrower than the record's motivation implies.
Reverse verification (red → green), with byte-level restore proof
Both ablations mutate
packages/objectql/src/plugin.ts. That file is what the suite actually resolves: the test imports./plugin.jsrelative to its own directory inside the same package, onlysrc/plugin.tsexists at that specifier, no vitest alias is configured in this package or at the root, andpackages/objectqlhas nodist/at all — so no stale build can stand between the mutation and the measurement.Each leg proved the mutation reached disk before any result was read (grep count of the anchor text falling 1 → 0 and of the injected marker rising 0 → 1, plus the blob hash moving), and each restore is proved by bytes, not by exit code.
packages[]directly instead of topologicallyordered.slice(0, 1))Ablation A fails exactly where it should:
Ablation B exists because A went red on the ordering assertion while the extension-in-effect assertion stayed green — the read-time-folding fact above. That could equally have meant the assertion was vacuous, so it was probed directly: with only the first package registered, it goes red with
expected undefined to be defined, and the metadata-service pin goes red withexpected undefined to be 'number'. Neither pin is vacuously green; they measure different things, and the file's header says which is which so the next reader does not "simplify" one away.Restore proof, identical for both legs:
The restore leg names
HEADexplicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry atrap … EXIT INT TERMwith absolute paths so an interrupted run cannot leave the tree mutated.Verification
All of the following ran on the final commit of this branch,
cfa99f3d, with a clean working tree.pnpm --filter @objectstack/objectql test→ 253 files / 4363 tests passed, exit 0. That is every existing load-path test in the package plus the new suite.pnpm --filter @objectstack/objectql exec vitest run src/artifact-load-path.test.ts→ 12/12, exit 0.pnpm --filter @objectstack/objectql typecheck→ exit 0. Confirmed the new files are genuinely in a program rather than merely not-failing:tsc --listFilesOnlyshowsartifact-packages.tsandplugin.tsin the main program andartifact-load-path.test.tsintsconfig.test.json's.pnpm --filter @objectstack/runtime typecheck→ exit 0.runtime, 2 inrest→ 145 tests passed, exit 0.pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives for this diff — 36 commands, re-derived after the diff grew — 31 exit 0, 0 red. Every exit code was captured before any pipe.Five of the 36 returned a non-zero that is NOT MEASURED, not a finding; each says so in its own output:
check-engine-split-ratio(exit 2 — shallow clone, refuses to compute rather than print a wrong ratio),check-test-completeness(exit 3 — needs a saved test-run log the family passes no argument for),check-half-states(exit 3 — needs repo-scoped egress this container lacks; unrelated to this diff),check:dual-build-cjs-loadsandcheck:type-check-debt(exit 3 — both need a full-monorepo build first, which is CI's step). CI runs all five with their prerequisites met.check:dispatcher-error-vocabularyis worth calling out: it was red (3 unclassified sites) and is now green at 55/55 classified. It did not appear in the gate family derived from the first version of this diff — it only entered once the diff touched the file it reads — so it was found by re-deriving the family against the actual diff and by asking which gates a new error code implies. Re-derivation after that commit confirms it in the list.Clause-② — no contract review
The diff touches
packages/objectql/src/**,packages/runtime/src/dispatcher-error-vocabulary.tsand one changeset. It does not touchpackages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifestbranch is byte-identical by construction and pinned by D7, and the only newly refusable shapes arepackages[]entries, a key that landed hours ago and that nothing produces yet. The three new refusal codes are loader-internal and reach no wire — that is measured above, not assumed, and recorded as three classified rows.One thing a reviewer should still weigh deliberately rather than skim:
resolveArtifactPackageOrderandArtifactPackageErrorare exported from@objectstack/objectql's index, which is new public surface on a published package, declared asminorin the changeset. Nothing in-repo imports them through the index today — the load path and the tests both use the relative path. The export exists so the other doors ADR-0130 names as consumers (the CLI'sos compile/os build, the marketplace install path) can adopt the same read of both shapes and the same single sorter instead of each re-deriving one, which is the drift D5 forbids by name. If the reviewer prefers zero-consumer surface not to ship ahead of its first consumer, dropping the two lines fromindex.tscosts nothing and changes no behaviour.Kept as a draft pending that review.
Generated by Claude Code