Skip to content

fix(spec)!: composeStacks refuses a datasource whose external.connector names no connector instance the composed inputs declare - #27939

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-683cod
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-683cod

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV

Requested by 田騏 · project thread

Full review owed at the reviewer tier. Developed on the default tier while the reviewer tier's quota is out. This PR touches the published spec (packages/spec/src/**), so it lands only after a full review at the reviewer tier and a CONTRACT_REVIEW_TIER contract review record PASS on the current head.

Fixes #27803

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: follow-up #27786 (it teaches external.connector in the datasources skill rule and the two data-modeling guides; the composed check applies that same rule, with the same findings, so it adds nothing an author writes differently)

维护者速读

  • 改了什么: composeStacks 合成多个栈时,按所有输入的 connectors 检查每个数据源的 external.connector:名字不存在、只是目录描述(无 provider)、实例没有 auth,三种情况都在构建时拒收,提示与单栈 defineStack 完全一致。另附三处只改注释的小修(injected-system-column-provenance.ts 两处、IDataEngine.listDatasourceDefs 一处)。
  • 为什么改: 单栈严格构建已拒收写错的连接器名,但以 strict: false 构建的输入会把悬空的名字带进合成产物,作者要到首次连接才发现。
  • 风险与回滚: 只收窄合成层的接受集;被拒的都是作者写错的引用。跨输入的正确配对(数据源在 A 包、连接器实例在 B 包)照常通过。回滚即 revert。
  • 先看: packages/spec/src/stack.zod.ts 新增的 collectComposedDatasourceConnectorErrors 及其在 composeStacks 第 3b 步的调用;stack-datasource-connector.test.ts 新增的 composeStacks 一组。
  • 两分钟验证: pnpm --filter @objectstack/spec exec vitest run src/stack-datasource-connector.test.ts,预期 25 个测试全过。
  • 需要您做的: 无,席位落地(需审阅档全量复审与合约复审 PASS)。

What changes

  • packages/spec/src/stack.zod.ts: a new collectComposedDatasourceConnectorErrors(stacks, preserve) runs collectDatasourceConnectorErrors over the datasources and the connectors of every input, in the shape of collectComposedExternalBindingDatasourceErrors. composeStacks calls it in step 3b, the artifact cross-reference pass, so its findings arrive under the same StackCrossReferenceError (STACK_CROSS_REFERENCE_INVALID, 422) and read exactly as the per-stack ones do.
    • It newly refuses a dangling name, a catalog descriptor (no provider) or an instance with no auth (or auth: { type: 'none' }) carried by an input built with strict: false.
    • A datasource in one input that borrows an instance another input declares composes.
    • providedCollectionEntries reads each input's top level and every packages[] body (the providedFlowNames reading), so a nested multi-package input is judged on what its package bodies carry. An entry object a nested input holds in both halves is read once.
    • A connector name declared more than once is judged by registration slot. In one slot (the flattened artifact's one package, or one package body) the later entry is the one the registry keeps; when it cannot lend but an earlier one could, the finding names both declarations and prescribes the duplicate's fix. In two slots (two packages of a preserve artifact, or a package body beside the flattened top level) the registry keeps both and the lender answers the first registered, so when one entry can lend and the other cannot, the borrow is refused as ambiguous in either declaration order. When both can lend, it composes.
    • The per-stack collector gains an optional shadowedLender hook for the duplicate's prescription, and three small predicates (connectorHasProvider, connectorHasCredential, connectorCanLend) shared by both collectors. All module-private.
  • Doc-only should-fixes (scope additions on the card):
    • isInjectedColumnDefinition: plugin-sharing's equalsShippedDef copy is gone (it reads isOwnerAnchorUnprovable), so plugin-security's is the one left.
    • isOwnerAnchorUnprovable's fail direction: ordinary owner scoping on a column the remote lacks matches no row or raises (Postgres and MySQL raise, SQLite matches no row); neither widens.
    • IDataEngine.listDatasourceDefs?: each entry carries the stored driver id, and omits the key for a definition that never named one (checked against ObjectQL.listDatasourceDefs in packages/objectql/src/engine.ts).
  • .changeset/27803-composed-datasource-connector.md: @objectstack/spec patch, the two declaration lines, a BREAKING banner naming every CLI command that loads a composed project, and an ADR-0087 not-required (no-migration-prescription) marker.

Why

The contract review of PR #27783 (record 6511230689, should-fix d) left the composed counterpart of the external.connector check out of that PR. D7 ruling 6486136301: "refused at build when the named instance is not declared". A composition is a build too.

Verification

All on head 1f471e09.

  • Tests in packages/spec/src/stack-datasource-connector.test.ts: 25, of which 17 in the composeStacks group. A cross-input pair composes in both orders under last and preserve; a dangling name in a strict: false input is refused under both; a descriptor and an auth-less instance another input declares are refused with their findings; a name declared twice in the flattened artifact is judged against the later entry and its finding names both declarations; two packages of a preserve artifact that disagree on whether their entry can lend are refused as ambiguous in both orders, and two lending packages compose in both orders; a nested multi-package input is read through its package bodies (a borrower resolves against a body's instance, a body's datasource is judged, a body beside another input's top level counts as two slots), and an entry it carries in both halves is read once; the per-stack strict refusal is unchanged. Each refusal asserts the envelope (STACK_CROSS_REFERENCE_INVALID, 422), one finding, and the composeStacks artifact cross-reference validation failed (1 issue): header.
  • Ablations (each on a committed fix, restored by a trap): the new call removed from composeStacks, 4 of the first 8 cases red; package bodies not read, the 3 nested cases red; the identity de-duplication removed, the both-halves case red; every entry put in one slot, the 2 preserve ambiguity cases and the body-beside-top-level case red. The other cases stayed green each time.
  • Package: pnpm --filter @objectstack/spec build, test and typecheck under the verify lock: build exit 0; 756 test files passed, 22498 tests passed (1 todo); typecheck passed.
  • Local gates: node scripts/pm/dispatch-gates.mjs --commands, derived after merging origin/main, named 82 families; all were run with exit codes recorded before any pipe. check:type-check-debt exited 3 until a pnpm install picked up the lockfile change main brought, then exit 0. dispatch-gates --ran: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero)". check-adr-0087-registration accepts the changeset's disposition, and check-changeset-no-major with this body passes (patch, a non-protocol narrowing). No generated reference moved. The full gate battery is CI's.
  • Dogfood battery (owed by the (narrowing) arm), after building the closure, under the verify lock: VERDICT command-exit 0, Test Files 357 passed, 3 skipped (360); Tests 2875 passed, 20 skipped (2895); no failure.

Acceptance notes

  • A duplicated connector name is judged per registration slot. The registry keys a package-owned item by package id and name, and the lender answers the first registered entry of a name, so last-wins holds only inside one slot. This is beyond the card's three named tests and is pinned in both orders.
  • The refusal reaches every CLI command that loads a composed project (os build, os validate, os lint, os dev, os serve), because the author's config runs composeStacks; the changeset says so.
  • Not covered by this PR: strict defineStack still resolves external.connector against its own stack only, so a package can borrow a sibling package's instance only by building the borrower with strict: false. An artifactConnectors promise of the artifactFlows shape would close that; it is the epic PM's to card.
  • Worktree: objectstack-issue-27803 on the claimed branch, as the Claim names.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (A build-time cross-reference collector beside its per-stack twin; no schema changed.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint is CI's.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: no generated artifact changes (only JSDoc and a build-time collector; the gate run left the tree clean).
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2cLiZUHvCtV2YGRWAd

…ainst the connectors of every input

The composed counterpart of the per-stack check (ADR-0015 amendment D7): an
input built with strict: false carried a dangling connector name, a
descriptor or an instance with nothing to lend into the artifact with no
verdict. Also three doc-only should-fixes in injected-system-column-provenance
and the IDataEngine.listDatasourceDefs member doc.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2cLiZUHvCtV2YGRWAd
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/injected-system-column-provenance.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/actions-as-tools.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/ai/knowledge-rag.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/contracts/data-engine.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/contracts/index.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/index.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/data-service.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/services-checklist.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/kernel/services.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/permissions/tenant-audit-census.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/protocol/knowledge.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/protocol/objectql/query-syntax.mdx (via IDataEngine (symbol, a top-level interface))

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

  • content/docs/releases/implementation-status.mdx (via IDataEngine (symbol, a top-level interface))
  • content/docs/releases/v17/17-0.mdx (via IDataEngine (symbol, a top-level interface))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/injected-system-column-provenance.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 168 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 a2f434dcc076a34f42f47e64b27414d4a2c87f9e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a2f434dcc076a34f42f47e64b27414d4a2c87f9e

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (HTTP 429 rate_limit, 2026-10-30T04:44Z) · head 88031dd

The seat's at-tier review call is refused by the model quota (Fable still out at the last probe, 2026-10-30T04:26Z), and per contract-review.md the tier is not lowered. Owed on this head: one same-shape CONTRACT_REVIEW_TIER record. Under the 2026-10-29T19:39Z order it also serves as the full review of the spec change.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 88031ddf199544b43ef9ef31c8644f7a89d5e606 · 2026-10-30T05:03Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 88031ddf199544b43ef9ef31c8644f7a89d5e606
Local-runs: none
Scope: packages/spec/src/contracts/data-engine.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/spec/src/stack.zod.ts + packages/spec/src/stack-datasource-connector.test.ts, packages/spec/src/stack-artifact-flow-crossref.test.ts, packages/spec/src/integration/connector.zod.ts, packages/spec/src/shared/connector-auth.zod.ts, packages/spec/src/data/datasource.zod.ts, packages/objectql/src/engine.ts, packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts, packages/plugins/plugin-security/src/federated-phantom-anchors.ts, packages/lint/src/validate-external-owner-sharing.ts, docs/adr/0015-external-datasource-federation.md, packages/cli/src/commands/compile.ts, packages/cli/src/commands/validate.ts, packages/cli/src/utils/config.ts, .changeset/27803-composed-datasource-connector.md

Review at CONTRACT_REVIEW_TIER on the schema face (three hits paths) plus the declared narrowing face (Clause-②: yes (narrowing) on the PR body and the changeset), taken on the triage hand-off 6515181445 under claim 6515418529; per that hand-off this one record also serves as the full review of the spec change at the reviewer tier. Inputs: card #27803 (body and its four comments: scope additions 6511327300 and 6511649400, claim 6514239195, os-dev-report 6515154966), the records those scope additions cite (6511315513 on #27794, 6511604842 on #27788), the filing records 6511230689 and 6511651329 on #27783 and ruling 6486136301 on #27298, PR #27939 (body, 5-file list, net diff against main from the files endpoint, its three comments), and the 54 check-runs on 88031ddf: 40 completed success, 14 completed skipped, zero failures and nothing pending. Every one of the ten required contexts that ran is success (Lint & Repo Gates, TypeScript Type Check, Test Core and all eight shards, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); Lint UI is path-filtered skipped; Check Changeset, PR-body guards, Spec property liveness, Dogfood Regression Gate and every Lint · and Type Check · lane are success. The dispatching seat's conclusions and the dev report's local counts were read as claims about the tree, never as its verdicts. The two prose paths (the changeset and the test file) were read to judge the hits and the narrowing, never reviewed. Nothing was built, run or re-run; the live tree was read at the head (git show) and at origin/main ed0e71db.

① Derived judgments

  1. collectComposedDatasourceConnectorErrors (stack.zod.ts :4498-4502): a module-private function, not exported; packages/spec/api-surface/*.json untouched and check:api-surface green under Lint & Repo Gates. No public export moves. RIGHT.
  2. The accept set of composeStacks (an exported root function of @objectstack/spec) NARROWS: step 3b (:7531-7539) now runs collectDatasourceConnectorErrors over every input's flattened datasources and connectors, so a composition of two or more built inputs is refused where it was accepted. The refused population, read in the collector (:4404-4460): a borrowing datasource whose name no input's connectors entry carries, one whose entry has no provider string, one whose entry's auth is not a record or is type: 'none'. Reachable only through an input built with strict: false (the per-stack pass refuses the same three in strict defineStack, :4557), or through two inputs declaring one connector name (④). The declared face is this narrowing; the title carries ! and the changeset the BREAKING banner. RIGHT as a declaration; see ④ for the one population it refuses that it should not.
  3. defineStack is untouched: validateCrossReferences (:4521) and its call site (:5779) do not move, so a strict stack's own verdict on external.connector is byte-for-byte what main renders; the CONTROL test pins it. RIGHT.
  4. The single-input early return (:7426) is before step 3b, so composeStacks([loose]) still hands a strict: false stack back unjudged; the declared boundary of the artifact pass, unchanged. RIGHT, and worth saying since the changeset's "Before/Now" could be read as every composition.
  5. Last-wins across inputs: the collector's Map keeps the last entry per name over the flattened input order, so a connector name two inputs declare is judged against the later input's entry. collectDuplicateEntryErrors (:3035) refuses a repeated name inside ONE stack only, and ADR-0048 §3.4 leaves cross-package reuse untouched, so the cross-input duplicate reaches this collector and nothing else refuses it. The doc's claim that the later entry is "the one the runtime keeps" is SAME_STACK_ENTRY_IDENTITY's connectors row (:2993, lastWinsByName, the table's measured "boot keeps the last entry under a name, on both paths"). The consequence, pinned in the fourth test: two strict inputs can now be refused when the later one replaces the borrowed instance with a descriptor or an auth-less entry, and accepted in the other order. RIGHT as a reading of the table; judged in ④.
  6. Doc-only hunk, data-engine.ts :499-502 (IDataEngine.listDatasourceDefs?): no key, type or member moves; {@link EngineDatasourceDef.driver} resolves (:261). Against the only implementer at the head (ObjectQL.listDatasourceDefs, engine.ts :9320-9327): driver is spread only when defined, so a definition that never named one is listed without the key, and registerDatasourceDef (:9259) keeps the stored id on a driver-less re-registration. The sentence states exactly that. RIGHT; it is the nit record 6511604842 ④ named.
  7. Doc-only hunk, injected-system-column-provenance.ts :233-235 (isInjectedColumnDefinition): at the head plugin-sharing's federated-phantom-anchors.ts has no equalsShippedDef and imports isOwnerAnchorUnprovable from @objectstack/spec/data (:88, :117); plugin-security's still declares equalsShippedDef (:104) and reads it at :133. The sentence is true of the tree. RIGHT; should-fix 1 of record 6511315513, closed.
  8. Doc-only hunk, injected-system-column-provenance.ts :441-444 (isOwnerAnchorUnprovable fail direction): "Postgres and MySQL raise on the unknown column, SQLite matches no row" is the plugin-sharing module header's own measured statement (:33-36, SQLite reinterprets the unresolvable identifier as a string literal, Postgres/MySQL raise column "owner_id" does not exist). Both outcomes are closed; "neither widens" stands. RIGHT; should-fix 2 of record 6511315513, closed. Function bodies untouched; api-surface/data.json and export-origins/data.json unchanged.
  9. The test file: no published surface; imports composeStacks from the same module. Read to judge the fixtures (owner() is a strict plugin stack carrying only connectors; looseBorrower() is strict: false carrying only the borrowing datasource). Lane seat's ACCEPT.
  10. No generated artifact moves: the hunks are JSDoc and a build-time collector, no .describe(), no authorable key, no export; check:docs, check:authorable-surface, check:api-surface all ride the green Type Check · source gates lane. RIGHT.

② Semver level

  • .changeset/27803-composed-datasource-connector.md: '@objectstack/spec': patch; line-initial Clause-②: yes (narrowing) and Metadata-protocol: none, both matching the PR body's lines; one BREAKING banner naming the population (composeStacks, and so os build on a multi-package artifact); exactly one ADR-0087 marker, not-required (no-migration-prescription), with its reason. @objectstack/spec is the only published package the diff moves. The level follows the kind: none takes patch on the package the diff moves, breaking or not, keeping the banner and the disposition (AGENTS.md Post-Task Checklist 3). Check Changeset success twice, Lint & Repo Gates success (carries check:adr-0087-registration). CONSISTENT.
  • Clause-②: the line reads yes (narrowing) and that is right. composeStacks is a published root export whose accept set narrows (① item 2); (narrowing) is the arm that carries the BREAKING signal. The card dispatched the same arm.
  • Metadata-protocol none is right: no key, accepted value, default or meaning of anything an author writes moves. ExternalDatasourceSettingsSchema.connector (datasource.zod.ts :366) keeps its regex and optionality, DeclarativeConnectorEntrySchema (connector.zod.ts :1171) is untouched, and the rule itself ("must name a declared instance with a provider and an auth other than none") is the one the D7 note already states; what moves is the door that enforces it (composition beside strict defineStack), the same kind record 6511604842 accepted as none for a door-level narrowing.
  • The disposition's claim holds on the tree: nothing for objectstack migrate meta to rewrite, because no stored shape changes; the "remedy" paragraph is an authoring fix (declare the instance or correct the name), not a consumer-code rewrite. The gate agreed.
  • The banner's population is understated, not false: the composition runs inside the author's objectstack.config.ts (config.ts :640-665 wraps composeStacks for every command that loads the config), so the refusal reaches os validate, os lint, os dev and os serve as well as os build. See ③.

③ Boundary flags

  1. open_questions: [] on the dev report. One out_of_scope_findings entry: "os validate was not checked for whether it composes; the changeset names only os build". Answered from the tree: neither command composes on its own; loadConfig evaluates the author's config module, where the author's composeStacks(...) call runs (config.ts :640-665, the shim that re-produces each wrapped input before the real composeStacks), and validate.ts :255-262 receives the already-composed stack (refuseUnbuiltStack says the STACK_* refusals ran in the producer). So the new refusal reaches every command that loads the config. Should-fix (changeset text, no gate): say "and so in every CLI command that loads a multi-package project (os build, os validate, os lint, os dev, os serve)" or simply "and so in the CLI". Not a must-fix: an under-stated population misleads nobody into an unsafe act.
  2. Dev flag on the PR body: a full review at the reviewer tier and a CONTRACT_REVIEW_TIER record are owed before landing. Answered by this record.
  3. Skills-docs: follow-up #27786 on the body: the card exists (open, "teach external.connector (D7) beside credentialsRef in the datasources skill rule and the two data-modeling guides"). The reasoning holds: the composed check applies the same rule with the same findings, so an author writes nothing differently; nothing is owed here.
  4. Card tasks against the tree: task 1 (a composed collector of the ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211 shape refusing the three findings with the same fixes) is done in form; its "declared across the composed inputs" scenario holds for flat inputs and fails for a nested multi-package input (④ must-fix 1). Task 2's three named tests are present (cross-input pair resolved in both orders under last and preserve; dangling name from a strict: false input refused under both; per-stack strict refusal unchanged), plus a fourth for the last-wins arm. Scope additions 6511327300 and 6511649400 (three doc should-fixes) are each closed on the tree (① items 6-8).
  5. The hand-off asked that the last-wins arm be judged specifically. Judged in ④ under stack.zod.ts: the refusal is right in direction and its message is wrong in its prescription.
  6. Author objectstack-fleet[bot] through the relay; head repo equals base repo; the PR is a draft with needs:contract-review; branch claude/project-thread-683cod as the claim 6514239195 names (the card thread's platform-assigned branch, the measured exception the claim records). The diff touches no governed-register surface, so the PR's tier is the contract tier of the published spec, not Tier H.
  7. The docs-drift advisory (6515157814) lists eleven pages by the IDataEngine anchor; the one IDataEngine hunk is a doc sentence on an optional member that none of those pages restates (record 6511604842 ③ already measured that no page documents the keys listDatasourceDefs answers). Nothing owed.
  8. Body text (prose, the lane seat's; no gate reads it): the body's first paragraph names the model the PR was developed on and the model whose review is owed. AGENTS.md's attribution rule keeps model identifiers out of PR titles and bodies; say "the default tier" and "the reviewer tier". Should-fix (body text). PR-body guards are green, so this is the rule, not a gate.
  9. Not a flag on this diff, noted for the epic: the only way a package can legitimately borrow a sibling package's connector instance is to build the borrower with strict: false, because strict defineStack still resolves external.connector against its own connectors alone and there is no artifactConnectors promise of the artifactObjects / artifactFlows shape (spec: defineStack's per-stack cross-reference validation refuses permissions[].objects and data[].object that name an object owned by ANOTHER PACKAGE OF THE SAME ARTIFACT — so ADR-0130's co-owned artifact and the "permission sets stay whole in the app package" addendum cannot both hold once the app package owns objects #18202, spec+cli: an artifact's per-package checks refuse an action → flow and a widget → dataset reference that another package of the SAME artifact satisfies (the #18202 family, for flows and datasets) #25514). The ADR-0015 D7 note says "declared in the same stack". A cross-package borrow is the scenario the card's first test accepts, so the asymmetry is real; a carrier of the spec+cli: an artifact's per-package checks refuse an action → flow and a widget → dataset reference that another package of the SAME artifact satisfies (the #18202 family, for flows and datasets) #25514 shape, not this card's.

④ Design judgment

packages/spec/src/stack.zod.ts (hits; the narrowing face) — judged against the ruling it implements (6486136301: "refused at build when the named instance is not declared"; the card: "names no connector instance declared across the composed inputs"), against the precedents it cites (collectComposedExternalBindingDatasourceErrors :4467-4473, SAME_STACK_ENTRY_IDENTITY :2935-3033), against the sibling collector in the same step that handles a nested multi-package input (collectArtifactFlowClaimErrors :7204 with providedFlowNames :7176-7191), and against its first consumer (composeBuiltStacks step 3b :7531-7539, which is os build's composition through the author's config).

  • Shape: the collector flattens every input's datasources and connectors and hands the pair to the per-stack collector, so the three findings, their fixes and the STACK_CROSS_REFERENCE_INVALID / 422 envelope read exactly as the per-stack ones do. The Array.isArray guards match the precedent's; a non-array collection in a strict: false input is refused by step 3 (CONCAT_ARRAY_FIELDS, connectors: 'concat' :1487) before 3b runs, so the guards are belt, not the only brace. Placement after collectComposedExternalBindingDatasourceErrors and before collectArtifactFlowClaimErrors is the right slot: it needs neither objects nor the manifest strategy. Holds for flat inputs.
  • Must-fix 1 — a nested multi-package input is refused for a connector it carries. A composeStacks(..., { manifest: 'preserve' }) artifact of two or more packages carries each package's collections ONLY in packages[i].manifest (assemblePackageBody :7097 folds every package-owned collection, connectors included, into the body; step 8 :7629 deletes the flattened copy), and the tree accepts such an artifact as an input to a further composition (step 0 provenance; stack-provenance.test.ts :63-67; stack-artifact-flow-crossref.test.ts :157-164 "resolves against a nested multi-package input, whose flows live only in its package bodies"). The flow collector sitting one line below the new call reads both places for exactly this reason: providedFlowNames (:7176-7191) collects stack.flows and then each packages[].manifest.flows, its doc saying "reading the top level alone would refuse a flow the input carries". The new collector reads stack.connectors alone. So composeStacks([composeStacks([ownerPkg, otherPkg], { manifest: 'preserve' }), looseBorrower]) is refused with "which is not defined in connectors" although the artifact carries the instance and the runtime, which registers each package body's connectors by name, would resolve it. This is a new false refusal of a legal composition, in the direction the file itself names as the worst one (the assembled-package-body key doc, :1555-1559: a refusal of "a key its author correctly wrote" that "would look like a defect in the author's metadata"), and it is reached by the nested preserve shape os build writes. The precedent collector (ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211) shares the top-level-only read but fails in the other direction: an unknown datasource is skipped (collectExternalBindingDatasourceErrors :4367 if (datasource === undefined) continue;), so it under-refuses a nested input and never over-refuses. The fix is one helper of the providedFlowNames shape over connectors (and, for symmetry, over datasources, so a datasource a nested body carries is judged against the whole artifact rather than skipped), plus one test of the :157 shape in the composeStacks group. The doc paragraph "A cross-input pair resolves ... because the composed artifact registers both" then holds for the nested case too; today it does not.
  • Last-wins arm (the hand-off's specific ask). Direction: right. A composition that declares sap_s4 twice registers the later entry (the connectors row, :2993), so a datasource that borrowed from the earlier instance would boot against a descriptor or an auth-less entry; refusing the composition is the one-grant behaviour D7 asks for, and accepting the other order is right because the instance that boots is the one that lends. Tried to break it: two strict inputs each with a full instance under one name, differing in credentialRef: accepted, both lend, the later one is what connects, and whether that is the author's intent is ADR-0048 §3.4's hygiene question, not this collector's. Two inputs where the earlier is a descriptor and the later an instance, borrower strict in the later input: accepted, right. Borrower strict: false in a third input with the pair in either order: judged against the later entry, consistent. Should-fix 1 (message, no gate): in this arm the finding's prescription is wrong for the author. The reused per-stack message says "Give it the provider that serves this vendor and an auth credential, or name a connector instance that has one", but the artifact already carries such an instance under that very name, in the earlier input; what the author must do is rename or remove the later entry, or move the borrow. The composition door knows the name is declared twice (the flattened list has two entries) and should say so, as collectComposedDefaultRecordPageErrors (:3147) labels each finding with the input stacks involved. One sentence appended when the name occurs more than once in the flattened connectors would do; the subject and envelope stay as they are.
  • enabled: false accepted across inputs, as per stack: defensible, same D7 reasoning as record 6511230689. A strict: false input's connectors entry with a provider and an auth record that the strict parse would have refused (e.g. { type: 'bearer' } with no credentialRef): accepted by this collector, which is not a schema validator; the per-stack collector shares the boundary and strict: false is the author's own choice to skip the schema. Not a finding.
  • The docblock: accurate on every sentence but the nested case (above); one wrapped line (:4484) runs past the file's comment width, Lint · ESLint green, cosmetic. The step-3b comment (:7523-7525) and the per-stack doc's pointer (:4400-4402) are right.
  • The test group (8 cases): the fixtures isolate what each case claims (the owner stack has no datasource, the borrower no connector), the refusal helper asserts the envelope, one finding, and the composition header, and the CONTROL that the per-stack message does not say composeStacks pins which door refused. What it lacks is the nested-input case (must-fix 1). Not run here; Test Core 8/8 is success on the head.

packages/spec/src/contracts/data-engine.ts (hits) — judged against the nit it closes (record 6511604842 ④: the listDatasourceDefs? member doc naming driver as an answered key) and against the implementer and consumers that record read (ObjectQL.listDatasourceDefs :9320-9327, registerDatasourceDef :9250-9260; secret-reference-union.ts reads only external.credentialsRef).

  • The new sentence makes two claims and both hold at the head: each entry carries the stored driver id (:9323 spreads it when defined), and the key is omitted for a definition that never named one (an absent key is never stored as undefined-with-key, since the spread is conditional; the kept id on re-registration is :9259's keep, which the member doc of registerDatasourceDef? already states). The link target exists. Tried to break it: a def registered with driver: '' is listed with driver: '' (defined, so spread), and the sentence does not say otherwise. No consumer narrows on the key's absence. Holds; no finding.

packages/spec/src/data/injected-system-column-provenance.ts (hits) — judged against the two should-fixes it closes (record 6511315513 ④ should-fix 1 and 2) and against the modules the sentences describe at the head.

Findings. Must-fix 1: collectComposedDatasourceConnectorErrors reads each input's top-level connectors only, so a nested multi-package preserve artifact is refused for an instance it carries in a package body; mirror providedFlowNames over connectors (and datasources) and pin the :157 shape. Should-fix 1: the last-wins arm's prescription tells the author to add what the artifact already has; say the name is declared twice and by which inputs. Should-fix 2 (③.1): the changeset's population names os build alone where the refusal reaches every command that loads the project. Should-fix 3 (③.8): the PR body names models; say the tiers. Noted for a carrier (③.9): no strict-mode way to borrow a sibling package's instance.

Implemented-by: claude/project-thread-683cod
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: FAIL


Generated by Claude Code

… bodies and names a shadowed instance

A multi-package input carries its connectors and datasources only in its
package bodies, so reading the top level alone refused an instance the
input carries. A connector declared twice whose kept entry cannot lend now
names both declarations and prescribes the duplicate's fix.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2cLiZUHvCtV2YGRWAd
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 807e50d5a889dd9c7b9cf2d5dbeabf22dcf10002 · 2026-10-30T05:33Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 807e50d5a889dd9c7b9cf2d5dbeabf22dcf10002
Local-runs: none
Scope: packages/spec/src/contracts/data-engine.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/spec/src/stack.zod.ts + packages/spec/src/stack-datasource-connector.test.ts, .changeset/27803-composed-datasource-connector.md, packages/objectql/src/registry.ts, packages/objectql/src/plugin.ts, packages/services/service-automation/src/connector-credential-source.ts, packages/services/service-automation/src/plugin.ts, packages/services/service-datasource/src/datasource-connector-credential.ts, packages/cli/src/commands/lint.ts, packages/cli/src/commands/serve.ts, packages/cli/src/commands/validate.ts, packages/cli/src/utils/config.ts, and the files record 6515560402 cites, which stand as read there

Delta review at CONTRACT_REVIEW_TIER on the schema face (three hits paths) plus the declared narrowing face (Clause-②: yes (narrowing) on the PR body and the changeset), under claim 6515793980 on this head. The previous record on this PR is 6515560402 (FAIL on 88031ddf: one must-fix, three should-fixes). What moved since: one PR commit, 489498a4 (the composed collector reads nested package bodies, and a name declared more than once gets a duplicate-naming prescription), and the merge 807e50d5 of origin/main ed0e71db (eighteen main commits, #27819 and #27911 among them). Inputs: card #27803 (body and its four comments, unchanged since the previous record), PR #27939 (body, unchanged; 5-file list; net diff against main at ed0e71db from the files endpoint, read beside git diff ed0e71db 807e50d5 locally; its eight comments), and the 49 check-runs on 807e50d5 at 2026-10-30T05:44Z: 39 completed success, 10 completed skipped, zero failures, nothing pending. Every one of the ten required contexts that ran is success (Lint & Repo Gates, TypeScript Type Check, Test Core and all eight shards, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); Lint UI is path-filtered skipped; Check Changeset, PR-body guards, Spec property liveness, Dogfood Regression Gate and every Lint · and Type Check · lane are success. At the first read (05:35Z) Type Check UI, Type Check · workspace and three Test Core shards were in_progress; they were read as pending, not green, and re-read until complete. The merge was separated from the PR's own commits: the merge's tree equals git merge-tree --write-tree 489498a4 ed0e71db byte for byte, so no conflict was resolved by hand; between the merge base 88d60203 and ed0e71db, main touched none of the PR's five files except that fourteen unrelated changesets arrived under .changeset/, and under packages/spec/src it moved only data/type-compat.ts, its test, kernel/public-auth-features.ts and kernel/manifest-packaging-retirement.test.ts, none of which the collectors read. #27819 and #27911 are runtime-only (packages/types, service-automation, service-datasource, driver-odata); #27819 is read below as the first live consumer of the premise the narrowing's last-wins arm rests on. Nothing was built, run or re-run; the live tree was read at the head and at origin/main ed0e71db with git show. The two doc-only hits files are byte-identical to the previous head (git diff 88031ddf 807e50d5 over them is empty) and main did not touch them, so their sections stand as judged in record 6515560402.

① Derived judgments

  1. collectComposedDatasourceConnectorErrors (stack.zod.ts :4563), providedCollectionEntries (:4513) and the three predicates connectorHasProvider / connectorHasCredential / connectorCanLend (:4463-4476) are module-private; collectDatasourceConnectorErrors (:4407) gains an optional second parameter and is module-private too. packages/spec/api-surface/*.json untouched; check:api-surface rides the green Lint & Repo Gates. No public export moves. RIGHT.
  2. The accept set of composeStacks NARROWS, and the move widens the refused population past the previous head: a borrowing datasource is now read from each input's top level AND from each packages[].manifest body, and judged against the connectors of every input's top level and bodies (:4578-4580). So a nested preserve artifact whose package body carries a dangling, descriptor-bound or auth-less borrow is refused where the previous head skipped it, and a nested artifact whose body carries the lending instance is accepted where the previous head refused it (the previous record's must-fix 1). Still reachable only through a strict: false input, a hand-built packages[] body, or two inputs declaring one connector name. The title carries ! and the changeset the BREAKING banner. RIGHT as a declaration; see ④ for the one population it refuses that it should not and the one it accepts that it should not.
  3. defineStack is untouched in effect: validateCrossReferences (:4609) calls collectDatasourceConnectorErrors with no shadowedLender, so the per-stack messages are byte-for-byte what main renders (the shadow arm is undefined and every ternary takes its old branch, :4433-4456); the CONTROL test pins it. RIGHT.
  4. The single-input early return (:7527) is unchanged and before step 3b; composeStacks([loose]) still hands a strict: false stack back unjudged. RIGHT, stands as judged.
  5. Last-wins across inputs, re-derived. The collector's Map keeps the last entry per name over the flattened order (:4418-4420), and the move's declarations map (:4566-4575) is built over the same order with the same filter, so kept in shadowedLender (:4583) is the entry the collector judged; consistent. The claim that the later entry is "the one the runtime keeps" is SAME_STACK_ENTRY_IDENTITY's connectors row (:2993, lastWinsByName :2924). That row's header (:2860-2923) states the measurement's scope: two entries under one identity in ONE stack, on a source boot and an artifact-backed boot, and it says in so many words that "Cross-package reuse of a name is untouched (ADR-0048 §3.4): a multi-package project (composeStacks(…, { manifest: 'preserve' })) carries each package's collections in its own packages[i] body, which this check does not read." The registry confirms why: registerItem stores a package-shipped item under the composite key packageId:name (registry.ts :5397, the key at about :5538, the rule stated at :2679-2686), so two entries of one name inside one package collide on one slot and the last write wins, while two packages' entries of one name occupy two slots and both survive, and listItems('connector') (:6199) returns every slot in registration order. The move now reads package bodies, which is exactly where cross-package reuse lives, and labels the arm "the entry the composed artifact keeps … which the runtime keeps". WRONG on the multi-package path; judged in ④ as must-fix 1. Record 6515560402 ① item 5 read the row and not its header's scope; this record corrects it.
  6. Doc-only hunk, data-engine.ts :499-502: unchanged since 88031ddf; stands as judged in record 6515560402 ① item 6. RIGHT.
  7. Doc-only hunks, injected-system-column-provenance.ts :233-235 and :441-444: unchanged since 88031ddf; stand as judged in record 6515560402 ① items 7 and 8. RIGHT.
  8. The test file: no published surface; imports composeStacks and defineStack from the same module. 21 cases at the head (8 per-stack, 13 composed: two it.each of 4 and 2, five single cases, a nested group of 3, one CONTROL). Read to judge the fixtures; lane seat's ACCEPT.
  9. No generated artifact moves: JSDoc, a build-time collector and three predicates; no .describe(), no authorable key, no export. check:docs, check:authorable-surface, check:api-surface, check:doc-authoring (the new runtime strings carry an ADR id and no tracker number) all ride green lanes. RIGHT.

② Semver level

  • .changeset/27803-composed-datasource-connector.md: '@objectstack/spec': patch; line-initial Clause-②: yes (narrowing) and Metadata-protocol: none, both matching the PR body; one BREAKING banner; exactly one ADR-0087 marker, not-required (no-migration-prescription), with its reason. @objectstack/spec is the only published package the diff moves; none takes patch on the package the diff moves, keeping the banner and the disposition (AGENTS.md Post-Task Checklist 3). Check Changeset success, Lint & Repo Gates success (carries check:adr-0087-registration). CONSISTENT in level and kind.
  • Clause-②: yes (narrowing) is right; composeStacks is a published root export whose accept set narrows (① item 2).
  • Metadata-protocol none is right: no key, accepted value, default or meaning of anything an author writes moves; what moves is the door that enforces the D7 rule, now one level deeper (package bodies). Same kind record 6511604842 accepted as none.
  • The banner's population is now right: "every CLI command that loads a project whose config composes two or more packages (os build, os validate, os lint, os dev, os serve)". Verified at ed0e71db: lint.ts :969, validate.ts :255 and serve.ts :9 import and call loadConfig, and config.ts :634-665 is the shim under which the author's composeStacks runs for real. Should-fix 2 of record 6515560402 is TAKEN.
  • The "Now" paragraph's sentence "A connector name declared more than once is judged against the later entry, which is the one the runtime keeps" states a runtime fact that holds for a flattened (last) artifact and not for a preserve multi-package artifact (① item 5, ④ must-fix 1). The changeset text follows the fix.

③ Boundary flags

  1. Must-fix 1 of record 6515560402 (nested multi-package input refused for an instance it carries): DISCHARGED. providedCollectionEntries reads the top level and every packages[].manifest body (:4527-4533) in the providedFlowNames shape (:7264), for connectors and datasources alike, and the test group "a nested multi-package input, whose collections live only in its package bodies" (test :213-253) pins the :157 shape: a CONTROL asserting the nested artifact's top-level connectors is undefined (step 8 did strip it) and that the borrower resolves against the body's instance in both orders and under preserve and last; a datasource carried only by a body judged (dangling name refused with the per-stack sentence and the 422 envelope); and the body label in a shadow finding.
  2. Should-fix 1 of 6515560402 (the last-wins arm's prescription): TAKEN in form, and the form carries this record's must-fix (④).
  3. Should-fix 2 (changeset population): TAKEN (② above).
  4. Should-fix 3 (the PR body names models): NOT TAKEN; the body's first paragraph is unchanged. Still a should-fix (body text, no gate): say "the default tier" and "the reviewer tier". The body's Verification paragraph is also stale on the head: it says the file holds 17 tests and the composed group 8 cases, the tree holds 21 and 13; its ablation counts (4 red / 13 green) describe the previous head. Prose, the lane seat's; folded into the same should-fix.
  5. No dev report on the move. The card's only os-dev-report (6515154966) names head 88031ddf; the move's account is its commit message (489498a4) and no comment on the card or the PR says what was run on 807e50d5. The record judges the tree, so nothing here depends on it, but the executor's own record is the report comment (AGENTS.md, claim discipline): should-fix (card record), one report line on the card naming the head and what was run.
  6. open_questions: [] and the one out_of_scope_findings entry (os validate) on the previous report: answered by the changeset's new population (② above). Nothing new was raised.
  7. Dev flag on the PR body (a full review at the reviewer tier and a CONTRACT_REVIEW_TIER record owed before landing): answered by this record. Skills-docs: follow-up #27786: stands as judged.
  8. Merge resolution: clean auto-merge, no hand resolution (preamble). main's feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819 brought the runtime half of D7 (service-automation's connector credential source, service-datasource's borrow, the OData driver's presentation) and no spec change; fix(service-datasource)!: external validate answers an unknown datasource 404 RESOURCE_NOT_FOUND #27911 changed service-datasource's validate door. Neither touches the collectors; feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819 is the live consumer the arm's premise is now judged against (④). The move breaks nothing record 6515560402 judged: defineStack's verdicts (① item 3), the doc hunks (① items 6-7), the composed external-binding collector and the flow-claim collector are untouched, and step 3b's order (:7620-7628) is as before with the new call in the same slot.
  9. Card tasks against the tree: task 1 (a composed collector of the ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211 shape, three findings, same fixes) done; its "declared across the composed inputs" scenario now holds for flat and nested inputs. Task 2's three named tests present, plus the last-wins case and the nested group. Scope additions 6511327300 and 6511649400 closed (stand as judged).
  10. Judged, not a finding: the build accepts a disabled instance (enabled: false) while feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819's lender refuses it at connect (lendRefusal :109-112). Not contradictory: the build judges the declaration (D7: "refused at build when the named instance is not declared", and a disabled instance is declared), the runtime judges the switch, and both texts say the switch turns the vendor off for records as for actions. feat(spec): an API-backed datasource names a declared connector instance in external.connector and borrows its credential (ADR-0015 D7) #27783's rule, mirrored here by card design.
  11. Judged, not a finding: a package body carrying datasources in the record spelling is skipped by collect's Array.isArray guard (:4521) rather than refused, the under-refusing direction of the ADR-0015 §4.2: the object save door and the datasource save door still accept an external binding on a managed datasource the stack does not declare (follow-up to #25168) #25211 precedent; a strict parse normalizes the map form before assembly and a strict: false hand-built body is the author's own bypass.
  12. Author objectstack-fleet[bot] through the relay; head repo equals base repo; draft with needs:contract-review; branch claude/project-thread-683cod as claim 6514239195 names. No governed-register path in the diff; the PR's tier is the contract tier of the published spec, not Tier H.
  13. Docs-drift advisory (6515157814, recomputed on the merge commit): the IDataEngine anchor's one hunk is unchanged; stands as judged (record 6515560402 ③.7).

④ Design judgment

packages/spec/src/stack.zod.ts (hits; the narrowing face) — judged against the ruling it implements (6486136301), the card, the precedents it cites (collectComposedExternalBindingDatasourceErrors :4497, providedFlowNames :7264, SAME_STACK_ENTRY_IDENTITY :2935 with its header :2860-2923), its first consumer (composeBuiltStacks step 3b :7620-7628 under the author's config), the artifact shapes the tree produces (assemblePackageBody :7185, preservePackageEntries :6938, step 8 :7700-7712 with packagesCarryEveryInputCollection :7000 and packagesReproduceComposedCollections :7102), and the runtime that now lends the credential (registry.ts registerItem :5397 / listItems :6199 at ed0e71db; connector-credential-source.ts findDeclared :78-84 and lendRefusal :95-114; service-automation/plugin.ts readDeclaredConnectorItems :1925-1960 and mergeDeclaredConnectorSources :537; objectql/plugin.ts manifest.register :740-800).

  • The nested-body reading (the discharged must-fix). providedCollectionEntries mirrors providedFlowNames and labels each entry with its declaration site through stackLabel (:6095) and the body's id ?? name, which is the manifest id a body carries at its top (assemblePackageBody spreads the manifest first, :7187). Placement unchanged and right. Tried to break it: a nested preserve artifact whose bodies did not reproduce the flattened half keeps BOTH halves (step 8 strips only when packagesReproduceComposedCollections holds; its doc :7045-7101 names objectConflict: 'merge' / 'override' and a cross-package action binding as legal compositions that fail reproduction). Fed to a further composition, such an input yields every datasources and connectors entry twice: once from its top level and once from its body (the body holds the input's own array by reference, :7189, and the flattened half holds the same element objects after arrays.flat()). The connector side is harmless (the duplicate declarations are one object; shadowedLender finds no lender that the kept entry is not). The datasource side judges one borrowing datasource twice, so a dangling name in such an input yields two identical finding lines under a "(2 issues)" header. No false verdict; a wrong count and a doubled line. The precedent providedFlowNames is immune because it collects into a Set. Should-fix 1: de-duplicate entries by identity (a Set over the entry objects in providedCollectionEntries, or over stableCollectionIdentity), and pin the both-halves nested shape; no test covers it.
  • Must-fix 1 — the last-wins arm asserts, and acts on, a runtime fact that is false on the multi-package path the move now reads. The arm (the shadow branch :4433-4456, shadowedLender :4581-4590, the docblock :4556-4562, the changeset's "Now" sentence) says that when a connector name is declared more than once, the later declaration is "the entry the composed artifact keeps … which the runtime keeps", and it refuses or accepts the composition by that reading: [A(instance + borrower), B(descriptor)] refused, [B(descriptor), A] accepted. The cited row's own header limits the measurement to two entries in ONE stack and states that cross-package reuse in a preserve artifact is untouched because the bodies are not read (① item 5). The move reads the bodies. On that path the artifact keeps BOTH entries: each package body registers through manifest.register in dependency-topological order (objectql/plugin.ts :740-800, resolveArtifactPackageEntries), each body's items under its own composite key packageId:name (registry.ts :5538), so A:sap_s4 and B:sap_s4 are two slots, listItems('connector') returns both in registration order, the post-boot /meta view keys by (package, name) too (registry.ts :2683-2686), and feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819's lender takes the FIRST of that listing whose name matches (findDeclared :78-84 over readDeclaredConnectorItems :1932). Consequences, each reached by os build on a multi-package project with one connector name reused across two packages whose entries differ in lendability: (a) order [A, B] is a legal composition on main today and the lender answers A's instance when A registers first, so the new refusal is of an artifact that boots and borrows — a false refusal of "a key its author correctly wrote" (the file's own worst direction, :1555-1559), and the finding's reason ("which the runtime keeps") is untrue; (b) order [B, A] is accepted, and when B registers first the lender answers the descriptor and the connect fails with "declares no provider" — the late failure D7's build-time rule exists to prevent, behind a build that said yes. The verdict is order-dependent on a path where declaration order is not what decides, and inverted against the runtime whenever registration order follows declaration order. The flattened last path is unaffected: one package id, one slot, last write wins, so the arm's existing test (test :189-207, default options) holds. The fix: scope the "kept / shadowed" reasoning to declarations that share one registration slot (entries at one input's top level, or inside one package body), and treat same-named entries in DIFFERENT package bodies of a preserve composition as what the registry makes them, two kept instances, refusing the borrow as ambiguous in BOTH orders when they differ in lendability (one grant, D7) and naming both packages and that registration order decides which lends; reword the message, the docblock and the changeset sentence to say which path keeps what; and pin the preserve shape in both orders (the nested group's third case, test :243-252, reaches the shadow arm with a body-carried instance and a top-level descriptor under the outer default last, a mixed shape, and asserts only the label). Accepting both orders is not a fix: (b) stays.
  • The three predicates and the reused per-stack messages: connectorCanLend ignores enabled, consistent with the rule's accepted-disabled reading. The shadow value is computed once before the provider check, so a kept entry with a provider and no auth gets the auth sentence plus the duplicate prescription, and a kept descriptor gets the provider sentence plus it (test :189-207 pins the latter; :209-212 pins that two non-lending declarations fall back to the per-stack prescription). Holds on the last path.
  • The docblock: accurate on every sentence except the kept-entry claim (above); the step-3b comment (:7614-7616) and the per-stack doc's pointer (:4397-4404) are right. One comment line (:4537) runs past the file's comment width; Lint · ESLint green, cosmetic.
  • The test group (13 composed cases): the fixtures isolate what each case claims; every refusal asserts the envelope, one finding and the composition header; the nested CONTROL asserts the strip happened before asserting the resolution. What it lacks: the preserve shape of the last-wins arm in both orders (must-fix 1) and the both-halves nested input (should-fix 1). Not run here; Test Core 8/8 success on the head.

packages/spec/src/contracts/data-engine.ts (hits) — byte-identical to 88031ddf, untouched by main's merge; stands as judged in record 6515560402 ④ (the listDatasourceDefs? sentence holds against ObjectQL.listDatasourceDefs). No finding.

packages/spec/src/data/injected-system-column-provenance.ts (hits) — byte-identical to 88031ddf, untouched by main's merge; stands as judged in record 6515560402 ④ (both sentences hold against plugin-sharing, plugin-security and the lint reader). No finding.

Findings. Must-fix 1: the last-wins arm reads "the later declaration is the one the runtime keeps" onto the preserve multi-package path, where the registry keeps both entries under composite keys and the lender answers the first registered; the composed verdict is then wrong in one order or the other and its reason untrue; scope the arm to one registration slot, refuse the cross-package ambiguity in both orders by name, reword the message / docblock / changeset, and pin the preserve shape both ways. Should-fix 1: a nested input that kept both halves is read twice, doubling a datasource's finding; de-duplicate by identity and pin it. Should-fix 2 (③.4): the PR body names models and carries the previous head's test counts; say the tiers and the head's numbers. Should-fix 3 (③.5): one report line on the card for the move's head. Previous record's must-fix 1 discharged; should-fixes 1 and 2 taken; should-fix 3 not taken.

Implemented-by: claude/project-thread-683cod
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: FAIL


Generated by Claude Code

… the composed external.connector check

The registry keeps one connector entry per package, and the lender answers
the first registered, so last-wins holds only within one slot. Two packages
that disagree on whether their entry can lend are refused as ambiguous in
either order. An entry a nested input carries in both halves is read once.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2cLiZUHvCtV2YGRWAd
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 1f471e098a7b9502c6745c076236e6a172f88e17 · 2026-10-30T06:06Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1f471e098a7b9502c6745c076236e6a172f88e17
Local-runs: none
Scope: packages/spec/src/contracts/data-engine.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/spec/src/stack.zod.ts + packages/spec/src/stack-datasource-connector.test.ts, .changeset/27803-composed-datasource-connector.md, packages/objectql/src/registry.ts, packages/objectql/src/plugin.ts, packages/core/src/artifact-packages.ts, packages/metadata/src/plugin.ts, packages/runtime/src/app-plugin.ts, packages/runtime/src/artifact-collections.ts, packages/services/service-automation/src/connector-credential-source.ts, packages/services/service-automation/src/plugin.ts, packages/services/service-automation/src/connector-credentials.ts, packages/cli/src/utils/config.ts, and the files records 6515560402 and 6515991063 cite, which stand as read there

Delta review at CONTRACT_REVIEW_TIER on the schema face (three hits paths) plus the declared narrowing face (Clause-②: yes (narrowing) on the PR body and the changeset), under claim 6516210368 on this head. The previous record on this PR is 6515991063 (FAIL on 807e50d5: one must-fix, three should-fixes). What moved since: one PR commit, 0300a14 (the composed collector judges a duplicated connector name per registration slot; three files, the changeset, the test file and stack.zod.ts), and the merge 1f471e09 of origin/main a2f434dc (ten main commits, #27941 among them). Inputs: card #27803 (body and its four comments, unchanged since the previous record: the card's updated_at is 04:42Z), PR #27939 (body, unchanged; 5-file list; net diff against main at a2f434dc from the files endpoint, read beside git diff 807e50d5 1f471e09 locally; its eleven comments), and the 49 check-runs on 1f471e09, read twice: at 06:07Z Type Check UI and Test Core shards 1, 4 and 6 were in_progress and were read as pending, not green; at 06:13Z all 49 were complete, 39 success, 10 skipped, zero failures, nothing pending. Every one of the ten required contexts that ran is success (Lint & Repo Gates, TypeScript Type Check, Test Core and all eight shards, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); Lint UI is path-filtered skipped; Check Changeset, PR-body guards, Spec property liveness, Dogfood Regression Gate and every Lint · and Type Check · lane are success. The merge was separated from the PR's own commits without git merge-tree (the shallow clone refuses it): the file set of git diff 0300a14c 1f471e09 equals the file set of git diff ed0e71db a2f434dc (66 files, 3396/184), the PR's five files are blob-identical between 0300a14 and the head, git diff a2f434dc 1f471e09 names exactly those five files, and main touched none of them between ed0e71db and a2f434dc; so the merge is main plus the PR's five files and nothing was resolved by hand. Under packages/spec, packages/objectql, packages/core and service-datasource the ten main commits moved nothing; #27941 moved service-automation's plugin.ts, connector-credentials.ts, engine.ts and service-cluster, and is read below as the merge's one carry-over on the lender side. Nothing was built, run or re-run; the live tree was read at the head and at origin/main a2f434dc with git show. The two doc-only hits files are byte-identical to 88031ddf (git diff 88031ddf 1f471e09 over them is empty) and main did not touch them, so their sections stand as judged in records 6515560402 and 6515991063.

① Derived judgments

  1. collectComposedDatasourceConnectorErrors (stack.zod.ts :4602) gains a second parameter preserve: boolean and stays module-private; providedCollectionEntries (:4539) gains a preserve parameter and returns the new module-private ProvidedCollectionEntry shape (:4512, with slot); connectorBorrowSubject (:4465) is a new module-private helper; collectDatasourceConnectorErrors (:4407) keeps its signature. packages/spec/api-surface/*.json untouched; check:api-surface rides the green Lint & Repo Gates. No public export moves. RIGHT.
  2. The accept set of composeStacks NARROWS, and the move re-shapes the refused population at one point only: a connector name whose kept declarations sit in two or more registration slots and disagree on lendability is now refused in EVERY input order (:4624-4638), where the previous head refused one order and accepted the other; a name whose declarations share one slot is judged as before (last wins, :4639-4652). The three findings reachable through a strict: false input, the cross-input pair, the nested-body reading and the single-input early return (:7592) are unchanged. The title carries ! and the changeset the BREAKING banner. RIGHT as a declaration.
  3. defineStack is untouched in effect: validateCrossReferences calls collectDatasourceConnectorErrors with no shadowedLender, the shadow arm is undefined, and the subject string connectorBorrowSubject renders is byte-for-byte the previous template literal (:4466). The per-stack CONTROL tests pin it. RIGHT, stands as judged.
  4. Slot derivation (:4555-4567), judged against how the artifact boots. A packages[] body registers through manifest.register (objectql/plugin.ts :727-871): resolveArtifactPackageEntries (core/artifact-packages.ts :256) returns the bodies in dependency-topological order and ql.registerApp(manifest, scope, source) (:836) stores each body's items under the composite key packageId:name (registry.ts :5538-5539); two bodies of one id are refused there as DUPLICATE_ARTIFACT_PACKAGE, so package:${id} is one slot per body. RIGHT. A top-level entry of a preserve composition is folded into a body of the input's own manifest id (preservePackageEntries :7016-7027, for an input without packages), so package:${ownId} is right for that input; two plain inputs sharing a manifest id make two bodies of one id and are refused at boot, so the one-slot reading is moot there. RIGHT. A top-level entry of a last composition registers under the composed artifact's one manifest, so 'artifact' is one slot for every top-level entry. RIGHT for an artifact without packages[]; see ④ for the one shape where the top level sits beside package bodies.
  5. The kept entry per slot is the last in declaration order (:4624); within one slot that is SAME_STACK_ENTRY_IDENTITY's connectors row (:2993, lastWinsByName :2924, measured on a source boot and an artifact-backed boot), and registerItem overwrites a repeated storage key (:5539-5541). RIGHT. Across slots the lender takes the first listItems('connector') entry whose name matches (findDeclared, connector-credential-source.ts :78-84, over readDeclaredConnectorItems, service-automation/plugin.ts :1934-1966), and listItems returns Map insertion order (registry.ts :6199-6206), which is registration order. So "the first one registered lends" is RIGHT, and the move's refusal of a cross-slot disagreement in either order is the reading the previous record's must-fix asked for.
  6. Doc-only hunk, data-engine.ts :499-502: unchanged since 88031ddf; stands as judged in record 6515560402 ① item 6. RIGHT.
  7. Doc-only hunks, injected-system-column-provenance.ts :233-235 and :441-444: unchanged since 88031ddf; stand as judged in record 6515560402 ① items 7 and 8. RIGHT.
  8. The test file: no published surface; imports composeStacks and defineStack from the same module. 25 cases at the head (8 per-stack, 17 composed: it.each of 4, 2 and 2, nine single cases). Read to judge the fixtures; lane seat's ACCEPT.
  9. No generated artifact moves: JSDoc, a build-time collector, one interface and one helper; no .describe(), no authorable key, no export. The new runtime string names no tracker number (check:doc-authoring). check:docs, check:authorable-surface, check:api-surface ride green lanes. RIGHT.

② Semver level

  • .changeset/27803-composed-datasource-connector.md: '@objectstack/spec': patch; line-initial Clause-②: yes (narrowing) and Metadata-protocol: none, both matching the PR body; one BREAKING banner; exactly one ADR-0087 marker, not-required (no-migration-prescription), with its reason. @objectstack/spec is the only published package the diff moves; none takes patch, keeping the banner and the disposition (AGENTS.md Post-Task Checklist 3). Check Changeset success, Lint & Repo Gates success (carries check:adr-0087-registration). CONSISTENT in level and kind; stands as judged in record 6515991063 ②.
  • Clause-②: yes (narrowing) is right (① item 2). Metadata-protocol none is right: no key, accepted value, default or meaning of anything an author writes moves.
  • The "Now" paragraph, re-read on the move: "A connector name declared twice in the flattened artifact is judged against the later entry, which is the one the registry keeps" holds (① item 5, one slot); "A connector name that two packages of the artifact declare is kept twice, and the first one registered lends, so when one of the two can lend and the other cannot, the borrow is refused as ambiguous in either order" holds against registerItem, listItems and findDeclared (① items 4-5). The "Unchanged" sentence states a necessary condition ("refused only when another input declares the borrowed connector name with an entry that cannot lend") and the tree satisfies it in both arms. The population sentence (five CLI commands) stands as judged. The previous record's changeset finding (the "which the runtime keeps" sentence read onto the preserve path) is TAKEN.

③ Boundary flags

  1. Must-fix 1 of record 6515991063 (the last-wins arm read onto the preserve multi-package path): DISCHARGED. Declarations are grouped by name and then by slot (:4607-4616); the kept entry per slot is judged; two or more kept entries that disagree on lendability refuse the borrow as ambiguous in either order, naming both sites and the first-registered rule (:4624-4638); the shadow prescription is scoped to one slot (:4639-4652); the docblock (:4590-4601), the per-stack doc (:4400-4405), the in-loop comment (:4433-4436) and the changeset say which path keeps what; and the preserve shape is pinned in both orders (test :222-235) with a CONTROL that two lending packages compose in both orders (:237-241). Judged in ④.
  2. Should-fix 1 of 6515991063 (a nested input keeping both halves read twice): TAKEN. providedCollectionEntries keeps a Set over the entry objects, reads the bodies first and lets the top level add only what no body carries (:4546-4553, :4566); pinned by "reads an entry the nested input carries in both halves once" (test :291-306: one finding, a "(1 issue)" header).
  3. Should-fix 2 of 6515991063 (the PR body names models and carries the previous head's test counts): NOT TAKEN. The body is unchanged: its first paragraph names two models by marketing name, its 维护者速读 verification line says 17 tests, its Verification paragraph says the composed group holds 8 cases and the ablation went 4 red / 13 green; the tree holds 25 tests, 17 composed. Still a should-fix (body text, no gate): say "the reviewer tier" and "the default tier", and the head's numbers.
  4. Should-fix 3 of 6515991063 (one report line on the card for the move's head): NOT TAKEN. The card still has four comments, the newest the os-dev-report of 04:42Z naming 88031ddf; no PR comment from the dev names 807e50d5, 0300a14 or this head; the move's only account is the commit message of 0300a14. The record judges the tree, so nothing here depends on it; the executor's own record is the report comment (AGENTS.md, claim discipline). Still a should-fix (card record): one report line naming the head and what was run.
  5. Merge carry-over: clean (preamble). fix(service-automation): a connector credential written through the door reaches every replica #27941 adds a cluster lane to the credential door (connector-credentials.ts :44-74, service-automation/plugin.ts :2846, :2879, :2905-2965, :3698-3699, a publish after store.set / store.clear and a receiver that runs materializeDeclaredConnectors) and touches neither readDeclaredConnectorItems, mergeDeclaredConnectorSources, findDeclared nor lendRefusal; connector-credential-source.ts, service-datasource, registry.ts, objectql/plugin.ts and core/artifact-packages.ts did not move between ed0e71db and a2f434dc. The lender-side premise ① item 5 rests on is as it was. The merge breaks nothing record 6515991063 judged.
  6. The move adds nothing outside the review's findings: git diff 807e50d5 0300a14c is the per-slot grouping, the ambiguity arm, the identity de-duplication, the connectorBorrowSubject extraction (a refactor the per-stack string test pins), the preserve flag threaded from step 3b (:7704), the reworded docblocks and changeset sentence, and five test changes (two renamed cases, one re-asserted, two groups added). collectComposedExternalBindingDatasourceErrors, the flow-claim collector, step 3b's order and the two doc hunks are untouched.
  7. Dev flag on the PR body (a full review at the reviewer tier and a CONTRACT_REVIEW_TIER record owed before landing): answered by this record. Skills-docs: follow-up #27786: stands as judged. open_questions: [] and the one out_of_scope_findings entry: answered (record 6515991063 ③.6).
  8. Card tasks against the tree: task 1 done, with the composed scenario now holding for flat inputs, nested inputs and the cross-package duplicate; task 2's three named tests present. Scope additions 6511327300 and 6511649400 closed (stand as judged). Docs-drift advisory 6515157814: the IDataEngine anchor's hunk is unchanged; stands as judged.
  9. Author objectstack-fleet[bot] through the relay; head repo equals base repo; draft with needs:contract-review; branch claude/project-thread-683cod as claim 6514239195 names. No governed-register path in the diff.

④ Design judgment

packages/spec/src/stack.zod.ts (hits; the narrowing face) — judged against the ruling it implements (6486136301), the previous record's must-fix, the precedents it cites (SAME_STACK_ENTRY_IDENTITY :2935 with its header :2860-2923, providedFlowNames :7342), its first consumer (composeBuiltStacks step 3b :7704 under the author's config through config.ts's shim), the artifact shapes the tree produces (preservePackageEntries :7016, assemblePackageBody :7263, step 8 :7791-7796), and the runtime that registers and lends: manifest.register and registerApp (objectql/plugin.ts :727-871, :836), resolveArtifactPackageEntries (core/artifact-packages.ts :256), registerItem / listItems (registry.ts :5538-5541, :6199-6206), the residual rule unclaimedTopLevel / claimedArtifactSlots / artifactItemSlot (metadata/plugin.ts :405-426, :335, :314; connectors in its field map at :200), AppPlugin.init's hand-over of a packages[] bundle as it arrived (runtime/app-plugin.ts :599-612), readDeclaredConnectorItems / mergeDeclaredConnectorSources / captureReloadedConnectors (service-automation/plugin.ts :1934-1966, :539-556, :1886-1899) and findDeclared / lendRefusal (connector-credential-source.ts :78-84, :94-114), all at a2f434dc.

  • The discharged must-fix, tried on the preserve shape in both orders. [A(instance + borrower), B(descriptor)] and [B, A] under preserve: two bodies, package:com.acme.orders and package:com.acme.sap, kept entries instance and descriptor, lending and notLending both non-empty, the borrower moved out of judged and one ambiguity finding rendered (:4654-4660) — the same verdict and the same sentence in both orders, naming both packages and the first-registered rule; the test pins both orders and asserts the old "which the runtime keeps" phrase is absent. At boot both bodies register under their composite keys, listItems lists both, findDeclared answers the first registered: the build can indeed not know which one the datasource gets, and refusing until one name means one instance is D7's one-grant rule. The reverse CONTROL (two lending instances with different credentialRef) composes in both orders: whichever lends, the borrow resolves; accepted. The flattened last arm is unchanged in verdict (test :195-211: [strictPair, owner(descriptor)] refused with the shadow prescription, the reverse accepted) and now says "in the flattened artifact". No finding on this shape.
  • The slot of a last artifact's top level beside package bodies (① item 4's deferred case). composeStacks([nested, plain]) under the default last carries packages[] (the nested input's bodies, concatenated by step 3) AND the plain input's flat connectors; the collector reads them as two slots (package:com.acme.orders and 'artifact'), the test "treats a package body and another input's top level as two slots" (test :278-289) pins that reading, and the ambiguity sentence says "The runtime keeps one entry per package and lends from whichever registers first". The tree does something else with that shape. At boot manifest.register registers the bodies and never the top level (objectql/plugin.ts :133-135); the top level is the residual, and the residual rule (unclaimedTopLevel, metadata/plugin.ts :405-426) registers only an item whose (type, name) slot no body claims, compared on the NAME alone across every body (claimedArtifactSlots); so a top-level sap_s4 beside a body's sap_s4 is registered by no door, and the boot keeps the body's entry only. On a dev artifact reload the opposite holds: captureReloadedConnectors reads the payload's top-level metadata.connectors (:1888), and mergeDeclaredConnectorSources drops every registry item of a declared name and appends the artifact's list (:549-556), so the top-level entry replaces the body's. The collector's VERDICT on this shape is therefore right in both lendability directions: the datasource could get either entry depending on whether the artifact was booted or reloaded, which is exactly "the build cannot know which one the datasource gets". Its REASON is not: there is no second registration slot, the boot drops the top-level twin and the reload replaces with it. Should-fix 1: make the 'artifact' slot's claim true where it sits beside package bodies — either word the ambiguity sentence for that case (a top-level entry a body's name claims is dropped at boot and replaces the body's on reload) or state the mechanism once in ProvidedCollectionEntry.slot's doc and providedCollectionEntries' docblock (:4531-4537), and rename the test so it asserts the refusal rather than "two slots"; and pin the preserve reading of the same mixed input (a nested input that carries packages and its own un-reproduced top level gets slot package:${ownId} at :4566, while the residual rule registers that top level under the composed artifact's manifest.id, the last input's). No false verdict was found; the refusal direction is the safe one on a shape the tree itself calls partially flattened (artifact-collections.ts :77-79, the "Not D" ruling).
  • Judged, not a finding: with every kept entry unable to lend across two slots (a descriptor in one, an auth-less instance in the other), the representative is the last slot's kept entry (:4639) and the per-stack sentence describes its defect only; the refusal is right, the prescription names one of two defects. With one slot the test "keeps the per-stack prescription when no declaration of the name could lend" (test :243-247) pins the fallback.
  • Judged, not a finding: the one-slot last-wins arm follows the boot (registerItem overwrites, the identity table's measurement). The dev reload path appends the artifact's whole top-level list and findDeclared takes the first, so after a reload a flattened cross-input duplicate would lend from the earlier entry. That is main's path for all 23 last-wins collections, outside this PR; the arm's refusal (the kept descriptor) stands, and its acceptance in the other order rests on the boot the table measured.
  • Judged, not a finding: connectors for the per-stack call carries one representative per non-ambiguous name (:4640), and a datasource naming an ambiguous name is withheld from judged (:4656-4658), so no "not defined in connectors" line is rendered for a name the collector refused as ambiguous; a non-record datasource reaches the per-stack filter as before.
  • The identity de-duplication: seen over entry objects, bodies first (:4546-4553, :4566). A composition input is always an in-memory built stack (step 0 refuses a JSON copy by its dropped provenance mark), and the body holds the input's own array by reference (assemblePackageBody :7265-7269), so identity is the right key. The test's both fixture builds the both-halves shape with one borrower object in both places and asserts one finding. Holds.
  • The docblocks: collectComposedDatasourceConnectorErrors (:4569-4601) is accurate on every sentence for the preserve and flattened paths; providedCollectionEntries (:4525-4537) says a top-level entry "registers under the composed artifact's one package otherwise", which is the sentence should-fix 1 qualifies for the beside-bodies case. The step-3b comment and the per-stack pointer are right.
  • The test group (17 composed cases): fixtures isolate what each case claims; every refusal asserts the envelope, one finding and the composition header; the preserve pair asserts both site labels and the first-registered sentence; the two-lenders CONTROL asserts both orders; the nested CONTROL asserts the strip before the resolution. What it lacks: the preserve reading of a mixed nested input (should-fix 1). Not run here; Test Core 8/8 success on the head.

packages/spec/src/contracts/data-engine.ts (hits) — byte-identical to 88031ddf, untouched by main's merge; stands as judged in record 6515560402 ④ and record 6515991063 ④. No finding.

packages/spec/src/data/injected-system-column-provenance.ts (hits) — byte-identical to 88031ddf, untouched by main's merge; stands as judged in record 6515560402 ④ and record 6515991063 ④. No finding.

Findings. No must-fix. Should-fix 1: the 'artifact' slot's stated mechanism ("one entry per package, the first registered lends") is untrue where a last artifact's top level sits beside package bodies — the boot drops a top-level entry a body's name claims and a reload replaces the body's with it — so the refusal there is right for a reason the message, the slot doc and the test's name do not give; word it, and pin the preserve reading of a mixed nested input. Should-fix 2 (③.3): the PR body names two models and carries the first head's test counts; say the tiers and the head's numbers (25 tests, 17 composed). Should-fix 3 (③.4): one report line on the card for the move's head. Previous record's must-fix 1 discharged; should-fix 1 taken; should-fixes 2 and 3 not taken.

Implemented-by: claude/project-thread-683cod
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27939 @ 1f471e098a7b9502c6745c076236e6a172f88e17 · 2026-10-30T07:21Z

维护者速读

  • 改了什么: composeStacks 合成多个栈时,会对照所有输入(包括各个包体)的 connectors 检查每个数据源的 external.connector。名字不存在、只是目录描述、实例没有 auth,这三种在构建时直接拒收,提示与单栈相同。同名连接器按注册槽位判:同一槽位以后声明的为准;两个槽位里一个能出借、一个不能,就以「有歧义」拒收。
  • 为什么改: 用 strict: false 构建的输入会把悬空的连接器名带进合成产物,作者要到第一次连接时才发现问题。
  • 风险与回滚: 收窄(BREAKING,ADR-0087 not-required):只有 strict: false 的输入、手工构造的 packages[] 包体,或者两个输入同名声明连接器时,才会被新拒收。dogfood 全部通过。还原即回滚。
  • 先看: packages/spec/src/stack.zod.ts 里的 collectComposedDatasourceConnectorErrors 和 providedCollectionEntries。
  • 两分钟验证: pnpm --filter @objectstack/spec exec vitest run src/stack-datasource-connector.test.ts,25 个测试全部通过。
  • 需要你做的: 无,席位落地。

席位意见:ACCEPT。契约复核 PASS 已在案(分诊 6516406275,CONTRACT_REVIEW_TIER,覆盖 schema 面和收窄面,针对当前 head;前两轮 FAIL 的 must-fix 已解决)。Fable 额度仍未恢复。按 2026-10-29 19:39Z 的指示,这份记录同时作为 spec 改动的完整复核。无 must-fix。

席位核对

  1. Shape: +437/−19 across 5 files: the composed collector in stack.zod.ts, its tests, two doc-only spec comments (injected-system-column-provenance.ts, contracts/data-engine.ts), and the changeset (BREAKING banner, ADR-0087 marker).
  2. Premise measured: on main, composeStacks never ran collectDatasourceConnectorErrors, so a strict: false input carried a dangling external.connector into the artifact (card spec: composed stacks refuse a datasource whose external.connector names no declared connector instance (composed counterpart of the #27742 check, #25211 shape) — epic #27261 #27803).
  3. Gate check-runs on 1f471e09: 56 runs: 41 success, 14 skipped, 0 failed. One is in progress, re-running after the body edit. Local: 82 derived gates, all exit 0. Dogfood: 357 files and 2875 tests passed.
  4. Accepted deviations: none. Record should-fix 2 is taken: the PR body now names tiers, not models, and carries this head's counts. Should-fix 3 is taken: the report line is on the card.
  5. Out of scope, routed: record should-fix 1 (the 'artifact' slot's stated mechanism beside package bodies, and the preserve reading of a mixed nested input) is filed as composeStacks connector check: say why a top-level connector beside package bodies is refused as ambiguous, and pin the preserve reading of a mixed nested input #28006. The verdict is right in both directions, so it does not block.
  6. Landing: not governed (check-governed-merges --pr 27939; 456 lines ≤ 3000). A same-shape PASS stands on the current head. The seat lands it through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 07:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 07:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 6da8c3c Oct 30, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants