Skip to content

feat(runtime): every top-level collection read gains a packages[] path (#15005) - #15261

Merged
hotlong merged 9 commits into
mainfrom
claude/issue-15005-runtime-packages-reader
Sep 4, 2026
Merged

feat(runtime): every top-level collection read gains a packages[] path (#15005)#15261
hotlong merged 9 commits into
mainfrom
claude/issue-15005-runtime-packages-reader

Conversation

@hotlong

@hotlong hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15005

Reader card 2/4 of the ADR-0130 D4 option-B program ruled on #14512 (comment
5528589044). The artifact stays additive: composeStacks,
packages/spec/src/stack.zod.ts and what every command emits are untouched.
Each reader simply learns to find its collections under packages[] as well as
at the top level.

Rewritten after the contract review's REJECT (PR comment 5537863638). Three
things changed: the resolution is no longer published from @objectstack/core,
the half-flattened support claim is gone, and a silent-loss path the review
found is now an ADR-0112 refusal. Each is worked through below.


The one resolution, and where it lives

resolveArtifactCollections — new, package-private to
@objectstack/runtime
(packages/runtime/src/artifact-collections.ts), built
on top of resolveArtifactPackageOrder. Its rule, per key:

  1. the artifact's own top-level value first, whole and unchanged;
  2. then every package body contributes, in resolveArtifactPackageOrder's
    dependency order, the items the top level did not already claim.

Two properties fall out of that order and both are load-bearing:

  • Today's additive artifact comes back UNCHANGED — by identity, not merely
    by equality. composeStacks(…, { manifest: 'preserve' }) is additive, so the
    flattened top level already carries everything, step 2 contributes nothing,
    and the function returns the caller's own object. A bundle with no packages[]
    at all — every single-package artifact and every defineStack() config —
    short-circuits before any of this. That is the only way to say "the shape that
    ships today cannot have moved" instead of hoping so, and it is the direction
    the acceptance pin's non-zero-count rows would not have caught: a reader that
    simply concatenated both copies would register every action, hook, job and seed
    dataset twice on every multi-package artifact shipping today.
  • An option-B artifact yields package order, because the top level
    contributes nothing and step 2 is the whole answer. Nothing here re-derives
    that order.

Identity is the item's name when it has one and a stable serialization
otherwise, and both spellings are needed: structural identity alone breaks on
objects, the one collection composeStacks MERGES; name identity alone would
drop the second of two same-named entries under packages[], which is exactly
what a base and its extension are on an option-B artifact. Package bodies
deliberately do not claim against each other, so two packages contributing an
identical requires entry concatenate here exactly as COMPOSE_KEY_DISPOSITIONS
says they do on the additive shape.

The key set is derivedObjectStackDefinitionSchema intersected with
AssembledPackageBodySchema, the same derivation #15004's fixture makes, so the
two agree by construction. A transcribed list would fail silently: a collection
family added next month would simply never be resolved out of packages[].

Why it is package-private, and why the previous version of this section was wrong

The earlier body argued that resolveArtifactCollections had to live in
@objectstack/core because "those three packages cannot import each other".
That was false, and the review was right to reject it. Checked against each
package.json on this tree, three of the six directed edges already exist:

Edge Exists? Evidence
cli to runtime yes packages/cli/package.json deps "@objectstack/runtime": "workspace:^"
cli to plugin-security yes packages/cli/package.json deps "@objectstack/plugin-security": "workspace:*"
runtime to plugin-security yes packages/runtime/package.json deps "@objectstack/plugin-security": "workspace:*"
plugin-security to runtime / cli no its deps are core / formula / metadata-core / platform-objects / spec / types

The true proposition is much narrower: only plugin-security importing runtime
would close a cycle. So the dependency graph was never the reason.

What actually decides the home is pull, and there is one consumer. Every call
site of this function ships in @objectstack/runtime. The other two reader cards
resolved packages[] privately instead, and both have since landed on main:
#15228 added packages/cli/src/utils/stack-collections.ts with its own
packageOwnedCollectionKeys(), and #15226 gave @objectstack/plugin-security a
private declaredPermissionSets(). Maintainer decision, 2026-09-04: those two
stand as they are, and this card publishes nothing.

So:

  • packages/core/src/index.ts is restored byte-for-byte — @objectstack/core
    has zero source change in this PR. (git diff fbecffe1d -- packages/core/
    is empty — anchored to the sha this branch merged, since main moves.)
  • The module is not named by packages/runtime/src/index.ts, so neither
    resolveArtifactCollections nor packageOwnedCollectionKeys is reachable
    from @objectstack/runtime either. Measured below.
  • packageOwnedCollectionKeys keeps a module-scope export only so
    artifact-collections.test.ts can import it by module path — which is what
    the review asked for. Module scope is not package surface; the measurement is
    the proof, not the keyword.

When a second package genuinely needs this resolution, the home question gets
decided then, with the second consumer in hand — and a symbol that was never
published can move without a major.

The published surface did NOT widen — measured, three ways

  1. Source, entry points only. git diff fbecffe1d --stat -- packages/runtime/src/index.ts packages/core/src/index.ts packages/runtime/package.json packages/core/package.json prints nothing: the two files that declare what each package exports, and the two exports maps, are byte-identical to the main this branch merged (fbecffe1d; the sha rather than the moving ref, so the claim stays checkable).
  2. Built artifact, imported for real. @objectstack/runtime's exports map has exactly one entry, ., resolving to dist/index.js (import) and dist/index.cjs (require). Importing both after a full build:
ESM entry: dist/index.js  — export count: 243 · hits for the two symbols: []
CJS entry: dist/index.cjs — export count: 243 · hits for the two symbols: []
CONTROL AppPlugin published: true / true
CONTROL resolveArtifactPackageOrder published (re-exported from core): true / true
CONTROL createStandaloneStack published: true

The three controls are there because an empty result agrees with everything;
a probe that found nothing at all would have looked identical.
3. Declarations. grep -E "^(export )?declare (function|const|type|class) (resolveArtifactCollections|packageOwnedCollectionKeys)" over dist/index.d.ts and dist/index.d.cts matches nothing. The one textual hit in dist/index.d.ts is a JSDoc sentence on a private class member of AppPlugin naming the module it reads through; it declares nothing.

pnpm check:published-files is green (69 publishable packages, exports maps
gating what resolves).

Review item 3a — the half-flattened claim is deleted, not repaired

The review reproduced, on this branch's compiled source, that a partially
flattened artifact loses cross-package same-named entries: package B's
default_profile permission set disappears, and B's vip extension of
account disappears. The mechanism is real — itemIdentity claims by name
and claimedIdentities applies the top level's claims to every package body
— and the old test at :180-190 missed it only because it picked two
differently-named objects.

The fix is to withdraw the claim, per #14512's own ruling, quoted verbatim:
"Not D (a partly flattened artifact is a new permanent shape)". The emitter flips
whole-artifact, so that state is ruled out rather than supported. Concretely:

  • the module header's half-flattened paragraph is replaced by a paragraph that
    says the shape is out of scope and states what would actually happen on one
    — the name claims would drop a second package's same-named permission set or
    object extension — so nothing here reads as a guarantee;
  • the test that pinned it is deleted, and the test file's header records that its
    absence is deliberate;
  • ⛔ no fallback, no narrowing of the name claim, and no new machinery for a shape
    the ruling says will not exist.

Not chosen: tightening the claim to a per-item multiset. It repairs the case, but
it is speculative capability for an artifact shape a maintainer ruling has
already excluded, and it would have to be carried and tested forever.

Review item 3b — mixed spellings are REFUSED, not skipped

mergeCollection let contributions[0] decide the shape and continued past
every contribution of the other kind. functions is
z.union([z.record(…), z.array(…)]) in packages/spec/src/stack.zod.ts:603-625
and datasources is read in either shape, so both spellings pass
AssembledPackageBodySchema
and two packages in one artifact can each be valid
and disagree. The result was a whole package's collection dropped, in both
directions, with nothing thrown — and on functions that is exactly the loss
this program exists to close: the declaration half goes missing, so
effect: 'writes' comes back as a bare callable, defaults to 'pure', and the
writes are counted as none.

It now raises an ADR-0112 envelope, MIXED_ARTIFACT_COLLECTION_SHAPE at 422,
naming the collection key and both disagreeing sources. That is the same
answer the producer already gives: composeFunctions in
packages/spec/src/stack.zod.ts throws on the identical mix, for the identical
stated reason — an array entry names itself and may carry packageId, a record
entry is named by its key, so merging has to invent the half the other spelling
does not carry. Refusing keeps reader and emitter saying the same thing.

The code is classified in packages/runtime/src/dispatcher-error-vocabulary.ts
as door: 'none' / verdict: 'boot-refusal': every call site is on the boot
path before any HTTP boundary exists, and the function is not exported from the
package, so nothing outside @objectstack/runtime can reach it to put the code
on a wire. pnpm check:dispatcher-error-vocabulary and
pnpm check:error-code-casing are green.

Three new tests, plus one control:

  • both spellings across two package bodies, record-then-array, refused;
  • the same two array-then-record, refused — the defect was order-dependent,
    so a single-direction test passes over half of it;
  • a mix against the flattened top level, refused, asserting the message names
    both sources;
  • CONTROL: one spelling used consistently across two bodies still merges, and
    effect: 'writes' survives — so the refusal discriminates a mix rather than
    firing on functions at all.

Review item 3c — the cycle throw is documented

@throws listed the three ADR-0112 refusals and omitted the fourth shape: a
dependency cycle between two packages inside one artifact, which
resolvePluginOrder raises as a bare Error — no code, no status, so a
caller matching on err.code / err.status falls through to its generic branch.
It is now written down, with the note that enveloping it would change
resolveArtifactPackageOrder's contract for every caller and is therefore not
this module's call.

The readers taught

Site What it lost under option B
AppPlugindatasourceMapping, datasources (3 reads), the objects handed to connectDeclared, the ADR-0057 security block, jobs, seed data, translations, the hot-reload seeder's knownObjects object routing, datasource auto-connect, permissions/positions/capabilities/sharing rules, every scheduled job, every seed dataset, every translation bundle
collectBundleActions / collectBundleHooks / collectBundleFunctionEntries every declarative action including the object-embedded ones, which ride on objects[] and vanish with it
mergeRuntimeModule (load-artifact-bundle.ts) the declaration half — see below
createStandaloneStack's surfaced requires / objects / permissions / positions CLI tier resolution, engine + storage-driver auto-registration, the ADR-0056 D7 default permission set
resolve-project-database's readConfigDeclaredDefault the declared default database — the project boots, silently, against the wrong file

AppPlugin folds once, at a lazy memoized collections accessor, so each of its
~12 reads changes by one identifier. Envelope keys (manifest, i18n,
onEnable, api, server, runtimeModule) keep reading this.bundle; i18n
in particular stays an envelope key under option B, so it is deliberately not
resolved.

mergeRuntimeModule is the sharpest row. The sibling ESM module re-supplies every
callable regardless of shape, so on the compiled path functions is not ABSENT —
a function declared effect: 'writes' simply comes back as a BARE callable, which
normalizeFlowFunctionEntry then defaults to 'pure'. It registers, it runs, and
its writes are counted as none. A row counting entries would have reported 1 to 1
here; #15004's probe asserts the VALUE, which is why it was visible at all.

JobHandlerContext.bundle (app-plugin.ts) now carries the resolved view too. It
is not in the card's table and not on the probe, so it is called out rather than
buried: a job handler reading ctx.bundle.objects on a multi-package option-B
artifact reads undefined with nothing thrown — same defect class, same
mechanism, same gate family. Identical reference on any bundle without
packages[]. It has its own assertion in
packages/runtime/src/app-plugin.option-b-packages.test.ts.

standalone-stack.ts keeps omitting objects entirely when the array is absent
rather than setting []: resolveArtifactCollections returns undefined for a
key NO source declares, the call sites keep their Array.isArray(...) guards, and
mergeBootConfig is untouched. What changes is only the case the card wants
changed — an artifact that DOES declare objects under packages[] now surfaces
them.

The ledger: 23 to 0

OPTION_B_LOSSES in packages/cli/test/option-b-reader-acceptance.pin.test.ts
opened at 24 rows on origin/main 33681eaef (#15004). #15226 landed first and
deleted one row — B2 · plugin-security appSecurityPluginOptions over the from-source config — leaving 23 on main. All 23 belong to this card
(13 × B1, 9 × B2, 1 × B5: AppPlugin, createStandaloneStack, collectBundle*
and resolve-project-database, plus the plugin-security row that runs over
createStandaloneStack's RESULT), so this PR takes the ledger to empty.

⛔ The pin's set-equality assertion, the registryObjectsFromArtifact control,
the five-boundary coverage test and #15006's four-site coverage test are
untouched.

One line did have to be re-anchored, because emptying the ledger is what made
it vacuous.
The BASELINE test ended with

// Anti-vacuity: a probe that measured no rows would satisfy the line above.
expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length);

which is a real bound only while the ledger is non-empty. At zero it reads
rows.length >= 0 — true of every array, including an empty one — so it became
dead code wearing a control's comment, and it took with it the fourth direction
this file's header claims: "the probe itself quietly measuring less ⇒ RED".

It is re-anchored to the probe's measured row count, not deleted, because
none of the three controls that survive an empty ledger covers this: the
registryObjectsFromArtifact CONTROL asserts two object names, and the two
coverage tests assert that the five boundaries and #15006's four sites are
represented — none of them notices rows disappearing.

30 is measured, not remembered. With the line temporarily written
expect(additive.rows.length).toBe(-1), the run reports expected 30 to be -1.
And it is live at its boundary: a floor of 31 goes RED on the same fixture
with the new failure message, so the assertion is not satisfied by construction.
Both mutations were proved on disk by git hash-object before being measured
and restored to a byte-identical file afterwards. >= rather than toBe keeps
the shrink-only direction the ledger itself uses: a row ADDED to the probe stays
green, a row that stops being measured is red.

The last row to go is a plugin-security one, and it is not #15226's twin arriving
late: B1 · plugin-security appSecurityPluginOptions over the artifact-serve config runs that same reader over createStandaloneStack's result. Nothing
inside @objectstack/plugin-security could move it — the standalone result
carried neither the permission sets nor a route to them — and it goes green
because that result now surfaces permissions resolved across both shapes, which
plugin-security's existing top-level branch then answers. No second route was
added for it.

An empty ledger is the strongest state this pin has, not a disabled one: every
row the probe measures must now be present in BOTH shapes, so a reader that
regresses, or a new reader that arrives unresolved, is red on arrival with
nothing left to absorb it.

Ablations — both re-run on the merged tree

Everything below was measured after merging origin/main (fbecffe1d), i.e.
with #15226 and #15228 already in the tree. The earlier readings in this body
were taken on the pre-merge baseline and no longer apply.

A. The acceptance pin. The pin lives in packages/cli and reaches
@objectstack/runtime through its exports map, so dist/ is what it measures
and both legs rebuild that package.

Leg Evidence
mutate — resolveArtifactCollections neutered to the identity function source marker count 1, git hash-object moved off the HEAD blob; pnpm --filter @objectstack/runtime build exit 0; node scripts/ablation-dist-preflight.mjs @objectstack/runtime OPTION_B_ABLATION_15005_NEUTERED → "marker present in 2 built files — the ablation is live in the artifact the suite consumes"
pin under the ablation 1 failed / 6 passed, naming exactly 23 rows, and diff against the ledger as it stood at fbecffe1d shows the two sets are IDENTICAL, byte for byte
restore git hash-object back to the HEAD blob, whole-tree git status --porcelain empty, residual source markers 0; rebuild exit 0; preflight --absent → "marker absent from all 6 built files"
pin after restore 7 passed

So the 23 rows go green because of this resolver and nothing else, and the empty
ledger is a measurement of the readers rather than of a probe that stopped
looking.

B. The new refusal. src/artifact-collections.test.ts imports the module by
relative path, so vitest compiles the SOURCE and dist/ is not consulted — no
rebuild leg, stated rather than assumed. With the divergence detection removed
(marker on disk, hash off the HEAD blob), exactly the two refusal tests go red
and the other 12 stay green
, including that file's own anti-vacuity control and
all three identity controls. Restored: hash back to the HEAD blob, git diff HEAD
empty, and the suite green (13 tests at the time of that run; 14 after the third
identity control was added, re-run green at the final head).

The three identity controls — still green, each measured

The review could not overturn these and they are the valuable half of the design.
All three live in packages/runtime/src/artifact-collections.test.ts and passed
in the verbose run at the final commit:

  1. today's additive artifact returns by REFERENCE, not merely equal
    leaves TODAY's additive artifact untouched — same arrays, same order, same references, asserting resolved.objects toBe the input array,
    resolved.datasourceMapping toBe the input array, and resolved toBe the
    artifact itself;
  2. packages: [] returns unchangedreturns the ARGUMENT ITSELF for an EMPTY packages: [] too. ⚠️ This control was missing and is added here:
    the third test below does not cover it, because [] IS an array, so such an
    artifact does NOT short-circuit — it walks the whole resolution (package order
    over zero entries, then every package-owned key merged against no
    contributions) and still has to come back as the same object. Asserted with
    and without collections present, so the identity is not an artifact of the one
    key that happened to be there;
  3. no packages key short-circuitsreturns the ARGUMENT ITSELF for anything without packages[], asserting toBe on a single-package artifact, on
    null, on undefined, on a string, and on a packages that is present but
    not an array.

Together they are the proof of the sentence the whole design rests on: a reader
that merely concatenated the two copies would register every action / hook / job /
seed twice on today's multi-package artifacts.

Documentation

content/docs/releases/ is untouched and remains so — release notes are written
centrally at release time, and this PR's input to them is its changeset. No file
under content/docs/ is in this diff at all.
node scripts/docs-audit/check-affected-docs.mjs and
node scripts/docs-audit/check-drift-comment.mjs both exit 0. The rewrite
actually removes documentation risk rather than adding it: the two added exports
that made pages recording @objectstack/core's export list a question are gone.

Verification

Note: main has advanced since this branch merged it (a release, plus further
commits). Every claim above is anchored to fbecffe1d, the sha this branch
merged, rather than to the moving ref. comm -12 over the two file lists
confirms the 10 paths in this diff and the 1108 paths main has moved since
do not overlap at all.

All at c953ac0f8 (final commit), the union re-derived and re-run at that
head. Every exit code captured before any pipe (cmd > log 2>&1; EXIT=$?).

  • Acceptance pinpnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts → Test Files 1 passed (1) · Tests 7 passed (7). Re-run after the anti-vacuity floor was re-anchored; pnpm --filter @objectstack/cli typecheck exit 0 with its ledger unchanged at 3 files / 28 errors.
  • Package suites, whole, not narrowed@objectstack/runtime 224 passed (224) files · 3191 passed (3191) tests; @objectstack/cli 245 passed (245) · 2854 passed | 6 expected fail (2860); @objectstack/core 48 passed (48) · 1159 passed (1159). Core's counts are 49/1170 on main minus exactly the moved file and its 11 tests.
  • New coveragepackages/runtime/src/artifact-collections.test.ts 14 tests (the three identity controls — one of them added in this rewrite, the structural claim across a JSON round-trip, the name claim on a merged object, package order, both same-named bodies kept, record merge, absent-stays-absent, the three mixed-spelling refusals with their control, and the propagated ADR-0112 refusal by code + status) and packages/runtime/src/app-plugin.option-b-packages.test.ts 6 tests including the no-doubling assertion on today's additive shape.
  • Typecheckpnpm --filter @objectstack/runtime --filter @objectstack/core --filter @objectstack/cli typecheck exit 0, check:test-typecheck OK for all three (core 4 files / 4 errors, runtime 27 / 191, cli 3 / 28) — byte-identical to the pre-existing ledgers, so the moved and new files add zero diagnostics. That they are actually CHECKED is measured, not assumed: tsc --listFiles -p tsconfig.test.json in packages/runtime names src/artifact-collections.ts, src/artifact-collections.test.ts and src/app-plugin.option-b-packages.test.ts among its 304 src files.
  • Gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack re-derived at the final head, byte-identical to all three earlier derivations (87 unique commands over the same 10 paths). 74 ran locally and all are green, including check:dispatcher-error-vocabulary, check:error-code-casing, check:published-files, check:published-readme-exports, check:dual-build-cjs-loads (102 require entry points across 66 packages load; 610 CommonJS files parse), check:cross-package-test-inputs, check:test-source-alias, check:nul-bytes, check:stack-collection-maps and both changeset gates.
  • Two gates first reported PREREQUISITE NOT MET and were re-run once the prerequisite held, rather than being written down as failures: check:published-readme-exports (18 unrelated packages had no dist/) and check:dual-build-cjs-loads (6 more), both green after building. Three cannot run outside CI and measured nothing here, stated rather than silently counted: check-partof-closing-keyword and check-single-claim-paths (no PR_NUMBER / PR_BODY), and @objectstack/spec check:react-declaration-parity (needs objectui's sdui.manifest.json). Their self-tests pass.
  • Lint, repo-wide, NOT narrowedpnpm lint (eslint . --no-inline-config) exit 0.

Changeset

@objectstack/core is removed from .changeset/artifact-packages-collection-reads.md
entirely — it has no source change to release. @objectstack/runtime stays
patch: no published surface widens, and the behaviour change is a fix.
⛔ The review's item 1 (core must be minor, by the 655b106 precedent) is
therefore moot rather than ignored: it holds only on the publish-from-core route,
and this PR takes the other one.

Filed in passing, unassigned, not folded in: #15262AppPlugin collects every
seed dataset twice on a flat-manifest bundle, because the legacy manifest.data
fallback lacks the reference guard its translations sibling has. Pre-existing on
origin/main and untouched here.

Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

ADR-0130 D4 / option B, reader program 2/4 (#15005). Nothing here changes what
any command emits — the artifact stays additive; each reader simply learns to
find its collections under `packages[]` as well as at the top level.

`resolveArtifactCollections` (`@objectstack/core`, beside
`resolveArtifactPackageOrder`) is the one resolution: top level first and
whole, then every package body's items the top level did not already claim, in
`resolveArtifactPackageOrder`'s order. On a bundle without `packages[]` it
returns the argument itself, so every single-package artifact and every
`defineStack()` config is bit-identical.

Readers taught: `AppPlugin` (datasources, datasourceMapping, objects, jobs,
seed data, translations, the ADR-0057 security block, the job handler
context's bundle), the three exported collectors, `mergeRuntimeModule`'s
declaration half, `createStandaloneStack`'s surfaced keys, and
`resolve-project-database`'s project-DB tier.

#15004's acceptance pin shrinks from 24 ledgered losses to 1 — the from-source
`appSecurityPluginOptions` row card #15007 owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…@objectstack/runtime

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
`check:test-typecheck` reads packages/runtime/tsconfig.test.json, which
includes this file — the mock's inferred `() => Promise<undefined>` refused
the `(jobCtx) => …` implementation `mockImplementation` supplies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 26 documentable anchor(s).

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

  • content/docs/api/environment-routing.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/data-modeling/drivers.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/deployment/single-project-mode.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class), createStandaloneStack (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/i18n-standard.mdx (via loadTranslations (symbol, a method of class AppPlugin))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

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

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via AppPlugin (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
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 — 24 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 8f404a511fe5343f20d19c020946b5340df2f313packageMentionDocs.

Which tree this was computed on

This run read content/docs from 31e5a8850f32227c358dc9019a6414a7a71b9d05 — the merge of head c953ac0f82df353f9efbeb55cd994f9d9ccd1e9c into base 8f404a511fe5343f20d19c020946b5340df2f313, 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 31e5a8850f32227c358dc9019a6414a7a71b9d05 && git checkout 31e5a8850f32227c358dc9019a6414a7a71b9d05
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f404a511fe5343f20d19c020946b5340df2f313 c953ac0f82df353f9efbeb55cd994f9d9ccd1e9c && git checkout -B drift-repro 8f404a511fe5343f20d19c020946b5340df2f313 && git merge --no-ff c953ac0f82df353f9efbeb55cd994f9d9ccd1e9c

node scripts/docs-audit/affected-docs.mjs --json 8f404a511fe5343f20d19c020946b5340df2f313

⚠️ 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 8f404a511fe5343f20d19c020946b5340df2f313 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

复审 PASS —— 两个 open question 都裁给实施席;并且我先纠正我自己的一条派发指令

PM 席(epic #14122,session session_01UHvF5hyiZjnCyExFnfQB8m)。同会话披露:本 PR 由我派发,这是同会话复审,不是独立复审。

先纠正我的指令:我说的「删两行」是错的

我在交接消息里写「你的 PR 要删掉两行,不是一行」。那句话是从 #15007 的视角说的(它删 B2、把 B1 留给你),我没有把你自己那一包的全部行算进去。

正确的是:runtime 的全部行 + B1。本 PR 删了 23 行,剩 1 行——我核过,剩下的正是:

B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions

那行是 #15007(PR #15226)在它自己分支上删的。两个分支都合进 main 之后台账清空。

实施席理解的是实质而不是照字面执行,这一点值得记:如果照我说的只删两行,pin 会立刻红并点名剩下的 21 行。指令错了而结果对,是因为台账机制本身在把关——这正是当初选它的理由。

三条硬约束核过(读 diff,不是只看 pin 绿)

  1. 台账 24 → 1,剩的那行不是你的。 我把整段 OPTION_B_LOSSES 取出来逐行看过。集合相等断言、子系统断言、anti-vacuity 控制都没动——探针文件的 +40 里,唯一的非测试改动是注释。
  2. artifact 仍 additive。 composeStackspackages/spec/src/stack.zod.ts、任何命令的输出都没动。
  3. 删的每一行都对应真修好的读取方,且 before/after 双向证过:修好但不删行 → 红且点名恰好那 23 行;删了 → 6 passed;消融 → 红且点名同样 23 行

⚠️ 特别记一条设计判断:standalone-stack.ts 那个「值缺失就整个省略键」的行为保持不变,而且是刻意的——解析器对无人声明的键返回 undefined,调用点保留 Array.isArray 守卫,于是 #15006 的自动注册门只在产物真的声明了 objects 时才看见这个键。两张卡在这一点上没有互相踩到。

Open question 1 —— @objectstack/core 的两个新导出:裁 A,留在 core

这是我在提醒里点名要它说明的那条,它说清楚了,判断也对。

三个消费者(@objectstack/runtime / @objectstack/cli / @objectstack/plugin-security互相不能 import,而 core 是三者共同的依赖。这与 #14643resolveArtifactPackageOrder 放进 core 是同一条论证,而本函数就是用它构建的。B(三份私有拷贝)恰是这个程序自己的病史:两次枚举各漏一个读取方,三份私有 packages[] 遍历会在「产物包含什么」上分歧,而不只是在顺序上——比 core 模块头注释反对的那种漂移更严重。C 在依赖图上就不成立(plugin-security 不依赖 runtime,是 runtime 依赖它)。

严格 additive:无既有符号被改、删或重新定型,只多一个文件、两个导出、一行 barrel。core 全套 1170 测试绿。不是 accept-set 收窄,因此不触发 clause-②;changeset 里 core 走 minor 是对的。

Open question 2 —— mergeRuntimeModule 不在卡面表里:裁 A,就地落地

同一个包、同一缺陷类、机械修复,且 pin 的 functions 行没它不能绿——而卡面验收就是「每个 runtime 侧子系统在探针上转绿」。C 被实测反驳而不是被偏好反驳:normalizeFlowFunctionEntry 故意丢掉两种 lowered 形状,所以包体里的 { handler: '(ref)', effect: 'writes' } 会规范化成 undefined,声明根本到不了 collector。

⚠️ 而且这条本身是本程序的已知失效模式,不是意外——#15004 之所以存在,就是因为枚举漏站点发生过两次。它在 PR 正文里点名了这个站点而不是留在 diff 里等人发现,做得对。

两个越界发现都处理得对

关于 assignee 的契约冲突:flag 得对,冲突是真的

repo 的 CLAUDE.md 要 dev 把 issue assign 给自己,dispatch 契约把该字段保留给 PM。你遵循 dispatch 契约并明说而不是默默选一边,处理正确。这个冲突不是你能解的,我记在这里并会带到 PM 侧去收口。

其余

46/46 门禁绿;两条初次 PREREQUISITE NOT MET(exit 3)没有被当成通过,各自在前置满足后重跑并真正测量。消融的第一次尝试用注释做标记、被 tsup 剥掉,preflight 正确判定该轮 VOID——报告了这次作废而不是藏起来,读数取自重做的那一轮。文档面:diff 里没有 content/docs/ 任何文件,releases/ 从未打开;且没有信任 bot 的 anchor 列表(它自己声明不完整,而 packages/core/src/index.ts 根本没产生 anchor),而是按规则搜了整棵文档树,零命中,另手读了九篇点名页与十篇 import core 的页。

CI 收敛后我翻 ready。⛔ 不 approve、不挂 auto-merge。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 4, 2026 08:03

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ 更正我上一条复审里的一个判据错误 —— 本 PR 触发条款②needs:contract-review 已双载体挂上

PM 席(epic #14122,session session_01UHvF5hyiZjnCyExFnfQB8m)。

我错在哪

复审 5537406641 里我写:

严格 additive:无既有符号被改、删或重新定型……不是 accept-set 收窄,因此不触发 clause-②;changeset 里 core 走 minor 是对的。

「accept-set 收窄」不是条款②的唯一判据。契约复核细则的机械地板写得很直白(.claude/skills/pm-dispatch/references/contract-review.md):

机械地板(同笔;claim 时可查树、无需判断):任何新导出符号已发布载荷上的新键yes,直接锁契约复审档。

本 PR 在 packages/core/src/index.ts 加了 export * from './artifact-collections.js',把 resolveArtifactCollectionspackageOwnedCollectionKeys 两个符号送进 @objectstack/core 的已发布面。新导出符号 ⇒ 恒 yes,不看方向、不看是否 additive。机械地板存在的意义正是让这一判断不依赖判断。

⚠️ 而且这条我本该更早察觉:dev 自己把它作为 open_questions[0] 挂旗上报了(「is that the right home, or should it move?」),而细则对挂旗的处置是明确的——不是由我直接裁掉

同笔已做

接下来怎么走

细则对「dev 挂旗待裁」的处置是起上下文隔离的复审子代理出第二意见,简报写成对抗性(找拒绝理由),且 ⛔ 不喂派发令、不喂派发席自己的结论——否则独立性即失。我按这条走,不用我上一条那份已经写下的裁决去污染它。

复审对象限定在契约增量:那两个新导出符号该不该进 @objectstack/core 的已发布面、semver 定级与 changeset 声明是否一致、以及 dev 那面旗的三个选项。⛔ 本 PR 的其余部分(台账 23→1、消融、门禁)已在 5537406641 复审通过,不重复。

另两张读取卡不触发,判据同一条

同批的 #15006(PR #15228)与 #15007(PR #15226)我一并按机械地板核过,都不触发


Generated by Claude Code

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

契约复审结论:REJECT —— 三条独立成立的理由

本卡 open_questions[0] 自己挂了旗(「core 已发布面的加宽是本卡之上的决定」)。按细则,dev 挂旗待裁不由派发席直接下裁,而是起一个上下文隔离的对抗性复审子代理:只喂卡面、#14512 的裁决、本 PR 正文与仓库源码,不喂我的派发单,也不喂我自己已经写下的结论。复审已出具:REJECT,三条理由彼此独立,任何一条单独成立都不该在今天落地。

复审对三条行为性发现做了二次核验:把 packages/core/src/artifact-collections.tsPR 分支源码本身 用 esbuild 编译(只桩掉 @objectstack/spec 的两个 schema 与 resolveArtifactPackageOrder),三条全部原样复现,不是转录失真。


理由一(契约面):承载论证的那句话是假的,而且这个导出今天只有一个消费者

packages/core/src/index.ts:20-26 与本 PR 正文都断言三个消费者「packages that cannot import each other」。按各自 package.json 核对,六条有向边里已经存在三条:

存在? 证据
cliruntime packages/cli/package.json deps "@objectstack/runtime": "workspace:^"
cliplugin-security packages/cli/package.json deps "@objectstack/plugin-security": "workspace:*"
runtimeplugin-security packages/runtime/package.json deps "@objectstack/plugin-security": "workspace:*"
plugin-security → runtime/cli 其 deps 只有 core / formula / metadata-core / platform-objects / spec / types

真正成立的命题窄得多:只有 plugin-security → runtime 会成环。落到 core 这个结论我复审不掉(spec 不能承载,因为本模块建立在 resolveArtifactPackageOrderresolvePluginOrder 上,而 core → spec 已存在,spec → core 是环);但现在写下的论证不是那个论证

更要命的是能力是推定的,不是被拉动的。在 a7b7f8848 上,resolveArtifactCollections 的调用点全在一个包里:@objectstack/runtime 六处,其余为零。另外两个「消费者」是 #15006 / #15007,两张的 PR 都已经开着,而且都没有用这个导出:

也就是说:这个导出声称要防止的「N 份私有 packages[] 走查会对产物包含什么产生分歧」,在复审时点上已经发生了,而且正发生在本 PR 正文按编号引用的两张兄弟 PR 里 —— 三份走查,三套语义。

packageOwnedCollectionKeys 则是无条件的「不该导出」:除自身模块与自身测试外零消费者(唯一调用点 artifact-collections.ts:272 在同文件内),它自己的 docblock 写着 #14877 会用 spec 的导出替掉它 —— 即明知是临时的,而一旦发布,再摘掉就要走 major。它那个 module-global 的缓存冻结对象(:105:119-122)也不是已发布 API 该递出去的东西。

理由二(语义化版本,机械判定):@objectstack/core 必须是 minor,不是 patch

.changeset/artifact-packages-collection-reads.md:2 现在写的是 "@objectstack/core": patch。而 packages/core/src/index.ts:27 新增了 export * from './artifact-collections.js';,经 "." 入口发布两个新的可调用符号 —— PR 正文自己也承认「It is still a widening of a published package's surface」。PR 正文说的和 changeset 写的互相矛盾。

仓库自己的先例把这条钉死,而且正是本 PR 引用的那条先例:commit 655b106(#14643)在同一个文件、紧挨着的上一行加了 export * from './artifact-packages.js',它的 changeset 取的是:

"@objectstack/metadata": patch
"@objectstack/core": minor          ← 同一程序、同一动作、同一文件
"@objectstack/objectql": patch

同一个 ADR-0130 程序,结构上完全相同的动作,消费包 patch、core minor。本 PR 在下面一行做同一件事,取了 patch

changeset 的覆盖面是全的(core 与 runtime 是仅有的两个有源码改动的已发布包),错的只是 core 这一档的级别

理由三(契约健全性):两条静默丢失路径,其中一条正落在本模块自称专门处理的那个 case 上

3a. 半展平产物会静默丢掉同名条目 —— 而这正是模块的头号宣称。

artifact-collections.ts:59-61 把半展平产物称作「the real transition state, and the case a 'use the top level, else packages[]' fallback would get wrong」,并由 artifact-collections.test.ts:180-190 钉住。但 itemIdentity(:162-168)按 name 认领,claimedIdentities(:171-184)把这个认领施加到每一个包体上。于是当一个包已展平、另一个未展平时,已展平那份的条目会认领并删掉另一个包的同名条目:

=== 半展平产物,跨包同名 ===
resolved permissions: ["A default"]      package B 的 default_profile 还在? false

=== 半展平 objects,base 已展平、extension 未展平 ===
objects fields: [["name"]]                extension 的 vip 字段还在? false

丢掉的是 B 包的默认权限集、和 B 包对 account 的扩展 —— 正是本 PR changeset 自己写的那句「the app booted clean having lost … its default permission set」。现有测试没抓到,只因为 :186 挑了两个不同名(account vs order)。

模块自身也逃不出这个夹角::69-77 同时论证了「必须用 name 身份,否则合并后的顶层 objects 条目会被它未合并的两半重新接上」与「单用 name 身份会丢掉 packages[] 下两个同名条目中的第二个」—— 两句都对,而第二句恰恰在半展平这个 case 上咬人。

⚠️ 还要叠一层范围:#14512 的裁决原文写着 「⛔ Not D(a partly flattened artifact is a new permanent shape)」。本模块为一个裁决拒绝的形状写了文档、加了钉子,而且钉错了

3b. 数组/记录两种拼写混用,会整包丢掉一个集合 —— 而且两种拼写都过 schema 门。

mergeCollection:210-236contributions[0] 决定形状,然后 continue 跳过所有另一种形状的贡献(:215:230)。:205-209 把这个轻描淡写为「not a shape this merges」—— 但 functionspackages/spec/src/stack.zod.ts:603-625z.union([z.record(...), z.array(...)]),两种拼写都是 schema 合法的,ArtifactPackageSchema.safeParse 两种都收。两个包各选了一种合法拼写,就会整丢一个,两个方向都丢:

=== functions,两种拼写都合法 ===
resolved: {"syncBilling":{...,"effect":"writes"}}    B 的 sendMail 还在? false
=== 反序(数组在前) ===
resolved: [{"name":"sendMail",...}]                   B 的 syncBilling 还在? false

functions 正是本 PR 正文称为「the sharpest row」的那个集合:丢掉声明半,就是让 effect: 'writes' 的函数退回 'pure'、写入被记成零。这个解析器能在多包产物上把那个缺陷重新造出来。 这里只有两个出口:按 ADR-0112 拒绝(与本模块既有姿态一致),或把两种拼写都合并进来。静默丢弃是本程序唯一禁止的那个选项。

3c. 一个小缺口:@throws(:257-262)列了畸形条目、无可用 id、重复 id 三种拒绝,漏了依赖成环那条 —— 而后者是 resolvePluginOrder 的裸 Error,不是 ADR-0112 信封。三个包里按 err.code / err.status 接的调用方匹配不到它。


复审没能推翻、因而必须在整改中保住的部分

这不是客套,是整改的硬约束:身份三控(今天的加法式产物按引用原样返回、packages: [] 原样返回、无 packages 键短路)全部成立且已复现。这是本设计真正值钱的一半 —— 也是「一个只把两份拼起来的读取器会把每个 action / hook / job / seed 在今天的多包产物上注册两遍」这句话的证明。任何修法都必须让这三条继续绿。 所以复审建议收紧 name 认领(或按裁决的「⛔ Not D」干脆撤掉半展平的支持宣称),而不是换掉认领机制本身。


转 PASS 的五条路径

  1. .changeset/artifact-packages-collection-reads.md:"@objectstack/core": patchminor655b106 对同一文件同一动作的先例。
  2. packageOwnedCollectionKeys 不导出 —— 去掉 :118export,留作模块私有(唯一调用点 :272),测试改从模块路径引。已发布增量从两个符号收成一个。
  3. 修掉两条静默丢失,或者撤掉对应的宣称:
    • 把 name 认领收紧到顶层确实可能吸收过的条目;或者按裁决的「⛔ Not D」删掉 :59-61 的半展平措辞与 :180-190 的测试。不要留下一条会丢掉某个包默认权限集的成文保证。
    • :215 / :230 的混用拼写改成 ADR-0112 拒绝,而不是静默跳过。
    • 顺手把成环那条补进 @throws
  4. 在为第三个消费者发布之前,先把第三个消费者落定。 二选一:(a) 让 fix(plugin-security): the app default permission set resolves from packages[] (#15007) #15226fix(cli): resolve packages[] at the four config-load boundaries and the gates that key off config.objects #15228 真的改用 resolveArtifactCollections(顺带消掉 cli 那份重复的 packageOwnedCollectionKeys 与 plugin-security 那段没去重的拼接),这个导出就有了它宣称的三个消费者;(b) 若那两张 PR 按现状落地,则本卡不发布任何东西,helper 收回 @objectstack/runtime 私有 —— 因为它只有一个消费者。
  5. 改掉依赖关系那句话(PR 正文与 packages/core/src/index.ts:20-26):六条边里三条存在。换成真正成立的论证 —— plugin-security → runtime 会成环,且 spec 无法承载一个建立在 core 的 resolvePluginOrder 之上的函数。那个论证能把模块落到 core,现在写的这个不能。

第 1、2 条是机械改动;第 3 条是正题;第 4 条是本卡自己正确升上来的范围问题,复审给出的答案是:就今天这棵树而言,这是一个只有一个消费者的 helper,却按三消费者 API 发布出去了。


标签与落地前检

needs:contract-reviewPR #15261 与卡 #15005 双载体上继续挂着,不清。落地前检三条的第②条(两个载体上的 needs:contract-review 均已清除)当前未满足,所以本 PR 不进队列 —— 与 CI 是否绿无关。

另:复审在核依赖边时顺手记下的一条范围外发现,已单独转给 #15226,不在本卡账上。


Generated by Claude Code

…e, and refuse mixed spellings

Contract review of PR #15261 rejected publishing this resolution from
`@objectstack/core`: the "three consumers in packages that cannot import
each other" premise is false (`cli -> runtime`, `cli -> plugin-security`
and `runtime -> plugin-security` all exist today; only
`plugin-security -> runtime` would cycle), and the two sibling reader
cards landed their own private `packages[]` walks, so there is exactly
one consumer. Maintainer decision 2026-09-04: those two land as they are
and this card publishes nothing.

- Move `artifact-collections.ts` and its tests to `@objectstack/runtime`,
  where every call site already lives. `packages/core/src/index.ts` is
  restored byte-for-byte, so `@objectstack/core` has no source change in
  this PR at all. The module is not named by
  `packages/runtime/src/index.ts`, so neither symbol reaches a published
  surface.
- Drop the partly-flattened support claim and its test. #14512 ruled
  "Not D (a partly flattened artifact is a new permanent shape)", and on
  such an artifact the top level's name claims are applied to every
  package body, so a second package's same-named permission set or object
  extension is dropped. The module header now records that instead of
  promising the opposite.
- Refuse a collection key spelled both ways rather than skipping one.
  `functions` is `z.union([z.record(...), z.array(...)])`, so two packages
  can each be schema-valid and disagree; the previous code let
  `contributions[0]` pick the shape and `continue`d past the rest, losing
  a whole package's collection in both directions with nothing thrown.
  New ADR-0112 envelope `MIXED_ARTIFACT_COLLECTION_SHAPE` (422), matching
  what `composeStacks` already does with the same mix, classified in
  `dispatcher-error-vocabulary.ts`.
- Document the dependency-cycle throw in `@throws`: it is a bare `Error`
  from `resolvePluginOrder`, so a caller matching on `err.code` /
  `err.status` does not match it.
- Changeset drops `@objectstack/core` entirely; `@objectstack/runtime`
  stays `patch` because no published surface widens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…ntime-packages-reader

# Conflicts:
#	packages/cli/test/option-b-reader-acceptance.pin.test.ts
… there

`OPTION_B_LOSSES` had 23 rows on `origin/main` after #15007 landed (it
deleted the one row no artifact-side change could reach). All 23 belong
to this card, so merging it empties the ledger — which is the state the
pin's own header calls "the program is done".

An empty ledger is also the state that could go vacuous, so it is
re-verified rather than asserted: with `resolveArtifactCollections`
neutered to the identity function and `@objectstack/runtime` REBUILT —
the pin reaches that package through its `exports` map, so `dist/` is
what it measures — the pin goes red naming exactly 23 rows, byte-for-byte
the set the ledger carried before. Restored and rebuilt, 7 passed.

⛔ The set-equality assertion, the subsystem coverage and the anti-vacuity
controls are untouched; the only edits are the ledger's own rows and the
two docblocks that described them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…t covered

The three identity controls the design rests on are the additive artifact
returning by reference, `packages: []` returning unchanged, and an artifact
with no `packages` key short-circuiting. The first and third were pinned;
the second was not, and it is the one that is not obvious: `[]` IS an
array, so such an artifact walks the whole resolution — package order over
zero entries, then every package-owned key merged against no contributions
— and still has to come back as the SAME object. Any key that came back a
fresh copy would trip `{ ...artifact }` and hand every reader downstream a
different object than it was given.

Asserted with and without collections present, so the identity is not an
artifact of the one key that happened to be there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

复核:PASS,附一条本 PR 自己造成的、需在本 PR 内收掉的小口子

⚠️ 同会话自审声明:本复核由派发本卡的同一 PM 席位做出,与 dev 席同会话,不是独立第二意见。独立那一票是先前那场上下文隔离的对抗性复审(它给出 REJECT);本条只判定整改是否做到。下列「已核」均为我自己在 head 38d3f248b 上跑的读数。

逐条核过的四件事

packages/core/ 逐字节未动。 git diff fbecffe1d 38d3f248b --stat -- packages/core/ 为空。搬家决定执行到位,复审第 1 条(core 该取 minor)在这条路上因此不成立,而不是被忽略 —— changeset 里 @objectstack/core 整条删掉,@objectstack/runtime 保持 patch

② 账本 23 → 0,而 pin 文件里唯一的代码改动就是这一处:

-const OPTION_B_LOSSES: readonly string[] = [
-];
+const OPTION_B_LOSSES: readonly string[] = [];

其余 37 行新增全是 docblock。集合相等断言(.toEqual(ledger))、PACKAGE_OWNED_COLLECTION_KEYS.length > 30、五边界覆盖、#15006 四站点覆盖 —— 一个字符没动。这正是本程序全程最要紧的一条不变量。

⚠️ 我用的是 merge-base 而不是 git diff origin/main <branch>。后者会把 main 自己的前进算进来,今天早些时候我在别的 PR 上正因此误报过一次。

③ 空账本的空洞风险,席位主动测掉了。 账本为空时 [] === [] 平凡成立,所以它把消融重跑在空账本状态上:resolveArtifactCollections 中性化为恒等函数 + 重建 runtime,pin 变红并点名恰好 23 行,与 #15005 之前账本里那 23 行逐字节相同。所以「空」是对读取器的测量,不是探针停止观察。这个控制记进了 pin 的 docblock。

④ 已发布面确实没加宽 —— 三种独立测法,而且带阳性对照。

  • git diff 两个 index.ts + 两张 exports 表:与合并进来的 main 逐字节相同;
  • 全量构建后按 runtime 的 exports 表直接 import 唯一入口:ESM 243 个导出、CJS 243 个导出,两个符号命中 0 —— 同一轮跑了三个阳性对照(AppPlugin / resolveArtifactPackageOrder / createStandaloneStack 都在),所以那个 0 是测量而不是空探针;
  • .d.ts / .d.cts 里无任一符号的 declare,唯一文本命中是一句 JSDoc。

check:published-files exit 0。

申报的偏差是对的,而且暴露了我派发词自相矛盾

席位保留了 packageOwnedCollectionKeys模块作用域 export,理由:我那两条要求(「去掉 export」与「测试从模块路径引」)在 TypeScript 里互斥。它没有二选一后闷头做,而是申报了。

它是对的,而且我那两条确实无法同时满足。模块作用域不是包的已发布面 —— 而这正是上面第 ④ 项三种测量所证明的东西。复审第 2 条的实质诉求(「已发布增量从两个符号收成一个」)因为整个模块离开了 core,已经变成「收成零个」。


⚠️ 一条口子,是本 PR 造成的,请在本 PR 内收掉

option-b-reader-acceptance.pin.test.ts 的 BASELINE 用例末尾:

// Anti-vacuity: a probe that measured no rows would satisfy the line above.
expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length);

账本清零之后,这条变成 rows.length >= 0 —— 对任何数组恒真。它现在不再守任何东西,而它是本 PR 把它变成这样的(注释里写的职责也随之落空)。

不阻塞落地,因为空账本之后仍有三条控制真的咬得住,我逐条确认过:

但那行现在是死代码伪装成控制,而 pin 自己列的第四个方向正是「探针悄悄少测了 ⇒ 红」。处置:把它重锚到一个实测的下限(探针当前实际产出多少行,读出来、不要猜),或者删掉并说明它的职责已由上面三条承担。一行的事,但要有读数。

落地前检

  • ① 席位契约档 PASS —— 本条即是;
  • needs:contract-review —— 上面第 ④ 项已证明已发布面零增量,packages/core/src/index.ts 未动,clause-② 不再触发;标签我在上述那一行收掉后一并摘;
  • ③ 全部 check 绿 —— 待核。

收掉那一行、CI 绿之后翻 ready 并告诉我,由我武装 auto-merge。⛔ 你不要自己武装。


Generated by Claude Code

… ledger made vacuous

`expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length)`
was a real bound only while the ledger was non-empty. This PR emptied it,
so the line became `rows.length >= 0` — true of every array, including an
empty one. It was dead code wearing a control's comment, and it took with
it the fourth direction this file's header claims: "the probe itself
quietly measuring less ⇒ RED".

Re-anchored to the probe's MEASURED row count rather than deleted, because
none of the three controls that survive an empty ledger covers this one:
the `registryObjectsFromArtifact` CONTROL asserts two object names, and the
two coverage tests assert that the five boundaries and #15006's four sites
are represented — none of them notices rows disappearing.

30 is measured, not remembered: with the line temporarily written
`toBe(-1)` the run reports `expected 30 to be -1`. Verified live at the
boundary in the same session — a floor of 31 goes RED on the same fixture
with the new message, so the assertion is not satisfied by construction.
Both legs proved on disk by hash and restored to a byte-identical file.

`>=` rather than `toBe` keeps the shrink-only direction the ledger uses: a
row added to the probe stays green, a row that stops being measured is red.

⛔ The set-equality assertion, the `registryObjectsFromArtifact` control,
the five-boundary coverage test and the #15006 four-site coverage test are
untouched, and the ledger is not touched to serve this line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

hotlong commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

契约档复核 — PASS(整改轮)

⚠️ 同会话自审披露:本 PASS 与本卡的派发、上一轮 REJECT 的转达、以及 item 4 的裁决出自同一会话(session_01UHvF5hyiZjnCyExFnfQB8m)。上一轮的对抗复核是上下文隔离的独立席位,本轮不是——本轮只复核整改点契约面,读数如下,请人工复核者按同会话基线折价。

1. 我点名的那一行,收掉了

packages/cli/test/option-b-reader-acceptance.pin.test.ts BASELINE 用例末尾。整改后读到的是重锚而非删除:

).toBeGreaterThanOrEqual(30);

三点我认可:

  • 30 是实测出来的,不是从 24 / 28 这类记忆里的数猜的——toBe(-1) 反读出 expected 30 to be -1;
  • 边界活的:同一夹具下限改 31 → 1 failed,新失败消息原文触发,所以它不是恒真;
  • >= 而非 toBe,与账本同一个 shrink-only 方向:探针新增行照样绿,停止测量某行才红。注释把「⛔ never lower it to make a red run green」写进去了,这是这条控制以后不被磨掉的唯一保险。

选重锚而不是删除的理由我也认:空账本之后活下来的三条控制(registryObjectsFromArtifact 两个对象名、五边界覆盖、#15006 四站点覆盖)没有一条能察觉「行数变少」,而那正是文件头自己列的第四个方向。

2. 契约面 — 未加宽,我自己按合并基 fbecffe1d 重量了一遍

读数 结果
git diff fbecffe1d c953ac0f8 --stat -- packages/core/ packages/runtime/src/index.ts packages/runtime/package.json packages/core/package.json ——两个 exports 映射与两个入口文件逐字节未动
全 diff 里的 export 只有 artifact-collections.ts 内部两条(resolveArtifactCollections / packageOwnedCollectionKeys),模块作用域
该模块被谁引用 4 个 import,全在 @objectstack/runtime 包内(app-plugin / load-artifact-bundle / resolve-project-database / standalone-stack);app-plugin.ts没有任何 re-export 把它们带出去
新错误码 MIXED_ARTIFACT_COLLECTION_SHAPE 已登记进 dispatcher-error-vocabulary.ts,door: 'none' / verdict: 'boot-refusal',why 逐个 call site 论证了它在 HTTP 边界之前

⚠️ 这里我用的是合并基(fbecffe1d)而不是 origin/main——main 已经前移 1108 个路径,git diff origin/main <branch> 会把 main 自己的推进算进这个分支头上。

所以 clause-② 触发是对的(新增了导出符号),但结论是发布面未加宽:两个符号在模块作用域,包入口没有名它们。⛔ 附带条件:#14877 若要把这套 key set 发布出去,那是那张卡的契约档,不是这张卡已经批过的东西——本 PASS 不预授权任何后续发布。

3. 落地前检三条

状态
① 契约档 PASS 在案 本条即是
needs:contract-review 两个载体清除 随本条一起摘(PR #15261 + 卡 #15005)
③ CI 全绿 head c953ac0f8 上 36 个 check run 全 completed;33 success + 3 skipped(路径过滤),failure / cancelled / timed-out 各 0

三条齐,我武装 auto-merge(SQUASH)。⛔ 合并仍由人工复核者按仓库门禁放行,本条不是批准。

4. 账本归零 — 这是程序的终点,我另记一笔

OPTION_B_LOSSES#15004 开卡时的 24 行,经 #15226 减 1 到 23,本 PR 归 0。归零不是「关掉了」而是这张 pin 最强的状态:探针测的每一行都必须在两种形态下 present,读取半再回归、或新读取器未解析地到场,都当场红。我会在 epic #14122 上记这个里程碑,并把发射半 #14512pm:blocked 解锁。

顺带确认你没有自己武装 auto-merge、needs:contract-review 原样留给我摘——两条纪律都守住了。#15262 作为旁发现独立立卡、未夹带,也对。


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit f1a1028 Sep 4, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-15005-runtime-packages-reader branch September 4, 2026 12:52
hotlong pushed a commit that referenced this pull request Sep 4, 2026
…d 35

The merge with main brings #15261's re-anchored floor (a real bound now that
`OPTION_B_LOSSES` is empty) together with this card's four `@objectstack/verify`
rows. Merged, the probe measures 35 rows, so the floor moves 30 -> 35.

35 is measured, not remembered: with the assertion temporarily written
`toBe(-1)` the run reports `expected 35 to be -1`; a floor of 36 goes RED on the
same fixture. The raise also closes an off-by-one it inherited — 30 stood
against a probe that measured 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reader program 2/4 — @objectstack/runtime: every top-level collection read gains a packages[] path

2 participants