Skip to content

Commit 8eac321

Browse files
fix(spec): the extension refusals for hooks and listViews say what defineStack() does with another package's object (#24496)
Fixes #24471 Clause-②: no Dispatched by the `domain:spec` seat 2 (claim 6261361740, branch `claude/issue-24471-extension-guidance-siblings`). This is an `os-dev` subagent of session `session_019oFY6Hi35J7atvgVFnmoaK`. ## What this does `ObjectExtensionSchema` refuses `hooks` and `listViews` on an `objectExtensions` entry. Both refusals told the author to declare a top-level hook, or a top-level `view`, "bound to the target object instead". `defineStack()` refuses both when the target is an object the stack does not define, and an extended object is one of those. So the route each sentence named was refused at build. This PR re-trues both sentences to what the code does. **The accept set does not change.** `packages/spec/src/stack.zod.ts` is untouched: the hooks arm (`:3830`) and the views arm (`:3846`), behind the `:3812` early return, accept and refuse exactly what they did. The new sentences, verbatim (`packages/spec/src/data/object.zod.ts:4005` and `:4010`): ```text hooks: `hooks` cannot be contributed through an object extension — the merge does not carry them. A hook follows the ownership of its object: declare it as a top-level hook in the stack that defines the object. `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included. listViews: `listViews` cannot be contributed through an object extension — the merge does not carry them. Declare the views as a top-level `view` in the stack that defines the target object. `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included. ``` - **`hooks`.** ADR-0130 §1.5 records this refusal as deliberate: "the split must follow hook ownership". The one route consistent with that line is a hook in the stack that defines the object, and that is the route the sentence names. It was measured as the same-stack control below. - **`listViews`.** No ADR governs it. The sentence states what is refused, and names the one route measured to work: a view in the stack that defines the object (the same-stack control). It does not name the container-`object` binding, either as a route or as refused. See P2 and the acceptance notes. - **`actions`.** The sentence beside them (re-trued by #24458) is unchanged. - **Docs mirror.** `content/docs/data-modeling/object-extensions.mdx:86`–`:87`, the `hooks` and `listViews` rows of the redirect table, now say the same. - **Changeset.** `.changeset/24471-extension-guidance-hooks-list-views.md`: `patch` on `@objectstack/spec`, refusal text only, `Clause-②: no`. ## The premises, each read on this branch **P1: holds. No producer needs a wider route.** Measured with `git grep` over `examples/**` and `packages/**` (tests, fixtures and markdown excluded), with each hit read. Tree: this branch, merged with `origin/main` `ae6bd1a69`. - **Top-level `hooks:`.** There are three producers, and each hook names an object its own stack defines: - `examples/app-crm`: `crm_opportunity`; - `examples/app-showcase`: `showcase_task` (three hooks), `showcase_project`, `showcase_inquiry`; - `examples/app-todo`: `todo_task`. - No plugin, service or `platform-objects` manifest declares a metadata `hooks` collection. The other `hooks:` hits are runtime ports and types. - **Top-level `views:`.** There are five producers, and each container binding (`object:` or `data.object`) names an object its own stack defines: - `examples/app-crm`: `crm_lead`, `crm_opportunity`, `crm_activity`; - `examples/app-showcase`: six `showcase_*` objects; - `examples/app-todo`: `todo_task`; - `packages/qa/downstream-contract`: `dc_account`; - the scaffold template `packages/create-objectstack/src/templates/blank/src/views/index.ts` exports nothing. - The one `sys_user` hit in a view file is an action param lookup (`examples/app-showcase/src/ui/views/project.view.ts:78`), not a view binding. - **`strict: false`.** No stack outside the CLI's own config loader calls `defineStack` with it. - **`objectExtensions`.** There are three entries, and none carries `hooks` or `listViews`: - `examples/app-showcase` (`showcase_account`: `fields`, `label`, `priority`); - `packages/plugins/plugin-auth` (`sys_sso_provider`: `fields`); - `packages/qa/downstream-contract` (`dc_account`: `fields`, `label`, `priority`). **P2: holds as the card states. The views arm reads fewer bindings than "any name".** A `tsx` probe ran against `src` on this branch, before and after the merge, identically. The stack defines one object, `extender_note`, and has `objectExtensions: [{ extend: 'sys_organization', … }]`: | input | `defineStack()` | |:--|:--| | hook, `object: 'sys_organization'` | refused, `STACK_CROSS_REFERENCE_INVALID` 422: `✗ Hook 'g3_hook' references object 'sys_organization' which is not defined in objects.` | | hook, `object: 'extender_note'` (control) | accepted | | view, `list.data.object: 'sys_organization'` | refused, 422: `✗ View[0].list references object 'sys_organization' which is not defined in objects.` | | view, `form.data.object: 'sys_organization'` | refused, 422: `✗ View[0].form references object 'sys_organization' which is not defined in objects.` | | view, `list.data.object: 'extender_note'` (control) | accepted | | view container, `object: 'sys_organization'` + `listViews` | **accepted** | | view container, `object: 'nope_typo'` + `listViews` | **accepted** (typo control) | | object-less stack, hook on `sys_organization` | accepted (the `:3812` early return) | The views arm judges `list.data` and `form.data` only. A container's own `object`, which is the binding `getViewsByObject()` reads, and its `listViews` / `formViews` entries are not judged. The typo passes too, so the acceptance comes from what the check reads, not from a design. `os validate` judges the container's `object` (`packages/lint/src/validate-object-references.ts`, the view-container leg). Hence the sentence's precise wording: it says what is refused and nothing broader. This is an open question in the report for the seat, not a change here. **P3: holds.** ADR-0130 still reads as quoted. `docs/adr/0130-release-artifact-as-co-ownership-boundary.md:155`–`:157` say: "a hook attached to a foreign object, are REJECTED … the split must follow hook ownership." `:446` restates it under Consequences. Its two addenda (2026-09-02 and 2026-09-22) do not mention hooks. No other ADR cites ADR-0130. The code still fences it: `DefineStackOptions.artifactObjects` keeps `hooks[].object` out of its widening (`stack.zod.ts:2154`), and `stack-artifact-crossref.test.ts:189` pins that. ## Tests On head `a55182033`, the merge of `origin/main` `ae6bd1a69` through `scripts/pm/os-regen-merge.sh`. No regeneration was owed. - `packages/spec/src/stack-extension-hook-view-foreign-object.test.ts` (new, 7 cases): - a hook on an extended `sys_organization` is refused, with `code` + `status` + the located message; - the same-stack hook is accepted; - a view whose `list` reads the extended target is refused, and so is one whose `form` does; - the same-stack view is accepted; - each guidance sentence names the owning stack and the refusal of an extended target. - Full `@objectstack/spec` suite (`vitest run --project local`): 700 files, 21284 tests passed (1 todo), exit 0. `@objectstack/spec` typecheck (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`): exit 0, test layer unchanged at 52 files / 242 errors / 132 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` after a rebuild: all 14 generated artifacts are current. The guidance strings feed no generated file. The only copies are `object.zod.ts` and the docs table. - **Ablations**, one per changed sentence, each landed by `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob moved). Each was restored to the HEAD blob `0f61b6835ad5` with `git diff HEAD` empty. The test imports `src`, so no `dist/` is involved. - The `hooks` sentence put back to "declare a top-level hook bound to the target object instead": exactly the `hooks` guidance case went red, 1 of 7. - The `listViews` sentence cut back to "... target object instead.": exactly the `listViews` guidance case went red, 1 of 7. - The five refusal and control cases stay green under both ablations, as expected, because this diff does not change the behaviour they pin. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on head `a55182033` derives 108 commands. Each was run with its exit code captured before any pipe, and `--ran` reconciles them: 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN, and every one exited 0. - `check:dev-prereqs` (local only) passes. - The full farm is CI's. ## Acceptance notes - **The container-`object` binding is not judged by `defineStack()`.** See P2. Whether the runtime serves a code-shipped container bound to another package's object as that object's list views was NOT MEASURED here. The `listViews` sentence therefore neither offers it nor refuses it. The report carries the options for the seat. No producer writes such a container (P1). - **An object-less stack skips both arms** (`stack.zod.ts:3812`). There, a hook or view on another package's object is accepted. That leniency is pinned for the artifact-scoped classes (`stack-artifact-crossref.test.ts:306`). Per the ruling, the sentences do not present it as a route. In that corner they over-state the refusal, which errs toward not prescribing a route. - **Commits.** They carry the model-free trailer pair. This PR squash-lands under its own title and body. --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ca426cb commit 8eac321

4 files changed

Lines changed: 129 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(spec): the object-extension refusals for `hooks` and `listViews` say what `defineStack()` does with a hook or view on another package's object
8+
9+
`ObjectExtensionSchema` refuses `hooks` and `listViews` on an `objectExtensions` entry. Both refusals used to tell the author to declare a top-level hook, or a top-level `view`, "bound to the target object instead". For an object the stack does not define, `defineStack()` refuses both at build, an extended object included, so the route the refusal named could not be taken.
10+
11+
- **`hooks`** now reads: a hook follows the ownership of its object, so declare it as a top-level hook in the stack that defines the object; `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included. The refusal is the recorded decision of ADR-0130 §1.5 ("the split must follow hook ownership").
12+
- **`listViews`** now reads: declare the views as a top-level `view` in the stack that defines the target object; `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included.
13+
- **Unchanged.** What `defineStack()` accepts and refuses, every error code, status and issue path. Only the refusal text moves, with its mirror in the object-extensions guide.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ each one by name with the alternative to use instead:
8383
| Key you might reach for | Declare this instead |
8484
|:---|:---|
8585
| `actions` | A top-level action with `objectName: '<target>'` in the same stack — it stays in the stack's top-level `actions`, and 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 |
86-
| `hooks` | A top-level hook bound to the target object |
87-
| `listViews` | A top-level `view` bound to the target object |
86+
| `hooks` | A top-level hook in the stack that defines the target object — a hook follows the ownership of its object, and `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included |
87+
| `listViews` | A top-level `view` in the stack that defines the target object — `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included |
8888
| `fieldGroups` | Add the fields here and declare the groups on the owning object, or assign a Page for the layout |
8989

9090
The same holds for any other key: the extension schema is strict, so an unknown key

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3996,12 +3996,22 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({
39963996
'top-level `actions`, and the engine\'s schema registry attaches it to the target ' +
39973997
'object wherever the object is served, whichever package registers first. A target no ' +
39983998
'loaded package registers fails the boot.',
3999+
// `hooks` and `listViews` name only what `defineStack()` measurably does.
4000+
// Its cross-reference check (`validateCrossReferences` in `stack.zod.ts`)
4001+
// judges a top-level hook's `object`, and a top-level view's `list` /
4002+
// `form` data source, against the objects the stack itself defines — an
4003+
// extended target is not one of them. For hooks that refusal is a recorded
4004+
// decision, ADR-0130 §1.5: "the split must follow hook ownership".
39994005
hooks:
4000-
'`hooks` cannot be contributed through an object extension — declare a top-level ' +
4001-
'hook bound to the target object instead.',
4006+
'`hooks` cannot be contributed through an object extension — the merge does not carry ' +
4007+
'them. A hook follows the ownership of its object: declare it as a top-level hook in ' +
4008+
'the stack that defines the object. `defineStack()` refuses a top-level hook bound to ' +
4009+
'an object the stack does not define, an extended one included.',
40024010
listViews:
4003-
'`listViews` cannot be contributed through an object extension — declare a ' +
4004-
'top-level `view` bound to the target object instead.',
4011+
'`listViews` cannot be contributed through an object extension — the merge does not ' +
4012+
'carry them. Declare the views as a top-level `view` in the stack that defines the ' +
4013+
'target object. `defineStack()` refuses a top-level `view` whose `list` or `form` data ' +
4014+
'source is an object the stack does not define, an extended one included.',
40054015
fieldGroups:
40064016
'`fieldGroups` cannot be contributed through an object extension — the merge does ' +
40074017
'not carry them. Add the fields here and declare the groups on the owning object, ' +
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `ObjectExtensionSchema` refuses `hooks` and `listViews` on an extension and
5+
* says what to do instead. Each sentence makes a claim about `defineStack()`,
6+
* and this file holds each claim at `defineStack()` itself:
7+
*
8+
* - a top-level hook bound to an object the stack does not define is refused at
9+
* build, even when the stack extends that object through `objectExtensions`.
10+
* ADR-0130 §1.5 records that refusal as deliberate: "the split must follow
11+
* hook ownership". A hook on an object the stack defines is accepted;
12+
* - a top-level view whose `list` or `form` data source is an object the stack
13+
* does not define is refused the same way, extended or not. A view on an
14+
* object the stack defines is accepted.
15+
*
16+
* Both refusals come from `validateCrossReferences` in `stack.zod.ts`, which
17+
* keeps exactly what it accepts and refuses; only the guidance text names it.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { defineStack } from './stack.zod';
22+
import { ObjectExtensionSchema } from './data/object.zod';
23+
24+
const manifest = { id: 'com.test.extender', name: 'extender', version: '1.0.0', type: 'app' as const };
25+
const OWN = { name: 'extender_note', label: 'Note', fields: { title: { type: 'text' as const, label: 'Title' } } };
26+
const FOREIGN = 'sys_organization';
27+
const extendsForeign = [{ extend: FOREIGN, fields: { tier: { type: 'text' as const, label: 'Tier' } } }];
28+
29+
const hook = (object: string) => ({ name: 'probe_hook', object, events: ['beforeInsert'], handler: 'noop' });
30+
const listView = (object: string) => ({
31+
list: { type: 'grid', data: { provider: 'object', object }, columns: ['name'] },
32+
});
33+
const formView = (object: string) => ({
34+
form: { type: 'simple', data: { provider: 'object', object }, sections: [{ label: 'Main', fields: [{ field: 'name' }] }] },
35+
});
36+
37+
const refusalOf = (config: unknown): any => {
38+
try {
39+
defineStack(config as any);
40+
return undefined;
41+
} catch (error) {
42+
return error;
43+
}
44+
};
45+
46+
describe('defineStack and a top-level hook bound to an object outside the stack', () => {
47+
it('refuses a hook on an object the stack only extends', () => {
48+
const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(FOREIGN)] });
49+
expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 });
50+
expect(err.message).toContain(`Hook 'probe_hook' references object '${FOREIGN}'`);
51+
});
52+
53+
it('accepts a hook on an object the stack defines (the control)', () => {
54+
const stack = defineStack({
55+
manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(OWN.name)],
56+
} as any);
57+
expect(stack.hooks?.map((h) => h.object)).toEqual([OWN.name]);
58+
});
59+
});
60+
61+
describe('defineStack and a top-level view whose data source is an object outside the stack', () => {
62+
it('refuses a view whose `list` reads an object the stack only extends', () => {
63+
const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(FOREIGN)] });
64+
expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 });
65+
expect(err.message).toContain(`View[0].list references object '${FOREIGN}'`);
66+
});
67+
68+
it('refuses a view whose `form` reads an object the stack only extends', () => {
69+
const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [formView(FOREIGN)] });
70+
expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 });
71+
expect(err.message).toContain(`View[0].form references object '${FOREIGN}'`);
72+
});
73+
74+
it('accepts a view on an object the stack defines (the control)', () => {
75+
const stack = defineStack({
76+
manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(OWN.name)],
77+
} as any);
78+
expect(stack.views).toHaveLength(1);
79+
});
80+
});
81+
82+
describe('the extension refusal says what defineStack does', () => {
83+
const guidanceFor = (key: 'hooks' | 'listViews') => {
84+
const parsed = ObjectExtensionSchema.safeParse({ extend: FOREIGN, [key]: key === 'hooks' ? [] : {} });
85+
expect(parsed.success).toBe(false);
86+
return JSON.stringify(parsed.error?.issues);
87+
};
88+
89+
it('`hooks` names the owning stack and the refusal of an extended target', () => {
90+
const text = guidanceFor('hooks');
91+
expect(text).toContain('declare it as a top-level hook in the stack that defines the object');
92+
expect(text).toContain('refuses a top-level hook bound to an object the stack does not define, an extended one included');
93+
});
94+
95+
it('`listViews` names the owning stack and the refusal of an extended target', () => {
96+
const text = guidanceFor('listViews');
97+
expect(text).toContain('top-level `view` in the stack that defines the target object');
98+
expect(text).toContain('data source is an object the stack does not define, an extended one included');
99+
});
100+
});

0 commit comments

Comments
 (0)