Skip to content

Commit b69d0f5

Browse files
hotlongclaude
andauthored
fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) (#8770)
* fix(metadata): refuse a /meta type name the platform does not have (#8421) `PUT /api/v1/meta/fieldz/showcase_task.title` answered 200 and persisted a `sys_metadata` row under `type='fieldz'` — a namespace for a metadata type that does not exist. #7894 closed the sibling case (a plural spelling of a DECLARED type) and left this one open, because a static predicate cannot tell `fieldz` from a plugin kind and the live-registry alternative was measured to be worse than the defect. Maintainer ruling 2026-08-14, joint with #8586: retiring `additionalTypes` removed the last channel by which a plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a declaration the boundary has not heard about. `@objectstack/spec` gains `unrecognisedMetaTypeRefusal`, deliberately separate from the #7894 verdict: one says you spelled a declared type wrongly and can name the replacement, the other says there is no such type and never guesses. Its accept set is the static spelling contract — the map's keys AND the canonical singulars they fold to — so the six plugin kinds with no registry entry (`theme`, `webhook`, `connector`, `sharing_rule`, `analytics_cube`, `rag_pipeline`) stay writable, including the first create of a kind that has no items yet. The boundary applies it at `saveMetaItem` only, which is measured rather than timid: an ordinary `registerApp` puts `data`, `kind` and `package` into the live type set that `GET /api/v1/meta/types` advertises, so a read-side refusal would 400 types this same service publishes; and refusing DELETE would strand rows minted under an unrecognised type before this change. The residue pin in `metadata-url-spelling.test.ts` is flipped, not deleted, and #7894's positive control keeps every assertion it was written with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * test(spec): widen the declared-set probe to Set<string> for tsconfig.test.json Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * chore(changeset): record the ADR-0087 disposition for the /meta accept-set narrowing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * test(metadata-protocol): key the stub rows structurally, keeping the tsc ratchet at 63 The stub's `keyOf` took `Record<string, unknown>`, which a `Row` interface is not assignable to — one new TS2345 in a package whose measured error count is a shrink-only ledger entry. Reading the four key fields structurally serves both callers without a cast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * fix(metadata): exempt the compound arity and already-stored namespaces from the /meta mint refusal (#8421) * chore(changeset): record the compound-arity and stored-namespace exemptions (#8421) * fix(metadata): propagate a metadata-store outage from the namespace probe instead of inventing 'no rows' (#8421) * fix(metadata): derive /meta/types' allowRuntimeCreate from the mint door's own contract (#8421) GET /meta/types synthesised allowRuntimeCreate: true for every live type with no static registry entry, while the mint door added by this card refuses the subset of them that is outside the static spelling contract. Two endpoints of one service contradicting each other is worse for an AI author than a narrower surface, so both doors now read one predicate. Maintainer ruling 2026-08-15 (verbatim, untranslated): 暂时不考虑让插件申明新的元数据类型 The premise of 'no registry entry => assume plugin-declared => writable' has expired, so policy/data/package/kind stop being advertised as runtime-creatable. The six URL-map-only plugin kinds (theme, webhook, connector, sharing_rule, analytics_cube, rag_pipeline) are unaffected and pinned as the discriminating control. 暂时 is recorded as a CURRENT posture at both sites. isRuntimeCreateAllowed keeps its permissive arm deliberately: it is now the residue/clearance arm behind the mint door, not the read door's twin, and narrowing it would strand rows minted before the refusal shipped. Fixture corrections: trigger (ADR-0088-retired) replaced by mapping as the runtime-creatable specimen in two objectql cases; policy's acceptance becomes its own CHANGED BEHAVIOUR refusal case; #7894's never-heard-of-kind positive control keeps its metaUrlSpellingRefusal half and updates its boundary half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * chore(changeset): record the /meta/types advertisement narrowing and the 暂时 posture (#8421) The changeset described the mint-door refusal only, and its 'what breaks' section still named the accept-set question as open with the maintainer. Both are now settled: GET /api/v1/meta/types stops advertising policy/data/package/ kind as allowRuntimeCreate, the six URL-map-only plugin kinds are explicitly unaffected, and the ruling's 暂时 is recorded as a current posture with the two code sites that carry the trail back to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd6bdf8 commit b69d0f5

13 files changed

Lines changed: 1833 additions & 52 deletions
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) This narrows when an HTTP endpoint refuses. No authorable key, no stored shape and no spelling changes: `DEFAULT_METADATA_TYPE_REGISTRY` and the URL-spelling map are untouched, so `os migrate meta` has nothing to rewrite. Rows already at rest under an unrecognised type keep their shape, stay readable and stay deletable — the refusal is on the mint path only. #8586's own retirement entry (protocol 18) already carries the declared-kind half of this ruling. -->
9+
10+
11+
**BREAKING** accept-set narrowing on a published HTTP surface, landing after the
12+
v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write
13+
that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`:
14+
15+
```
16+
PUT /api/v1/meta/fieldz/showcase_task.title
17+
before → 200, sys_metadata row persisted with type='fieldz'
18+
after → 400 INVALID_REQUEST, nothing persisted
19+
```
20+
21+
`fieldz` — or any typo — was neither a declared metadata type nor a known plural
22+
spelling of one, so the boundary classified it as PLUGIN-registered, which every
23+
authorization gate is permissive toward by construction. The row was persisted
24+
under a type nothing reads and nothing serves, and the caller was told it had
25+
succeeded. That silence is the real cost: a metadata-type typo, from a human or
26+
from generated code, produced `success: true` and no indication the type is not
27+
real.
28+
29+
**Why this is only now safe to refuse.** #7894 closed the sibling case (a plural
30+
spelling of a type the platform DECLARES) and left this one open on purpose: a
31+
static predicate cannot tell `fieldz` from a plugin kind, and the live-registry
32+
alternative was measured to be worse than the defect — the live type set is
33+
ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which
34+
is the state each kind is in immediately before its first create. What changed
35+
is the platform, not the boundary's information: #8586 retired
36+
`MetadataPluginConfig.additionalTypes` and with it the last channel by which a
37+
plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a
38+
declaration this refusal has not heard about (maintainer ruling 2026-08-14).
39+
40+
**What still passes, pinned in both directions.** Every declared type in
41+
`DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every
42+
manifest spelling and the singular each folds to; and the six plugin kinds that
43+
have no static registry entry at all — `theme`, `webhook`, `connector`,
44+
`sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a
45+
deployment with zero themes is explicitly covered, because that first create is
46+
exactly what a live-registry check would have broken.
47+
48+
**The refusal is scoped to the door that mints.** Reads still ANSWER: a running
49+
kernel legitimately holds live type keys the static contract does not — `data`,
50+
`kind` and `package` all enter the registry during an ordinary `registerApp`,
51+
and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised
52+
names on the read path would answer 400 for types the same service advertises.
53+
`DELETE` is untouched for the mirror-image reason: rows minted under an
54+
unrecognised type before this change are real, nothing rewrites them on upgrade,
55+
and refusing their deletion would turn the accumulation this fixes into an
56+
accumulation nobody can clear.
57+
58+
**…but one published ADVERTISEMENT narrows with it, and that is a second
59+
behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps
60+
listing every live type, and every entry keeps every field — what changes is the
61+
VALUE of one boolean:
62+
63+
```
64+
GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind}
65+
before → allowRuntimeCreate: true
66+
after → allowRuntimeCreate: false
67+
```
68+
69+
The listing synthesised `allowRuntimeCreate: true` for every live type with no
70+
static registry entry, on the same expired premise as the write door: a name the
71+
registry does not carry might be a kind some plugin declared. It now derives that
72+
flag from the SAME predicate the mint door enforces, so the two endpoints agree
73+
by construction instead of via two rules maintained apart. Nothing ever honoured
74+
a runtime create on those four — they are internal bookkeeping (seed datasets,
75+
package rows, kind descriptors) — so the advertisement was a promise the platform
76+
did not keep, which is the same defect this card is about, relocated to the read
77+
door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime
78+
never honoured it, and the 2026-08-07 ruling removed the declaration rather than
79+
converging the read path onto it.
80+
81+
⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`,
82+
`sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are
83+
in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and
84+
stay mintable. A UI reading this field (Setup → Metadata, the Studio designers)
85+
therefore loses create affordances on exactly the four types whose creates were
86+
already refused, and keeps them everywhere else.
87+
88+
**The premise behind both halves is a CURRENT posture, not a closed door.**
89+
Maintainer ruling, 2026-08-15, verbatim and untranslated:
90+
暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types
91+
*for now*. That word is recorded deliberately: plugin-declared kinds were
92+
considered and deferred, not ruled out. If they are ever wanted, the two sites
93+
that encode the deferral name it and its date in place —
94+
`getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in
95+
`@objectstack/metadata-protocol` — so the decision is findable rather than
96+
re-derived from the code's silence.
97+
98+
**Two shapes reaching the mint door are exempt, and each is a fact about the
99+
request rather than a claim the caller makes.**
100+
101+
1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.*
102+
`PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`,
103+
`name='views/all_leads'` — one operation reaching one save, the shape both
104+
the runtime dispatcher and the REST route document verbatim. `lead` is an
105+
object, i.e. runtime data no static contract can enumerate, so a type verdict
106+
applied there would refuse every object name that is not coincidentally a
107+
metadata type. The ruling is about metadata TYPE names like `fieldz`.
108+
⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore
109+
still accepted, because at that arity `fieldz` is a claim about an object and
110+
the only way to check it is the live-registry lookup this card ruled out.
111+
2. *A namespace that already exists is not being minted.* `duplicatePackage`
112+
re-saves every row of a package under a new name, taking each type from the
113+
stored row — measured: a package holding one pre-existing residue row
114+
answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not
115+
be duplicated at all. That contradicts the `DELETE` reasoning above, so the
116+
store (never the request) exempts a type that already has rows. The probe
117+
runs only once the refusal has already fired, and a store that cannot answer
118+
refuses — a fresh deployment has no residue to protect.
119+
`migrate meta --stored` was read as a third victim and measured NOT to be
120+
one: an unrecognised type has no manifest collection, hence no ADR-0087
121+
chain, hence no notice, so such a row is reported `canonical` and the mint
122+
door is never reached.
123+
124+
**What breaks.** A caller creating metadata at runtime, at the simple arity,
125+
under a type name that is in neither half of the static spelling contract and
126+
has no rows already. That set is **not** empty in this repo — measured on
127+
`objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind
128+
ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three
129+
are corrected here rather than exempted, and each for its own reason: the
130+
`trigger` specimens were debt independent of any ruling (a retired kind cannot
131+
demonstrate a live tier, and they were green only through the hole this card
132+
closes), `policy` becomes a refusal case of its own, and #7894's control keeps
133+
its `metaUrlSpellingRefusal` claim while its boundary expectation follows the
134+
narrowing. An out-of-tree plugin that made its kind live by registering an item
135+
of it, and then accepted runtime writes to that kind through `/meta`, needs its
136+
spelling in the contract; there is no declared-kind channel to register one
137+
through today — that is the trade #8586's retirement made, and the `暂时` above
138+
is what makes it revisitable.
139+
140+
`@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside
141+
the #7894 verdict it deliberately does not merge with: one says *you spelled a
142+
declared type wrongly* and can name the replacement, the other says *there is no
143+
such type* and never guesses. The residue pin #7894 left behind
144+
(`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by
145+
nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own
146+
claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a
147+
misspelling of nothing, which is what makes that control true by construction —
148+
but the BOUNDARY it drives now refuses six of the twelve names it exercises,
149+
and that case says so in place rather than leaving it to inference.

0 commit comments

Comments
 (0)