diff --git a/.changeset/accordion-item-icon-liveness.md b/.changeset/accordion-item-icon-liveness.md
new file mode 100644
index 0000000000..f08b335c49
--- /dev/null
+++ b/.changeset/accordion-item-icon-liveness.md
@@ -0,0 +1,41 @@
+---
+"@objectstack/spec": patch
+---
+
+docs(spec): record the live read point of `page:accordion` `items[].icon` — a `.describe()` plus an accept-pin, so a liveness sweep stops re-deriving a false retirement candidate (#9881)
+
+`PageAccordionProps.items[].icon` parsed, rendered, and said nothing about
+itself. A liveness sweep therefore read it as declared-but-unenforced and opened
+a retirement candidate against it — which cost a full dispatch cycle before the
+cross-repo read point was found and the candidate was closed premise-overtaken.
+Nothing on the spec side recorded that liveness, so the next sweep would have
+derived the same false candidate from the same absence.
+
+**The key is live**, re-verified at the objectui pin this repo builds against
+(`.objectui-sha` = `82a94170c`) rather than taken from the card:
+
+- `packages/components/src/renderers/layout/containers.tsx:851-853` —
+ `PageAccordionRenderer` renders `{item.icon && }`
+ inside the `AccordionTrigger`, grouped with the label in the trigger's single
+ wrapping span.
+- `containers.tsx:898` — `ComponentRegistry.register('accordion', …)` publishes
+ the key to the Studio block designer in the `items` input, documented as
+ `[{ label, icon?, collapsed?, children }]`.
+
+**Nothing about what parses changes.** The key was already declared and already
+optional; this adds the prose that makes its liveness readable, and the test that
+keeps it readable:
+
+- a `.describe()` naming the consumer behaviourally, in the file's house idiom —
+ the same shape `record:alert`'s own `icon` uses ("Read on this component —
+ contrast …"), with the file:line anchors and the measured pin in the docblock
+ above the key, where this file keeps them;
+- an accept-pin asserting the key parses on a `page:accordion` item and survives
+ to the parsed output, that an undeclared sibling on the same item is still
+ refused (so the accept is not vacuous on a schema that stopped being strict),
+ and that the `.describe()` still names the consumer — deleting it is what
+ re-opens the false candidate, so it is pinned rather than left to review.
+
+The item `value` prescribed against one line above is the deliberate contrast:
+the same renderer overwrites that key with `panel-`, and a read point is
+precisely what separates the two verdicts.
diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts
index 64313c1af0..81b1bc07fc 100644
--- a/packages/spec/src/ui/component.test.ts
+++ b/packages/spec/src/ui/component.test.ts
@@ -269,6 +269,58 @@ describe('PageAccordionProps variant (#6776)', () => {
});
});
+// #9881 — the accept-pin for `page:accordion` items[].icon, a key a liveness
+// sweep once read as declared-but-unenforced. It has a live cross-repo consumer:
+// objectui's `PageAccordionRenderer` renders `{item.icon && }` inside the `AccordionTrigger`
+// (`packages/components/src/renderers/layout/containers.tsx:851-853`), and the
+// same file's `ComponentRegistry.register('accordion', …)` publishes the key to
+// the Studio block designer at `:898` (the `items` input, documented as
+// `[{ label, icon?, collapsed?, children }]`). Measured at the pin this repo
+// builds against — `.objectui-sha` = 82a94170c.
+//
+// #9397 spent a full dispatch cycle re-deriving that read point from scratch
+// after the sweep proposed retiring the key. This block plus the `.describe()`
+// it pins are what stop the next sweep repeating it: the liveness verdict is
+// now readable from the spec side alone, with no cross-repo hunt.
+describe('PageAccordionProps items[].icon liveness (#9881)', () => {
+ const accordion = ComponentPropsMap['page:accordion'];
+
+ it('accepts an icon on a panel item — the value objectui LazyIcon renders in the trigger', () => {
+ const result = accordion.safeParse({
+ items: [{ label: 'Details', icon: 'circle-alert', children: [] }],
+ });
+ expect(result.success).toBe(true);
+ const items = (result.success ? result.data : undefined) as
+ | { items: { icon?: string }[] }
+ | undefined;
+ // Carried through to the parsed output, not stripped: what the renderer
+ // reads is what an author writes.
+ expect(items?.items[0]?.icon).toBe('circle-alert');
+ });
+
+ it('still refuses an undeclared sibling on the same item — the accept above is not vacuous', () => {
+ // Without this the green above would also be green on a schema that had
+ // stopped being strict, which is the failure mode an accept-pin exists to
+ // exclude.
+ const result = accordion.safeParse({
+ items: [{ label: 'Details', iconName: 'circle-alert', children: [] }],
+ });
+ expect(result.success).toBe(false);
+ expect(JSON.stringify(result.error?.issues)).toContain('unrecognized_keys');
+ });
+
+ it('keeps a `.describe()` that names the consumer, so the read point survives a rename', () => {
+ // The describe is the artifact an auditor reads instead of hunting across
+ // repos; deleting it is what re-opens the false candidate, so it is pinned
+ // rather than left to review.
+ const itemShape = (PageAccordionProps as unknown as {
+ def: { shape: { items: { def: { element: { def: { shape: Record } } } } } };
+ }).def.shape.items.def.element.def.shape;
+ expect(itemShape.icon?.description).toContain('LazyIcon');
+ });
+});
+
// #5775 — the two tab-item keys the renderer honours and the schema did not
// declare. `value` is the load-bearing one: it is the `?tab=` token, and the
// index-derived fallback (`tab-`) silently points at a different tab as soon
diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts
index 42570af696..55342832dc 100644
--- a/packages/spec/src/ui/component.zod.ts
+++ b/packages/spec/src/ui/component.zod.ts
@@ -1461,7 +1461,32 @@ export const PageAccordionProps = strictObject({
},
}, {
label: I18nLabelSchema,
- icon: z.string().optional(),
+ /**
+ * Panel-trigger icon, and the reason this key carries a docblock at all: a
+ * liveness sweep read it as declared-but-unenforced and opened a retirement
+ * candidate against it, which cost a full dispatch cycle before the
+ * cross-repo read point was found (#9397, closed premise-overtaken; #9881
+ * is the rider that records the liveness here so the next sweep cannot
+ * re-derive the same false candidate).
+ *
+ * The key is LIVE at the objectui pin this repo builds against
+ * (`.objectui-sha` = `82a94170c`): `containers.tsx:851-853` renders
+ * `{item.icon && }` inside the
+ * `AccordionTrigger`, grouped with the label in the trigger's one wrapping
+ * span, and the renderer's registration publishes the key to the Studio
+ * block designer at `:898` (the `items` input, documented as
+ * `[{ label, icon?, collapsed?, children }]`).
+ *
+ * Vocabulary is Lucide, resolved through objectui's `LazyIcon`
+ * (`lib/lazy-icon.tsx` — kebab-case or PascalCase, normalised to
+ * kebab-case, with a fallback when the name is not a real Lucide icon), the
+ * same slot every other authorable icon on this surface uses. Contrast the
+ * item `value` prescribed against above: the same renderer OVERWRITES that
+ * one, and a read point is precisely what separates the two verdicts.
+ */
+ icon: z.string().optional().describe(
+ 'Lucide icon name rendered in the panel trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `value` beside it, which the renderer overwrites with `panel-`.',
+ ),
collapsed: z.boolean().default(false),
children: z.array(z.unknown()).describe('Child components'),
})),