feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@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

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3) - #14223

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve
Sep 1, 2026
Merged

feat(spec): composeStacks gains manifest: 'preserve' — N package identities survive composition (ADR-0130 row 3)#14223
huangyiirene merged 2 commits into
mainfrom
claude/issue-14164-composestacks-preserve

Conversation

@hotlong

@hotlonghotlong commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#14164

Implements ADR-0130's Consequences follow-up row 3. composeStacks' manifest option 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's packages list (ADR-0130 D4), in stack order.

Why a new value rather than a fix to the old ones

manifest: 'first' | 'last' | index is 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 object name IS 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 rejects composeStacks-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:

  • stack declares packages → those entries;
  • stack declares no packages → its singular manifest as 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, reusing ArtifactPackageEntrySchema (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 singular manifest is 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 reading composed.manifest sees a key disappear. Nothing is registered twice either: D4's read-both rule reads a packages-carrying artifact through packages, and manifest is the fallback branch for artifacts that have none.

packages' declared COMPOSE_KEY_DISPOSITIONS entry stays 'concat' and keeps its meaning. Preserve composes packages itself and concatenates in stack order too, so the two agree rather than compete — pinned as an equality where every stack carries packages.

The pin #14191 left on purpose — updated, not deleted

#14191 left leaves the singular manifest pick-one semantics alone in packages/spec/src/stack-artifact-packages.test.ts so 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 no packages key 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') { in packages/spec/src/stack.zod.ts replaced by if (false) {, so preserve falls through to the default and builds no list.

  • The mutation reached what the test resolves. The pins import ./stack.zod relatively, so vitest resolves package source, not dist — 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 blob b540201d moving to 7bf25448.
  • Mutated leg: 7 failed | 71 passed — the preserve pins are red; the 'first' / 'last' / index pins stay green, which is the correct direction (they must be unaffected by the new branch).
  • Restore leg, proven by bytes: restored with git checkout HEAD -- FILEPATH — pinned to HEAD rather than the bare form, which reads the index the mutation may have polluted, and with an absolute path resolved from git rev-parse --show-toplevel rather than a relative one. Working blob back to b540201d = the HEAD blob, git diff HEAD empty. Restored run: 78 passed (78).

The first ablation pass reported 5 red, not 7 — it caught two vacuously-green pins of my own: 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. Both were fixed to assert the addition first (commit a47ef393) 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 run450 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)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (52 commands): 47 exit 0. The other 5 are PREREQUISITE NOT MET / NOT MEASURED, not findings — check-dev-prereqs, check:dual-build-cjs-loads, check:type-check-debt and check:doc-formula-expressions all want the whole workspace built (66 of 67 packages have no dist/), and check-test-completeness / check-half-states exit 3 by design outside CI. @objectstack/spec and @objectstack/formula were built to clear that class as far as it goes locally; check:api-surface then passed on real declarations (exit 0). CI owns the repo-scale remainder.
  • Lint, as a declared narrowingeslint --no-inline-config --format json over 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-config resolves for each file, exit 0), the count is read from the JSON output, and the invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured at eslint.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-review is attached and the PR is draft pending that review.

⚠️ An independent review is required. A verdict rendered by the session that wrote this diff does not satisfy the independence the gate wants — recorded as a process finding on #14191.

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 packages in dependency-topological order (D5, through resolvePluginOrder) and the installPackage co-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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/getting-started/examples.mdx(via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx(via composeStacks (symbol, a top-level function))

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

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

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
  • 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 — 128 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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0185976db0933f220d40f2982ad111f31a2709da — the merge of head a47ef3932791047aacabed1d0386ef6d211fc87a into base 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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

a47ef393 上 48 项检查全部完成,无一失败(success 或 skipped)。六个必需上下文按 check-run 名称逐个核对(AGENTS.md §522:必需集按名匹配,改名会静默脱钩,所以按名核而不是按数):

必需上下文结论
Lint & Repo Gates✅ success (14:51:34)
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;Flag docs affected by code changes 的 advisory 已单独核过——两页命中页(getting-started/examples.mdxglossary.mdx都没有对 manifest 取值做封闭枚举'preserve' 叠加后两页逐字仍成立,无文档改动可欠;第三页 content/docs/releases/v17.mdx 是 release-owned,只读不碰。


为什么它仍然是 draft,以及我不做什么

本 PR 保持 draft,我不 arm auto-merge、不翻 ready。 §522 的前置是"已绿 且已接受 且非 draft"三者齐备;这里只满足第一条。

Clause-② 的裁决不由本会话作出。 这个 diff 是本会话(session_01UHvF5hyiZjnCyExFnfQB8m)派发并署名的,写 diff 的一方自审不满足该门要求的独立性——这正是 #14191 上记录在案的 process finding,不重蹈。

需要的就一件事:一次独立的 Clause-② 复审。PR 正文已把最该要第二意见的那处判断题标了出来——preserve 保留单数 manifest(按 'last' 选)而非清空它,理由是叠加而非删键、D6 的物级身份、以及 D4 的 read-both 规则使重复注册不可能;ADR-0130 两边都没钉死,所以它是决定性的一处,不是风格问题。

复审通过后即可 ready → 合并队列。在那之前它就该停在这里。


Generated by Claude Code

@huangyiireneClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— head a47ef393,同笔清标 + 转 ready + 挂 auto-merge

项目总监席契约复审记录 · session session_01KGtaLpkW1mycWgkbSb3H6t · 档位机读认定在案(claude-fable-5 = CONTRACT_REVIEW_TIER)· 独立性成立(复审会话 ≠ 署名会话 session_01UHvF5hyiZjnCyExFnfQB8m,#14191 的 process finding 得到满足)· 对全 diff 复审(4 文件 +535/−17),⛔ 非对报告。

被点名的判断题,本席审定:保留单数 manifest 正确,维持

PR 正文与车道都把这一处交给第二意见 —— preserve 保留单数 manifest(按 'last' 选)而非清空。三条理由逐一对树核验成立:

  1. 无重复注册的机械保证:D4 read-both 规则(feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 落的那条)规定「有 packages ⇒ 走 packages,manifest 是无 packages 时的回退支」—— preserve 输出必带 packages,故其 manifest 永不被当包迭代;
  2. D6 物级身份:一件 artifact 一个版本,清空 manifest 反而把 artifact 级身份留空;
  3. 加法不删键:读 composed.manifest 的既有消费者看不到键消失。
    ⇒ ADR-0130 未钉的这一处由本复审钉下:preserve = 默认输出 + 包清单,后来者⛔ 不得改为清空式。

条款②与裁决符合性

  1. 双肢命中如实申报(spec 路径 + 新公开选项值),draft 候审姿态正确;
  2. 恰好加宽一个值,测量而非断言 ✅ —— 邻拼写(preserve-all)与负索引拒绝同钉,默认值 'last' 单独钉(选项 schema parse),default-未动的负半(三个 pick 策略都不铸 packages 键)钉住 —— D7「Reviewer attention is not a mechanism」被照字面执行;
  3. read-both 规则单源 ✅ —— preservePackageEntries 应用的是 D4 同一规则(带双备键的 stack 只贡献一次),wrapper 形状ArtifactPackageEntrySchema而非重declare(ADR-0116 漂移防线),平铺体拒绝作负对照;与 COMPOSE_KEY_DISPOSITIONS'concat' 一致性钉为相等式;
  4. feat(spec): the release artifact may carry N package manifests — optional packages[] (ADR-0130 D4) #14191 留置哨钉的处置正确 ✅ —— 断言未动、只改陈述理由并改题为 BY DEFAULT,哨钉的「让后续变更可见」使命完成而非被删绿;
  5. 消融自纠入册 ✅ —— 首轮 5 红暴露两枚自写的空洞钉(零次迭代恒绿、加法未发生时平凡成立),修钉后二轮 7 红恰合预declare —— 这是消融纪律的教科书用法;字节还原证明齐;
  6. 单栈短路按身份返回的钉与理由(单 manifest 即 read-both 支 2 的「一个包」)成立;changeset minor 纯加宽定级正确;12068 测试绿,车道已按名核六必需上下文全绿,文档漂移两命中页无封闭枚举、零欠账。

范围内观察(⛔ 不阻落地,记给 D1/D3 实施者):preserve 不去重 —— 两个输入若携带同一 manifest.id,包清单出现重复条目;按 ADR-0130 分工,拒绝重复身份属 installPackage 共有权门(D1/D3)的裁量,组合期不预判。届时该卡请带上这一读数。

收口(同笔)

needs:contract-review 摘除 → draft 转 ready → auto-merge 挂上(合并队列资格 = 全检查绿,已满足)。落地即 Fixes #14164 关卡。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130:composeStacks 增加 preserve(保留 N 份 manifest)模式,默认 'last' 不变

3 participants

@hotlong@huangyiirene@claude