ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

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

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder - #14240

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological
Sep 2, 2026
Merged

ADR-0130 D5+D7: register an artifact's N packages in topological order, reusing resolvePluginOrder#14240
hotlong merged 4 commits into
mainfrom
claude/issue-14162-artifact-load-topological

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

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-manifest artifact registers bit-identically through that new path.

What changed

packages/objectql/src/plugin.ts — the manifest service's register() no longer makes one ql.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.all for 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:

  • Both shapes are read (D4).packages present → iterate it; packages absent → 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.
  • Ordering reuses 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.
  • The wrapper shape is not re-derived: the landed ArtifactPackageEntrySchema from @objectstack/spec is 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:

  1. Declared dependencies enter the sort as optionalDependencies, not as hard edges.manifest.dependencies is 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 is resolvePluginOrder's optionalDependencies semantics 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.
  2. The entry schema is a gate on the WRAPPER only, and the body handed to registerApp is the caller's original entry.manifest, never a parsed clone. Measured on this tree against the landed schema: a full ArtifactPackageEntrySchema.safeParse of an assembled package payload fails with manifest.objects.0: expected string, received object, because ManifestSchema.objects is z.array(z.string()) (glob patterns) while what reaches this load path is an assembled payload whose objects are object definitions. Refusing on that would refuse exactly the artifacts this path exists to register. A parsed clone is also not the authored body — ManifestSchema applies defaults (defaultDatasource, scope) and strips undeclared keys, so registering it would put different bytes into the registry than the singular-manifest branch 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, because check:dispatcher-error-vocabulary refuses any code-stamping site it cannot classify. All three are boot-refusal / door: 'none', and each why carries the measurement: 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.

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 packages array is deliberately ordered extension-before-base and asserts the extension is present and in effect on the extended object.

⚠️Measured while writing it, and recorded because it changes how the pin must be read: on today's registry the "extension in effect" half does not by itself discriminate the two implementations. objectExtensions register 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:

  • the extension-in-effect assertion, because it is D5's literal acceptance criterion and the property that must never regress; and
  • the assertion that discriminates — the registry's own installed-package sequence, which is real post-install registry state and the first item in D7's own comparison list, not the sorter's return value.

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.js relative to its own directory inside the same package, only src/plugin.ts exists at that specifier, no vitest alias is configured in this package or at the root, and packages/objectql has no dist/ 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.

AblationMutationResult
A — the card's named oneiterate packages[] directly instead of topologically2 failed / 10 passed
B — vacuity proberegister only the first package (ordered.slice(0, 1))3 failed / 9 passed
restored12 passed / 12

Ablation A fails exactly where it should:

× installs an extension-before-base artifact, and the extension is in effect
AssertionError: expected [ 'com.acme.crm.cpq', 'com.acme.crm' ]
to deeply equal [ 'com.acme.crm', 'com.acme.crm.cpq' ]
× orders a three-package chain declared backwards in the array
AssertionError: expected [ 'com.acme.c', 'com.acme.b', …(1) ]
to deeply equal [ 'com.acme.a', 'com.acme.b', …(1) ]

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 with expected 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:

HEAD_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
post: anchor=0 inject=1 MUT_BLOB=592611904a2d227a2a413ecd312c65d6559df51b (A)
post: anchor=0 inject=1 MUT_BLOB=92953aed2bc18e6f9a395b356981fa1da21e2ad6 (B)
BACK_BLOB=37ce69c5680eefa9ed3973b11a03e3073f0c0ecd
RESTORE PROVEN BY BYTES: blob == HEAD blob; git diff HEAD empty; 0 residual markers

The restore leg names HEAD explicitly (git checkout HEAD -- path) rather than the bare form, which reads from the index and would have restored the mutation itself; both scripts carry a trap … EXIT INT TERM with 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 test253 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.ts12/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 --listFilesOnly shows artifact-packages.ts and plugin.ts in the main program and artifact-load-path.test.ts in tsconfig.test.json's.
  • pnpm --filter @objectstack/runtime typecheck → exit 0.
  • The six tests that read the vocabulary table this PR edits — 4 in runtime, 2 in rest145 tests passed, exit 0.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0.
  • The gate family node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 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-loads and check: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-vocabulary is 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.ts and one changeset. It does not touch packages/spec/src/**, adds no authorable key, and moves no accept/reject verdict on any existing input: the singular-manifest branch is byte-identical by construction and pinned by D7, and the only newly refusable shapes are packages[] 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: resolveArtifactPackageOrder and ArtifactPackageError are exported from @objectstack/objectql's index, which is new public surface on a published package, declared as minor in 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's os 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 from index.ts costs nothing and changes no behaviour.

Kept as a draft pending that review.


Generated by Claude Code

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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 9 documentable anchor(s). ⚠️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.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd5aa4db67334da2cf539dce45244a631228da35 — the merge of head 07370fdb4c2762f9400473e8e0abe05f29a042ab into base a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a39b02a6b8bde47d0b18bee8c6cb8ee22e883fd8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

文档漂移 advisory:六页逐页核过,零欠账;但它对本 PR 的新公开面看不见

六页命中,全部经由 ObjectQLPlugin 这一个锚

逐页读了它们实际怎么用这个符号:

用法本 diff 是否证伪
data-modeling/objects.mdx:134lifecycle 清扫默认开启❌ 未触及
kernel/services-checklist.mdx:329元数据协议的注册来源❌ 未触及
kernel/services.mdx:130服务表里 LifecycleService 的注册者一行❌ 未触及
permissions/authentication.mdx:140,151new 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 新增两个公开导出resolveArtifactPackageOrderArtifactPackageError)的地方。所以:

advisory 对这两个新公开面保持沉默,不代表它们没有文档影响——而是它压根没看。 把"漂移检查通过"读成"新导出也覆盖了"会是误读。

这一点对本 PR 正文交给复审者权衡的那处判断题直接相关(零消费者的公开面要不要先于首个消费者发布):文档侧没有为它提供任何佐证,任一方向都没有。


Generated by Claude Code

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿;唯一未决项是独立复审,⛔ 本会话不代劳

cfa99f3d 上 32 项检查全部完成,无一失败。六个必需上下文按 check-run 名称逐个核(§522:必需集按名匹配,改名会静默脱钩):

必需上下文结论
Lint & Repo Gates✅ success (16:06:52)
TypeScript Type Check✅ success
Test Core✅ success — 汇总 + 6 分片全过
Dogfood Regression Gate✅ success — 汇总 + 3 分片全过
Build Core✅ success
Temporal Conformance (live PG + MySQL)✅ success

Governed Surface Queue Guard 亦 success。


复审者请优先看这三处

① 新公开导出,零消费者(PR 正文自己标出的判断题)。resolveArtifactPackageOrder / ArtifactPackageError@objectstack/objectql 的 index 导出,changeset 定 minor,而仓内今天无人经 index 引用它们。⚠️ 与此直接相关:本 PR 的文档漂移 advisory 看不见这件事——它自报 packages/objectql/src/index.ts 未产出锚点、"this is not a clean bill of health for those files"。所以文档侧对这两个导出任一方向都没有佐证,⛔ 别把它的沉默读成背书(详见上一条评论)。

② D5 动机与今日实现的落差(PR 正文记录的实测)。ADR-0130 D5 以"顺序错了不报错,扩展就是不生效"自证其必要性;实测在今天这棵树上扩展仍然生效——resolveObject 在读取期调用 foldExtendersobjectExtensions 按目标 FQN 注册成 contributor、按 kind 选基层、按 priority 叠加(ADR-0029 D9.3:"It is ORDERING ONLY … selects its base layer by KIND"),折叠与到达顺序无关。两种顺序深比对整个 registry,差异只有包记录序列一处。

⚠️这句夸大的源头是 epic #14122 §七② 的原文,作者是本席,已在 epic 上立更正。要求本身不变,pin 也仍然有牙(消融 B 已证两半都不空转);要不要让 ADR 的动机段与实现自洽,是独立的受管面决定。

③ 派生出的 spec 张力已单独立卡 #14242needs-user-decision):ArtifactPackageEntrySchema 的 body 半边描述编写期形状,而 packages[] 在装载期读,那时 body 已是组装形态。本 PR 只把 schema 当包装层的闸、传原始 body,是刻意的窄口径容纳,不是加宽 spec——加宽属 spec 决定。


不 arm auto-merge、不翻 ready。 §522 前置是"已绿 且已接受 且非 draft",这里只满足第一条。
Clause-② 的裁决不由本会话作出——PR 署的是 session_01UHvF5hyiZjnCyExFnfQB8m,写 diff 的一方自审不满足独立性(#14191 的 process finding)。


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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head 07370fdb,含两处复审修正

独立性声明(先说清,不藏)

  • 复审模型档位:claude-fable-5-1(CONTRACT_REVIEW_TIER)。
  • ⚠️复审会话 = 署名会话session_01UHvF5hyiZjnCyExFnfQB8m。这不满足feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 process finding 所要求的"复审会话 ≠ 署名会话"。
  • 依据:维护者在 PM 会话中的明示授权,原文——「你现在有 fable 了,你可以自己复审,继续处理所有问题」。该规则的目的是防 agent 自我认证;维护者亲自授权不在其防范范围内。此处如实记录,供后来者判断这份 PASS 的效力基础。
  • 复审对象是全 diff(6 文件 +784/−7),不是报告。

对树核实的四处(PR 正文的断言,逐条对源码重验)

断言实测
resolvePluginOrder 接 Map、可选边缺席跳过、环仍抛、独立节点保序plugin-order.ts:66 签名 Map<string,P>:93if (plugins.has(dep)) visit(dep)visiting 集合在两个循环前加入 ⇒ 可选边同样触发 :75 的环抛;DFS 后序 + Map 插入序 ⇒ 保序
registerApp 处理 manifest.objectExtensionsengine.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 定级正确(纯加法、零既有面移动),且两行随时可删。⚠️ 文档漂移 advisory 对此任一方向都无佐证index.ts 未产出锚点),已在上一条评论说明。

两处发现,已修(07370fdb

  1. JSDoc 孤儿。bridgeArtifactObjectsToMetadataService 被插在 bridgeManifestObjectsToMetadataService 的文档块与其声明之间,原文档(_packageId 同名规则、notify: false、"never throws")成了孤儿,被描述的方法丢了全部文档。已把新方法整块搬到原文档块之前;两个签名的上一行现在都是各自文档的 */
  2. ?? 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 警。

其余

收口

07370fdb 上 CI 正在重跑(改动为文档搬移 + 一个运算符,本地已验)。非受管面(6 文件对 GOVERNED_SURFACES 零命中)。draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14162 关卡,然后派 #14163


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D5+D7:装载路径按拓扑序注册物内 N 包(复用 resolvePluginOrder)+ 存量产物逐位相同测试

2 participants

@hotlong@claude