Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/embedded-action-objectname-crossref.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/spec": minor
---

fix(spec): `defineStack`'s action cross-reference walk now checks `objectName` on OBJECT-EMBEDDED actions too (#7456)

The third arm #7397 deliberately left open. The registered action walk in
`validateCrossReferences` applies **three** checks — flow target, modal target, and
`objectName` → declared object — but #7397's PR mirrored only the first two onto
`config.objects[].actions[]`. The third split the same way the target arms did before
#7397:

```
embedded { name: 'probe_on', type: 'script', target: 'doThing', objectName: 'probe_missing' } -> ACCEPTED
REGISTERED { name: 'probe_on', type: 'script', target: 'doThing', objectName: 'probe_missing' } -> REJECTED
"Action 'probe_on' references object 'probe_missing' which is not defined in objects."
```

Same action object, two authoring positions, opposite verdicts — the b/f, c/g, d/i pattern
from #7397's probe table, one key over.

**Now**: `config.objects[].actions[]` is walked and every action's `objectName` (when set) is
subjected to the **same** existence check as a registered action's. Message keeps the
registered wording from `references` onward and changes only the subject, same convention
as the flow/modal arms:

```
Action 'probe_on' on object 'probe_task' references object 'probe_missing' which is not
defined in objects.
```

**Ruling.** #7456 filed this as an observation-class finding because mirroring the check is
an acceptance-surface change with two live readings: **A** — existence check (verbatim
mirror, what this PR does) vs **B** — consistency check (the value must equal the owning
object's own name). The maintainer's four-lens review on 2026-08-11 deferred the A/B/C
question to the spec/contract cadence; the 2026-08-11T14:32Z findings-triage round then
graded it **promote** under the standing 元判据 — a silently-dropped declaration on one
sibling authoring position joins the sibling branch's existing refusal set — and ruled
**Option A**. This PR implements exactly that. It does **not** foreclose B or C: an embedded
action naming a *different* declared object than its owner is still accepted (only a
*dangling* value newly refuses), and whether the key should instead be retired at this
position remains open.

**Acceptance-face narrowing.** A stack carrying a dangling embedded `objectName` now fails
to build where it previously built clean. Census of the shipped corpus (`examples/*`,
`content/docs/**`) found **zero** `*.object.ts` files — or any other file — declaring
`objectName` at the embedded position at all, dangling or otherwise; the key is used only at
the registered/top-level position in shipped metadata today.

`objectName` still gives no new RUNTIME meaning at the embedded position:
`mergeActionsIntoObjects` continues to build its map only from `config.actions`, never from
`obj.actions[].objectName` — this PR only makes a dangling value refused at authoring time.
51 changes: 51 additions & 0 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,21 @@
* with opposite verdicts; a/h and e/j confirm the legitimate shapes survive in
* both. Row d's verdict is fixed by the same #6739 ruling, not re-decided here.
*
* A THIRD arm was left deliberately unmirrored by #7397's own PR: `objectName`
* → declared object, "one key over" from the target arms above (#7456):
*
* ```
* k embedded objectName -> missing object : ACCEPTED (l registered: REJECTED) ← split
* ```
*
* #7456's maintainer-ruled disposition is Option A (existence check, verbatim
* mirror — the same 元判据 that fixed rows b/f, c/g, d/i: a silently-dropped
* declaration joins the sibling branch's existing refusal set). Option A does
* NOT check that an embedded `objectName` agrees with its owning object — an
* embedded action naming a DIFFERENT declared object is accepted, same as
* before; only a DANGLING `objectName` newly refuses. Options B (consistency)
* and C (retirement) remain open and are not exercised here.
*
* Message shape is contract here (one condition ⇒ one wording), so these pin
* full message text rather than `toThrow()` alone: a bare throw assertion
* cannot tell "refused for the right reason" from "refused because the fixture
Expand DownExpand Up@@ -350,6 +365,42 @@ describe('defineStack — object-embedded action cross-references: flow targets
});
});

describe('defineStack — object-embedded action cross-references: objectName → object (#7456)', () => {
const dangling = { name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_missing' };

it('rejects a dangling embedded objectName (probe row k) with the registered rule\'s wording, subject adjusted to the owning object', () => {
expect(refusals(embeddedStack(dangling))).toEqual([
"Action 'probe_on' on object 'probe_task' "
+ "references object 'probe_missing' which is not defined in objects.",
]);
});

it('closes the b/f, c/g, d/i pattern one key over (rows k/l): the same action gets the same verdict embedded or registered', () => {
expect(refusals(embeddedStack(dangling)).length).toBe(1);
expect(refusals(registeredStack(dangling)).length).toBe(1);
});

it('accepts an embedded objectName naming its own owning object', () => {
expect(refusals(embeddedStack({ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_task' }))).toEqual([]);
});

it('accepts an embedded objectName naming a DIFFERENT declared object — Option A is an existence check only, not a consistency check (B stays open)', () => {
const config = {
manifest: baseManifest,
objects: [
{ ...objects[0], actions: [{ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_note' }] },
{ name: 'probe_note', label: 'Probe Note', fields: { body: { type: 'text' as const } } },
],
pages,
};
expect(refusals(config)).toEqual([]);
});

it('leaves an embedded action with no objectName alone — unchanged from before #7456', () => {
expect(refusals(embeddedStack({ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing' }))).toEqual([]);
});
});

describe('defineStack — object-embedded action cross-references: the b/f, c/g, d/i splits (#7397)', () => {
it.each([
['modal → nothing (rows b/f)', modalAction('probe_nowhere')],
Expand Down
24 changes: 19 additions & 5 deletions packages/spec/src/stack.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1175,11 +1175,19 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
// Scope of the modal branch is the same #6739 ruling the other two arms
// read: a `type: 'modal'` target names a PAGE, only.
//
// `objectName` is deliberately NOT checked here. The key exists on this
// shape (unlike the inline one), but what it should MEAN on an action
// already embedded on an object — a mere existence check, or a consistency
// check against the owning object's name — is an open contract question,
// filed separately rather than settled as a side effect of this walk.
// Third arm: `objectName` → declared object, mirrored onto the embedded
// position (#7456, Option A — existence check, ruled 2026-08-11/12). This
// is the identical check the registered walk applies above; only the
// SUBJECT differs, same as the flow/modal arms just above. It does NOT
// give `objectName` new meaning at this position — `mergeActionsIntoObjects`
// still builds its map only from `config.actions`, so the key stays inert
// for merge purposes. It only makes a dangling value refused at authoring
// time, same as it already is in the registered position.
//
// Deliberately NOT checked: whether `objectName` here must equal the
// owning object's own name (Option B), or whether the key should be
// retired at this position instead (Option C) — both stay open per the
// issue and are not foreclosed by this existence check.
if (config.objects) {
for (const obj of config.objects) {
for (const action of obj.actions ?? []) {
Expand All@@ -1194,6 +1202,12 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
`Action '${action.name}' on object '${obj.name}' references page '${action.target}' (via modal target) which is not defined in pages.`,
);
}

if (action.objectName && !objectNames.has(action.objectName)) {
errors.push(
`Action '${action.name}' on object '${obj.name}' references object '${action.objectName}' which is not defined in objects.`,
);
}
}
}
}
Expand Down
Loading