Skip to content

Commit f9854b6

Browse files
committed
fix(spec): the extension refusals for hooks and listViews say what defineStack() does with another package's object
Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude <noreply@anthropic.com>
1 parent e11ae4b commit f9854b6

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
@@ -3988,12 +3988,22 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({
39883988
'top-level `actions`, and the engine\'s schema registry attaches it to the target ' +
39893989
'object wherever the object is served, whichever package registers first. A target no ' +
39903990
'loaded package registers fails the boot.',
3991+
// `hooks` and `listViews` name only what `defineStack()` measurably does.
3992+
// Its cross-reference check (`validateCrossReferences` in `stack.zod.ts`)
3993+
// judges a top-level hook's `object`, and a top-level view's `list` /
3994+
// `form` data source, against the objects the stack itself defines — an
3995+
// extended target is not one of them. For hooks that refusal is a recorded
3996+
// decision, ADR-0130 §1.5: "the split must follow hook ownership".
39913997
hooks:
3992-
'`hooks` cannot be contributed through an object extension — declare a top-level ' +
3993-
'hook bound to the target object instead.',
3998+
'`hooks` cannot be contributed through an object extension — the merge does not carry ' +
3999+
'them. A hook follows the ownership of its object: declare it as a top-level hook in ' +
4000+
'the stack that defines the object. `defineStack()` refuses a top-level hook bound to ' +
4001+
'an object the stack does not define, an extended one included.',
39944002
listViews:
3995-
'`listViews` cannot be contributed through an object extension — declare a ' +
3996-
'top-level `view` bound to the target object instead.',
4003+
'`listViews` cannot be contributed through an object extension — the merge does not ' +
4004+
'carry them. Declare the views as a top-level `view` in the stack that defines the ' +
4005+
'target object. `defineStack()` refuses a top-level `view` whose `list` or `form` data ' +
4006+
'source is an object the stack does not define, an extended one included.',
39974007
fieldGroups:
39984008
'`fieldGroups` cannot be contributed through an object extension — the merge does ' +
39994009
'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)