Uh oh!
There was an error while loading. Please reload this page.
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:
|
hotlong
commented
Aug 14, 2026
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 | objectqloverlay-precedence.test.ts — accepts brand-new trigger | trigger | specimen in a runtime-creatable loop |
| 2 | objectqlprotocol-meta.test.ts — accepts brand-new trigger and seed | trigger | as above |
| 3 | objectqlprotocol-meta.test.ts — accepts brand-new plugin-registered type | policy | see below |
| 4 | runtimemeta-field-overlay-lock.test.ts — #7894 POSITIVE CONTROL | my_plugin_kind | deliberate positive control |
| 5 | restmeta-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 notpolicy, 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)
hotlong
commented
Aug 15, 2026
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
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.The ruling this implements
Maintainer ruling of 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.
What changed is the platform, not the boundary's information: with
additionalTypesretired (#8586 / PR #8702), there is no channel left by which a plugin can DECLARE a metadata kind, so an unrecognised name can no longer be a declaration the refusal has not heard about.Both premises the
domain:specseat flagged were re-verified on the merged tree rather than inherited:packages/metadata/src/plugin.ts:272, the only non-test caller ofsetTypeRegistry;registerKindwas confirmed to store kind DESCRIPTORS as items of thekindtype rather than as new types.Two deviations from a literal reading, both measured
1. The accept set is the spelling contract's closure, not the registry. Six legal kinds have no static registry entry at all —
theme,webhook,connector,sharing_rule,analytics_cube,rag_pipeline. A registry-quantified refusal would breakPUT /meta/theme/dark, which is the operation the plugin path exists to serve. So the verdict accepts the URL map's keys AND the canonical singulars they fold to, which is derived rather than hand-written.2. The refusal is on the door that MINTS, not on all six entry points. Measured against a real
registerApp(realObjectQL, not a mock):data(a manifest's seed datasets),kind(contributes.kinds) andpackageall enterSchemaRegistryduring an ordinary app registration, andgetMetaTypes()enumerates exactly that live set — so refusing unrecognised names on the READ entries would answer 400 for types the same service advertises atGET /api/v1/meta/types.deleteMetaItemstays open for the mirror-image reason: rows minted under an unrecognised type before this change are real, nothing rewrites them on upgrade, and refusing their deletion would turn the accumulation this card is about into an accumulation nobody can clear./metatype spelling", unqualified as to verb. This PR refuses a strict SUBSET of that — the mint verb only. If reads were meant to refuse too, that is a follow-up, and the measurement above is the argument against it.REWORK (2026-08-15) — two measured regressions of the first cut, fixed
CI triage at
f59a135f0found 5 failures across 3 packages, all raising the same verdict at the same frame, in three consumer packages the first cut never ran. Two of the five were regressions this rework repairs; the rest are the accept-set question, which is with the maintainer and deliberately left red (see the last section). No fixture encoding that question was edited and no gate was touched.Fix A — the COMPOUND arity is not a metadata-type claim
/metadata/lead/views/all_leadsistype='lead',name='views/all_leads': one operation reaching onesaveMetaItem, the shape the runtime dispatcher's own/metabranch andrest'sPUBLISHED_COMPOUNDroute both document verbatim. The segment in the:typeposition is an OBJECT name — runtime data no static contract can enumerate — so applying a static type verdict there refused every object name that is not coincidentally a metadata type. The ruling is about metadata TYPE names likefieldz; this was not a narrowing anyone approved.The exemption is the request's ARITY (
namecarries a/), never the string in the type slot: at the simple arityPUT /meta/lead/all_leadsis still refused, and that pair is pinned on both sides.PUT /meta/fieldz/a/bis therefore still accepted, because at that arityfieldzis a claim about an OBJECT, and the only way to check it is the live-registry lookup this very card ruled out.The second, masked site is now unmasked.
runtime'sdomains/meta-save-capability-gate.test.ts:118drives this exact path and stayed GREEN through the regression — its caller holds no capabilities, soPERMISSION_DENIEDanswers before the protocol is resolved, and itssaveMetaItemis avi.fn()that could not have refused anything. A 403 arriving first is not evidence about the door behind it.packages/runtime/src/meta-compound-arity-mint-door.test.tsis new:manage_metadataheld, the REALObjectStackProtocolImplementationover a real store, and the assertion reads the stored ROW rather than the response body. Its last case keeps the capability gate itself pinned — the fix moved the door behind it, not the gate.Fix B — a namespace that ALREADY EXISTS is not being minted (measured first)
The triage read two production re-save paths off their call sites. Measured before changing anything, and they do NOT behave the same:
f59a135f0migrateStoredMetadata(source: 'migrate-stored')applyConversionsToStoredItemkeys the ADR-0087 chain on the type's manifest collection, and an unrecognised type has none — body untouched, no notice,outcome: 'canonical',saveMetaItemnot called. Structural, not lucky: nothing can declare a conversion for a type the platform does not have. Re-measured with a convertible-looking body under the residue type: stillcanonical.duplicatePackage(copy/clone){success: false, copiedCount: 0, failedCount: 1}with the refusal text infailed[0].error— one pre-existing row made a whole package unduplicatable.So the exemption is real and needed, and it is keyed on the STORE rather than on anything the caller says: once the static verdict has fired,
sys_metadatais probed for the type, and a type that already has rows is not being minted. The copy re-saves under a NEW name, so an exemption keyed on the exact item would not have reached it. The probe never runs on the ordinary save path, and it cannot be spoofed by a request.An UNPROVISIONED store counts as "no rows" and the refusal stands — a fresh deployment has no residue to protect. Any other read failure propagates as
503 SERVICE_UNAVAILABLErather than being invented into an existence claim, which is whatcheck:durability-log-level(AGENTS.md "Absence must be loud", #5186) requires and what it caught on the first draft of this probe.Reverse verification of the rework — direction predicted before running
Both legs ablate the fix in
srcand rebuild@objectstack/metadata-protocol, becauseruntimeresolves it throughdistand stack traces source-map back tosrc— a source-only revert measures the pre-mutation artifact.scripts/ablation-dist-preflight.mjsproved the mutation reached the artifact each time (--absenton ablate, present on restore).The tripwires
metaUrlSpellingRefusal('fieldz')is null — that is what keeps meta-plural-url-bypass:PUT /meta/fields/<name>walks around the whole two-tier registry gate — 4 registry types have no entry inPLURAL_TO_SINGULAR#7894's positive control true by construction — and the second half now asserts the new verdict refuses it. The "Known residue, deliberately not closed here" doc comment is rewritten to say where the residue went.PUT /meta/fields/<name>walks around the whole two-tier registry gate — 4 registry types have no entry inPLURAL_TO_SINGULAR#7894's positive control keeps every assertion it was written with. It is a statement aboutmetaUrlSpellingRefusal, and that verdict still cannot refuse a plugin kind, whatever it is named.my_plugin_kind,address,status,kudos,analysis,seriesare no longer mintable, because they are in neither half of the static contract. That narrowing is pinned in its own case, namedCHANGED BEHAVIOUR, instead of being left for a reader to infer.Verification
Union re-derived from the real changed paths (
node scripts/pm/dispatch-gates.mjs, three-dotorigin/main...HEAD) and run at043d0697f, this branch's head, 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 the derivation named as convention-triggered ratchets:check:type-check-debt --re-measure(33 ledger entries, 1968 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 were run on a FULLY built workspace, as lint.yml does.Test suites, all at
043d0697fwith the dependency closures built:origin/mainab8b10f82@objectstack/restTests 1921 passed (1921), exit 01921 passed@objectstack/runtimeTests 1 failed | 2395 passed (2396)2391 passed@objectstack/objectqlTests 3 failed | 3626 passed (3629)3629 passed@objectstack/metadata-protocol21 passed (21)@objectstack/specmetadata-url-spelling.test.ts 20 passed (20)restis now byte-identical to its control: the compound-arity failure is gone.runtimegained 5 tests (the new unmasked-site file), all passing.⛔ The remaining red is DELIBERATE — 4 tests, all the accept-set question
The triage's 5 failures are now 4, and every one of them refuses a name at
refuseUnmintableMetaTypefor the reason the maintainer is being asked to rule on. Making them green would decide that question and erase the evidence, so nothing here touches them:objectqloverlay-precedence.test.ts— accepts brand-new triggertriggerobjectqlprotocol-meta.test.ts— accepts brand-new trigger and seedtriggerseedis accepted;triggeris what fails)objectqlprotocol-meta.test.ts— accepts brand-new plugin-registered typepolicygetMetaTypes()advertisespolicyasallowRuntimeCreate: truewhile the mint door refuses itruntimemeta-field-overlay-lock.test.ts— #7894 POSITIVE CONTROLmy_plugin_kindAll four are simple-arity saves of a type with no stored rows, so neither exemption added by this rework can reach them — which is exactly why they are unchanged from the triage measurement rather than accidentally repaired.
The open question is the maintainer's: should the accept set admit the plugin-registered types (
policy,data,package,kind) soGET /meta/typesand the write door agree, or should those types stop being advertised as writable? Both keep the invariant the failingobjectqlfixture guards.Generated by Claude Code