Repository navigation
fix(objectql): withdrawing an extender package restores the owner's bridged object copy, so GET /meta/object/NAME drops the extension field at once - #24221
Conversation
…bject copy Claude-Session: https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu Co-authored-by: Claude <noreply@anthropic.com>
…cal DELETE Claude-Session: https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e66f494dee7e5f380e3b227c2ba6229b5775ad34 && git checkout e66f494dee7e5f380e3b227c2ba6229b5775ad34
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 3a452eeae41444d2fd05ec9acbdb4c5a9462c898 && git checkout -B drift-repro b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 && git merge --no-ff 3a452eeae41444d2fd05ec9acbdb4c5a9462c898
node scripts/docs-audit/affected-docs.mjs --json b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761
|
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24182, its only closing keyword. |
| Scope | 5 files (+215 / −4), the claim's surface (6239113460). packages/objectql/src/plugin.ts: the bridge records which owners' copies each extender rewrote (extendedObjectCopies), and the manifest service gains restoreExtendedObjectCopies. Plus its pin, the declared cross-lane call in packages/cloud-connection/src/marketplace-install-local-plugin.ts (+8, the call only), the pin beside the #24070 cases, and the changeset. The cross-lane edit is declared on #6024. Not governed (check-governed-merges.mjs --pr 24221: 0 of 5 paths; 219 lines). |
| Mechanism | Read at the head. The record is written where the bridge re-registers a copy stamped with another package's id. The restore runs only once the registry no longer holds the package, reuses the existing currentObjectCopyBody, and writes only a copy stamped with the registry owner's id, the same rule the bridge overwrites by. An authored copy stays. It never rejects; a failed restore logs once at error, naming the objects and the remedy. |
| H1 / H2 | H1 confirmed: the objectql pin asserts the registry serves the owner's body right after uninstallPackage, so only the copy lags. H2 falsified by the dev (the install-local DELETE fires no event), so the claim's conditional cross-lane call is used. |
| The call site | ctx.getService('manifest') in a try, then restoreExtendedObjectCopies?.(…). Every manifest lookup in this file is duck-typed the same way (there is no shared interface for it), and the optional call can only no-op where no ObjectQL bridge exists, which is exactly where nothing was rewritten. It runs only when the registry withdrew (!withdrawal), and the DELETE answer is unchanged. |
| Review face | None of the four contract faces: no published schema, no governed rule text, and Clause-②: no with no narrowing arm. The new verb sits on the runtime manifest service object, which no published type declares. This is the seat's review of record at TIER_DEFAULT; no second agent. |
| Prose face | Changeset, sentence by sentence. "What was wrong" matches the live base reading (by-name 200 with ext_score, while the listing and the data plane drop it). "What changes" matches the diff: the bridge's record, the verb, and the call right after the registry withdrawal. "Not touched" matches the owner-stamp rule, the unchanged DELETE answer, and the error log with its remedy. |
| Pins and ablation | objectql: restore plus a CONTROL (a package the registry still holds keeps the field). cloud-connection: one case through the real DELETE on a real kernel, with the owner's name, stamp and listing as controls; the four #24070 cases are unchanged. One ablation (the restore's guard turned into an unconditional return) reddens 1 case in each package, both controls stay green, and the restore is clean (blob back to 8fbce83c). |
| Live | pnpm dev:crm -- --fresh, base against head, both in the PR body. Base: after the DELETE, the by-name read is 200 WITH ext_score. Head: 200 WITHOUT it, name kept, _packageId: com.example.crmlite. The listing and ?fields=ext_score → 400 read the same on both. |
Clause-② |
no, at a line start in the PR body and the changeset; patch for @objectstack/objectql and @objectstack/cloud-connection. |
| Gates | Dev union: 67 derived, 67 run, 0 NOT MEASURED. check:slot-lookup was red on the first pass (two untyped lookups in the pin) and is fixed by typing them. CI on this head at this read: 40 check-runs, 34 success, 6 skipped (each on the EXPECTED_SKIPS roster in scripts/pm/check-expected-skips.mjs), 0 failed. |
Disposition:
open_questions[0](where the counterpart triggers): A, the verb plus the one-call edit, as built. The bridge that rewrote the copy owns its restore, producer-side. B would re-run every resync on each uninstall, and C would copy the bridge's rule into another package.open_questions[1](record or recompute): A, record at bridge time. AfteruninstallPackagethe registry no longer says what the package extended, and B would rewrite unrelated copies on every uninstall.out_of_scope_findings(an extender's hot upgrade that drops an extension;DELETE /packages/:idwithout the counterpart): Acceptance notes, noted, not filed. Both are read-only inferences with no wrong answer measured, below the filing gate. The second has no bridged extender reaching it today (install-local packages are refused there).deviations: accepted (the detached checkout in the dev's own container; the optional helper parameters inside the claimed pin;os devin place ofos start, both arming the late bridge; the model-free trailer; the REST reads).
Landing: ordinary queue (pr_ready + automerge_enable through the relay); every check on this head is green.
Generated by Claude Code
Fixes #24182
Clause-②: no
What this changes
After
DELETE /api/v1/marketplace/install-local/:manifestIdof a package that extends another package's object,GET /api/v1/meta/object/NAMEnow serves the owner's body without the withdrawn extension's fields at once. The object listing and the data plane already did. A restart is no longer needed.packages/objectql/src/plugin.ts(the bridge's owner):bridgeManifestObjectsToMetadataServicenow records, for each package it bridges as an EXTENDER, the names of the objects whose copy it rewrote. Those are copies stamped with another package's id (the owner's) and re-registered with the merged body. The record is the new fieldextendedObjectCopies.restoreExtendedObjectCopies(ctx, packageId), exposed on themanifestservice besideregister. Once the registry no longer holds the package, it re-registers each recorded copy with the registry's current definition (currentObjectCopyBody, the same shape the bridge writes), through the bridge's own verb (register(..., { notify: false })). It adds no second merge._packageIdis the registry owner's id is written, so an authored copy stays. A name the registry no longer serves is left to the owner's own withdrawal (PR fix(cloud-connection): an install-local uninstall withdraws the metadata-service copies of its objects, so the meta object reads drop them at once #24181's half). A package the registry still holds is not withdrawn, and nothing is done.errorwith the consequence and the remedy (restart), the levelfollowRuntimeObjectCopyuses for the same registry/copy disagreement.packages/cloud-connection/src/marketplace-install-local-plugin.ts: the claim's declared cross-lane edit, the call only. Right afterwithdrawObjectCopies, and only when the registry withdrew, the DELETE handler callsmanifest.restoreExtendedObjectCopies(manifestId). The DELETE answer is unchanged.packages/objectql/src/plugin.integration.test.ts(the bridge tests, two cases) andpackages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts(beside the install-local DELETE leaves the withdrawn package's objects in GET /api/v1/meta/object until a restart, while their data route already answers 404 #24070 pin, one case through the real DELETE). That file'sinstall/uninstallhelpers now take an optional manifest and id; the existing four cases call them unchanged..changeset/24182-extender-withdrawal-restores-owner.md:patchfor@objectstack/objectqland@objectstack/cloud-connection.The mechanism, measured
registry.uninstallPackage(EXTENDER)the registry already serves the owner's body without the extension field. The objectql pin asserts it:registry.getObject('crm_contact').fieldshas noext_score. The stale state is only the owner's metadata-service copy, which the extender's bridge rewrote under the owner's stamp. The counterpart re-puts the registry's current definition, with no second merge.ctx.triggerin the uninstall path, andmetadata:reloadedis announced only by the hot install.SchemaRegistry.uninstallPackageemits nothing. And after the withdrawal the registry no longer says which objects the package extended. So the bridge has to remember what it rewrote, and the door that withdrew the package has to call its counterpart. The call is the one cloud-connection line block named above.DELETE /packages/:id(packages/runtime/src/domains/packages.ts) also callsregistry.uninstallPackageand does not call the counterpart. It refuses an install-local package with422 WRITABLE_PACKAGE_REQUIRED(measured by PR fix(cloud-connection): an install-local uninstall withdraws the metadata-service copies of its objects, so the meta object reads drop them at once #24181). A writable package's objects reach the metadata service throughfollowRuntimeObjectCopy, not through this bridge, so the record holds nothing for it. Package disable of an extender was not measured. Both are in Acceptance notes, not widened into this PR.Tests (head
3a452eeae)-t extender, 2 cases) and cloud-connection meta-reads file (5 cases)ABLATION_24182) viascripts/ablation-replace.mjs(anchor 1 to 0, blob8fbce83ctobaaba360),@objectstack/objectqlrebuilt,ablation-dist-preflightmarker present (exit 0)serves the owner's body without the withdrawn fieldfailed (1 failed / 1 passed); cloud-connectionowner + extender installed, DELETE the extenderfailed (1 failed / 4 passed). Both controls stayed greenablation-replacerestored: blob equals HEAD8fbce83c,git diff HEADempty; rebuilt,ablation-dist-preflight --absentexit 0pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2pnpm --filter @objectstack/objectql typecheckandpnpm --filter @objectstack/cloud-connection typecheckplugin.integration.test.tsundertsconfig.test.json, held its debtpnpm exec eslint --no-inline-configover the four changed.tsfilesThe full-suite runs were taken before the last commit, which only types two slot lookups in the objectql pin. After it, the objectql typecheck and the whole
plugin.integration.test.ts(46 / 46) were re-run green.The public door, before and after (live)
Setup:
pnpm dev:crm -- --fresh -p PORTfrom this worktree withOS_CLOUD_URL=off(the realMetadataPluginandMarketplaceInstallLocal), signed in as the seeded dev admin. Package Acom.example.crmlite(objectscrmlite_account,crmlite_contact) and package Bcom.example.crmliteext(only anobjectExtensionsfieldext_scoreoncrmlite_account) are installed inline through install-local. Then B is DELETEd.packages/objectql/src/plugin.tsand the install-local plugin restored in the tree tobb7ccde66's blobs, both packages rebuilt,ablation-dist-preflight --absentonrestoreExtendedObjectCopiesexit 0 for both):GET /meta/object/crmlite_accountis200withext_scoreandname;DELETEanswers200,cleanups: [security.package-permissions: success];GET /meta/object/crmlite_accountis200withext_score.GET /meta/objectlistscrmlite_accountwithoutext_score, andGET /data/crmlite_account?fields=ext_scoreanswers400. This is the card's reading.dist/), the same sequence:GET /meta/object/crmlite_accountis200withoutext_score,namekept,_packageId: com.example.crmlite;os dev(throughpnpm dev:crm), notos start. Both arm the late bridge this fix lives in.git diff HEADempty) before anything else.Gates (after the final commit,
3a452eeae)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 67 commands. All 67 exit 0, each code captured before any pipe.check:slot-lookupwas red: this PR's objectql pin added two untypedgetServicelookups (23 to 25). They are typed now (IMetadataService,ObjectQL). The ratchet holds at 101 unswept, none new.check-engine-split-ratio --days 90(exit 2) andcheck-plugin-teardown-shape --self-test(exit 3) refused on the shallow clone. Aftergit fetch --shallow-since=2026-07-12 origin main, both exit 0.dispatch-gates.mjs --ran: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero (all 67 carry an exit code).test:repo, the CLIintegrationtier (no spawn entry touched), the type-check lanes beyond the two packages' owntypecheck, the dogfood shards and the repo-widepnpm lint.Acceptance notes
DELETE /packages/:idwithdraws through the same registry verb without calling this counterpart. Today no bridged extender reaches it: install-local packages are refused there, and writable packages are not bridged by this path. Package disable of an extender was not measured.start()has armed the late bridge, so its rewrites are recorded. A boot-time code package is not withdrawn by this door.Generated by Claude Code