Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/schema-free-meta-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
---
"@objectstack/spec": minor
---

Schema-free `/meta` spelling entry, and the package becomes tree-shakeable (#10096, #10031).

- New fine-grained export `@objectstack/spec/meta-spelling`: the `/meta/:type`
URL-spelling contract — `META_URL_TO_SINGULAR`, `canonicalMetaUrlType`,
`metaUrlSpellingRefusal`, `unrecognisedMetaTypeRefusal` — importable for a few
hundred bytes instead of the schema graph the same symbols cost through
`/shared` (measured +246.9 KB minified / +69.7 KB gzipped marginal on a graph
already carrying `/ui` + `/kernel`). `/shared` keeps all four symbols
(re-exported from the one declaration); nothing moves or breaks.
- The map is now materialized at build time (`gen:meta-url-spelling`, gated by
`check:meta-url-spelling`). The module-load `assertMetaUrlSpellingsAgree()`
moved into that gate — same assertion, build-time enforcement home.
- `package.json` declares `sideEffects: false` (module-scope evaluation purity
measured per entry), and emitted bundles carry `/* @__PURE__ */` on deferred
schema construction, so consumer bundlers can drop schemas an entry never
reaches instead of retaining a subpath's whole module graph.
- Standing principle recorded in the package docs: a browser-reachable spec
export surface must be schema-free (maintainer ruling 2026-08-20, #10096).
1 change: 1 addition & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,7 @@ packages/spec/authorable-surface.base.json merge=os-regen
packages/spec/authorable-defaults/** merge=os-regen
packages/spec/json-schema.manifest/** merge=os-regen
packages/spec/api-surface/** merge=os-regen
packages/spec/src/meta-spelling/meta-url-data.generated.ts merge=os-regen
packages/spec/export-origins/** merge=os-regen
packages/spec/api-surface-signatures.json merge=os-regen
docs/protocol-upgrade-guide.md merge=os-regen
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -1695,6 +1695,18 @@ jobs:
- name: Check skill docs are generated from SKILL.md frontmatter
run: pnpm --filter @objectstack/spec check:skill-docs

# [#10096] The schema-free `/meta` URL-spelling data module
# (src/meta-spelling/meta-url-data.generated.ts). Re-derives the
# three-limb union from PLURAL_TO_SINGULAR + DEFAULT_METADATA_TYPE_REGISTRY
# and fails on staleness OR on a manifest/derived spelling disagreement —
# this step is the build-time enforcement home of what used to be a
# module-load `assertMetaUrlSpellingsAgree()` (maintainer ruling
# 2026-08-20: the assertion may move, never drop). Reads src via tsx,
# needs no build; belongs in this unfiltered required job for the same
# reason as its artifact-gate neighbours.
- name: Check the meta-url-spelling data module is current and spellings agree
run: pnpm --filter @objectstack/spec check:meta-url-spelling

- name: Check spec-changes.json is regenerated with the ADR-0087 registries
run: pnpm --filter @objectstack/spec check:spec-changes

Expand Down
26 changes: 26 additions & 0 deletions packages/spec/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,32 @@ The **Source of Truth** for the ObjectStack Protocol. Contains strictly typed Zo
- **Automation**: Flows, Workflows, Triggers.
- **AI**: Agents, RAG Pipelines, Models, MCP Servers.

## Export surfaces

The package publishes one entry per protocol domain (`@objectstack/spec/data`,
`/ui`, `/kernel`, …) plus fine-grained vocabulary entries
(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract).
Each entry is a self-contained bundle: what an entry's module graph reaches is
what every consumer of that entry pays for.

**Standing principle** (maintainer ruling 2026-08-20, recorded verbatim on
objectstack#10096):

> **浏览器可达的 spec 导出面必须 schema-free。** A `@objectstack/spec` export
> surface that browser/client consumers reach must carry vocabulary — maps,
> folds, enums, pure predicates — without linking the zod schema/validation
> machinery. The schema graph is the server/publish side's dependency, never
> the price of spelling a URL segment or reading a posture predicate.

Adding an export that browser/client code will import? Either place it on a
schema-free entry (`/meta-spelling` is the reference pattern: derivation from
the schema graph happens at build time via `gen:meta-url-spelling`, gated by
`check:meta-url-spelling`), or verify the entry it lands on keeps a
schema-free module graph. The package declares `sideEffects: false`, so
bundlers may drop what a consumer does not reach — module-scope side effects
in any published module are therefore also a defect (measured, not assumed;
see objectstack#10031).

## Usage

**Recommended: Use `ObjectSchema.create()` with `Field.*` helpers for strict TypeScript validation:**
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/api-surface/meta-spelling.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
{
"description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.",
"entry": "./meta-spelling",
"exports": [
"META_URL_TO_SINGULAR (const)",
"canonicalMetaUrlType (function)",
"metaUrlSpellingRefusal (function)",
"unrecognisedMetaTypeRefusal (function)"
]
}
10 changes: 10 additions & 0 deletions packages/spec/export-origins/meta-spelling.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
{
"description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `<src path>#<declared name> (<kind>)`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.",
"entry": "./meta-spelling",
"exports": {
"META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)",
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
"metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
"unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
}
}
8 changes: 4 additions & 4 deletions packages/spec/export-origins/shared.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,7 @@
"KeySetGuidance": "src/shared/suggestions.zod.ts#KeySetGuidance (interface)",
"MAP_SUPPORTED_FIELDS": "src/shared/metadata-collection.zod.ts#MAP_SUPPORTED_FIELDS (const)",
"METADATA_ALIASES": "src/shared/metadata-collection.zod.ts#METADATA_ALIASES (const)",
"META_URL_TO_SINGULAR": "src/shared/metadata-url-spelling.ts#META_URL_TO_SINGULAR (const)",
"META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)",
"MapSupportedField": "src/shared/metadata-collection.zod.ts#MapSupportedField (type)",
"MetadataCollectionInput": "src/shared/metadata-collection.zod.ts#MetadataCollectionInput (type)",
"MetadataFormat": "src/shared/metadata-types.zod.ts#MetadataFormat (type)",
Expand DownExpand Up@@ -97,7 +97,7 @@
"ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)",
"ViewNameSchema": "src/shared/branded-types.zod.ts#ViewNameSchema (const)",
"applyProtection": "src/shared/protection.zod.ts#applyProtection (function)",
"canonicalMetaUrlType": "src/shared/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
"cel": "src/shared/expression.zod.ts#cel (function)",
"cron": "src/shared/expression.zod.ts#cron (function)",
"expression": "src/shared/expression.zod.ts#expression (function)",
Expand All@@ -108,7 +108,7 @@
"keySetMatches": "src/shared/suggestions.zod.ts#keySetMatches (function)",
"lazySchema": "src/shared/lazy-schema.ts#lazySchema (function)",
"levenshteinDistance": "src/shared/suggestions.zod.ts#levenshteinDistance (function)",
"metaUrlSpellingRefusal": "src/shared/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
"metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
"normalizeMetadataCollection": "src/shared/metadata-collection.zod.ts#normalizeMetadataCollection (function)",
"normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)",
"normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)",
Expand All@@ -122,6 +122,6 @@
"strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)",
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
"tmpl": "src/shared/expression.zod.ts#tmpl (function)",
"unrecognisedMetaTypeRefusal": "src/shared/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
"unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
}
}
13 changes: 13 additions & 0 deletions packages/spec/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"import": {
Expand DownExpand Up@@ -166,6 +167,16 @@
"default": "./dist/shared/index.js"
}
},
"./meta-spelling": {
"import": {
"types": "./dist/meta-spelling/index.d.mts",
"default": "./dist/meta-spelling/index.mjs"
},
"require": {
"types": "./dist/meta-spelling/index.d.ts",
"default": "./dist/meta-spelling/index.js"
}
},
"./openapi.json": "./json-schema/openapi.json",
"./package.json": "./package.json"
},
Expand All@@ -188,6 +199,8 @@
"gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts",
"gen:authorable-surface-base": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --update-base",
"gen:openapi": "tsx scripts/build-openapi.ts",
"gen:meta-url-spelling": "tsx scripts/build-meta-url-spelling.ts",
"check:meta-url-spelling": "tsx scripts/build-meta-url-spelling.ts --check",
"gen:docs": "tsx scripts/build-docs.ts",
"check:docs": "tsx scripts/build-docs.ts --check",
"check:generated": "tsx scripts/check-generated.ts",
Expand Down
160 changes: 160 additions & 0 deletions packages/spec/scripts/build-meta-url-spelling.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env tsx
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Generate (and gate) `src/meta-spelling/meta-url-data.generated.ts` — the
* schema-free materialization of the `/meta` URL-spelling contract (#10096).
*
* ## Why the map is generated instead of derived at module load
*
* The map's two sources are heavy on purpose-unrelated weight:
* `DEFAULT_METADATA_TYPE_REGISTRY` lives in the kernel zod graph, and
* `PLURAL_TO_SINGULAR` sits in a module that reaches the ADR-0087 conversion
* layer. Deriving at module load therefore made ONE string fold cost a
* browser consumer +60.1 KB gzipped (#10096's measurement). The 2026-08-20
* maintainer ruling minted the standing principle 「浏览器可达的 spec 导出面必
* 须 schema-free」 and approved moving the derivation to build time: this
* script derives the union HERE, writes it as a pure data module, and the
* `/meta-spelling` entry ships vocabulary with no schema closure.
*
* ## This check is the enforcement home of `assertMetaUrlSpellingsAgree()`
*
* The module-load assertion that pinned the derived limb against the manifest
* limb moved here, per the same ruling (⛔ dropping it to save bytes was
* explicitly forbidden — the agreement must keep an enforcement home). Both
* modes of this script run it, so a disagreement fails generation AND fails
* CI's `check:meta-url-spelling` — loudly, before any artifact is written.
*
* The pluralization/camelCase rules are IMPORTED from the runtime module
* (`restPluralOfMetaType` / `camelCaseOf`) rather than copied, so the checked
* refusal `hint`s and the checked-in map can never drift from the one rule.
*
* Usage:
* pnpm --filter @objectstack/spec gen:meta-url-spelling # rewrite the artifact
* pnpm --filter @objectstack/spec check:meta-url-spelling # verify it is current
* Exit: 0 = OK; 1 = spelling disagreement, or (--check) stale artifact.
*/

import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { DEFAULT_METADATA_TYPE_REGISTRY } from '../src/kernel/metadata-plugin.zod';
import { PLURAL_TO_SINGULAR } from '../src/shared/metadata-collection.zod';
import { restPluralOfMetaType, camelCaseOf } from '../src/meta-spelling/metadata-url-spelling';

const OUT = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'src',
'meta-spelling',
'meta-url-data.generated.ts',
);

/**
* The three-limb union, exactly as `shared/metadata-url-spelling.ts` built it
* at module load before #10096 (limb 1 manifest spellings verbatim; limb 2
* registry-derived REST plurals; limb 3 camelCase spellings for snake_case
* registry types). Order is load-bearing only for output stability.
*/
function buildMetaUrlMap(): Record<string, string> {
const out: Record<string, string> = {};
for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) out[plural] = singular;
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
out[restPluralOfMetaType(entry.type)] = entry.type;
const camel = camelCaseOf(entry.type);
if (camel !== entry.type) out[restPluralOfMetaType(camel)] = entry.type;
}
return out;
}

/**
* Fail the build rather than serve two answers for one spelling. A
* disagreement here means the derived limb and the manifest limb have
* drifted, which is the same class of silent divergence #7894 is about.
* (Formerly asserted at module load in `shared/metadata-url-spelling.ts`;
* moved to this build-time home by the 2026-08-20 ruling on #10096.)
*/
function assertMetaUrlSpellingsAgree(map: Record<string, string>): void {
for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) {
const derived = map[plural];
if (derived !== singular) {
console.error(
`✗ [metadata-url-spelling] '${plural}' resolves to '${derived}' in the URL map but ` +
`'${singular}' in PLURAL_TO_SINGULAR. One spelling may not name two types.`,
);
process.exit(1);
}
}
}

function render(map: Record<string, string>, declared: string[]): string {
const mapLines = Object.entries(map)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`)
.join('\n');
const declaredLines = declared.map((t) => ` ${JSON.stringify(t)},`).join('\n');
return `// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* AUTO-GENERATED by \`pnpm --filter @objectstack/spec gen:meta-url-spelling\`
* (scripts/build-meta-url-spelling.ts) — ❌ never edit by hand.
*
* Schema-free materialization of the \`/meta\` URL-spelling contract (#10096):
* the three-limb union of \`PLURAL_TO_SINGULAR\` and the REST plurals derived
* from \`DEFAULT_METADATA_TYPE_REGISTRY\`, frozen at build time so the
* \`@objectstack/spec/meta-spelling\` entry links no zod machinery.
* \`check:meta-url-spelling\` re-derives this union from the live sources on
* every CI lap — including the manifest/derived agreement assertion that used
* to run at module load — so this file cannot silently lag or drift.
*
* @module
*/

/** Plural (and camelCase) URL spelling → canonical singular metadata type. */
export const META_URL_TO_SINGULAR: Readonly<Record<string, string>> = Object.freeze({
${mapLines}
});

/**
* Every metadata type with a STATIC \`DEFAULT_METADATA_TYPE_REGISTRY\` entry,
* in registry order. Read only by the refusal predicates one module over.
*/
export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray<string> = Object.freeze([
${declaredLines}
]);
`;
}

const map = buildMetaUrlMap();
assertMetaUrlSpellingsAgree(map);
const declared = DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type);
const next = render(map, declared);

const check = process.argv.includes('--check');
const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '';

if (check) {
if (current === next) {
console.log(
`✓ meta-url-spelling data is current (${Object.keys(map).length} spellings, ` +
`${declared.length} registry-declared types; manifest/derived agreement holds).`,
);
process.exit(0);
}
console.error(
`✗ src/meta-spelling/meta-url-data.generated.ts is stale relative to its sources\n` +
` (PLURAL_TO_SINGULAR and/or DEFAULT_METADATA_TYPE_REGISTRY changed).\n` +
` Regenerate with: pnpm --filter @objectstack/spec gen:meta-url-spelling`,
);
process.exit(1);
}

if (current === next) {
console.log('✓ meta-url-spelling data already current — nothing written.');
} else {
writeFileSync(OUT, next);
console.log(
`✓ wrote src/meta-spelling/meta-url-data.generated.ts ` +
`(${Object.keys(map).length} spellings, ${declared.length} registry-declared types).`,
);
}
10 changes: 10 additions & 0 deletions packages/spec/scripts/check-generated.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,16 @@ const GATED: ReadonlyArray<{
},
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
{ check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' },
// [#10096] The schema-free `/meta` URL-spelling data module. Cheap: tsx-loads
// the two source maps (lazySchema keeps the kernel module light), re-derives
// the three-limb union, and runs the manifest/derived agreement assertion
// that used to live at `shared/metadata-url-spelling.ts` module load — this
// gate is that assertion's build-time enforcement home (ruling 2026-08-20).
{
check: 'check:meta-url-spelling',
gen: 'gen:meta-url-spelling',
artifact: 'src/meta-spelling/meta-url-data.generated.ts',
},
{ check: 'check:skill-docs', gen: 'gen:skill-docs', artifact: 'skill docs (from SKILL.md frontmatter)' },
{ check: 'check:skill-refs', gen: 'gen:skill-refs', artifact: 'skill references' },
{ check: 'check:react-blocks', gen: 'gen:react-blocks', artifact: 'react-blocks contract' },
Expand Down
1 change: 1 addition & 0 deletions packages/spec/scripts/export-origins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,6 +56,7 @@ const ENTRY_NAMESPACES: ReadonlyArray<[string, () => Promise<object>]> = [
['./identity', () => import('../src/identity/index')],
['./integration', () => import('../src/integration/index')],
['./kernel', () => import('../src/kernel/index')],
['./meta-spelling', () => import('../src/meta-spelling/index')],
['./qa', () => import('../src/qa/index')],
['./security', () => import('../src/security/index')],
['./shared', () => import('../src/shared/index')],
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/scripts/lib/category-title.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,6 +78,10 @@ export const CATEGORY_TITLES: Readonly<Record<string, string>> = {
identity: 'Identity Protocol',
integration: 'Integration Protocol',
kernel: 'Kernel Protocol',
// [#10096] The schema-free `/meta` URL-spelling entry. "Vocabulary", not
// "Protocol": the entry carries the spelling contract's data and folds
// without the schema machinery every Protocol category links.
'meta-spelling': 'Meta-Spelling Vocabulary',
migrations: 'Migrations Protocol',
qa: 'QA Protocol',
security: 'Security Protocol',
Expand Down
Loading
Loading