Skip to content

Commit 3a6d92f

Browse files
fix(spec): record:activity's props row names items / loading as the host feed slot (#21422)
Fixes #21279 Clause-②: no ## What changes `RecordActivityProps`, the `ComponentPropsMap['record:activity']` row, gains a `guidance` table that names `items` and `loading`. It uses the shape `RecordHistoryProps` already uses for `entries` / `loading`. Both keys stay undeclared, so the accept set does not change. Only the unrecognized-key refusal changes: it now names them as the host's feed slot and gives the remedy, which is to omit them. No new vocabulary. - `packages/spec/src/ui/component.zod.ts`: the two `guidance` entries, plus a docblock that records the renderer read points and the `record:chatter` `feed` mount. - `packages/spec/src/ui/component-record-blocks.test.ts`: pins through the row (below). - `.changeset/21279-record-activity-host-feed-guidance.md`: an `@objectstack/spec` `patch`, message text only. ## Premise check (base `4b20c84748`) 1. **Before.** `RecordActivityProps` was `strictObject({ surface, history, guidanceSets }, …)` with no `guidance` map. Measured through the row: `{ items: [ … ] }` gave one `unrecognized_keys` issue at path `[]` with keys `["items"]`, and the message was only the generic line: "Unrecognized key(s) on this `record:activity`: `items`. Until this shape was closed, …". `loading` got the same, and so did `feed.items` on `record:chatter`. 2. **The renderer reads both keys.** objectui at the `.objectui-sha` pin `89cad75d5570`, `packages/plugin-detail/src/renderers/record-activity.tsx`: - `:161-166` reads `items` as an array, off the node or out of the `properties` bag, and `loading` as `schema.loading ?? bag.loading`. - `:265-266` the host `loading` wins over the discussion context's flag and over the self-fetch state. 3. **Generated reference page.** `check:docs` (inside `check:generated`) is green with nothing regenerated. The reference page does not lift `guidance` text, so no generated file is in this diff. ## After Through `ComponentPropsMap['record:activity']`, the same single `unrecognized_keys` issue (keys `["items"]`) now carries: ```text Unrecognized key(s) on this `record:activity`: `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. Until this shape was closed, … ``` `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." (Round 2, head `ab58157585`: both bullets now state who reads the key on each mount, because the table is one flat map shared by the standalone row and the `record:chatter` / `record:discussion` `feed`. See *Review round 1*.) Control: the `record:history` `entries` / `loading` messages are byte-identical before and after. I captured both probe outputs and the diff of the history section exits 0. ## Pins (`component-record-blocks.test.ts`, through the row) - `items` (a feed and `[]`) and `loading` (`true` and `false`) are each refused with exactly one issue: `code` `unrecognized_keys`, `path` `[]`, `keys` the one key. The refusal is about the key, not a value domain. - The `items` message carries the named first sentence, the omit remedy and `sys_activity`. The `loading` message carries its named first sentence and "Omit it with `items`". - CONTROL: an unrelated unknown key on the row keeps the generic refusal, with no host-channel line. - The `record:chatter` and `record:discussion` `feed`, which is the same object, still refuses both keys at path `["feed"]`, and (round 2) the message carries the mount-true clause "On a `record:chatter` / `record:discussion` `feed` nothing reads it." with "Omit it". - The existing `record:history` pins are the unchanged control. ## Reverse verification (ablation) The ablation ran from the committed fix, with `node scripts/ablation-replace.mjs` wrapping the run. It changed the literal anchor `guidance: {` + `items: '`items` is the HOST` to `guidanceAblated: {` …, which disables the whole table. - Mutation landed: anchor 1 to 0, blob `a301b1717b3a` to `a2e40a0d5943`. - `vitest run src/ui/component-record-blocks.test.ts`: **2 failed / 30 passed**. The two named-message pins went red. The other three pins (refusal, control, chatter `feed`) stayed green, as they should: the ablation removes the message and leaves the accept set alone. - Restored: blob equals HEAD (`a301b1717b3a`), `git diff HEAD` is empty, and `git status` is clean. - Direction observed: red, the ordinary direction. ## Verification (head `b6224772eb`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **600 files, 17611 passed, 1 todo**, exit 0. Before the merge, on `29f3682ea5`: 599 files, 17578 passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0. That covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck`. The edited test file is in `tsconfig.test.json`'s program (`--listFilesOnly`: 1 hit) and has no debt-ledger entry. - `pnpm --filter @objectstack/spec build && … check:generated`: all 15 generated artifacts up to date. Nothing was regenerated. - `node scripts/pm/check-widening-tells.mjs --declaration no --diff`: exit 0. `component.zod.ts` was judged against a declared surface and no widening tell fired. The changeset and the test file are NOT MEASURED by construction. `Clause-②: no` holds. - Gate union from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (85 commands), each run on this head with its exit code captured before any pipe. `--ran` reconciliation: **83 exit 0, 1 NOT MEASURED, 1 unrun.** - NOT MEASURED: `check:dual-build-cjs-loads`. It exited 3, PREREQUISITE NOT MET: it needs a whole-workspace build, and 63 of 77 build tasks are turbo cache misses here. - Unrun on this head: `check:type-check-debt`. Its `--re-measure` builds the whole workspace closure and then type-checks every package. On `29f3682ea5` it passed its self-test and coverage leg, then hit my 300 s per-command cap during the closure build. That run left partial `dist/` trees in this worktree, which I removed before the rerun. It does not fit the foreground cap on this shared box, so it is declared to CI. - The diff adds no file and edits no `tsconfig`. - Lint, narrowed and proven: `eslint --no-inline-config --format json` on the two edited TS files gives **2 files, 0 errors, 0 warnings**. - Population: both are in eslint's own population (`ESLint.isPathIgnored` is false for both). - Invariance: `eslint.config.mjs` never enables type-aware linting (0 `projectService`, no `parserOptions.project`; its own comment at `:327-328` says so). So this diff cannot move the verdict on any untouched file. Repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The `record:chatter` / `record:discussion` `feed` mount** (superseded in round 2). Round 1 left the host-channel sentence describing the standalone block only, and declined chatter-mount wording as splitting one shape. The at-tier record `5954789199` (FAIL) overruled that: the guidance table is one flat map that cannot be scoped per mount, so the fix is wording true on both. Round 2 (`ab58157585`) states each mount. - **`PROPS_HISTORY`'s shared tail** ("…was not read there…") is pre-existing-false for host-channel keys the renderer does read, on `record:history` and here. Not owed by this PR (record `5954789199` ③); the seat carries it. - **`main` merged once.** Merged `fa7b565212` (merge `b6224772eb`). No conflict, none of this diff's files, no `os-regen` deferral. After the merge I ran a frozen install, a spec rebuild and `check:generated`, then the full spec suite and the gate union on the merge head. `main` has moved since, to `41a3c8df15`: a comment-only edit in `view.zod.ts` with no overlap, so I did not merge again. ## Review round 1 - At-tier contract review **FAIL** on `b6224772eb` (`5954789199`), on one point: the new bullets were false on the `record:chatter` / `record:discussion` `feed` mount. Everything else (accept set unchanged, `record:history` byte-identical, `patch` / `Clause-②: no`) was judged right. - Patch round 2 (`ab58157585`, +47 / -29 in the same 3 files): the bullets are reworded for both mounts, the chatter pin asserts the mount-true clause, and the changeset sentence is corrected. Two ablations each turn the expected pins red, and the restore is proven (dev report `5955605217`). --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3bddd4a commit 3a6d92f

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `record:activity`'s props row names `items` / `loading` as the host's feed slot when it refuses them
6+
7+
Clause-②: no
8+
9+
`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.
10+
11+
- 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`.
12+
- 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.

‎packages/spec/src/ui/component-record-blocks.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,78 @@ describe('ComponentPropsMap["record:history"] (#8744)', () => {
280280
});
281281
});
282282

283+
describe('ComponentPropsMap["record:activity"] — the host feed slot is named, not declared', () => {
284+
// `record:history`'s `entries` / `loading` pair, on the activity block: the
285+
// standalone renderer takes `items` and `loading` as a host-supplied feed.
286+
// Both keys stay REFUSED (the accept set is unchanged); only the refusal
287+
// names them. The row is also `record:chatter` / `record:discussion`'s
288+
// `feed`, where nothing reads either key, so each message states both mounts.
289+
const row = ComponentPropsMap['record:activity'];
290+
const refusalOf = (bag: Record<string, unknown>) => {
291+
const result = row.safeParse(bag);
292+
expect(result.success).toBe(false);
293+
const issues = result.error!.issues;
294+
expect(issues).toHaveLength(1);
295+
return issues[0]!;
296+
};
297+
298+
it('refuses `items` and `loading` at the bag, for every value — the key, not a value domain', () => {
299+
for (const [key, values] of [
300+
['items', [[{ id: 'a1', type: 'comment' }], []]],
301+
['loading', [true, false]],
302+
] as const) {
303+
for (const value of values) {
304+
const issue = refusalOf({ [key]: value });
305+
expect(issue.code).toBe('unrecognized_keys');
306+
expect(issue.path).toEqual([]);
307+
expect((issue as { keys?: string[] }).keys).toEqual([key]);
308+
}
309+
}
310+
});
311+
312+
it('names `items` as the standalone block\'s host data channel, with the omit remedy', () => {
313+
const { message } = refusalOf({ items: [{ id: 'a1', type: 'comment' }] });
314+
expect(message).toContain('`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s data channel');
315+
expect(message).toContain('Omit it');
316+
// The self-fetch fallback is scoped to the standalone block — the chatter mount has none.
317+
expect(message).toContain('a standalone `record:activity` with no discussion context self-fetches the record\'s own `sys_activity` rows');
318+
});
319+
320+
it('names `loading` as the standalone block\'s host fetch state, with the omit remedy', () => {
321+
const { message } = refusalOf({ loading: true });
322+
expect(message).toContain('`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s fetch state for its `items` feed');
323+
expect(message).toContain('Omit it with `items`');
324+
});
325+
326+
it('CONTROL: an unrelated unknown key keeps the generic refusal, with no host-channel line', () => {
327+
const issue = refusalOf({ inventedFeedKey: [] });
328+
expect((issue as { keys?: string[] }).keys).toEqual(['inventedFeedKey']);
329+
expect(issue.message).toContain('Unrecognized key(s) on this `record:activity`');
330+
expect(issue.message).not.toContain('data channel');
331+
expect(issue.message).not.toContain('fetch state');
332+
});
333+
334+
it('the `record:chatter` / `record:discussion` `feed` (the same object) refuses both keys and says nothing reads them there', () => {
335+
// At the `.objectui-sha` pin the chatter renderer reads its rows and its
336+
// loading flag off the discussion context and never reads `feed.items` /
337+
// `feed.loading`, so the mount-true clause is the one asserted here.
338+
for (const type of ['record:chatter', 'record:discussion'] as const) {
339+
for (const key of ['items', 'loading'] as const) {
340+
const result = ComponentPropsMap[type].safeParse({ feed: { [key]: key === 'items' ? [] : true } });
341+
expect(result.success).toBe(false);
342+
const issues = result.error!.issues;
343+
expect(issues).toHaveLength(1);
344+
const issue = issues[0]!;
345+
expect(issue.code).toBe('unrecognized_keys');
346+
expect(issue.path).toEqual(['feed']);
347+
expect((issue as { keys?: string[] }).keys).toEqual([key]);
348+
expect(issue.message).toContain('On a `record:chatter` / `record:discussion` `feed` nothing reads it.');
349+
expect(issue.message).toContain('Omit it');
350+
}
351+
}
352+
});
353+
});
354+
283355
describe('the `record:discussion` / `record:chatter` pair (#8744)', () => {
284356
it('judges both names with one accept face', () => {
285357
const authored = { position: 'bottom', collapsible: false } as const;

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,6 +1610,40 @@ export const RecordActivityProps = strictObject({
16101610
surface: 'this `record:activity`',
16111611
history: PROPS_HISTORY,
16121612
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
1613+
/**
1614+
* The host feed slot, NAMED rather than declared — `RecordHistoryProps`'
1615+
* `entries` / `loading` pair, on this block. Measured at the `.objectui-sha`
1616+
* pin `89cad75d5570` (objectui `plugin-detail/src/renderers/
1617+
* record-activity.tsx:161-166`, `:265-266`): the renderer takes `items` and
1618+
* `loading` off the node or out of this bag as a feed a host composing the
1619+
* block in code already owns, and the host flag wins over every other
1620+
* loading source. Neither is authorable: an authored feed is a snapshot that
1621+
* never updates, and an authored `loading: true` pins the loading state on.
1622+
* So both stay UNDECLARED — the accept set is unchanged — and this table only
1623+
* makes the refusal name them instead of the generic unrecognized-key line.
1624+
*
1625+
* The same object is `record:chatter` / `record:discussion`'s `feed`, so the
1626+
* entries answer there too. That renderer reads its rows and its loading
1627+
* flag off the host's discussion context, reads neither `feed.items` nor
1628+
* `feed.loading`, and never self-fetches (`renderers/record-chatter.tsx`,
1629+
* same pin). `guidance` is one flat table keyed by key name — it cannot be
1630+
* scoped to a mount — so each entry says who reads the key on EACH mount,
1631+
* and every clause is true wherever the refusal is raised.
1632+
*/
1633+
guidance: {
1634+
items: '`items` is not authorable surface. On a standalone `record:activity` it is the HOST\'s '
1635+
+ 'data channel: a host composing that block in code passes the feed it already owns, and the '
1636+
+ 'renderer presents it in place of its own sources; hand-authored items would ship a static '
1637+
+ 'snapshot of the feed that never updates. On a `record:chatter` / `record:discussion` `feed` '
1638+
+ 'nothing reads it. Omit it: the block presents the record page\'s discussion feed, and a '
1639+
+ 'standalone `record:activity` with no discussion context self-fetches the record\'s own '
1640+
+ '`sys_activity` rows.',
1641+
loading: '`loading` is not authorable surface. On a standalone `record:activity` it is the host\'s '
1642+
+ 'fetch state for its `items` feed and wins over the block\'s other loading sources, so '
1643+
+ 'authored `true` pins the loading state on forever. On a `record:chatter` / '
1644+
+ '`record:discussion` `feed` nothing reads it. Omit it with `items`: each block takes its '
1645+
+ 'loading state from its own source.',
1646+
},
16131647
}, {
16141648
/**
16151649
* Feed/activity kinds to show — an OPEN vocabulary (commit 1a6a19c31, executing the

0 commit comments

Comments
 (0)