Skip to content

feat(spec,objectql)!: a top-level action bound to another package's object is listed on that object as served, and an unregistered target fails the boot - #24458

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24400-bound-action-foreign-object
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24400-bound-action-foreign-object

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24400

Clause-②: yes (narrowing)

Dispatched by the domain:spec seat 2 (claim 6258620187, branch claude/issue-24400-bound-action-foreign-object). Dev session session_016ejhoxYxma7A4FRNYqEyUL. The engine half is the rider declared to domain:engine on #6367.

What this does

The extension refusal for actions told an author: declare a top-level action with objectName: 'TARGET', and defineStack() attaches it to the object. For an object another package owns, that was false in two ways (P1 below). This PR makes the route real and re-trues the sentence to say exactly what happens.

  • Engine (@objectstack/objectql). SchemaRegistry attaches every code package's bound top-level action to its target object at the one step every object read ends in, finishServedObject (packages/objectql/src/registry.ts:3472), via attachBoundActions (:3516). Registration order doesn't matter, because the attach runs at read time and the cached fold is dropped when an action registers or leaves.
    • Attached by reference, deduplicated by name: an action the object already declares keeps the object's declaration, the router's own rung-1-before-rung-2 precedence.
    • The object's own actions keep their served order; attached ones follow, sorted among themselves by order.
    • A disabled package's bound actions are not attached.
  • Loud refusal. A bound action whose target no loaded package registers fails the boot at kernel:ready (packages/objectql/src/plugin.ts:932) with INVALID_METADATA (422), naming each action, its package and the object. The ObjectQL plugin already refuses unresolved picklists at this same point; it judges here because every package has registered by then, so "absent" is final. After the boot, an artifact arriving through the manifest service is judged before any of it registers (plugin.ts:761).
  • Spec (@objectstack/spec). defineStack()'s cross-reference check accepts an action bound to an object outside the stack in exactly two cases (packages/spec/src/stack.zod.ts:4066): an object the stack extends through objectExtensions (the guidance's own context), and a known platform object (isPlatformProvidedObjectName, a named list, not the sys_ prefix). Any other name is still refused at build. The action stays in the top-level actions (documented on mergeActionsIntoObjects).
  • The re-trued sentence (ObjectExtensionSchema guidance.actions, packages/spec/src/data/object.zod.ts), mirrored in content/docs/data-modeling/object-extensions.mdx: declare a top-level action with objectName: 'TARGET' in the same stack. defineStack() accepts it because the stack extends the target and keeps it in the top-level actions. The engine's schema registry attaches it to the target object wherever the object is served, whichever package registers first. A target no loaded package registers fails the boot.

The premises, each read before and after

P1: holds, and is wider than the card says. Booted showcase (pnpm exec objectstack dev --fresh), built at origin/main db1f1fd20, with one probe action added to the app stack: { name: 'probe_rename_org', objectName: 'sys_organization', type: 'url' }.

  • Before, strict defineStack (the shipped config): the compile is refused. ✗ Action 'probe_rename_org' references object 'sys_organization' which is not defined in objects. The route the guidance prescribes was refused at build in any stack that declares an object of its own.
  • Before, defineStack(..., { strict: false }): the server boots. GET /api/v1/meta/action/probe_rename_org answers 200 (_packageId: com.example.showcase). GET /api/v1/meta/object/sys_organization answers 200 with actions = create_organization, update_organization, delete_organization, set_active_organization, invite_user, leave_organization, rename_organization, change_slug. The probe is absent, and nothing reports it.
  • After, on this branch with the shipped strict config: the server boots, and the same read answers those eight in the same order, followed by probe_rename_org.
  • Controls on the same build: the stock showcase and the stock CRM both boot with no refusal, and their sys_organization actions are unchanged (the eight above, same order). showcase_task's actions are unchanged too.

P2: holds. The one meeting point is SchemaRegistry.finishServedObject. It is the tail of foldExtendersOntoDefinition and of foldObjectExtendersOnto (registry.ts:3459, :3704, :3706). Every serving door reads through it:

  • REST GET /meta/object/NAME is getMetaItem → readMetaItem (packages/metadata-protocol/src/protocol.ts). Step 1 (stored row) and step 2 (MetadataService) go through foldObjectExtendersFromRegistry (:9333) → foldObjectExtendersOnto. Step 3 is registry.getItem('object') (:11677) → getObject → resolveObject (registry.ts:3379).
  • engine.getSchema() is registry.getObject. So is the action router's object lookup (resolveRouteActionDeclaration, rung 1, packages/runtime/src/action-execution.ts).
  • Studio reads the REST door.
  • MCP's describe_object projects name, label, fields and enable only (packages/runtime/src/domains/mcp.ts:592, packages/mcp/src/stdio-data-bridge.ts:703). It serves no actions, so it has nothing to attach.
  • No per-door code was added. The served-read pin hits the protocol's real getMetaItem on a real ObjectQL in both registration orders.

P3: holds. Who may run the action is unchanged.

  • The attached entry is the registry's own standalone item, by reference. A test asserts toBe(registry.getItem('action', name)). So the router's rung 1 now finds the very declaration its rung 2 found before, carrying its own requiredPermissions, requiresMembershipReach and execution identity.
  • Handler registration (ObjectQL.actions) is untouched.
  • Nothing else on the object moves: the attach writes only the served actions array, so the owning package's managedBy and object-level write policy are as before.
  • Before this PR the action was already dispatchable through rung 2. Only its listing was missing.

Siblings (G3), measured, not fixed here

The extension guidance also tells an author to declare a top-level hook, or a top-level view, bound to the target object. Measured on this branch's built spec with objectExtensions: [{ extend: 'sys_organization', … }] in a stack that has one object of its own:

  • hooks: refused at build. ✗ Hook 'g3_hook' references object 'sys_organization' which is not defined in objects. It is accepted only in an object-less stack. ADR-0130 §1.5 records this refusal as deliberate ("the split must follow hook ownership"), so the sentence and the ADR disagree.
  • listViews: refused at build. ✗ View[0].list references object 'sys_organization' which is not defined in objects.

Neither is the same mechanism (one is a recorded ADR decision, the other a different cross-reference rule plus a different serving path), so both go to the report's out-of-scope findings for the seat.

Changeset (G4)

.changeset/24400-bound-action-foreign-object.md: minor on @objectstack/spec (a widening: defineStack accepts two more target kinds for the existing objectName) and minor on @objectstack/objectql (the boot refusal is a narrowing, since a stack that booted with a dangling bound action now fails; it ships as minor under the launch-window convention, with ! in the title). Clause-②: yes (narrowing) is the spelling the gate reads for a diff that widens and narrows. The changeset carries the ADR-0087 not-required (no-migration-prescription) marker and the FROM, TO and fix lines. No new error code: the refusal answers the existing INVALID_METADATA.

Tests

On head 0027ae7fe0 (after merging origin/main fa26308bb0 through scripts/pm/os-regen-merge.sh; no regeneration was owed, and check:generated reports all 14 artifacts current):

  • packages/objectql/src/bound-action-foreign-object.test.ts (new, 14 cases):
    • the served read in both orders;
    • the entry is the declaration by reference;
    • own order kept, attached ones sorted;
    • control object unchanged;
    • a same-stack action attached once;
    • a same-name declaration keeps the object's;
    • a disabled package's action withdrawn and restored;
    • uninstall withdraws from the cache;
    • the boot refusal; a later package's target boots;
    • a late artifact refused before anything of it registers, and one that brings or names its object registers;
    • the refusal message lists every action.
  • packages/spec/src/stack-bound-action-foreign-object.test.ts (new, 6 cases): an extended target is accepted and stays top-level; a known platform object is accepted; a non-extended foreign name is still refused, and so is sys_organizaton; same-stack is merged as before; the extension refusal names the route.
  • Full suites (this package or downstream): @objectstack/objectql 445 files / 8368 tests passed, @objectstack/spec 698 files / 21273 passed (1 todo), and downstream @objectstack/metadata-protocol 246 / 20655, @objectstack/rest 298 / 6079, @objectstack/runtime 421 / 6626, all passed. @objectstack/spec and @objectstack/objectql typecheck clean.
  • Ablations, each landed by scripts/ablation-replace.mjs and restored byte-identical (hash-checked):
    • the attach removed from finishServedObject: 8 of 14 engine tests red. The 6 still green are the control, same-stack, same-name and the refusal cases, which don't depend on the attach.
    • the kernel:ready throw removed: exactly the boot-refusal test red.
    • the objectExtensions acceptance removed from defineStack: exactly the extended-target test red.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final head derives 116 commands, and each was run with its exit code captured before any pipe. 114 exit 0. --ran reconciles 116 derived, 114 run, 2 NOT MEASURED, 0 unrun:

  • pnpm --filter @objectstack/spec run check:skill-examples exits 3, PREREQUISITE NOT MET (packages/client-react/dist not built here).
  • pnpm check:dual-build-cjs-loads exits 3, PREREQUISITE NOT MET (eight packages this diff does not touch have no dist/).

check:engine-split-ratio and check-plugin-teardown-shape --self-test refused on the shallow clone; both pass after git fetch --unshallow. check:dev-prereqs passes. The full farm is CI's.

Acceptance notes

  • A stored sys_metadata overlay row written back from a served read (the Studio GET → edit → PUT round-trip) carries the attached action's copy. The attach then deduplicates by name, so the read is unchanged, but the copy outlives the binding package's uninstall. Extenders have the same seam, which subtractExtenderContributions covers for validations and indexes. Not covered here for actions.
  • The MetadataService copy bridged from getAllObjects() at boot carries the attached actions. It is re-bridged when packages arrive late, but not when a binding package is withdrawn. That is the same staleness class as the existing extender copies (restoreExtendedObjectCopies).
  • Two packages binding the same action name to one object, or a foreign bound action named like one the object already declares: the attach keeps the first (the object's own) and does not report the other. The engine's handler map OBJECT:ACTION already collides silently on the same pair, before this PR. Reported in the os-dev report as a finding, not fixed here.
  • Commits on this branch carry no trailer pair; the PR squash-lands under its own title and body.

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 26 documentable anchor(s).

28 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 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8.

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 39 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 144 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 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8

⚠️ 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 725a5b1311ecc051edafcd8122ad47dfe1ccfbf8 → 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

席位核对 (seat check) · PR #24458 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-19T05:41Z

ACCEPT at head 0027ae7fe025bb571c13d9a6c25b4e0bf1aeaff0 (claim 6258620187, mode:cloud session session_016ejhoxYxma7A4FRNYqEyUL, report 6259877793 on #24400). A contract review at CONTRACT_REVIEW_TIER (schema face: stack.zod.ts, data/object.zod.ts, plus a narrowing at the boot door) is owed. It runs on this head, isolated from the seat. needs:contract-review is on the PR until its record is adopted.

item reading
Shape Draft, base main, assignee zhuangjianguo. Line 1 is Fixes #24400. Clause-②: yes (narrowing) starts a line. 8 files, +756 / −12. Not governed. The changeset is minor on @objectstack/spec and @objectstack/objectql, with a ! title, FROM / TO / Fix lines and an ADR-0087 not-required (no-migration-prescription) marker.
The route SchemaRegistry.finishServedObject (packages/objectql/src/registry.ts) attaches each code package's bound top-level action to its target object at read time, by reference, in either registration order. A bound action whose target no loaded package registers fails the boot at kernel:ready with INVALID_METADATA (422). defineStack() accepts a foreign objectName only for an object the stack extends through objectExtensions, or a named platform object. Any other name is still refused at build.
Premises P1 true, and wider than the card says: on main, strict defineStack refused the guidance's route at compile, and with strict: false the action registered but was absent from GET /api/v1/meta/object/sys_organization. After the change it is listed after the eight platform actions, which keep their order. P2 true: finishServedObject is the one tail of every object read (REST's three layers, engine.getSchema(), the router's rung 1); MCP describe_object serves no actions. P3 true: the attached entry is the registry item itself (toBe), and the handler map and managedBy are untouched.
CI On this head: 41 runs, 36 success, 5 skipped, 0 failure.
Serial Open PR file lists read in this act. Only PR #24451 (#24340, domain:services) touches packages/spec/src/data/object.zod.ts, in defaultRowScope's docblock (about :2973). This PR edits ObjectExtensionSchema's guidance.actions (about :3983). The hunks are disjoint, so whichever lands second merges. No open PR touches packages/objectql/src/registry.ts, plugin.ts or stack.zod.ts.
Accepted deviations (a) Clause-②: yes (narrowing) instead of the claim's yes (widening): dispatch G4 named this spelling for a diff that widens and narrows. (b) content/docs/data-modeling/object-extensions.mdx, outside the claim's file surface: a one-row hand-written mirror of the re-trued sentence. Leaving it would keep the false promise in the docs. (c) G1 measured false: defineStack refused a foreign target in any stack with objects, so the spec half is a scoped widening, not documentation only.
Open question 1 (which foreign targets defineStack accepts) Seat answer: A, as implemented. It is the narrowest acceptance that makes the guidance's own route real (the guidance is written for an extension body), plus the named platform list #24318 needs. A typo still fails at build. B moves every typo to boot, and C reopens artifactObjects' recorded scope. Not escalated: it executes the premise-bound ruling (implement the promised route), and no product semantics split.
Open question 2 (a name collision) Seat answer: A for this PR. The object's own declaration wins, by the router's rung-1 precedence. Refusing a collision (B) is a narrowing that the ruling never named, and it needs a declaration-sameness measurement first. B would also hard-fail #24318's transition boot. The contract review is asked to judge this against the card's "never silently dropped".
Findings Two class-b findings, measured at the public door (defineStack): the same guidance tells an author to declare a top-level hook or view bound to the target, and both are refused at build. One card for the family is filed at adoption. Two items are noted, not filed: the handler-key collision in ObjectQL.actions (read in code, no booted reach), and the stored-overlay staleness. Both are in the PR's Acceptance notes.
Landing After a contract-review PASS and a green head: pr_ready then automerge_enable. Fixes #24400 closes the card.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0027ae7fe025bb571c13d9a6c25b4e0bf1aeaff0
Local-runs: none
Scope: packages/spec/src/data/object.zod.ts, packages/spec/src/stack.zod.ts + packages/objectql/src/registry.ts, packages/objectql/src/plugin.ts, packages/objectql/src/action-governance.ts (read, unchanged), packages/spec/src/system/constants/platform-object-names.ts (read, unchanged), packages/metadata-protocol/src/protocol.ts (read, unchanged), packages/runtime/src/action-execution.ts (read, unchanged), packages/objectql/src/engine.ts (read, unchanged), packages/metadata-core/src/code-artifact-provenance.ts (read, unchanged), packages/core/src/kernel.ts and lite-kernel.ts (read, unchanged), packages/cloud-connection/src/marketplace-install-local-plugin.ts (read, unchanged), packages/objectql/src/bound-action-foreign-object.test.ts, packages/spec/src/stack-bound-action-foreign-object.test.ts, content/docs/data-modeling/object-extensions.mdx, .changeset/24400-bound-action-foreign-object.md

Inputs: card #24400 (body and comments 6256196520, 6256677238, 6258620187, 6258640430, 6259877793), PR #24458 (body, 8-file list, net diff against merge base fa26308bb0 of 756+/12-), and the 45 check-runs on this head. The seat check on the PR was not weighed.

① Derived judgments

1. The served widening lands at the one tail — right. finishServedObject (registry.ts :3472) is the last line of foldExtendersOntoDefinition (:3459). Three callers reach it and nothing reads an object around it: resolveObject (:3379, through foldExtenders), the code layer resolveOwnerLayer (:4364, through foldExtenders), and foldObjectExtendersOnto on all three of its branches (:3704 no fqn, :3706 no extender, and the fold). The protocol's readMetaItem runs step 1 (stored row, protocol.ts :11588) and step 2 (MetadataService copy, :11640) through foldObjectExtendersFromRegistry (:9333 to foldObjectExtendersOnto); step 3 is registry.getItem('object') to getObject to resolveObject. engine.getSchema() is registry.getObject (engine.ts :11208); the router's object lookup is ql.getSchema(objectName) ?? registry.getObject (action-execution.ts :2961); getAllObjects resolves each fqn through resolveObject (:4449). MCP describe_object serves no actions. Nothing sees it twice: attachBoundActions builds present from the body's own names first, so the same-stack merged copy, a MetadataService copy already carrying it, and a stored overlay written back from a served read each absorb the attach (pinned: same-stack once).

2. Order independence and cache correctness — right. The attach is read-time, and every event that changes the answer drops the fold: registerItem('action') invalidates the target object (:5092, invalidate takes a bare name, :6413); unregisterItem('action') and unregisterItemsByPackage with any action/ removal call invalidateAll (:5126, :5289); enablePackage and disablePackage call invalidateAll (:5998, :6016); re-registering the target deletes its fqn (registerObject, mergedObjectCache.delete). boundCodeActions leaves out a disabled package's items (isPackageDisabled, :5639). No stale path on the registry. Pinned: both registration orders, disable then enable, uninstall. Two copies outside the registry are stale by the pre-existing class the dev's acceptance notes name (the boot-bridged MetadataService copy, a stored overlay round-tripped from a served read): both re-attach on read, but a withdrawn action persists in the copy. See ③.

3. Cost — in proportion on the stock tree, not indexed. boundCodeActions() walks the whole action collection on every attach. In the registry that is once per object per cache fill; through foldObjectExtendersOnto (uncached) every protocol read of a stored row or MetadataService copy pays one scan of all standalone actions, and a cold getAllObjects() pays objects times actions. A per-objectName index is a cheap follow-up, not a defect here.

4. P3, who may run it — right, nothing loosened. The attached entry is the registry item by reference (fresh.push(action); pinned toBe(registry.getItem('action', name))), so the router's rung 1 (obj.actions.find) now returns the very declaration rung 2 (registry.getItem('action') under ownsRoute) returned before, carrying its own requiredPermissions, requiresMembershipReach and execution identity. Only actions is rewritten ({ ...body, actions }): managedBy, object-level write policy and fields are untouched. The handler map (registerAction, key OBJECT:ACTION, engine.ts :5523) is untouched. Write-through: the served object is a fresh object with a fresh array; its entries are shared exactly as every cached fold's entries already are on every registry read, so no new mutation class is introduced, and a grep of rest, runtime and metadata-protocol found no in-place write to an action entry. One visible wire difference: the attached entry carries the registry's _packageId stamp (pinned) while the object's own entries do not; downstream suites are green.

5. The build-time widening in defineStack — right, and only the one arm. extendedObjectNames is read off objectExtensions[].extend (:4056); isPlatformProvidedObjectName is PLATFORM_PROVIDED_OBJECT_NAMES.has(name), the union of PLATFORM_OBJECTS_BY_PACKAGE and CLOUD_PROVIDED_OBJECT_NAMES (platform-object-names.ts :160, :197), a named set and not hasPlatformObjectPrefix; pinned by the sys_organizaton control. The embedded-action arm, the hooks arm (:3822), the views arm (:3846) and the nav arm still judge against objectNames alone; artifactObjects scope is untouched. validateCrossReferences has one caller (:4941), so composeStacks moves only through defineStack. strict: false returns before validation and mergeActionsIntoObjects is unchanged. Edge, by design: the named list includes cloud-only objects (sys_app, sys_environment and the rest), so on an OSS runtime such a binding passes the build and fails the boot by the new rule, loudly.

6. The boot narrowing — right, judged after every package registered. The kernel:ready hook is registered in start() (plugin.ts :857, hook at :906); ObjectKernel fires it in phase 3 after every plugin's start() (kernel.ts :607) and LiteKernel through triggerHookOrThrow (:140), so the throw fails both boots (pinned, INVALID_METADATA 422 with the message). findUnboundActionTargets reads resolveObjectKey, the read path's own name resolution. A target from a later package in the same boot is not refused (pinned). Post-boot, the manifest door judges after the picklist seal and before the registerApp loop and registerUnclaimedTopLevelObjects (:761 to :772); declared holds the residual's objects, every ordered manifest's objects in both authored spellings, and nested plugins[]; a refused install registers nothing (pinned: getItem('action') is undefined after the refusal). Not judged: a direct post-boot ql.registerApp by a plugin (its action attaches on read once the object appears) and tenant-authored action rows from resyncAuthoredActions (deliberately outside the population, isCodeArtifactBody).
Blast radius, read on this head: every top-level objectName binding in examples/** is same-stack (app-crm crm_convert_lead to crm_lead; showcase to showcase_task, showcase_invoice, showcase_field_zoo); app-multi-package declares no top-level actions; no plugin, service, cloud-connection or platform-objects package declares a top-level defineAction (sys_organization's eight are embedded on the object); scaffolding templates carry none. No shipped composition fails its boot; the dev's booted stock showcase and CRM controls agree.
One order-sensitive seam, by precedent: marketplace-install-local-plugin registers its kernel:ready hook in start() after ObjectQL's, so the ledger rehydrates through the sealed door one artifact at a time in ledger order; a marketplace package binding to an object another marketplace package owns depends on that order. The picklist seal at the same door has the identical shape, so this is the existing class, not a new one.

7. The name collision — an acceptable deferral, on condition it is filed. attachBoundActions skips a name the object already carries and logs nothing. What runs in that case is unchanged by this PR: rung 1 returns the object's own declaration and its gate, exactly as before (rung 2 was never reached for a name the object declared), and the handler is whichever registerAction wrote OBJECT:ACTION last (a bare Map.set, engine.ts :5523, no diagnostic), pre-existing. The shadowed listing is the listing face of that pre-existing handler-face collision. The card's "never silently dropped" is breached only for a same-name pair, which the card's own evidence does not contain, and a hard refusal would break #24318's transition (the platform object still declaring change_slug while the composition adds it). Accepted as a deferral because the seat carries the card; see ③.

8. The re-trued sentence — true clause by clause. "In the same stack" holds; "defineStack() accepts it because the stack extends the target" is :4062; "keeps it in the top-level actions" holds (mergeActionsIntoObjects merges only into config.objects and preserves the top-level array); "the engine's schema registry attaches it to the target object wherever the object is served" holds for every door that serves actions (item 1); "whichever package registers first" holds (item 2); "a target no loaded package registers fails the boot" holds (item 6). The one case the sentence does not state is item 7's same-name shadow. The docs row mirrors the sentence in substance. The hooks and listViews sentences beside it remain false as the dev measured: both arms refuse any name outside objectNames (:3822, :3846) and only the objectNames.size === 0 return (:3811) lets an object-less stack through; ADR-0130 §1.5 records the hook refusal as deliberate ("the split must follow hook ownership", line 157). Out of scope here; the seat files both.

9. The pins — right, with three unpinned corners. objectql, 14: the served read in both orders (2), the entry by reference, own order kept with attached ones sorted, the control object, same-stack once, same-name keeps the object's, disable then enable, uninstall, the boot refusal (code, status, message), a later package's target boots, a late artifact refused before anything of it registers, a late artifact with a registered or self-brought target registers, the message lists every action and answers nothing for none. spec, 6: an extended target accepted and kept top-level, a platform object accepted, a non-extended name refused, the typo refused (the named-list control), same-stack merged as before, the guidance text. Unpinned and judged by reading: the nested plugins[] branch of the two manifest collectors, the residual-object branch of the late door, and the stored-row and MetadataService layers of the protocol read (the harness has neither, so the served-read pin exercises step 3 only). The dev's ablation counts (8 of 14, 1 of 14, 1 of 6) agree with this pin map.

10. Repo rules. Contract-first: the attach lives in the producer's fold beside picklist resolution, not in a consumer shim. Runtime strings carry no tracker number. No governed path in the file list (Governed Surface Queue Guard success); content/docs/data-modeling is a hand-written tree; 768 changed lines; the card's claim names this branch (claim check success).

② Semver level

The changeset declares @objectstack/spec minor and @objectstack/objectql minor, a feat(spec,objectql)!: title, Clause-②: yes (narrowing), one marker adr-0087: not-required (no-migration-prescription) closed on facts, and FROM, TO and Fix lines. Each is what the diff publishes:

  • spec: an accept-set widening of defineStack (two more target kinds for the existing objectName) and the ObjectExtensionSchema guidance text; no schema shape or export moves (TypeScript Type Check, which runs every spec artifact gate, is green).
  • objectql: new entries in the served object's existing actions array (widening), new exports (describeUnboundActionTargets, collectManifestBoundActions, collectManifestObjectNames, the two types, SchemaRegistry.findUnboundActionTargets), and the boot refusal (narrowing, BREAKING). The FROM/TO/Fix lines describe it exactly, and the Unchanged clause is true.
  • yes takes at least minor (met); (narrowing) is BREAKING and ships minor under the launch-window convention; one arm is the closed pair's rule, and yes (narrowing) is the gate's spelling for a diff that widens one surface and narrows another (clause2-line.mjs :93). The claim's yes (widening) is superseded by the PR body and the changeset, which agree; the dev's deviation (a) is right.
  • Gate runs on this head, read from job 117893969705 (Check Changeset, success): check-empty-changeset passes (one declaring changeset added); check-adr-0087-registration passes, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", listing .changeset/24400-bound-action-foreign-object.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription); check-changeset-no-major passes, no major bump, LEVEL AXIS yes (narrowing) read as BREAKING shipping minor, no moved package graded patch, protocol lockstep clean. The five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) all conclude success.

Semver level: right.

③ Boundary flags

Dev deviations (report 6259877793):

  • (a) Clause-②: yes (narrowing) against the claim's yes (widening): right, judged in ②.
  • (b) content/docs/data-modeling/object-extensions.mdx outside the claim's file surface: accepted; a one-row hand-written mirror of the re-trued sentence, and leaving it would keep the false promise in docs.
  • (c) G1 measured false (strict defineStack refused the route at build): accepted; it turns the spec half into a scoped widening, which minor on spec covers.
  • (d) The branch commits carry no Claude-Session / Co-authored-by trailer pair: reported, not fixed; the PR body carries the session footer. A landing-process point for the owning seat; no contract effect.
  • (e) The dev did not subscribe to the PR: the seat's act.

Open questions:

  • Q1 (which foreign targets build accepts), option A: right. Extended targets and the named platform list; a typo still fails at build; a cloud-only platform name on an OSS runtime fails loudly at boot by the new rule.
  • Q2 (name collision), option A: accepted as a deferral on one condition, escalated to the seat: file the card for B (refuse at kernel:ready, or at least a warn naming both packages, behind a sameness test for a composed stack's one declaration carried under two packages), citing registerAction's silent OBJECT:ACTION overwrite (engine.ts :5523) as the handler-face sibling of the same collision.

Acceptance notes and out-of-scope findings:

  • A stored overlay written back from a served read carries the attached copy (with the binder's _packageId) and outlives the binding package's uninstall; the boot-bridged MetadataService copy is not re-bridged on withdrawal. The served read is unchanged today (dedup by name). Escalated: the seat files a card for the subtractExtenderContributions-class sibling for actions.
  • No objectName index on the attach (item 3): noted for the seat; a rider candidate for the collision card.
  • Marketplace rehydrate order through the sealed door (item 6): the picklist seal's existing shape; noted, no card without a booted repro.
  • The protocol pin exercises the registry layer only (item 9): a stored-row pin would close it; not blocking.
  • hooks and listViews guidance sentences false at build (item 8): measurement confirmed; the seat files, as the dev's report routes them.

PR state read: draft, no governed path, 768 changed lines, author objectstack-fleet[bot].

Implemented-by: claude/issue-24400-bound-action-foreign-object
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-19T06:01Z


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 06:02
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 06:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit fb0d85b Oct 19, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24400-bound-action-foreign-object branch October 19, 2026 06:36
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/l tests tooling

Projects

None yet

2 participants