Skip to content

fix(metadata-protocol,spec): the plural /meta URL stops walking around the two-tier registry gate (#7894) - #8420

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-7894-meta-plural-url-bypass
Aug 13, 2026
Merged

fix(metadata-protocol,spec): the plural /meta URL stops walking around the two-tier registry gate (#7894)#8420
os-zhuang merged 3 commits into
mainfrom
claude/issue-7894-meta-plural-url-bypass

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes#7894

canonicalMetaType — the ONE canonical spelling of a metadata type at the /meta boundary (#4432) — folded plural to singular through PLURAL_TO_SINGULAR. That map is a manifest-collection map: its keys are the properties an author writes in defineStack(), and kernel/metadata-authoring-lint.ts iterates it to decide which stack-level collections exist.

Four registry types are legitimately absent from it, because none is a stack collection: field (fields live inside ObjectSchema.fields), seed, external_catalog, translation. At the URL boundary that absence did not read as "not a collection" — it read as "unknown type", and an unknown type takes the plugin-registered path, which every authorization gate is permissive toward by construction. So the plural URL was a door around the singular URL's lock, and the row persisted under type='fields', minting a second namespace.

Shape implemented — options 2 AND 3, per the rulings on the card

Split the two roles. New META_URL_TO_SINGULAR in @objectstack/spec/shared is the URL-spelling contract, built from three unioned limbs:

  1. every existing manifest spelling, verbatim;
  2. the REST plural of every DEFAULT_METADATA_TYPE_REGISTRY entry (Prime Directive Convert to monorepo with scoped packages #8 — derived, so a newly declared type can never again arrive unmapped);
  3. camelCase plurals for snake_case registry types.

Limb 1 is why this is non-breaking. A purely registry-derived map would have dropped six spellings whose singular has no registry entry at all — themes, webhooks, connectors, sharingRules, ragPipelines, analyticsCubes — which are plugin kinds carried only by the manifest map. PLURAL_TO_SINGULAR itself is untouched, so the authoring lint gains no fields: collection (verified: its collection set is connectors, views).

Refuse at the boundary. An unrecognised plural of a declared type (/meta/capabilitys) is now refused INVALID_REQUEST / 400, naming both the offending spelling and the canonical one, instead of answering 200. The rule is deliberately static — it fires only when a spelling's singular is a type the platform itself declares — so a plugin-registered kind can never trip it, whatever it is named and whenever it registers. The positive control therefore holds by construction, not merely by coverage.

Both changes land in canonicalMetaType / canonicalizeMetaRequestType, so all six /meta entry points inherit them with no edits at those sites.

Reviewers who read the card expecting a distinct fields refusal: measurements 1 and 2 deliberately converge. Folding is what closes this, so /meta/fields/… is now the same request as /meta/field/… and earns the same 403 NOT_OVERRIDABLE. Plural REST paths are the documented legitimate spelling (/meta/actions folds and must keep folding), so refusing this one specifically would give field a URL contract unlike every other type's — a special case in the spelling layer, which is the pattern #4432 exists to prevent.

⚠️ Note for the spec seat — this PR adds a public packages/spec export

META_URL_TO_SINGULAR (plus canonicalMetaUrlType, unmappedDeclaredTypeSpelling, restPluralOfMetaType, DECLARED_META_TYPES) is a new public surface on @objectstack/spec/shared, in a new module packages/spec/src/shared/metadata-url-spelling.ts. export-origins/shared.json and api-surface/shared.json are regenerated accordingly.

Flagging it explicitly because this is a cross-lane touch into domain:spec territory arriving from the metadata-protocol lane — the spec seat should see it here rather than discover it in a regenerated artifact. The placement was deliberate and reviewed: a URL-spelling contract belongs beside the manifest-collection map it was split out of, and the whole point of the card is that these are two different contracts that must stop sharing one map. Keeping the map in metadata-protocol instead would have put a spelling contract downstream of the registry it is derived from, and packages/rest needs it too (RestServer.metaTypeSingular reads the same fold).

Measurements — direction predicted before each run

#measurementpredictedactual
1PUT /meta/fields/showcase_task.titlerefused403 NOT_OVERRIDABLE (converges on 2, see above)
2PUT /meta/field/showcase_task.titleunchanged 403403 NOT_OVERRIDABLE, unchanged
3row with type='fields'nonenone; the field namespace stays clean too
4plugin-registered type still permittedpermitted200 for theme, themes, and a never-registered my_plugin_kind
5anti-vacuitynew refusals fail pre-fix3 RED / 11 GREEN, exactly as predicted

Measurement 5 is unusually strong here and cost nothing: this file already carried a case that asserted the defect (expect(res.status).toBe(200) plus a row under type='fields') with instructions to flip it. Inverting that case proves the harness demonstrably reached this boundary before the fix — a fresh test asserting 403 could pass by never arriving.

Reverse verification (revert + rebuild, since packages/runtime resolves @objectstack/metadata-protocol through its dist):

× the PLURAL url spelling folds onto the same lock expected 200 to be 403
× the other three unmapped types answer as singular seeds: expected 200 to be 422
× an unrecognised plural is refused, not forwarded expected 200 to be 400
both positive controls GREEN (unchanged)
all nine pre-existing #7743 cases GREEN (unchanged)

The seeds line is the fold made visible: it now answers 422 because it is judged by the real SeedSchema. Before, an unmapped spelling had no schema to be judged by, so it sailed past validation as well as past authorization.

Correction for the record: six dropped spellings, not seven

An earlier interim report of mine listed seven spellings that a purely registry-derived map would drop, including skills. That was wrong, and the cause was a regex-based derivation of DEFAULT_METADATA_TYPE_REGISTRY rather than the real value. skilldoes have a registry entry, so skills would have survived a pure derivation and does not belong on that list. The same regex also undercounted the registry at 26 entries when it has 27 — it had missed datasource.

Everything in this PR is derived from the built package, re-checked after the miscount was found. Neither correction moves any verdict: the unmapped set is still exactly field, seed, external_catalog, translation with the same flags, so the card's blast-radius table stands unchanged. The miscount could not have leaked into the fix either, because the non-breaking invariant is enforced by quantifying over all 29 manifest keys rather than over any hand-written enumeration.

Verification

Built first (stale dist lies in both directions), then, post-merge with origin/main:

  • @objectstack/spec 391 files / 10345 tests; @objectstack/metadata-protocol 80 / 1170; @objectstack/runtime 150 / 2310
  • @objectstack/objectql 196 / 3496; @objectstack/rest 110 / 1817 — the plural-sensitive consumers
  • typecheck clean on all three changed packages; test-typecheck-debt.jsonnot grown (one inferred-literal Set widened at its construction site instead)
  • gates: check:nul-bytes, check:cross-package-test-inputs, check:changeset-gate-self-tests, check:spec-parsed-alias, check:type-source-resolution, check:adr-anchors, check:merge-driver, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:error-code-casing, check:export-origins, check-changeset-no-major — all pass. check:i18n first reported "Nothing was checked" for want of a @objectstack/cli build; after building it, EXIT=0.
  • export-origins/ and api-surface/ regenerated for the new exports.

No new error code: INVALID_REQUEST already exists in the ledger under @objectstack/metadata-protocol.

Known residue, deliberately not closed — tracked as #8421

A spelling that is not a plural of anything — /meta/fieldz — is indistinguishable from a plugin kind by static means, so it still takes the plugin path and mints a namespace under a type that does not exist. It is the same second-namespace class as #4432 but narrower: it shadows no locked type, so unlike the bypass this PR closes there is no authorization escalation.

Closing it needs the live registered-type set at the boundary, which has a different risk profile — a live check that runs before a plugin registers its kind would refuse a legitimate type, turning a hygiene fix into a plugin-registration outage. That is why the rule shipped here is static.

Filed separately as #8421 so the gap is findable outside this PR, and pinned in packages/spec/src/shared/metadata-url-spelling.test.ts ("documents its residue rather than pretending to be total") so it cannot go quiet. If #8421 is ever fixed, that case inverts — flip it, do not delete it.

Generated by Claude Code

…d the two-tier registry gate (#7894)
canonicalMetaType folded plural to singular through PLURAL_TO_SINGULAR, which is
a MANIFEST-COLLECTION map. Four registry types are legitimately absent from it --
field, seed, external_catalog, translation -- because none is a stack collection.
At the URL boundary that absence read as "unknown type", and an unknown type takes
the plugin-registered path, which every authorization gate is permissive toward by
construction. PUT /meta/fields/showcase_task.title answered 200 and persisted a row
under type='fields' while PUT /meta/field/... answered 403 NOT_OVERRIDABLE.
Split the two roles: META_URL_TO_SINGULAR is the URL-spelling contract, derived
from DEFAULT_METADATA_TYPE_REGISTRY and unioned with every manifest spelling, so a
newly declared type cannot arrive unmapped and nothing that resolved before
resolves differently. PLURAL_TO_SINGULAR is untouched, so the authoring lint gains
no fields: collection.
The boundary also refuses an unrecognised plural of a DECLARED type with
INVALID_REQUEST/400 instead of forwarding it as a plugin type. The rule is static
-- it fires only when a spelling's singular is a declared type -- so a
plugin-registered kind can never trip it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…ng test
DEFAULT_METADATA_TYPE_REGISTRY.map(e => e.type) infers a literal union, so
Set.has(someString) did not typecheck under tsconfig.test.json. Widened at the
construction site rather than adding the file to test-typecheck-debt.json.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 12:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol, @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — PM review, domain:metadata seat

Green at 62467877: all 27 checks, each job's own conclusion verified individually. Path-fork check clean — 8 files, none under docs/adr/**, .claude/skills/** or skills/**.

Both rulings implemented as ruled, and I checked the predicate rather than the tests

unmappedDeclaredTypeSpelling returns non-null only when a candidate singular is in DECLARED_META_TYPES, which is derived from DEFAULT_METADATA_TYPE_REGISTRY. So the two invariants I set hold by construction, not by coverage:

  1. No spelling that works today may stop working — the first branch returns null for anything already in META_URL_TO_SINGULAR, and limb 1 unions in every manifest spelling verbatim. The test quantifies over all of PLURAL_TO_SINGULAR rather than listing cases, so a newly added manifest key is covered automatically.
  2. No unmapped spelling of a declared type may answer 200 — the candidate loop catches plural-of-declared, and the registry-derived limb means a newly declared type arrives already mapped.

assertMetaUrlSpellingsAgree() running at module load is the right place for the third property: the two limbs cannot silently disagree, because disagreement fails the load rather than serving two answers for one spelling.

Region discipline held. The protocol.ts diff is the import plus canonicalMetaType / canonicalizeMetaRequestType. The absolute line numbers moved from the pinned 124–159 because main advanced underneath, which is precisely why this repo anchors regions on function names and not line numbers — the semantic region is unchanged, and it stayed clear of #8333's and #7860's hunks.

Three things worth naming

The anti-vacuity arm is the strongest I have seen this shift, and it cost nothing. The file already carried a case asserting the defectexpect(res.status).toBe(200) plus a row under type='fields' — with written instructions to flip it. Inverting that case proves the harness demonstrably reached this boundary before the fix. As the new comment puts it: a fresh test asserting 403 could pass by never arriving; this one cannot. Writing a known gap as a self-inverting pin is a pattern this lane should keep using.

The address / status case. A naive "looks plural" heuristic would refuse address, status, kudos, analysis, series — ordinary English words a plugin might well name a kind. singularCandidates('address') yields addre/addres, neither declared, so they are permitted, and all five are pinned. That is the trap that would have turned an authorization fix into a plugin-registration outage.

The changeset does the thing #8411 is open about. It carries an explicit "Behaviour change to be aware of when upgrading" section naming what now gets refused, that the judgement is now the singular's authorization gates and its Zod schema, and that rows already written under a plural type are real and are not rewritten. That is the minor-plus-body-annotation convention #6099 established, done properly — and it is exactly what #8296's changeset lacks. I am not re-litigating patch versus minor per PR while #8411 is open on the convention itself; the annotation is the half that matters and it is here.

Also measured and worth recording: the seeds result. It now answers 422 because it is judged by the real SeedSchema — before, an unmapped spelling had no schema to be judged by, so it sailed past validation as well as authorization. The bypass was wider than the card claimed.

Residues, both filed:#8421 (a spelling that is not a plural of anything still takes the plugin path — same second-namespace class as #4432, but no authorization bypass since it shadows no locked type) and #8424 (whether all five new @objectstack/spec/shared exports belong on the published surface — routed to domain:spec, ⛔ not mine to rule, and explicitly not blocking this).

Marking ready and enqueueing. Fixes #7894 closes the card on merge.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 12:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit d56012fAug 13, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7894-meta-plural-url-bypass branch August 13, 2026 13:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

meta-plural-url-bypass: PUT /meta/fields/<name> walks around the whole two-tier registry gate — 4 registry types have no entry in PLURAL_TO_SINGULAR

2 participants

@os-zhuang@claude