Skip to content

feat(cloud-connection)!: refuse a managed upgrade that renames or retypes a stored field until it is declared or confirmed - #31243

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/project-thread-r2ii08
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/project-thread-r2ii08

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #30884

Clause-②: yes (narrowing) — a managed re-install that succeeds today is refused 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED on an undeclared suspected rename or a needs_confirm / destructive type change, and a declared rename is refused 501 NOT_IMPLEMENTED until #30885; the widening half is the new ledger code and the confirmSchemaUpgrade body key / --confirm-upgrade flag. Ruling record 6709061297.
Metadata-protocol: none
Skills-docs: updated here — content/docs/deployment/cli.mdx (--confirm-upgrade paragraph beside --confirm-access-upgrade), content/docs/data-modeling/fields.mdx (the renamedFrom row: such an upgrade is refused 501 until #30885); skills/** teaches neither flag nor renamedFrom, so no skill change.

BREAKING — accept-set narrowing at POST /api/v1/marketplace/install-local for a managed re-install. A re-install whose package upgrade plan lists an undeclared suspected rename, or a type change that is not safe, is now refused 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED until the same call carries the finding set's token; one that declares a field rename (renamedFrom) is refused 501 NOT_IMPLEMENTED until the runtime can carry the column (#30885).

Migration. Declare the rename on the new field (renamedFrom: ['<old key>']) and rebuild, or confirm the findings as listed: os package install <artifact> --confirm-upgrade <findingSet> (os deploy --confirm-upgrade <findingSet> against a self-hosted runtime), or "confirmSchemaUpgrade": "<findingSet>" in the install-local body. --dry-run previews the plan first.

ADR-0087 disposition (the changeset carries the one marker): not-required (no-migration-prescription) — no schema key is added, removed or re-shaped; the refusal judges two package bodies at the install door and prints its own way through.

维护者速读

  • 改了什么:受管软件包重装(升级)时,在写入任何东西之前先比较已装版本和新版本。没声明的疑似改名、或会改动已存数据列的类型变化(needs_confirm / destructive),返回 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED,附每条发现和一个确认令牌;带上令牌(--confirm-upgrade)重发即安装,并在安装记录里留下谁在何时确认了哪一组发现。声明了 renamedFrom 的改名在搬列能力(A field's declared renamedFrom carries the old column's data to the new name on package upgrade, instead of leaving it behind an empty new column (epic cloud#3652 Q3) #30885)落地前回 501。safe 类型变化、删字段、种子变化不拦。
  • 为什么改:今天改名会静默变成一个新的空列,旧数据还在旧列里但看不到;改类型也是静默的。这是 Q3 「默认拒绝,需显式映射」 的落实,田騏 已对本 PR 裁定 A。
  • 风险与回滚:只影响受管包的重装;首次安装、unmanaged 安装、无发现的升级行为不变。误报(真的删一个、加一个无关字段)代价是一次确认。回滚即还原本 PR;ledger 里新增的码是纯加法。
  • 先看:packages/cloud-connection/src/marketplace-install-local-plugin.ts 的 schemaUpgradeVerdict(哪些发现要确认、令牌怎么算)和 evaluateSchemaUpgradeGate(501 / 409 / 放行三条路);packages/spec/src/api/error-code-ledger.zod.ts 的新码一行。
  • 两分钟验证:pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-upgrade-plan.test.ts,预期全部通过(step 2c′ 一组,含孤儿安装、记录损坏或缺失、声明改名 501 等情形)。
  • 需要你做的:无,席位落地。

What changes

PR 2 of 2 on #30884 (PR 1 #30983 landed the comparison, the environment half and dryRun; handoff 6688284693). Under the ruling record 6709061297 (the maintainer chose 「A 全部同意(推荐)」; C1–C4 all A):

  • The ledger (packages/spec/src/api/error-code-ledger.zod.ts): one code, SCHEMA_UPGRADE_CONFIRMATION_REQUIRED, under @objectstack/cloud-connection (C1), in that array's alphabetical order. No other packages/spec/src/** hunk.
  • The verdict (schemaUpgradeVerdict, module-level in marketplace-install-local-plugin.ts): reads comparePackageVersions alone (C3), each side folded by resolveStackCollection as planUpgrade folds it. Declared renames are listed on their own; a suspected rename always needs confirming; a type change needs confirming when its category is needs_confirm or destructive, on a declared rename too (scope note 6685166393). The token is schema-upgrade: plus 32 hex of a SHA-256 over the findings' identities, sorted, never their sentences or row counts: the same two versions always give the same token, any change to the set gives another.
  • The gate (evaluateSchemaUpgradeGate, step 2c′ after the access gate 2c, C4), managed re-install only (conflict === 'marketplace'), before any write:
    • a declared rename ⇒ 501 NOT_IMPLEMENTED, naming each object.old → object.new, token or not (C2); the findings a token would still owe are listed in details.findings beside it;
    • findings ⇒ 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED with details.findingSet, details.findings (each with kind and a one-line describe), details.confirmWith; the message names both ways forward (declare renamedFrom and rebuild, or confirm with the token); a token from another set gets the stale-token sentence;
    • the matching token ⇒ installs, and the ledger entry records schemaUpgradeConfirmation (findingSet, confirmedBy, confirmedAt);
    • no finding ⇒ unchanged, planUpgrade never asked.
    • When the manifest service answers planUpgrade, each refused finding carries rows (the installed column's non-null rows) and its describe says so (C3); without it, or when the reading throws, the refusal is the same without counts.
  • The CLI: os package install --confirm-upgrade TOKEN sends confirmSchemaUpgrade, and the 409 prints every finding, the renamedFrom way forward and the exact retry. os deploy --confirm-upgrade TOKEN passes it through to the self-hosted install, as --confirm-access-upgrade is (see Acceptance notes: a file-surface extension).

Tests

Each under os-verify-lock.sh with its VERDICT command-exit line:

  • @objectstack/cloud-connection marketplace-install-local-upgrade-plan.test.ts: 16/16, ten of them the new step 2c′ describe — the 409 with findings, token, both ways forward and row counts, nothing written; the token installing and the ledger entry's schemaUpgradeConfirmation; another finding set refusing the old token; a needs_confirm retype refused; a declared rename refused 501 by name with and without a token, nothing written; a declared rename that also retypes needs_confirm still listing the type change; a safe retype, a removed field and a seed change installing unrefused; a no-finding re-install unchanged (planUpgrade never asked); no planUpgrade ⇒ the same refusal without rows; a first install never compared. Round 2 (seat r1 6712105906, Fable r2 6714005316) adds, across marketplace-install-local-upgrade-plan + -corrupt-ledger + -conflict (37/37): an orphan's retype refused 409 against the registry body, with a clean-orphan 200 control; an orphan row without a body refused 409 installed_record_missing, its token installing; a corrupt ledger entry refused 409 installed_record_unreadable (file, cause), its token installing; a corrupt entry plus a declared renamedFrom refused 501 with and without the record's token, nothing written; a destructive text→formula retype refused 409; the 501 tests with real tokens; true confirms nothing. marketplace-install-local-conflict.test.ts's orphan upgrade is unchanged (200, assertion untouched).
  • PR 1's control test now upgrades without a finding (V1 → V2 is a suspected rename, so it is refused by this PR — a stale fixture, re-judged, not weakened).
  • @objectstack/cli: package-install-dry-run.test.ts gains the --confirm-upgrade describe (the flag reaches the body as confirmSchemaUpgrade, absent without it; the 409 prints each finding, the renamedFrom way and the exact retry, exit 1); deploy.test.ts gains the pass-through (retry line names os deploy --confirm-upgrade, the token reaches the second POST only) and the Cloud-target refusal.
Suite readings
  • cloud-connection pnpm test on 39a525c236: Test Files 73 passed (73), Tests 969 passed (969), VERDICT command-exit 0; typecheck exit 0.
  • cli vitest run --project unit on a274e36e: Test Files 399 passed (399), Tests 5637 passed (5637), VERDICT command-exit 0; typecheck VERDICT command-exit 0. No cli file changed after a274e36e.
  • spec: the only hunk since round 1 is a clause in the ledger comment; spec pnpm test (round 1): Test Files 821 passed, Tests 25964 passed, 1 todo, VERDICT command-exit 0.
  • Merges of origin/main at 02481a8990, d04e0e2c88 and 39a525c236 (the last brings test(scenarios): give the external-driver crm run a measured 30s budget and a lighter stand-in #31284); no rebase, no force push.
Ablations (each via scripts/ablation-replace.mjs, anchor hit once, restored to the HEAD blob with git diff HEAD empty)

The door's tests import the plugin from src/ (relative import), so no build sits between a mutation and the run.

# Mutation in marketplace-install-local-plugin.ts Result
A1 suspected renames dropped from the verdict 4 failed / 12 passed
A2 the needs_confirm/destructive filter removed (a safe retype refused) 1 failed / 15 passed (a safe type change … install unrefused)
A3 any string token accepted 1 failed / 15 passed (a different finding set refuses the old token)
A4 the 501 branch disabled 2 failed / 14 passed (both declared-rename tests)
A5 a type change riding on a declared rename skipped (scope note) 1 failed / 15 passed (… still owes the type change its confirmation)
A6 schemaUpgradeConfirmation not written to the ledger entry 1 failed / 15 passed (the same call with the token installs …)

| A7 | the record-unavailable branch returns no finding | 2 failed |
| A8 | the declaredRenames.length === 0 guard removed | 2 failed (both declared-rename cases) |
| A9 | destructive dropped from the confirmed set | 1 failed |
| A10 | manifestId dropped from the token digest | 2 failed |
| A11 | registeredPackageBody returns undefined | 2 failed (conflict orphan upgrade + orphan compare) |
| A12 | declaredRenamesOfIncoming replaced by [] | 1 failed (corrupt entry + rename 501) |

Each restored, git diff HEAD empty, suites green again.

Acceptance notes

  • File surface, one extension to ratify: packages/cli/src/commands/deploy.ts (+ deploy.test.ts) is outside the claim's list. os deploy runs os package install in-process with retryCommand = 'os deploy', so without it the new refusal would print os deploy --confirm-upgrade TOKEN, a flag os deploy would reject: the self-hosted dev loop (the epic's target) would have no way through. The change mirrors --confirm-access-upgrade exactly. local-manifest-source.ts gains the schemaUpgradeConfirmation type on InstalledEntry (the ledger entry the claim names).
  • No installed body asks, never fails open. When the gate has no installed body to compare (the ledger file will not parse, the entry carries no body, or an orphan's registry row carries none), it answers 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED with one finding, installed_record_unreadable (file, cause) or installed_record_missing; that set's token installs and records schemaUpgradeConfirmation.
  • An orphaned install (no ledger file, the registry row found by findConflict step 3) is compared against the body the registry serves. For a multi-package artifact this reads one row; the JSDoc states the limit.
  • A declared rename is 501 on every path, including the record-unavailable one, where the renames are read from the incoming version alone (declaredRenamesOfIncoming); the record finding is listed beside it, with or without a token.
  • Reported, not refused: removed fields and objects, safe type changes and seed changes reach the installer through dryRun: true (PR 1); a successful install's answer gains nothing.
  • Two refusals in a row are possible (C4's accepted cost): an upgrade can answer the access 409 first, then the schema 409.
  • The dry run carries no schema token: an installer reads the token off the 409, not off the plan. Adding it to the dry run would be a widening of its own.
  • The 501 names the way to install now (drop renamedFrom, confirm the suspected rename) because it exists; it says the values stay in the old column.
  • Ledger position: the code sits in @objectstack/cloud-connection's array in alphabetical order (after RESEED_SKIPPED); its comment names ACCESS_UPGRADE_CONFIRMATION_REQUIRED as its sibling.
  • Out of scope, noted: cloud's hosted install door (sibling card 01c) reads the same 409.

Verification

All on 39a525c236 (after merging origin/main incl. #31284 59d9815a03):


Generated by Claude Code

🤖 Generated with Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/spec, touching 36 documentable anchor(s).

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 59d9815a038ed84a8bcdce75df40aeba58ccfaec.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 183 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 59d9815a038ed84a8bcdce75df40aeba58ccfaec → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 59d9815a038ed84a8bcdce75df40aeba58ccfaec

⚠️ 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 59d9815a038ed84a8bcdce75df40aeba58ccfaec → 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

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review

Head-sha: db650901b620245d2ca5f8dbfb64539af08da3ef

Served-tier: CONTRACT_REVIEW_TIER (Fable, narrowed scope: the ledger hunk + the code implementing the refusal). Verdict: CHANGES — 1 must-fix, 2 should-fix, 4 nits. Opus self-review of the rest (CLI --confirm-upgrade on os package install / os deploy, InstalledManifestEntry type, changeset, regenerated reference pages): no finding. The os deploy pass-through is outside the claim's file surface; the seat accepts it, because ruling (ii) names the CLI flag and the refusal prints os deploy --confirm-upgrade as its retry. Size 710 lines, no governed path ⇒ the seat lands it after ACCEPT.

Findings

must-fix — the gate admits blind when the installed body is unavailable (P:3779–3786), and the trade-off verdict is: fail-open is not acceptable; fail closed in the narrow, confirmable form below.
if (!previousEntry?.manifest) { warn; return {}; } makes 2c′ the only gate in handleInstall that, on an input it cannot read, concludes "no finding" and proceeds. Every sibling refuses or asks again: 2a's datasource-holder check answers 503 because "a check that did not run is never taken for 'no holder'" (P:2554–2566); 2b on a corrupt ledger entry deliberately "ASKS AGAIN rather than skip: the fail-safe direction", with the direction itself pinned (P:2582–2590; marketplace-install-local-posture-gate.test.ts:237); 2c answers 503 because "a comparison that did not run is never taken for 'no finding'" (P:3685–3698). Contract (ii) refuses a managed re-install carrying a suspected rename or a confirmable type change; with the record unreadable the gate does not know whether it carries one, and the Q3 ruling's own banner at this step is "AN UPGRADE NEVER CHANGES A STORED FIELD SILENTLY" (P:2654–2655) — a warn line is the definition of silent for the installer, who gets a 200. The premise "refusing would block the re-install that repairs the record" is only half true and does not cover the whole branch: (a) uninstall, reseed and purge already refuse an unreadable entry 500 with the repair prescription "repair or remove : " (unreadableLedgerEntry, P:6556–6585), so the operator already has a path that is not "re-install blind"; (b) the same branch is also reached on the orphan path — findConflict answers 'marketplace' when has() is false but the registry still holds the package (P:4842–4852) — where there is no record at all, and the warn then says "the record of the installed version could not be read", which is false; (c) the warn's prescription "Run the same install with dryRun: true to see what the record holds" (P:3783–3784) is wrong: the dry run compares against undefined when the entry is unreadable and says so (installedRecordUnreadable: true, P:5341–5343, 5357–5362) — it cannot show what the record holds. Concrete fix, no new status and no new code name: pass the ledger lookup (this.ledger.read(manifestId), entry + failure) rather than .entry into the gate; when no installed body is available, answer the ordinary 409 SCHEMA_UPGRADE_CONFIRMATION_REQUIRED with one finding { kind: 'installed_record_unreadable', file, cause, describe } (orphan path: kind: 'installed_record_missing'), its finding-set token digested from that identity, and the same confirmWith; the same request with the token installs (the re-install is still the repair), and the ledger entry records schemaUpgradeConfirmation — the admission becomes explicit, audited, and one round-trip, exactly 2b's "ask again" direction. An alternative that also satisfies the contract: use the live registry's package body (what findConflict step 3 reads) as the installed side, falling to the finding above only when neither exists. Not acceptable: the branch as written, or a 500 à la unreadableLedgerEntry (it pushes the operator to delete the file, which lands on the equally blind orphan path). Pin both halves with the existing corrupt-entry fixture (writeCorruptEntryFor, marketplace-install-local-corrupt-ledger.test.ts:147): the 409 with its finding, and the token install writing the entry. Today the branch has no pin at all.

should-fix — destructive is in the confirmed set (P:538) but no pin exercises it (T:278–286 pins needs_confirm only).
Ablating 'destructive' from SCHEMA_UPGRADE_CONFIRMED_CATEGORIES leaves the suite green, so half of ruling (ii)'s "needs_confirm / destructive" is unpinned. Add one it: installed company: { type: 'text' } → incoming company: { type: 'formula' } (formula is non-stored — T:318 already relies on score: formula being non-stored for the safe pin), which typeChangeCategory classes destructive (package-upgrade-plan.ts:202–207); assert 409 and findings[0].category === 'destructive'.

should-fix — the "501 even with a token" pin uses a token that cannot match (T:293 'schema-upgrade:0'), so the guard it exists for is not exercised.
The confirmation branch is declaredRenames.length === 0 && typeof token === 'string' && token === findingSet (P:3794). For a declared rename with no other finding the real finding set is the digest of [], which a caller can compute; drop the declaredRenames.length === 0 && guard and the test still passes while a real token bypasses C2 and installs over a declared rename. Compute the real token in the test ('schema-upgrade:' + createHash('sha256').update(JSON.stringify([])).digest('hex').slice(0, 32), mirroring P:629–635) and assert 501 with it; the same for the rename+retype case (T:305–312) if you want C2-over-scope-note pinned against a matching token.

nit — "true confirms nothing" is promised in the refusal (P:3853–3855) but not pinned here, unlike the access gate (marketplace-install-local-access-upgrade.test.ts:365). One it with confirmSchemaUpgrade: true asserting 409 and the stale sentence.

nit — logger is added to mount's return (T:119) and used by no added test. Either use it (the fail-open branch's warn, if any warn survives finding 1) or drop it.

nit — C2 says the 501 names 01b (#30885). The runtime string rightly carries no tracker number (AGENTS.md: refusal prose carries none) and says "once the runtime carries declared renames" (P:3836), but neither the step comment (P:2653–2664) nor the gate's JSDoc (P:3742–3766) cites 01b / #30885, so nothing in the code points a reader at the issue that lifts the 501. Add the citation to the comment, as the file already cites #30884.

nit — the finding-set identity (P:629–633) excludes manifestId, so a token issued for one package's finding set confirms an identical finding set on another package. The access token digests only its summary too (access-upgrade-report.ts:606–607), so this is parity, not a defect; folding manifestId into the digest is a one-line hardening if wanted.

Checked and fine

  • Spec hunk is exactly one code, SCHEMA_UPGRADE_CONFIRMATION_REQUIRED, under '@objectstack/cloud-connection' in sorted position beside the ACCESS sibling (error-code-ledger.zod.ts:1371–1383); the mdx gains the one sorted line; git diff --stat shows no other packages/spec hunk. The ledger comment states its emitter, status, step and CLI reader.
  • Token: schema-upgrade: + first 32 hex of sha256 (P:531, 634–635), same shape as the access token; digested over identities only (kind/object/field/type/candidates or kind/object/field/from/to/category/renamedFrom), never describe or rows; identities string-sorted and candidate lists sorted (P:629–633), so order-free and stable across the two bodies; the two refusals and the confirmation all read the same findingSet.
  • Stale token: an old token against a changed set answers 409 with the fresh set and the stale sentence (P:3853–3855, 3872); pinned T:261–276 including not.toBe(oldToken).
  • Safe / removed / seed never refuse: only suspectedRenames and typeChanges with category in the set become findings (P:600–613); removed fields, removed objects and seeds never enter findings; pinned T:314–332 with the plan asserted to contain all three.
  • C2 path: a declared rename answers 501 NOT_IMPLEMENTED before the token is honoured (P:3794 guard, P:3811–3850), carrying details.declaredRenames and the still-owed findings; pinned T:288–303 (modulo the should-fix on the token). Both ways forward are named in the 501 (wait for the runtime, or remove renamedFrom, rebuild and confirm) and in the 409 (declare renamedFrom and rebuild, or confirm with the token) — pinned T:238–239.
  • Scope note vs C2 order is coherent: a declared rename that also retypes needs_confirm is a type_change finding with renamedFrom (P:613–627, from package-upgrade-plan.ts:367–370), listed in the 501's details.findings, and the 501 message says the next refusal will ask for it (P:3841–3843); pinned T:305–312. The 501 is terminal today, so nothing slips.
  • No write before the gate: 2b, 2c, 2c′, 2d, 2e, 2e′ are all checks; the first write is step 3's register (P:2750), then the ledger (P:2813) and syncSchemas (P:2828). Pinned by register/syncSchemas not called and ledgerFiles() unchanged (T:220–244, 288–303).
  • Gate runs only for managed re-installs: conflict === 'marketplace' (P:2666), after the access gate (P:2647–2651) as C4 requires; a first install is never compared (T:350–355).
  • Ledger entry: schemaUpgradeConfirmation { findingSet, confirmedBy, confirmedAt } (P:2808; type local-manifest-source.ts:103), present in the entry object both ledger writes persist (P:2813, 2866 reuse entry); pinned T:246–259; absent when nothing was confirmed (T:331, 341).
  • Error shape mirrors ACCESS_UPGRADE_CONFIRMATION_REQUIRED field for field: success:false, error.{code,message,details.{manifestId,findingSet,findings,confirmWith.{body,cli}}} (P:3861–3886 vs P:3719–3740); the cli hint's --confirm-upgrade flag exists in the CLI diff.
  • C3: schemaUpgradeVerdict is pure (no ctx, P:592–635) and decides the refusal; environment rows attach only through readSchemaUpgradeRows when manifest.planUpgrade is a function (P:3901–3903), keyed by the INSTALLED key (renamedFrom ?? field, P:3805–3806), matching package-upgrade-environment.ts:64–65, 186–191; a failed reading yields rows: null and no count sentence (P:3807–3812); without planUpgrade the refusal is identical minus rows (T:340–345). The confirmation branch returns before any environment read (P:3794 before P:3803).
  • Existing dry-run CONTROL re-pointed to a finding-free upgrade (T:166–172) so it still asserts planUpgrade not called on the install path — correct, since the gate now reads it on refusals.

…installed record cannot be read; pins and docs from review

Claude-Session: https://claude.ai/code/session_017ihL2dvnT51fbSebUyE3zW
Co-Authored-By: Claude <noreply@anthropic.com>
…dy the registry serves

Claude-Session: https://claude.ai/code/session_017ihL2dvnT51fbSebUyE3zW
Co-Authored-By: Claude <noreply@anthropic.com>
… orphaned install

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

席位判读 — Test Core (2/8)、(6/8) 判为 flake,重跑一次 · 2026-11-07T22:31Z

  • 红的是 run 42418037441 在 02481a8990 上的 Test Core (6/8)(job 128540770520)与 Test Core (2/8)(job 128540770499)。6/8 只红 examples/app-todo 的 test/loop-body-iteration.test.ts(ESCALATION: the run reaches notify_owner …);2/8 只红 packages/qa/scenarios 的 test/runner/external-driver.test.ts(a full crm run through the command …)。
  • 两个包都不依赖 @objectstack/cloud-connection,也不涉及 install-local / confirmSchemaUpgrade;本 PR 只改 cloud-connection、cli、spec 账本一码、content/docs 与 changeset。
  • dev 在同一 head 全量构建后各本地复跑两次:loop-body-iteration 5/5 ×2,external-driver 8/8 ×2。external-driver 拉起子进程且带真实超时,2/8 分片同窗跑 spec test:repo(墙钟 924s 内测试时间 2566s),负载下的超时抖动最可能。main 同期 CI(2f369cd3a5、07559ce5ae)全绿。
  • 判为 flake。席位按 platform-readings 规则用 rerun-failed.mjs 重跑一次失败作业;第二次红即按真红处理。

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review

Head-sha: 02481a8990

Served-tier: CONTRACT_REVIEW_TIER (Fable delta r2, over the PR's own commits 7d661aa, a274e36 and 5c6cd1c; merges of main discounted). Fable verdict PASS, with every round-1 item resolved. It raised one new should-fix, which the seat adopts as owed before ACCEPT: a declared rename over an unreadable or missing record must keep its 501. The next round is an in-seat delta. Nits are optional.

Round-1 items

must-fix — gate admits blind when the installed body is unavailable: RESOLVED (per the seat's ruling, both halves).

  • The fail-open branch and its warn (incl. the false dryRun: true prescription) are gone: grep for the old sentences finds nothing in P. The no-body case now goes through installedRecordUnavailableVerdict (P:685–705) and the ordinary 409 (P:3933–3966).
  • Unreadable ledger entry: handleInstall passes the whole lookup (previousLookup, P:2669–2670) into the gate (P:2739); previous.failure set ⇒ installedBody undefined (P:3864–3866) ⇒ one finding { kind: 'installed_record_unreadable', file, cause, describe } (P:690–697; type P:576–584), token digested from [manifestId, [['installed_record_unreadable', file]]] (P:659–676), same confirmWith (P:3959–3962), same confirmation branch (P:3872–3880) ⇒ the token installs and the ledger entry records schemaUpgradeConfirmation (P:2882). Pinned C:354–387 with the existing writeCorruptEntryFor fixture: 409, finding kind/file/cause, token shape, register not called, the file still unreadable after the refusal, then the token ⇒ 200, entry.version === '1.0.1', schemaUpgradeConfirmation.findingSet equal to the issued token. Reachability checked: findConflict step 1 answers 'marketplace' on has() (the corrupt file exists, P:4919), 2b passes (GOOD_MANIFEST declares no unique), 2c passes (no access-check service in that fixture ⇒ warn + {}, P:3729–3736), so 2c′ is the first refusal.
  • Orphan path (ledger { entry: null }, registry still serves the package): the installed side is registeredPackageBody (P:3864–3865, P:4949–4957), i.e. the row findConflict step 3 matches (manifestIdOf(p) === manifestId, P:4937–4940 vs P:4952), converted forward like a ledger body (P:3866). Comparison side is right: the registry's package row stores toRecordManifest(converted body) (objectql installPackage, registry.ts ≈2474 — a JSON projection that keeps objects, fields[].type, data; drops only functions/exotics), so packageUpgradeSideOf (P:708–710) folds the same objects/data as it folds for a ledger body, and comparePackageVersions sees the same field types — no false type_change/suspected_rename from the body's provenance, and a field without type on either side yields no type change (plan typeOf), never a finding. The second convertPackageBodyForward over an already-converted body is a no-op replay (forward conversions rewrite retired shapes only). Pinned T:311–329: a retype against the registry's body is 409 needs_confirm with no write; CONTROL: the same orphan with an added field only ⇒ 200 and no schemaUpgradeConfirmation.
  • Only when neither yields a body: installed_record_missing (P:698–702; type P:585–589), token from [manifestId, [['installed_record_missing']]]; pinned T:331–349 (409, kind, token equals the restated digest, register/planUpgrade not called, ledger empty, then token ⇒ 200 with confirmedBy: INSTALLER).
  • The existing orphan test still passes by reading: marketplace-install-local-conflict.test.ts:93–112 registers { manifest: MANIFEST(id,'1.0.0') } with objects: [] and upgrades to the same empty objects ⇒ plan empty ⇒ {} ⇒ 200.

should-fix — destructive unpinned: RESOLVED. T:301–309 installs company: text → formula, asserts 409 and category: 'destructive'; ablating 'destructive' from the confirmed set now fails a pin.

should-fix — the "501 even with a token" pins used a token that cannot match: RESOLVED. T:283–285 restates the digest ([VENDOR, identities.map(JSON.stringify).sort()], matching P:659–676 exactly), T:287–291 proves the helper equals the door's token on a real finding set, T:360–378 sends tokenFor([]) — the real token of the empty set — and still asserts 501 (dropping the declaredRenames.length === 0 guard at P:3873 would now install and fail the pin), T:380–388 sends the real type_change token on the rename+retype case and asserts 501 with the finding listed.

nit — true confirms nothing unpinned: RESOLVED (T:293–299, asserts 409, the sentence, no register).
nit — unused logger in mount's return: RESOLVED (T:123 no longer returns it).
nit — cite 01b/#30885: RESOLVED (step comment P:2731, JSDoc P:3835–3836).
nit — manifestId in the digest: RESOLVED (P:659–676, documented at P:608–611 and P:662–663; pinned implicitly by T:287–291 since tokenFor folds VENDOR).

New findings (delta)

should-fix — on the unreadable/missing path a declared rename loses its 501 (P:704, declaredRenames: []). installedRecordUnavailableVerdict always answers an empty rename list, so an incoming body that carries renamedFrom over an unreadable record is refused 409 installed_record_unreadable, and its token installs it — a new field that starts empty while the values stay in the old column, which is the install C2 says "no token answers". The comparison cannot prove the rename applies (it needs the installed key), but a renamedFrom declaration with an unknown installed side is exactly a rename this runtime may be unable to carry, and the fail-closed direction the rest of this PR adopts says: refuse it as a declared rename. Fix: in installedRecordUnavailableVerdict (or just before it in the gate), scan packageUpgradeSideOf(converted).objects for fields with a non-empty renamedFrom array and return them as declaredRenames ({ object, from: renamedFrom[0], to: key, toType }, fromType omitted), so the existing 501 branch (P:3897–3931) fires with the finding listed beside it; the 409 keeps its one finding when no rename is declared. Pin in C beside C:360: the corrupt fixture + fields: { name2: { type: 'text', renamedFrom: ['name'] } } ⇒ 501 NOT_IMPLEMENTED, token or not. The seat may rate this must-fix; I rate it should-fix because the admission is explicit and the 409 sentence does say "cannot be checked for fields it renames".

nit — installed_record_missing is also answered when the ledger entry parses but carries no manifest (P:3864–3868). previous.entry?.manifest ?? (...) falls to installedRecordUnavailableVerdict(manifestId, undefined) there, whose describe says the runtime "holds no record of the installed version's package body" — true enough, but the kind's own doc (P:586) and the gate JSDoc (P:3827–3832) describe missing as the orphan case only. Either widen the two comments ("…or a record carrying no body") or name the file: pass previous.entry ? { file: safeFilename(manifestId), cause: 'the entry carries no manifest' } : previous.failure.

nit — the cause string is derived by hand twice (P:692, 694) while describeLedgerCause (P:1169–1171) exists for exactly this, and the hand version turns an Error with an empty message into "" where the helper falls back to name. Use the helper once into a local.

nit — multi-package orphan folds a partial installed side. The manifest service registers one registry row per packages[] entry (objectql plugin.ts:854+), so for a packages[] artifact the row registeredPackageBody finds under the artifact's manifest.id — when one exists at all — holds one package's body, while converted folds every package's objects; sibling packages then read as "added objects" and a retype inside them is a false negative on the orphan path only (the ledger path folds the whole artifact). Not a regression (db65090 was fully blind here) and beyond the ruling's wording, so a nit: fold the rows of every id in resolveArtifactPackageEntries(converted) that the registry holds, or state the limitation in the JSDoc at P:4943–4948.

Checked and fine

  • No write precedes the gate: 2c′ (P:2737–2742) still runs before step 3's register (P:2822), the ledger write (P:2882) and syncSchemas; registeredPackageBody and readSchemaUpgradeRows only read; pinned by register not called and ledgerFiles() unchanged in every new refusal pin (T:318–319, T:339–341, C:375).
  • The unreadable path cannot be bypassed: installedBody is undefined whenever previous.failure is set (the ?? right operand requires failure === undefined, P:3865), the verdict then always has one finding, and the only admit is token === findingSet (P:3873). Its token cannot confirm another situation: identities ['installed_record_unreadable', file] / ['installed_record_missing'] share no digest with any comparison set or with each other, and manifestId is folded in; cause is deliberately outside the identity so a differently-worded parse error yields the same token for the same file.
  • rows are never read without an installed body (P:3883), and the row-count decoration skips the two new kinds (P:3885); the 501 branch, the stale sentence and the 409 shape are unchanged for comparison findings; the no-body 409 message drops the renamedFrom advice, rightly (P:3937–3940).
  • registeredPackageBody typing (5c6cd1c): OverrideGateEngine.registry.getAllPackages?(): unknown (P:834–839) narrowed by Array.isArray; manifestIdOf(p: any) accepts the unknown element; ctx.getService<T> is the generic already used at P:1489, 1987, 2345. It and findConflict step 3 read the same service and the same manifestIdOf match, so the orphan 2c′ runs exactly when step 3 said 'marketplace'.
  • Spec ledger hunk is comment-only on the one existing code (error-code-ledger.zod.ts:1375–1376, now also naming the no-record case); content/docs/references/api/error-code-ledger.mdx:512 lists the code name only, so no regeneration is owed. The changeset gains the matching bullet; cli.mdx and the CLI reader distinguish field findings from the no-record finding (install.ts:477–487) and the CLI never prints the renamedFrom hint for a no-record refusal.
  • fields.mdx row now states the 501 for a declared rename, matching C2.
  • Existing dry-run and conflict pins are unaffected: the dry run still answers before 2c′ (P:2644) and still reports installedRecordUnreadable: true against no installed version.

…2c' has no installed record to compare

Read the renames the incoming version declares from that version alone on the
unreadable / missing path, so the record's token never admits a rename this
runtime cannot carry. Quote the ledger cause as describeLedgerCause does, widen
installed_record_missing to an entry carrying no body, and note the one-row
limit of the orphan comparison.

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review — ACCEPT

Head-sha: 39a525c236f62c248f1581c8561e1412df05e9e6

ACCEPT · Fable r1 6712105906 (CHANGES) → Fable delta r2 6714005316 (PASS + should-fix) → in-seat delta on b0db6383a1 (OK) · 2026-11-08T00:20Z

  • Seat r1 and Fable r2 findings are resolved. Step 2c′ now fails closed on every installed side. With no installed body (the ledger will not parse, the entry has no body, or an orphan row has none), it answers 409 installed_record_unreadable / installed_record_missing, and that set's token installs. An orphan is compared against the registry body. A declared rename answers 501 on every path, including the record-unavailable one, via declaredRenamesOfIncoming, with or without a token (Fable r2 should-fix). manifestId is in the digest, destructive is pinned, and the 501 tests use real tokens. Ablations A7–A12 went red and were restored.
  • The spec hunk is unchanged apart from one ledger comment clause: still one code, SCHEMA_UPGRADE_CONFIRMATION_REQUIRED. It is within Fable r2's PASS.
  • d04e0e2c88 and 39a525c236 are plain merges of origin/main (no conflict resolution in --remerge-diff). No file of this PR changed after b0db6383a1.
  • Docs are now in the PR (cli.mdx, fields.mdx). The Skills-docs: line, Tests, Ablations, Acceptance notes and Verification in the PR body were refreshed by the seat at the dev's request (report 6714956140).
  • Gates on 39a525c236: 116 derived, all exit 0, --ran 0 NOT-MEASURED. Lint 0, full build 0. Dogfood: 435 files passed, 3 skipped; 3610 tests passed, VERDICT 0. Check-runs: 57 success, 8 skipped, none red.
  • Landing: check-governed-merges: NOT governed, 1044 lines (≤ 3000). The seat readies this PR and enables auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 00:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 00:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42430423477 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/platform-objects:test:  FAIL  src/apps/translations/setup-nav-dead-key-tombstone.test.ts > conditionally-contributed Setup nav ids stay labelled (#6659) > fr carries a label for every con
      ↳ 失败原因: @objectstack/platform-objects:test: AssertionError: Setup nav ids with no label — no boot-time gate can see these; see this file header: expected [ 'nav_scim_credentials' ] to deeply equal []
    @objectstack/spec:test:      ✓ inverts the card's measured control: the three bogus keys now FAIL parse instead of adding zero warnings  323ms
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    
  • Test Core (6/8) — 失败步骤: Run this shard's tests

    @objectstack/example-todo:test:  FAIL  test/loop-body-iteration.test.ts > #19206 — the loop body runs and binds `currentTask` > ESCALATION: the run reaches `notify_owner` and the notice renders the re
      ↳ 失败原因: @objectstack/example-todo:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 31 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Ejected by queue build 42430423477. Neither red is this PR's. · 2026-11-08T01:00Z

The head is unchanged at 39a525c236, and the ACCEPT stands. The seat re-enables auto-merge once #31312 has landed and #31233 has left the queue. Re-queuing now would hit both reds again.

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42430899729 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/platform-objects:test:  FAIL  src/apps/translations/setup-nav-dead-key-tombstone.test.ts > conditionally-contributed Setup nav ids stay labelled (#6659) > fr carries a label for every con
      ↳ 失败原因: @objectstack/platform-objects:test: AssertionError: Setup nav ids with no label — no boot-time gate can see these; see this file header: expected [ 'nav_scim_credentials' ] to deeply equal []
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 34 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant