Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24182-extender-withdrawal-restores-owner
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24182-extender-withdrawal-restores-owner

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24182

Clause-②: no

What this changes

After DELETE /api/v1/marketplace/install-local/:manifestId of a package that extends another package's object, GET /api/v1/meta/object/NAME now 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):
    • bridgeManifestObjectsToMetadataService now 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 field extendedObjectCopies.
    • New restoreExtendedObjectCopies(ctx, packageId), exposed on the manifest service beside register. 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.
    • Same rule as the bridge's overwrite: only a copy whose _packageId is 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.
    • Never rejects. A copy that cannot be written is logged once at error with the consequence and the remedy (restart), the level followRuntimeObjectCopy uses 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 after withdrawObjectCopies, and only when the registry withdrew, the DELETE handler calls manifest.restoreExtendedObjectCopies(manifestId). The DELETE answer is unchanged.
  • Pins: packages/objectql/src/plugin.integration.test.ts (the bridge tests, two cases) and packages/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's install/uninstall helpers now take an optional manifest and id; the existing four cases call them unchanged.
  • .changeset/24182-extender-withdrawal-restores-owner.md: patch for @objectstack/objectql and @objectstack/cloud-connection.

The mechanism, measured

  • H1, confirmed. After registry.uninstallPackage(EXTENDER) the registry already serves the owner's body without the extension field. The objectql pin asserts it: registry.getObject('crm_contact').fields has no ext_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.
  • H2, falsified: there is no seam. The install-local DELETE fires no kernel event: no ctx.trigger in the uninstall path, and metadata:reloaded is announced only by the hot install. SchemaRegistry.uninstallPackage emits 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.
  • H3, read from source, not measured live. The protocol door DELETE /packages/:id (packages/runtime/src/domains/packages.ts) also calls registry.uninstallPackage and does not call the counterpart. It refuses an install-local package with 422 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 through followRuntimeObjectCopy, 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.
  • H4, the live re-measure is below: the card's setup reproduces on the base, and the head fixes it.

Tests (head 3a452eeae)

run result
objectql pin (-t extender, 2 cases) and cloud-connection meta-reads file (5 cases) all pass
ablation: the counterpart's guard line replaced by an unconditional early return (marker ABLATION_24182) via scripts/ablation-replace.mjs (anchor 1 to 0, blob 8fbce83c to baaba360), @objectstack/objectql rebuilt, ablation-dist-preflight marker present (exit 0) red: objectql serves the owner's body without the withdrawn field failed (1 failed / 1 passed); cloud-connection owner + extender installed, DELETE the extender failed (1 failed / 4 passed). Both controls stayed green
restore ablation-replace restored: blob equals HEAD 8fbce83c, git diff HEAD empty; rebuilt, ablation-dist-preflight --absent exit 0
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 438 files / 8243 tests passed
pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2 51 files / 601 tests passed
pnpm --filter @objectstack/objectql typecheck and pnpm --filter @objectstack/cloud-connection typecheck exit 0; objectql's test layer, which compiles plugin.integration.test.ts under tsconfig.test.json, held its debt
pnpm exec eslint --no-inline-config over the four changed .ts files exit 0

The 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 PORT from this worktree with OS_CLOUD_URL=off (the real MetadataPlugin and MarketplaceInstallLocal), signed in as the seeded dev admin. Package A com.example.crmlite (objects crmlite_account, crmlite_contact) and package B com.example.crmliteext (only an objectExtensions field ext_score on crmlite_account) are installed inline through install-local. Then B is DELETEd.

  • Base (packages/objectql/src/plugin.ts and the install-local plugin restored in the tree to bb7ccde66's blobs, both packages rebuilt, ablation-dist-preflight --absent on restoreExtendedObjectCopies exit 0 for both):
    • before the DELETE, GET /meta/object/crmlite_account is 200 with ext_score and name;
    • DELETE answers 200, cleanups: [security.package-permissions: success];
    • after it, GET /meta/object/crmlite_account is 200 with ext_score. GET /meta/object lists crmlite_account without ext_score, and GET /data/crmlite_account?fields=ext_score answers 400. This is the card's reading.
  • Head (rebuilt, preflight marker present in both dist/), the same sequence:
    • after the DELETE, GET /meta/object/crmlite_account is 200 without ext_score, name kept, _packageId: com.example.crmlite;
    • the listing and the data plane read as on the base, and the DELETE answer is byte-identical.
  • One deviation from the card's setup: os dev (through pnpm dev:crm), not os start. Both arm the late bridge this fix lives in.
  • The probe script lived in the session scratchpad. Each server was stopped by its recorded process group, and the tree was restored to HEAD (git diff HEAD empty) 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.
    • On the first pass, check:slot-lookup was red: this PR's objectql pin added two untyped getService lookups (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) and check-plugin-teardown-shape --self-test (exit 3) refused on the shallow clone. After git 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).
  • Not run locally and declared to CI: test:repo, the CLI integration tier (no spawn entry touched), the type-check lanes beyond the two packages' own typecheck, the dogfood shards and the repo-wide pnpm lint.

Acceptance notes

  • Read-only inference, not measured. A hot upgrade of an extender whose new version DROPS an extension re-bridges only the objects the new version still contributes to. The owner's copy would keep the dropped field until the extender is uninstalled (the record keeps the name, so this counterpart then restores it) or the runtime restarts. That is the same family on another door. Not fixed here.
  • Read-only inference, not measured (H3). DELETE /packages/:id withdraws 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.
  • The record is per process. A copy rewritten by the boot-time one-shot bridge is not recorded. Every install-local package (hot install and ledger rehydrate) registers after 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

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cloud-connection, @objectstack/objectql, touching 6 documentable anchor(s).

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

  • content/docs/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ObjectQLPlugin (symbol, a top-level class))

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

What this run could not see
  • the SDK route bridge reached 54 of 206 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 — 20 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 b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e66f494dee7e5f380e3b227c2ba6229b5775ad34 — the merge of head 3a452eeae41444d2fd05ec9acbdb4c5a9462c898 into base b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761, 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 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

⚠️ 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 b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 → 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:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-17T21:04Z · ACCEPT

Head: 3a452eeae4.

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. After uninstallPackage the 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/:id without 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 dev in place of os 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

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/m tests tooling

Projects

None yet

2 participants