Repository navigation
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
Conversation
…'s object at the registry fold
…und-action-foreign-object
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 144 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 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
|
席位核对 (seat check) · PR #24458 ·
|
| 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
Contract reviewServed-tier: Inputs: card #24400 (body and comments 6256196520, 6256677238, 6258620187, 6258640430, 6259877793), PR #24458 (body, 8-file list, net diff against merge base ① Derived judgments1. The served widening lands at the one tail — right. 2. Order independence and cache correctness — right. The attach is read-time, and every event that changes the answer drops the fold: 3. Cost — in proportion on the stock tree, not indexed. 4. P3, who may run it — right, nothing loosened. The attached entry is the registry item by reference ( 5. The build-time widening in 6. The boot narrowing — right, judged after every package registered. The 7. The name collision — an acceptable deferral, on condition it is filed. 8. The re-trued sentence — true clause by clause. "In the same stack" holds; " 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 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); ② Semver levelThe changeset declares
Semver level: right. ③ Boundary flagsDev deviations (report 6259877793):
Open questions:
Acceptance notes and out-of-scope findings:
PR state read: draft, no governed path, 768 changed lines, author Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Contract review adopted ·
Generated by Claude Code |
Fixes #24400
Clause-②: yes (narrowing)
Dispatched by the
domain:specseat 2 (claim 6258620187, branchclaude/issue-24400-bound-action-foreign-object). Dev sessionsession_016ejhoxYxma7A4FRNYqEyUL. The engine half is the rider declared todomain:engineon #6367.What this does
The extension refusal for
actionstold an author: declare a top-level action withobjectName: 'TARGET', anddefineStack()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.@objectstack/objectql).SchemaRegistryattaches 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), viaattachBoundActions(: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.order.kernel:ready(packages/objectql/src/plugin.ts:932) withINVALID_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 themanifestservice is judged before any of it registers (plugin.ts:761).@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 throughobjectExtensions(the guidance's own context), and a known platform object (isPlatformProvidedObjectName, a named list, not thesys_prefix). Any other name is still refused at build. The action stays in the top-levelactions(documented onmergeActionsIntoObjects).ObjectExtensionSchemaguidance.actions,packages/spec/src/data/object.zod.ts), mirrored incontent/docs/data-modeling/object-extensions.mdx: declare a top-level action withobjectName: 'TARGET'in the same stack.defineStack()accepts it because the stack extends the target and keeps it in the top-levelactions. 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 atorigin/maindb1f1fd20, with one probe action added to the app stack:{ name: 'probe_rename_org', objectName: 'sys_organization', type: 'url' }.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.defineStack(..., { strict: false }): the server boots.GET /api/v1/meta/action/probe_rename_organswers 200 (_packageId: com.example.showcase).GET /api/v1/meta/object/sys_organizationanswers 200 withactions=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.probe_rename_org.sys_organizationactionsare 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 offoldExtendersOntoDefinitionand offoldObjectExtendersOnto(registry.ts:3459,:3704,:3706). Every serving door reads through it:GET /meta/object/NAMEisgetMetaItem→readMetaItem(packages/metadata-protocol/src/protocol.ts). Step 1 (stored row) and step 2 (MetadataService) go throughfoldObjectExtendersFromRegistry(:9333) →foldObjectExtendersOnto. Step 3 isregistry.getItem('object')(:11677) →getObject→resolveObject(registry.ts:3379).engine.getSchema()isregistry.getObject. So is the action router's object lookup (resolveRouteActionDeclaration, rung 1,packages/runtime/src/action-execution.ts).describe_objectprojectsname,label,fieldsandenableonly (packages/runtime/src/domains/mcp.ts:592,packages/mcp/src/stdio-data-bridge.ts:703). It serves noactions, so it has nothing to attach.getMetaItemon a realObjectQLin both registration orders.P3: holds. Who may run the action is unchanged.
toBe(registry.getItem('action', name)). So the router's rung 1 now finds the very declaration its rung 2 found before, carrying its ownrequiredPermissions,requiresMembershipReachand execution identity.ObjectQL.actions) is untouched.actionsarray, so the owning package'smanagedByand object-level write policy are as before.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 withobjectExtensions: [{ 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:minoron@objectstack/spec(a widening:defineStackaccepts two more target kinds for the existingobjectName) andminoron@objectstack/objectql(the boot refusal is a narrowing, since a stack that booted with a dangling bound action now fails; it ships asminorunder 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-0087not-required (no-migration-prescription)marker and the FROM, TO and fix lines. No new error code: the refusal answers the existingINVALID_METADATA.Tests
On head
0027ae7fe0(after mergingorigin/mainfa26308bb0throughscripts/pm/os-regen-merge.sh; no regeneration was owed, andcheck:generatedreports all 14 artifacts current):packages/objectql/src/bound-action-foreign-object.test.ts(new, 14 cases):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 issys_organizaton; same-stack is merged as before; the extension refusal names the route.@objectstack/objectql445 files / 8368 tests passed,@objectstack/spec698 files / 21273 passed (1 todo), and downstream@objectstack/metadata-protocol246 / 20655,@objectstack/rest298 / 6079,@objectstack/runtime421 / 6626, all passed.@objectstack/specand@objectstack/objectqltypecheck clean.scripts/ablation-replace.mjsand restored byte-identical (hash-checked):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.kernel:readythrow removed: exactly the boot-refusal test red.objectExtensionsacceptance removed fromdefineStack: exactly the extended-target test red.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final head derives 116 commands, and each was run with its exit code captured before any pipe. 114 exit 0.--ranreconciles 116 derived, 114 run, 2 NOT MEASURED, 0 unrun:pnpm --filter @objectstack/spec run check:skill-examplesexits 3, PREREQUISITE NOT MET (packages/client-react/distnot built here).pnpm check:dual-build-cjs-loadsexits 3, PREREQUISITE NOT MET (eight packages this diff does not touch have nodist/).check:engine-split-ratioandcheck-plugin-teardown-shape --self-testrefused on the shallow clone; both pass aftergit fetch --unshallow.check:dev-prereqspasses. The full farm is CI's.Acceptance notes
sys_metadataoverlay 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, whichsubtractExtenderContributionscovers forvalidationsandindexes. Not covered here for actions.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).OBJECT:ACTIONalready collides silently on the same pair, before this PR. Reported in the os-dev report as a finding, not fixed here.Generated by Claude Code