Uh oh!
There was an error while loading. Please reload this page.
feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223
Conversation
…tities survive composition
ADR-0130 follow-up row 3. `manifest: 'first' | 'last' | <index>` is a deliberate
pick-one: it keeps one manifest and the other N-1 package identities are gone
from the output. ADR-0130 needs the other case — a release artifact that CARRIES
N packages, each keeping its own identity, so a product splits into modules
without renaming a single object.
`'preserve'` folds every input's package identity into `packages` (D4), in stack
order. Which entries a stack contributes is D4's read-both rule applied to the
inputs — `packages` present -> those entries; absent -> the singular `manifest`
as a single-element list — so a stack carrying both contributes its list once
and no de-duplication pass is needed. Entries are the `{ manifest: ... }`
wrapper `ArtifactPackageEntrySchema` declares; the shape is not re-derived.
The default stays `'last'`, and preserve is additive over it: the singular
`manifest` is still selected by the same rule, so preserve's output is the
default's output plus the package list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m…exposed Both passed while `manifest: 'preserve'` did nothing: a `for` over an absent `packages` list iterates zero times, and "the output is the default's output plus the list" holds trivially when the list is never added. Assert the addition first in each, so the pin measures the mode rather than tolerating its absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0185976db0933f220d40f2982ad111f31a2709da && git checkout 0185976db0933f220d40f2982ad111f31a2709da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2 a47ef3932791047aacabed1d0386ef6d211fc87a && git checkout -B drift-repro 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2 && git merge --no-ff a47ef3932791047aacabed1d0386ef6d211fc87a
node scripts/docs-audit/affected-docs.mjs --json 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2
|
hotlong
commented
Sep 1, 2026
CI 全绿;唯一未决项是独立 Clause-② 复审,⛔ 本会话不代劳
为什么它仍然是 draft,以及我不做什么⛔ 本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。 ⛔ Clause-② 的裁决不由本会话作出。 这个 diff 是本会话( 需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 复审通过后即可 ready → 合并队列。在那之前它就该停在这里。 Generated by Claude Code |
huangyiirene
commented
Sep 1, 2026
契约复审:PASS —— head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14164
Implements ADR-0130's Consequences follow-up row 3.
composeStacks'manifestoption accepts a fourth value,'preserve': instead of keeping one manifest and discarding the rest, it folds every input's package identity into the composed artifact'spackageslist (ADR-0130 D4), in stack order.Why a new value rather than a fix to the old ones
manifest: 'first' | 'last' | indexis a deliberate pick-one, and it is correct for the case it was written for — several stacks assembled into ONE published package, which has one identity. ADR-0130 introduces the other case: a release artifact that carries N packages, each keeping its own identity, so a product splits into modules without renaming a single object (the objectnameIS the table name, the REST path, the formula token and the saved-view key, ADR-0129 D1–D2). Composing N stacks under a pick strategy loses N−1 package identities — the lossiness ADR-0130 section 5 rejectscomposeStacks-as-is for. Both cases are real, so this is an added value, not a change of meaning for the existing ones.What the mode does
Which entries a stack contributes is D4's read-both rule applied to the inputs — the same rule the load path applies to an artifact, so composition and loading cannot disagree about what "the packages of this stack" means:
packages→ those entries;packages→ its singularmanifestas a single-element list.A stack carrying both therefore contributes its list once, not its list plus its manifest — nothing is emitted twice in the first place, so there is no de-duplication pass to get wrong later.
Every emitted element is the
{ manifest: ... }wrapper object, reusingArtifactPackageEntrySchema(landed by #14191). ⛔ The wrapper shape is not re-derived — a second declaration of one shape is the drift ADR-0116 exists about, and D4 reserved that exact position so a future{ ref, integrity }segment stays an additive key rather than a reshape. The pins assert it by feeding composed output to that schema rather than to a literal.'preserve'is additive over the default, not a fourth pick: the singularmanifestis still selected by the same'last'rule, so a preserve composition's output is the default's output plus the package list. The artifact keeps an artifact-level identity (D6 — one artifact, one version) and no consumer readingcomposed.manifestsees a key disappear. Nothing is registered twice either: D4's read-both rule reads apackages-carrying artifact throughpackages, andmanifestis the fallback branch for artifacts that have none.packages' declaredCOMPOSE_KEY_DISPOSITIONSentry stays'concat'and keeps its meaning. Preserve composespackagesitself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carriespackages.The pin #14191 left on purpose — updated, not deleted
#14191 left
leaves the singular manifest pick-one semantics aloneinpackages/spec/src/stack-artifact-packages.test.tsso this follow-up would be a visible change rather than a silent one. It is retitled... BY DEFAULT, and here is what it now records: its assertions did not move. Preserve is opt-in, the default is still'last', and a caller passing no options gets what it got before — one manifest kept, and nopackageskey minted underneath it. Only the pin's stated reason changed, from "until the follow-up lands" to "the follow-up landed and deliberately did not touch this path". ⛔ It was not deleted to make a suite green; the preserve mode's own behaviour is pinned in a separate file.Reverse verification — red to green
Ablation:
if (opts.manifest === 'preserve') {inpackages/spec/src/stack.zod.tsreplaced byif (false) {, so preserve falls through to the default and builds no list../stack.zodrelatively, so vitest resolves package source, notdist— the mutated file is the file under test, and no build stands between them. Confirmed on disk before reading any result, anchored on the text being changed rather than on the editor's exit code: injected-count 1, removed-count 0, and blobb540201dmoving to7bf25448.'first'/'last'/ index pins stay green, which is the correct direction (they must be unaffected by the new branch).git checkout HEAD -- FILEPATH— pinned toHEADrather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved fromgit rev-parse --show-toplevelrather than a relative one. Working blob back tob540201d= the HEAD blob,git diff HEADempty. Restored run: 78 passed (78).The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: a
forover an absentpackageslist iterates zero times, and "the output is the default's output plus the list" holds trivially when the list is never added. Both were fixed to assert the addition first (commita47ef393) before the second pass measured 7.Verification
Union re-run at final commit
a47ef393; the tree was clean for every run below.pnpm --filter @objectstack/spec exec vitest run— 450 files, 12068 tests, all passed (exit 0)pnpm --filter @objectstack/spec run typecheck— exit 0 (tsc --noEmit+check:scripts-typecheck+check:test-typecheck, so the new test file is type-checked, not merely present)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(52 commands): 47 exit 0. The other 5 arePREREQUISITE NOT MET/ NOT MEASURED, not findings —check-dev-prereqs,check:dual-build-cjs-loads,check:type-check-debtandcheck:doc-formula-expressionsall want the whole workspace built (66 of 67 packages have nodist/), andcheck-test-completeness/check-half-statesexit 3 by design outside CI.@objectstack/specand@objectstack/formulawere built to clear that class as far as it goes locally;check:api-surfacethen passed on real declarations (exit 0). CI owns the repo-scale remainder.eslint --no-inline-config --format jsonover the 3 changed source files: 3 files linted, 0 errors, 0 warnings. The narrowing is a measurement, not a skip: the population is read from eslint's own config (--print-configresolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs oneeslint.config.mjsthat never enables type-aware linting for any file (noparserOptions.project, no typed rules — stated and measured ateslint.config.mjs:328), so this diff cannot move the verdict on any untouched file.Changeset
.changeset/adr0130-composestacks-manifest-preserve.md—@objectstack/spec: minor. A pure widening: the accept set gained exactly one option value, the default is still'last', no existing value changed meaning, and nothing that parsed before is refused now. The refusal of a neighbouring spelling is pinned alongside the acceptance, so "widened by exactly one" is measured rather than asserted.Clause-② — yes, and this PR stays draft
Both limbs are hit: the path limb (
packages/spec/src/**) and the content limb (a new public option value widens the accept set).needs:contract-reviewis attached and the PR is draft pending that review.The one judgement call a reviewer should look at first: preserve retains the singular
manifest(selected by'last') instead of clearing it. The reasoning is above — additive rather than key-removing, D6's artifact-level identity, and D4's read-both rule making double registration impossible. ADR-0130 does not pin this either way, so it is the decision most worth a second opinion.⛔ Scope: this ships composition only. The load path that iterates
packagesin dependency-topological order (D5, throughresolvePluginOrder) and theinstallPackageco-ownership gate (D1/D3) are separate, dependent cards. Composing with'preserve'today registers no extra package.Authored in Claude Code session
session_01UHvF5hyiZjnCyExFnfQB8m(https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m) — kept in prose because a body edit rewrites the footer's session URL to the bare form.Generated by Claude Code