fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) - #8770
Conversation
…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.json Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…nown-meta-type-refusal
…t-set narrowing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
CI triage measurement —
|
| package | at f59a135f0 (PR head) |
at ab8b10f82 (control) |
|---|---|---|
@objectstack/objectql |
Tests 3 failed | 3626 passed (3629) |
Tests 3629 passed (3629) |
@objectstack/runtime |
Tests 1 failed | 2390 passed (2391) |
Tests 2391 passed (2391) |
@objectstack/rest |
Tests 1 failed | 1920 passed (1921) |
Tests 1921 passed (1921) |
Control runs were FULL suites, all green. The objectql numbers reproduce the CI job exactly.
Every failure raises the same verdict at the same frame — canonicalizeMetaRequestType at packages/metadata-protocol/src/protocol.ts:259, reached from saveMetaItem at :11303.
The five failures, by refused spelling
| # | package · test | refused | classification |
|---|---|---|---|
| 1 | objectql overlay-precedence.test.ts — accepts brand-new trigger |
trigger |
specimen in a runtime-creatable loop |
| 2 | objectql protocol-meta.test.ts — accepts brand-new trigger and seed |
trigger |
as above |
| 3 | objectql protocol-meta.test.ts — accepts brand-new plugin-registered type |
policy |
see below |
| 4 | runtime meta-field-overlay-lock.test.ts — #7894 POSITIVE CONTROL |
my_plugin_kind |
deliberate positive control |
| 5 | rest meta-published-overlay.test.ts — §6 COMPOUND arity |
lead |
collateral: fails in the fixture's ARRANGE step |
Three points the PR body does not currently cover
1. policy is advertised as writable by the same service that now refuses it. The failing objectql case carries a standing comment naming the invariant it guards:
theme,connector,data,policy,sharing_rule,webhook,analytics_cube,packageare registered by plugins at runtime — not inDEFAULT_METADATA_TYPE_REGISTRY.getMetaTypes()synthesises descriptors withallowRuntimeCreate: truefor them so the admin UI advertises them as writable. The write gate must agree, otherwise users see "writable" types 403 on save.
The accept set is 68 spellings folding to 33 canonical types. It carries theme, webhook, connector, sharing_rule, analytics_cube, rag_pipeline — but not policy, data, package or kind. So the PR closes the write door on a type GET /api/v1/meta/types still advertises as writable. That is the exact hazard the fixture was written to catch, restated as a 400 instead of a 403.
2. The compound arity puts an OBJECT name in the :type segment. runtime's own dispatcher fixture documents the shape verbatim:
/metadata/lead/views/all_leads-- typelead, nameviews/all_leads. The dispatcher reaches ONEsaveMetaItemfor both name shapes.
lead is an object, not a metadata type. Since the refusal is applied to the :type segment on the mint path, the compound-name PUT form now refuses every object name that is not coincidentally a metadata type. A second site in runtime (domains/meta-save-capability-gate.test.ts:118) uses the same shape and stays green only because a PERMISSION_DENIED 403 fires first — a latent site, masked rather than unaffected.
3. Two PRODUCTION paths re-save STORED rows through the refused door. The PR deliberately leaves deleteMetaItem open so pre-existing residue rows stay clearable. But residue rows are also re-saved, not only deleted:
packages/metadata-protocol/src/protocol.ts:12442—saveMetaItem({ type: singular, ..., source: 'migrate-stored' })packages/metadata-protocol/src/protocol.ts:14391—saveMetaItem({ type: row.type, ... })in the copy/clone path
Both take their type from an existing sys_metadata row. A row minted under an unrecognised type before this change would now be refused on migrate and on copy. No test covers either path with such a row, so this is reasoned from the call sites, not measured.
On the reported EPIPE — it is teardown noise, and it masked a second real failure
The Test Core (2/3) log has 37,540 lines; the failures sit around line 1,868, far above the tail. Reading the whole log resolves all three puzzles:
objectqlfailed with 3 assertions and printed the run's onlyTest Filessummary (line 1913).@objectstack/restalso failed in that same job —× §6 the COMPOUND arity resolves the runtime-published sub-resourceat line 4165, inside therest:testgroup (1978–4897), endingELIFECYCLE Test failedat 4896. Its summary block never printed, which is why a tail-scoped or summary-scoped grep finds no trace of it.- The
write EPIPEis inside theconnector-restgroup (opens 4898), after✓ src/rest-provider.test.ts (4 tests). It is the fork-pool pipe closing as turbo tore the run down following the earlier failures.
So turbo's Failed: @objectstack/objectql#test label and the visible EPIPE do belong to different tasks, and neither names the rest failure. EPIPE appeared in no local run across six suite executions.
Commands
git worktree add ../objectstack-triage-8770 f59a135f05e2033c9e73272cf144795762757821
pnpm --filter '@objectstack/objectql^...' --filter '@objectstack/runtime^...' \
--filter '@objectstack/rest^...' --filter '@objectstack/metadata-protocol^...' build # exit 0
pnpm --filter @objectstack/objectql test -- --maxWorkers=2 # exit 1
pnpm --filter @objectstack/runtime test -- --maxWorkers=2 # exit 1
pnpm --filter @objectstack/rest test -- --maxWorkers=2 # exit 1
Consumer sweep beyond the three above: metadata-core (162 passed), hono (73 passed), plugin-email (passed), plus a repo-wide static scan of 4,048 files for mint-path type literals outside the accept set. connector-rest failed locally only on Failed to resolve entry for package "@objectstack/service-automation" — an artifact of my partial build closure, unrelated to this PR.
Generated by Claude Code
Generated by Claude Code
…nown-meta-type-refusal
…s from the /meta mint refusal (#8421)
…robe instead of inventing 'no rows' (#8421)
CI red at
|
| test | type |
|---|---|
objectql overlay-precedence — accepts brand-new trigger |
trigger |
objectql protocol-meta — accepts brand-new trigger and seed |
trigger |
objectql protocol-meta — accepts brand-new plugin-registered type |
policy |
runtime meta-field-overlay-lock — #7894 POSITIVE CONTROL |
my_plugin_kind |
All four are simple-arity saves of a type with no stored rows, so neither exemption this rework adds can reach them. They are the unratified accept-set narrowing itself — the thing awaiting a ruling on #8421 — and making them green would erase the evidence that decision rests on.
⛔ Why this PR is not being pushed toward green
Two of the three routes to green are unavailable to me and the third is wrong:
- widening the accept set to admit
policy/data/package/kindis the pending decision; - narrowing what
GET /meta/typesadvertises is the same decision from the other side; - editing the fixtures to encode the divergence would ratify it silently — and it means
GET /meta/typesadvertising a type the write door refuses.
One thing I could not verify, stated rather than assumed
I confirmed from the CI log that the failing task is @objectstack/objectql#test. I could not independently confirm the failure count from CI: the objectql vitest summary sits above the fetchable window (482KB for 2000 lines; a larger tail exceeds the tool's limit). The "4 remaining" figure comes from the dev's full-suite run at this exact head plus an independent triage run at the previous head that found the same three objectql failures.
I am flagging that gap because I made the opposite mistake on this very PR earlier tonight — concluding "no assertion failure anywhere" from a log tail, when the failures sat ~2,900 lines above it. Two independent local measurements agreeing is good evidence; it is not the same as having read CI's own summary, and I would rather say so.
Also newly measured, and it needs its own disposition
Two of the four refuse trigger — a kind ADR-0088 retired outright, which the objectql fixtures still exercise as a runtime-creatable specimen. So this class has five names, not four, and trigger may be a stale fixture rather than part of the accept-set question.
Blocked on: the maintainer's ratification on #8421 — the two ruling narrowings, plus the accept-set contents, plus trigger's disposition. Nothing further from this seat until then.
Generated by Claude Code
…nown-meta-type-refusal
…oor'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
…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
Fixes #8421
PUT /api/v1/meta/fieldz/showcase_task.titleanswered200 {"success":true}and persisted asys_metadatarow undertype='fieldz'— a namespace for a metadata type that does not exist and never will. It now answers400 INVALID_REQUESTand persists nothing.Status: the hold is lifted. The four deliberately-red tests this PR carried are now correct and green, and the accept-set question they were waiting on is ruled. See "The four reds, resolved" below for why each expectation changed.
The rulings this implements
Ruling 1 — the premise (maintainer, 2026-08-15, verbatim, untranslated):
Plugins do not declare new metadata types. The "permissive by construction" posture — anything without a static registry entry is treated as plugin-registered, hence writable — is therefore a rule whose premise has expired.
暂时is load-bearing and is landed as such: a CURRENT posture, not a permanent architectural closure. Both code sites that encode the deferral name the decision and its date in place —getMetaTypes()'s synthesis comment andisRuntimeCreateAllowed's doc inpackages/metadata-protocol/src/protocol.ts— so a future author who needs plugin-declared kinds finds the decision rather than re-deriving it from the code's silence. The changeset carries the same trail for a reader who never opens the source.Ruling 2 — the accept set (four prisms aligned):
GET /meta/typesstops advertisingpolicy/data/package/kindas runtime-creatable. Read and write doors agree again, at the honest value.Ruling 0 — the original refusal (maintainer, 2026-08-14, verbatim: 「同意」), joint with #8586: the simplified option B — refuse by the static contract, no live-set sampling. Option C stays ruled out; the measurement on the card showed its hazard is steady-state rather than a boot race, because the live type set is ITEM-POPULATED and therefore omits every legitimate kind that has no items yet.
Ruling 2, implemented: one predicate, two doors
⛔ Not a blanket flip of the synthesis. Six legitimate plugin kinds have no static registry entry —
theme,webhook,connector,sharing_rule,analytics_cube,rag_pipeline— andPUT /meta/theme/darkis the operation the plugin path exists to serve. Breaking those is the one outcome that would make this change worse than the defect it closes.getMetaTypes()now derivesallowRuntimeCreatefromunrecognisedMetaTypeRefusal— the same predicaterefuseUnmintableMetaTypeenforces — instead of synthesisingtruefor anything lacking a registry entry. One fact, read twice, so the doors agree by construction rather than via two independently-maintained rules:Every live type is still LISTED either way — withdrawing the advertisement is not withdrawing the type, and reads still answer for all four. Only the value of one boolean changes.
isRuntimeCreateAlloweddeliberately did NOT narrow, and its doc now says why. It was the write-side twin of the read-side rule; it is now the residue/clearance arm behind the mint door. By the time it is consulted for an out-of-contract type, the caller is indeleteMetaItem/historyMetaItem/rollbackMetaItem/promoteDraftForPublish, or in a save behind one of the mint door's two exemptions. Returningfalsethere would route pre-existing residue rows off the repository path and strand them — turning the accumulation this card was filed about into an accumulation nobody can clear, which is the exact reasoning that keptdeleteMetaItemopen in the first place. Narrowing it would close nothing.No
packages/specsource change was needed for any of this.unrecognisedMetaTypeRefusalis unchanged andallowRuntimeCreate: z.boolean().default(true)in the spec schema is untouched — only a consumer of the predicate moved. (This PR's pre-existing spec edits are the ones the original refusal shipped with.)The four reds, resolved — one line each
objectqloverlay-precedence.test.ts— runtime-creatable specimenstriggerwas DEBT independent of any ruling: ADR-0088 retired the kind, and this same file assertsallowedFromRegistry.has('trigger')is false ~150 lines below. It was green only through the "no static registry entry" fall-through this card closes, while reading as a pin on the declaredallowRuntimeCreatetier. REPLACED bymapping, which really does declareallowOrgOverride: false, allowRuntimeCreate: true.objectqlprotocol-meta.test.ts— "accepts brand-new trigger and seed"seedhalf is untouched.objectqlprotocol-meta.test.ts— "accepts brand-new plugin-registered type"themehalf stays and stays green — it is the discriminating control. Thepolicyhalf becomes its OWN case,CHANGED BEHAVIOUR — policy is no longer one of them, asserting the ADR-0112 envelope and that nothing is persisted. Split rather than edited so the narrowing is visible to a reviewer, and so thethemecontrol cannot be masked by it.runtimemeta-field-overlay-lock.test.ts— #7894 POSITIVE CONTROLmetaUrlSpellingRefusalclaim is UNCHANGED and still holds by construction:my_plugin_kindis a misspelling of nothing, so #7894's verdict cannot fire on it. What moved is the BOUNDARY, which now consults a second verdict. The case asserts the refusal is the not-a-type-at-all one (names the type, offers no replacement spelling) and that no namespace is minted. Thethemepositive control directly above it is untouched and still 200.⛔ None of the four was edited to pass. Each was measured red at the merged head first, and the two
triggercases would have been corrected even had ruling 2 gone the other way.The discriminating control — new, and the reason the narrowing is reviewable
packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.tsdrives the REALgetMetaTypes()and the REALsaveMetaItem()on one protocol instance over a stub engine whose registry returns aregisterApp-shaped live set, and asserts the two doors agree across a sample spanning all three classes:view,hook(creatable) andagent(NOT creatable, so thefalsedirection is present too);theme, advertised creatable AND driven end-to-end through a write that persists a row;policy,data,package,kind, advertisedfalseand refused with the ADR-0112 envelope, persisting nothing.A fourth case quantifies all six URL-map-only kinds on the advertisement, and a fifth compares "advertised" against "write honoured" as a cross-product so a drift in either direction fails even if both tables were updated together and wrongly. A suite covering only the withdrawn four could not show the doors agree; one without
themecould not show the narrowing is narrow.Reverse verification — direction predicted before running, both legs
allowRuntimeCreate: true5 failed / 1465 passedpackage-wide, exactlypolicy,data,package,kindand the cross-productfalsefor everything without a registry entrytheme's advertisement, the cross-product, the six-kind quantifier); the withdrawn four GREEN3 failed / 15 passed, exactly those threeLeg B is the one that matters for review: it proves the control can actually distinguish "narrow" from "the outcome that would be worse than the defect". Both legs were run from the committed fix and restored with
git checkoutoff the branch; the restore was verified byte-identical (git diff HEADempty), not by re-typing.What #8770 already established — re-verified on the merged base, not inherited
The base was stale by 36 commits (
protocol.tsalone moved on six landings).mainwas merged FIRST — cleanly, no conflicts — and every measurement in this PR was re-taken at the merged head rather than carried over.The rework's two exemptions are unchanged and still hold, and their own pins (
protocol.stored-residue-resave.test.ts,meta-compound-arity-mint-door.test.ts) are green in the runs below: the compound arity (/metadata/lead/views/all_leadsputs an OBJECT name in the:typeslot, so the exemption keys on the request's arity, never the string in the type slot) and already-existing namespaces (measured:migrateStoredMetadatanever reaches the door,duplicatePackagedoes, and one residue row made a whole package unduplicatable — so the exemption keys on the STORE, cannot be spoofed by a request, and an unprovisioned store counts as "no rows").@objectstack/restwas reported "byte-identical to its control atab8b10f82" for the previous round. That comparison was NOT re-run this round —mainhas moved 36 commits andrestgained tests. What is measured here isrestfully green at this head (1948 passed), which is the claim that survives; the byte-identity sentence should be read as historical.Verification
Union re-derived from the REAL changed paths (
node scripts/pm/dispatch-gates.mjs) and run ateac5a2583— this branch's head, on a clean tree, after the final commit. Exit codes read unpiped, all 0:check:nul-bytes,check:engine-double-contract,check:error-code-casing,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:durability-log-level,check:filter-alias-parity,check:merge-driver,check:objectui-changeset,check:spec-parsed-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check-adr-0087-registration,check-changeset-no-major,check-cross-package-test-inputs,check-empty-changeset,check-dev-prereqs,check:doc-formula-expressions, plus the two convention-triggered ratchets:check:type-check-debt --re-measure(33 ledger entries, 1926 raw errors, none above its recorded number — the one surplus is@objectstack/lintat -1 and predates this branch) and speccheck:generated(all 13 artifacts up to date,check:api-surface/check:export-origins/check:authorable-surfaceamong them). Both ratchets ran on a FULLY built workspace, as lint.yml does.One gate the previous derivation did not name:
node scripts/check-engine-split-ratio.mjs, surfaced by re-deriving against the actual changed paths (matched viapackages/objectql). Run, exit 0.metadata-protocolhides its tests from tsc, so itspnpm typechecksays nothing about the TEST_DEBT ledger — the ledger was therefore measured directly, via the--re-measureratchet above. The new test file added no error above any recorded ceiling.Test suites, all re-run at
eac5a2583on a clean tree with the workspace closure built:eac5a2583e0cfc1daa)@objectstack/objectqlTest Files 210 passed,Tests 3672 passed (3672)3 failed / 3668 passed (3671)— the three reds@objectstack/runtimeTest Files 161 passed,Tests 2421 passed (2421)1 failed / 2420 passed (2421)— the fourth red@objectstack/metadata-protocolTest Files 99 passed,Tests 1470 passed (1470)5 failed / 1465 passedon leg A)@objectstack/restTest Files 118 passed,Tests 1948 passed (1948)restsource@objectstack/specTest Files 402 passed,Tests 10644 passed (10644)specsource is unchanged this roundA contract-surface change must run its consumers, so
objectql,runtimeandrestare all here — omitting exactly those three is what cost this card its first lap.objectqlgains 1 test (the splitpolicycase) andmetadata-protocolgains 18 (the agreement suite); everything else is flat.Package
typecheckis green formetadata-protocol,objectql,runtimeandspec.Generated by Claude Code