Skip to content

Commit 2306a76

Browse files
Jack Qclaude
andauthored
fix(spec): validate theme / analytics_cube at the /meta write door via UNREGISTERED_KIND_SCHEMAS (#10194) (#10440)
* fix(spec): bind theme / analytics_cube in UNREGISTERED_KIND_SCHEMAS (#10194) Same SHAPE-check-only treatment #6245 gave webhook/connector/sharing_rule: no MetadataTypeSchema member, no DEFAULT_METADATA_TYPE_REGISTRY entry, no capability or authorization change — only a 422 where PUT /meta/theme/:name and PUT /meta/analytics_cube/:name currently store any JSON as success:true. Both schemas gain ...MetadataProtectionFields (the sharing_rule precedent: strict schemas at the overlay door must declare the ADR-0010 stamp or 422 the runtime's own envelope). The map's closing invariant (entry === the stack collection's element schema) is now pinned for all five entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD * test: mirror the #6245 house pins for theme / analytics_cube; changeset (#10194) - protocol-meta.test.ts: valid+invalid pair per new kind; the schema-less fall-through control specimen moves theme -> rag_pipeline (the webhook precedent: the old specimen body is spec-INVALID and now 422s, and rag_pipeline is the only URL-map kind left that resolves no schema). - metadata-protocol fixtures: theme probe bodies made spec-valid (the door under test is authorization/advertisement, not the shape check). - changeset: minor @objectstack/spec, following #6245's landed grading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD * test(runtime): spec-valid theme probe bodies in the two door-under-test controls (#10194) Same fixture-triage class as the metadata-protocol/objectql sites: these controls measure the arity door and the #7894 permission verdict, not the shape check, so their probe bodies must parse under ThemeSchema now that theme resolves a schema through UNREGISTERED_KIND_SCHEMAS. Repo-wide sweep (type-keyed and URL-path spellings, theme + analytics_cube) finds no other site in this class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2cb59f commit 2306a76

17 files changed

Lines changed: 403 additions & 42 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `theme` / `analytics_cube` are validated at the `/meta` write door (#10194)
6+
7+
The two doors #6245 left open, closed the same way. Both are declared,
8+
authorable stack collections with real `.strict()` schemas —
9+
`defineStack({ themes })` validates with `ThemeSchema`,
10+
`defineStack({ analyticsCubes })` with `CubeSchema` — yet neither was bound in
11+
`UNREGISTERED_KIND_SCHEMAS`, so `getMetadataTypeSchema()` answered `undefined`
12+
and `saveMetaItem` took its documented "unregistered type → store without
13+
validation" branch: a body the stack door strictly refuses was stored,
14+
unvalidated and badged `success: true`, through the metadata door. For `theme`
15+
that is the console's own styling surface — a malformed one failed at render
16+
rather than at write, with nothing at the write point to say so.
17+
18+
**FROM** `PUT /meta/theme/:name` / `PUT /meta/analytics_cube/:name` with any
19+
JSON → `200 { success: true }`, stored unvalidated.
20+
**TO** a malformed body → `422 INVALID_METADATA` with structured `issues[]`,
21+
the same envelope every other kind already returned. A well-formed body is
22+
accepted exactly as before.
23+
24+
Each entry binds the **same schema its stack collection is validated against**
25+
(`ThemeSchema` at `stack.zod.ts` `themes:`, `CubeSchema` at `analyticsCubes:`),
26+
and that closing invariant is now pinned by identity for all five map entries.
27+
28+
**No new capability surface.** Shape validation only: no `MetadataTypeSchema`
29+
member, no `DEFAULT_METADATA_TYPE_REGISTRY` entry, so every authorization
30+
verdict keeps taking the identical "no static entry ⇒ synthesised
31+
`allowRuntimeCreate: true`" branch. The write *door* is unchanged; only the
32+
422 is new. #2657's B/C decision on whether these should become kinds is
33+
untouched and unprejudged. `rag_pipeline` is deliberately not bound — it has
34+
no stack collection to take a schema from (#6242 row 2).
35+
36+
Graded **minor**, following #6245's landed precedent for the identical change
37+
(itself following #5271): a write that previously returned 200 can now return
38+
422. Nothing well-formed changes behaviour, but a caller relying on the API
39+
accepting malformed bodies will see the difference.
40+
41+
**One schema change rides along per kind, and it is load-bearing.**
42+
`Theme` and `Cube` now declare the ADR-0010 protection envelope (`_lock`,
43+
`_lockReason`, `_lockSource`, `_lockDocsUrl`, `_packageId`, `_packageVersion`,
44+
`_provenance`) — the sharing_rule precedent from #6245: both metadata load
45+
paths call `applyProtection` on **every** type, and these shapes are
46+
`.strict()`, so binding the door without the spread would have aimed the new
47+
422 at the runtime's own stamp instead of at malformed author input. Additive
48+
and internal-only — no authored field changes.

‎content/docs/references/data/analytics.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ const result = AggregationMetricType.parse(data);
8080
| **joins** | `Record<string, { name: string; relationship: Enum<'one_to_one' \| 'one_to_many' \| 'many_to_one'>; sql: string }>` | optional | |
8181
| **refreshKey** | `{ every?: string; sql?: string }` | optional | |
8282
| **public** | `boolean` | optional (default: `false`) | |
83+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
84+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
85+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
86+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
87+
| **_packageId** | `string` | optional | Owning package machine id. |
88+
| **_packageVersion** | `string` | optional | Owning package version. |
89+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
8390

8491

8592
---

‎content/docs/references/ui/theme.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ const result = BorderRadiusSchema.parse(data);
105105
| **zIndex** | `never` | optional | [REMOVED] `theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
106106
| **customVars** | `Record<string, string>` | optional | Custom CSS variables (key-value pairs) |
107107
| **extends** | `string` | optional | Base theme to extend from |
108+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
109+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
110+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
111+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
112+
| **_packageId** | `string` | optional | Owning package machine id. |
113+
| **_packageVersion** | `string` | optional | Owning package version. |
114+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
108115

109116

110117
---

‎packages/metadata-protocol/src/protocol.code-only-types.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,14 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
396396
// `getMetaTypes()` synthesises those with allowRuntimeCreate:true;
397397
// the write gate must keep agreeing with what it advertises.
398398
const { protocol, rows } = makeProtocol(undefined);
399+
// [#10194] `theme` resolves a schema through UNREGISTERED_KIND_SCHEMAS
400+
// now, so the probe body must be spec-valid — the door under test
401+
// (authorization) is unchanged, but a malformed body would 422
402+
// before proving anything about it.
399403
const result = await protocol.saveMetaItem({
400404
type: 'theme',
401405
name: 'rc3_probe_theme',
402-
item: { name: 'rc3_probe_theme', label: 'Probe', tokens: {} },
406+
item: { name: 'rc3_probe_theme', label: 'Probe', colors: { primary: '#3b82f6' } },
403407
});
404408
expect(result.success).toBe(true);
405409
expect(metaRows(rows).length).toBe(1);
@@ -493,7 +497,8 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
493497
},
494498
{
495499
type: 'theme', // no static registry entry (plugin-registered)
496-
item: { name: 'rc3_receipt_view', label: 'Receipt', tokens: {} },
500+
// [#10194] spec-valid body — theme resolves a schema now.
501+
item: { name: 'rc3_receipt_view', label: 'Receipt', colors: { primary: '#3b82f6' } },
497502
},
498503
];
499504

‎packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ const SAMPLE: Array<{
155155
type: 'theme',
156156
klass: 'url-map-only',
157157
creatable: true,
158-
item: { name: 'probe_theme', label: 'Probe', tokens: {} },
158+
// [#10194] spec-valid body — `theme` resolves a schema through
159+
// UNREGISTERED_KIND_SCHEMAS now, and the "behaves as advertised" case
160+
// drives this body through a real write, so a malformed one would
161+
// 422 and misread the ADVERTISEMENT door this suite measures.
162+
item: { name: 'probe_theme', label: 'Probe', colors: { primary: '#3b82f6' } },
159163
},
160164
{ type: 'policy', klass: 'withdrawn', creatable: false, item: { name: 'probe_policy', label: 'Probe' } },
161165
{ type: 'data', klass: 'withdrawn', creatable: false, item: { name: 'probe_data', label: 'Probe' } },

‎packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ describe('#8421 — the traffic that must keep working', () => {
203203
{
204204
type: 'theme',
205205
why: 'PLUGIN kind — no static registry entry at all',
206-
item: { name: 'probe_item', label: 'Probe', tokens: {} },
206+
// [#10194] spec-valid body — theme resolves a schema now.
207+
item: { name: 'probe_item', label: 'Probe', colors: { primary: '#3b82f6' } },
207208
},
208209
];
209210

@@ -224,10 +225,12 @@ describe('#8421 — the traffic that must keep working', () => {
224225
// consults the static contract instead, so the first create of a
225226
// plugin kind is untouched.
226227
const { protocol, rows } = makeProtocol();
228+
// [#10194] spec-valid body — theme resolves a schema now, and this
229+
// control measures the STATIC-contract door, not the shape check.
227230
const result = await protocol.saveMetaItem({
228231
type: 'theme',
229232
name: 'dark',
230-
item: { name: 'dark', label: 'Dark', tokens: {} },
233+
item: { name: 'dark', label: 'Dark', colors: { primary: '#3b82f6' } },
231234
});
232235
expect(result.success).toBe(true);
233236
expect(metaRows(rows)[0]!.type).toBe('theme');

‎packages/objectql/src/metadata-validation-sweep.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
* 2. A deliberately broken payload (missing required field) →
1414
* expect `invalid_metadata` + status 422 + structured `issues[]`.
1515
*
16-
* Types without a Zod schema in the central registry (plugin-only types like
17-
* `theme`/`webhook`) are still expected to pass through unvalidated — that is
16+
* Types without a Zod schema in the central registry (today only
17+
* `rag_pipeline` among the URL-map kinds — `theme`/`webhook` and their
18+
* siblings all resolve schemas via `UNREGISTERED_KIND_SCHEMAS` since
19+
* #6245/#10194) are still expected to pass through unvalidated — that is
1820
* the documented fall-through, not a regression. We pin it explicitly so any
1921
* future coverage gap is visible in the report.
2022
*

‎packages/objectql/src/protocol-meta.test.ts‎

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,22 +1544,30 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
15441544
// old specimen: its body (`events`, an alias of `triggers`, plus the
15451545
// non-enum trigger `'x.created'`) is spec-INVALID and now 422s. Kept
15461546
// here it would have flipped this test red for a reason that has
1547-
// nothing to do with what it proves. `theme` and `policy` still carry
1548-
// the branch — neither resolves a schema. Each newly-bound type's own
1547+
// nothing to do with what it proves.
1548+
//
1549+
// [#10194] `theme` left it by exactly the webhook rule: it gained a
1550+
// SCHEMA (not a registry entry), and the old specimen body
1551+
// (`tokens: {}` — an alias of `customVars`, with the required `colors`
1552+
// missing) is spec-INVALID and now 422s. `analytics_cube` was bound in
1553+
// the same change. Of the six URL-map-only kinds, `rag_pipeline` is
1554+
// now the ONLY one that resolves no schema (it has no stack collection
1555+
// to take one from — #6242 row 2), so it carries the pure
1556+
// no-schema fall-through control below. Each newly-bound type's own
15491557
// behaviour, door and 422 both, is pinned in the tests below.
15501558
// ───────────────────────────────────────────────────────────────
15511559

15521560
it('accepts brand-new plugin-registered type (no static registry entry)', async () => {
15531561
mockEngine.findOne.mockResolvedValue(null);
15541562

1555-
const themeResult = await scoped.saveMetaItem({
1556-
type: 'theme',
1557-
name: 'my_theme',
1558-
item: { name: 'my_theme', label: 'Test', tokens: {} },
1563+
const result = await scoped.saveMetaItem({
1564+
type: 'rag_pipeline',
1565+
name: 'my_pipeline',
1566+
item: { name: 'my_pipeline', label: 'Test' },
15591567
organizationId: 'org_alpha',
15601568
});
15611569

1562-
expect(themeResult.success).toBe(true);
1570+
expect(result.success).toBe(true);
15631571
});
15641572

15651573
it('[#8421] CHANGED BEHAVIOUR — `policy` is no longer one of them', async () => {
@@ -1739,6 +1747,91 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
17391747
});
17401748
});
17411749

1750+
// ───────────────────────────────────────────────────────────────
1751+
// [#10194] `theme` / `analytics_cube` — the two doors #6245 left
1752+
// open, closed the same way and pinned the same way: the write door
1753+
// is UNCHANGED (no-static-entry authorization fall-through, verdict
1754+
// byte-identical), the shape check is new. Both halves per type, so a
1755+
// change that quietly CLOSED the door fails the "accepts" half.
1756+
// ───────────────────────────────────────────────────────────────
1757+
1758+
it('accepts a spec-valid `theme` item (write door unchanged by the schema binding)', async () => {
1759+
mockEngine.findOne.mockResolvedValue(null);
1760+
1761+
const result = await scoped.saveMetaItem({
1762+
type: 'theme',
1763+
name: 'my_theme',
1764+
item: {
1765+
name: 'my_theme',
1766+
label: 'My Theme',
1767+
colors: { primary: '#3b82f6' },
1768+
},
1769+
organizationId: 'org_alpha',
1770+
});
1771+
1772+
expect(result.success).toBe(true);
1773+
});
1774+
1775+
it('refuses a spec-INVALID `theme` item with 422 instead of storing it unvalidated', async () => {
1776+
mockEngine.findOne.mockResolvedValue(null);
1777+
1778+
// The exact body the old "plugin-registered types" case above used
1779+
// to save with `success: true`: `tokens` is an ALIAS of
1780+
// `customVars` (so the strict surface names the real key), and the
1781+
// required `colors` block is missing. Stored verbatim, this is the
1782+
// theme that fails at RENDER — the console's own styling surface —
1783+
// with nothing at the write point to say so.
1784+
await expect(
1785+
scoped.saveMetaItem({
1786+
type: 'theme',
1787+
name: 'my_theme',
1788+
item: { name: 'my_theme', label: 'Test', tokens: {} },
1789+
organizationId: 'org_alpha',
1790+
}),
1791+
).rejects.toMatchObject({
1792+
code: 'INVALID_METADATA',
1793+
status: 422,
1794+
});
1795+
});
1796+
1797+
it('accepts a spec-valid `analytics_cube` item (write door unchanged by the schema binding)', async () => {
1798+
mockEngine.findOne.mockResolvedValue(null);
1799+
1800+
const result = await scoped.saveMetaItem({
1801+
type: 'analytics_cube',
1802+
name: 'orders',
1803+
item: {
1804+
name: 'orders',
1805+
sql: 'orders',
1806+
measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } },
1807+
dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } },
1808+
},
1809+
organizationId: 'org_alpha',
1810+
});
1811+
1812+
expect(result.success).toBe(true);
1813+
});
1814+
1815+
it('refuses a spec-INVALID `analytics_cube` item with 422 instead of storing it unvalidated', async () => {
1816+
mockEngine.findOne.mockResolvedValue(null);
1817+
1818+
// `table` is an ALIAS of `sql`, and the required `measures` /
1819+
// `dimensions` records are missing — the same body `defineStack({
1820+
// analyticsCubes })` strictly refuses. Stored verbatim, this cube
1821+
// registers as a semantic layer with no semantics.
1822+
await expect(
1823+
scoped.saveMetaItem({
1824+
type: 'analytics_cube',
1825+
name: 'orders',
1826+
item: { name: 'orders', table: 'orders' },
1827+
organizationId: 'org_alpha',
1828+
}),
1829+
).rejects.toMatchObject({
1830+
code: 'INVALID_METADATA',
1831+
status: 422,
1832+
});
1833+
});
1834+
17421835
// ───────────────────────────────────────────────────────────────
17431836
// [#5488, overturning #5271's pins] `api` — the write door is now
17441837
// CLOSED, and it closes in front of the shape check.

‎packages/runtime/src/meta-compound-arity-mint-door.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,12 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', ()
254254
it('CONTROL — a recognised type at the simple arity is unaffected', async () => {
255255
const { engine, dispatcher } = makeStack();
256256

257+
// [#10194] spec-valid body — `theme` resolves a schema through
258+
// UNREGISTERED_KIND_SCHEMAS now, and this control measures the ARITY
259+
// door, so a malformed body would 422 and misread it.
257260
const res = responseOf(await dispatcher.handleMetadata(
258-
'/theme/midnight', ctx(), 'PUT', { name: 'midnight', label: 'Midnight' },
261+
'/theme/midnight', ctx(), 'PUT',
262+
{ name: 'midnight', label: 'Midnight', colors: { primary: '#3b82f6' } },
259263
));
260264

261265
expect(res.status).toBe(200);

‎packages/runtime/src/meta-field-overlay-lock.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,16 +697,21 @@ describe('#7743 — PUT /meta/field/<object>.<field> honours the registry overla
697697
const { engine, dispatcher } = makeStack();
698698

699699
// `theme` has no `DEFAULT_METADATA_TYPE_REGISTRY` entry at all.
700+
// [#10194] spec-valid bodies — `theme` resolves a schema through
701+
// UNREGISTERED_KIND_SCHEMAS now, and this control measures the #7894
702+
// PERMISSION verdict, so a malformed body would 422 and misread it.
700703
const singular = responseOf(await dispatcher.handleMetadata(
701-
'/theme/midnight', ctx(), 'PUT', { name: 'midnight', label: 'Midnight' },
704+
'/theme/midnight', ctx(), 'PUT',
705+
{ name: 'midnight', label: 'Midnight', colors: { primary: '#3b82f6' } },
702706
));
703707
expect(singular.status).toBe(200);
704708
expect(metaRow(engine, 'theme', 'midnight')).toBeDefined();
705709

706710
// …and via its plural spelling, which the URL map carries from the
707711
// manifest map's limb — still one namespace, the singular one.
708712
const plural = responseOf(await dispatcher.handleMetadata(
709-
'/themes/twilight', ctx(), 'PUT', { name: 'twilight', label: 'Twilight' },
713+
'/themes/twilight', ctx(), 'PUT',
714+
{ name: 'twilight', label: 'Twilight', colors: { primary: '#3b82f6' } },
710715
));
711716
expect(plural.status).toBe(200);
712717
expect(metaRow(engine, 'theme', 'twilight')).toBeDefined();

0 commit comments

Comments
 (0)