From eefd7857e83d64228a5670225f71947afa627b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:49:50 +0000 Subject: [PATCH 1/3] fix(spec): record:activity's refusal names `items` / `loading` as the host feed slot `RecordActivityProps` gains a `guidance` table in the shape `RecordHistoryProps` already uses for `entries` / `loading`: the renderer takes both keys as a host-supplied feed, so an authored one is a static snapshot (or a loading state pinned on). Both keys stay refused; only the unrecognized-key message now names them and the omit remedy. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .../src/ui/component-record-blocks.test.ts | 60 +++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 28 +++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/spec/src/ui/component-record-blocks.test.ts b/packages/spec/src/ui/component-record-blocks.test.ts index d1eafce555c..0bc3cdfeac6 100644 --- a/packages/spec/src/ui/component-record-blocks.test.ts +++ b/packages/spec/src/ui/component-record-blocks.test.ts @@ -280,6 +280,66 @@ describe('ComponentPropsMap["record:history"] (#8744)', () => { }); }); +describe('ComponentPropsMap["record:activity"] — the host feed slot is named, not declared', () => { + // `record:history`'s `entries` / `loading` pair, on the activity block: the + // renderer takes `items` and `loading` as a host-supplied feed. Both keys + // stay REFUSED (the accept set is unchanged); only the refusal names them. + const row = ComponentPropsMap['record:activity']; + const refusalOf = (bag: Record) => { + const result = row.safeParse(bag); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + return issues[0]!; + }; + + it('refuses `items` and `loading` at the bag, for every value — the key, not a value domain', () => { + for (const [key, values] of [ + ['items', [[{ id: 'a1', type: 'comment' }], []]], + ['loading', [true, false]], + ] as const) { + for (const value of values) { + const issue = refusalOf({ [key]: value }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual([]); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + } + } + }); + + it('names `items` as the host\'s data channel, with the omit remedy', () => { + const { message } = refusalOf({ items: [{ id: 'a1', type: 'comment' }] }); + expect(message).toContain('`items` is the HOST\'s data channel, not authorable surface'); + expect(message).toContain('Omit it'); + expect(message).toContain('sys_activity'); + }); + + it('names `loading` as the host\'s fetch state, with the omit remedy', () => { + const { message } = refusalOf({ loading: true }); + expect(message).toContain('`loading` is the host\'s fetch state for its `items` feed, not authorable surface'); + expect(message).toContain('Omit it with `items`'); + }); + + it('CONTROL: an unrelated unknown key keeps the generic refusal, with no host-channel line', () => { + const issue = refusalOf({ inventedFeedKey: [] }); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedFeedKey']); + expect(issue.message).toContain('Unrecognized key(s) on this `record:activity`'); + expect(issue.message).not.toContain('data channel'); + expect(issue.message).not.toContain('fetch state'); + }); + + it('the nested `record:chatter` `feed` (the same object) still refuses both keys at `feed`', () => { + for (const key of ['items', 'loading'] as const) { + const result = ComponentPropsMap['record:chatter'].safeParse({ feed: { [key]: key === 'items' ? [] : true } }); + expect(result.success).toBe(false); + const [issue] = result.error!.issues; + expect(issue!.code).toBe('unrecognized_keys'); + expect(issue!.path).toEqual(['feed']); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + } + }); +}); + describe('the `record:discussion` / `record:chatter` pair (#8744)', () => { it('judges both names with one accept face', () => { const authored = { position: 'bottom', collapsible: false } as const; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 03fda207923..a301b1717b3 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1610,6 +1610,34 @@ export const RecordActivityProps = strictObject({ surface: 'this `record:activity`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, + /** + * The host feed slot, NAMED rather than declared — `RecordHistoryProps`' + * `entries` / `loading` pair, on this block. Measured at the `.objectui-sha` + * pin `89cad75d5570` (objectui `plugin-detail/src/renderers/ + * record-activity.tsx:161-166`, `:265-266`): the renderer takes `items` and + * `loading` off the node or out of this bag as a feed a host composing the + * block in code already owns, and the host flag wins over every other + * loading source. Neither is authorable: an authored feed is a snapshot that + * never updates, and an authored `loading: true` pins the loading state on. + * So both stay UNDECLARED — the accept set is unchanged — and this table only + * makes the refusal name them instead of the generic unrecognized-key line. + * + * The same object is `record:chatter` / `record:discussion`'s `feed`, so the + * entries answer there too. That renderer reads its feed off the host's + * discussion context and reads neither `feed.items` nor `feed.loading` + * (`renderers/record-chatter.tsx`, same pin), so "omit it" is the right + * answer on both mounts. + */ + guidance: { + items: '`items` is the HOST\'s data channel, not authorable surface: a host that composes this ' + + 'block in code passes the feed it already owns through it, and the renderer presents that ' + + 'feed in place of its own sources. Hand-authored items would ship a static snapshot of the ' + + 'feed that never updates. Omit it — with no host items the block presents the record ' + + 'page\'s discussion feed, or self-fetches the record\'s own `sys_activity` rows.', + loading: '`loading` is the host\'s fetch state for its `items` feed, not authorable surface: the ' + + 'host flag wins over the block\'s own, so authored `true` pins the loading state on forever. ' + + 'Omit it with `items` — the block manages its own loading state.', + }, }, { /** * Feed/activity kinds to show — an OPEN vocabulary (commit 1a6a19c31, executing the From 29f3682ea5e607477e095aa5a64189ec3c422bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:59:48 +0000 Subject: [PATCH 2/3] chore(changeset): spec patch for the record:activity host feed slot message Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .../21279-record-activity-host-feed-guidance.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/21279-record-activity-host-feed-guidance.md diff --git a/.changeset/21279-record-activity-host-feed-guidance.md b/.changeset/21279-record-activity-host-feed-guidance.md new file mode 100644 index 00000000000..17954833ce9 --- /dev/null +++ b/.changeset/21279-record-activity-host-feed-guidance.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `record:activity`'s props row names `items` / `loading` as the host's feed slot when it refuses them + +Clause-②: no + +`ComponentPropsMap['record:activity']` (`RecordActivityProps`) refused an authored `properties.items` or `properties.loading` with only the generic "Unrecognized key(s) on this `record:activity`" line. Both are keys the objectui renderer reads, as a feed a host that composes the block in code already owns, so an author copying a TSX composition into a JSON page met no reason for the refusal. + +- The refusal now names `items` as the host's data channel and `loading` as the host's fetch state for that feed, with the remedy: omit them. With no host `items` the block presents the record page's discussion feed, or fetches the record's own `sys_activity` rows. This is the same shape `record:history`'s row already uses for `entries` / `loading`. +- The accept set does not change. Both keys stay refused, through the row and through `record:chatter` / `record:discussion`'s `feed`, which is the same object. Only the message text changes; `record:history` is unchanged. From ab581575851de1a92859746a04c1721f27b878e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:39:38 +0000 Subject: [PATCH 3/3] fix(spec): record:activity's host-feed guidance says who reads each key on both mounts `RecordChatterProps.feed` IS `RecordActivityProps`, so the `items` / `loading` guidance also answers `record:chatter` / `record:discussion` `feed.items` / `feed.loading`, where the chatter renderer reads neither key and never self-fetches. Each bullet now states the standalone `record:activity` facts (host data channel / host fetch state) and that nothing reads the key on a chatter or discussion `feed`; the remedy stays "Omit it", with the `sys_activity` self-fetch scoped to the standalone block. The chatter pin asserts that mount-true clause on both names. Shape and accept set unchanged; the changeset sentence is corrected. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...1279-record-activity-host-feed-guidance.md | 4 +- .../src/ui/component-record-blocks.test.ts | 42 ++++++++++++------- packages/spec/src/ui/component.zod.ts | 30 +++++++------ 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/.changeset/21279-record-activity-host-feed-guidance.md b/.changeset/21279-record-activity-host-feed-guidance.md index 17954833ce9..7f048f767c7 100644 --- a/.changeset/21279-record-activity-host-feed-guidance.md +++ b/.changeset/21279-record-activity-host-feed-guidance.md @@ -6,7 +6,7 @@ fix(spec): `record:activity`'s props row names `items` / `loading` as the host's Clause-②: no -`ComponentPropsMap['record:activity']` (`RecordActivityProps`) refused an authored `properties.items` or `properties.loading` with only the generic "Unrecognized key(s) on this `record:activity`" line. Both are keys the objectui renderer reads, as a feed a host that composes the block in code already owns, so an author copying a TSX composition into a JSON page met no reason for the refusal. +`ComponentPropsMap['record:activity']` (`RecordActivityProps`) refused an authored `properties.items` or `properties.loading` with only the generic "Unrecognized key(s) on this `record:activity`" line. Both are keys the objectui `record:activity` renderer reads, as a feed a host that composes the block in code already owns, so an author copying a TSX composition into a JSON page met no reason for the refusal. -- The refusal now names `items` as the host's data channel and `loading` as the host's fetch state for that feed, with the remedy: omit them. With no host `items` the block presents the record page's discussion feed, or fetches the record's own `sys_activity` rows. This is the same shape `record:history`'s row already uses for `entries` / `loading`. +- The refusal now says who reads each key on each mount the row reaches. On a standalone `record:activity`, `items` is the host's data channel and `loading` the host's fetch state for that feed. On a `record:chatter` / `record:discussion` `feed`, which is the same object, nothing reads either. The remedy is the same on both: omit them. The block then presents the record page's discussion feed, and a standalone `record:activity` with no discussion context fetches the record's own `sys_activity` rows. This is the same `guidance` shape `record:history`'s row already uses for `entries` / `loading`. - The accept set does not change. Both keys stay refused, through the row and through `record:chatter` / `record:discussion`'s `feed`, which is the same object. Only the message text changes; `record:history` is unchanged. diff --git a/packages/spec/src/ui/component-record-blocks.test.ts b/packages/spec/src/ui/component-record-blocks.test.ts index 0bc3cdfeac6..1de5765b4f3 100644 --- a/packages/spec/src/ui/component-record-blocks.test.ts +++ b/packages/spec/src/ui/component-record-blocks.test.ts @@ -282,8 +282,10 @@ describe('ComponentPropsMap["record:history"] (#8744)', () => { describe('ComponentPropsMap["record:activity"] — the host feed slot is named, not declared', () => { // `record:history`'s `entries` / `loading` pair, on the activity block: the - // renderer takes `items` and `loading` as a host-supplied feed. Both keys - // stay REFUSED (the accept set is unchanged); only the refusal names them. + // standalone renderer takes `items` and `loading` as a host-supplied feed. + // Both keys stay REFUSED (the accept set is unchanged); only the refusal + // names them. The row is also `record:chatter` / `record:discussion`'s + // `feed`, where nothing reads either key, so each message states both mounts. const row = ComponentPropsMap['record:activity']; const refusalOf = (bag: Record) => { const result = row.safeParse(bag); @@ -307,16 +309,17 @@ describe('ComponentPropsMap["record:activity"] — the host feed slot is named, } }); - it('names `items` as the host\'s data channel, with the omit remedy', () => { + it('names `items` as the standalone block\'s host data channel, with the omit remedy', () => { const { message } = refusalOf({ items: [{ id: 'a1', type: 'comment' }] }); - expect(message).toContain('`items` is the HOST\'s data channel, not authorable surface'); + expect(message).toContain('`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s data channel'); expect(message).toContain('Omit it'); - expect(message).toContain('sys_activity'); + // The self-fetch fallback is scoped to the standalone block — the chatter mount has none. + expect(message).toContain('a standalone `record:activity` with no discussion context self-fetches the record\'s own `sys_activity` rows'); }); - it('names `loading` as the host\'s fetch state, with the omit remedy', () => { + it('names `loading` as the standalone block\'s host fetch state, with the omit remedy', () => { const { message } = refusalOf({ loading: true }); - expect(message).toContain('`loading` is the host\'s fetch state for its `items` feed, not authorable surface'); + expect(message).toContain('`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s fetch state for its `items` feed'); expect(message).toContain('Omit it with `items`'); }); @@ -328,14 +331,23 @@ describe('ComponentPropsMap["record:activity"] — the host feed slot is named, expect(issue.message).not.toContain('fetch state'); }); - it('the nested `record:chatter` `feed` (the same object) still refuses both keys at `feed`', () => { - for (const key of ['items', 'loading'] as const) { - const result = ComponentPropsMap['record:chatter'].safeParse({ feed: { [key]: key === 'items' ? [] : true } }); - expect(result.success).toBe(false); - const [issue] = result.error!.issues; - expect(issue!.code).toBe('unrecognized_keys'); - expect(issue!.path).toEqual(['feed']); - expect((issue as { keys?: string[] }).keys).toEqual([key]); + it('the `record:chatter` / `record:discussion` `feed` (the same object) refuses both keys and says nothing reads them there', () => { + // At the `.objectui-sha` pin the chatter renderer reads its rows and its + // loading flag off the discussion context and never reads `feed.items` / + // `feed.loading`, so the mount-true clause is the one asserted here. + for (const type of ['record:chatter', 'record:discussion'] as const) { + for (const key of ['items', 'loading'] as const) { + const result = ComponentPropsMap[type].safeParse({ feed: { [key]: key === 'items' ? [] : true } }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + const issue = issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['feed']); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + expect(issue.message).toContain('On a `record:chatter` / `record:discussion` `feed` nothing reads it.'); + expect(issue.message).toContain('Omit it'); + } } }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index a301b1717b3..81692a690c7 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1623,20 +1623,26 @@ export const RecordActivityProps = strictObject({ * makes the refusal name them instead of the generic unrecognized-key line. * * The same object is `record:chatter` / `record:discussion`'s `feed`, so the - * entries answer there too. That renderer reads its feed off the host's - * discussion context and reads neither `feed.items` nor `feed.loading` - * (`renderers/record-chatter.tsx`, same pin), so "omit it" is the right - * answer on both mounts. + * entries answer there too. That renderer reads its rows and its loading + * flag off the host's discussion context, reads neither `feed.items` nor + * `feed.loading`, and never self-fetches (`renderers/record-chatter.tsx`, + * same pin). `guidance` is one flat table keyed by key name — it cannot be + * scoped to a mount — so each entry says who reads the key on EACH mount, + * and every clause is true wherever the refusal is raised. */ guidance: { - items: '`items` is the HOST\'s data channel, not authorable surface: a host that composes this ' - + 'block in code passes the feed it already owns through it, and the renderer presents that ' - + 'feed in place of its own sources. Hand-authored items would ship a static snapshot of the ' - + 'feed that never updates. Omit it — with no host items the block presents the record ' - + 'page\'s discussion feed, or self-fetches the record\'s own `sys_activity` rows.', - loading: '`loading` is the host\'s fetch state for its `items` feed, not authorable surface: the ' - + 'host flag wins over the block\'s own, so authored `true` pins the loading state on forever. ' - + 'Omit it with `items` — the block manages its own loading state.', + items: '`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s ' + + 'data channel: a host composing that block in code passes the feed it already owns, and the ' + + 'renderer presents it in place of its own sources; hand-authored items would ship a static ' + + 'snapshot of the feed that never updates. On a `record:chatter` / `record:discussion` `feed` ' + + 'nothing reads it. Omit it: the block presents the record page\'s discussion feed, and a ' + + 'standalone `record:activity` with no discussion context self-fetches the record\'s own ' + + '`sys_activity` rows.', + loading: '`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s ' + + 'fetch state for its `items` feed and wins over the block\'s other loading sources, so ' + + 'authored `true` pins the loading state on forever. On a `record:chatter` / ' + + '`record:discussion` `feed` nothing reads it. Omit it with `items`: each block takes its ' + + 'loading state from its own source.', }, }, { /**