From 845542a121419a0157dced353a12cd7db85a2ef6 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 18:07:16 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20`api`=20=E8=A1=A5=E8=BF=9B=20?= =?UTF-8?q?DEFAULT=5FMETADATA=5FTYPE=5FREGISTRY=20=E4=B8=8E=20BUILTIN=5FME?= =?UTF-8?q?TADATA=5FTYPE=5FSCHEMAS=20(#5271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #5206 (step 1, spec 车道)。 `api` 条目一直被产出(artifact ingest 把 `defineStack({ apis })` 映射为 `api`)、 被索引(`buildEndpointIndex`)、被执行(#5040 E5/E8),而 spec 里哪儿都没声明这个 kind。于是 `getMetadataTypeSchema('api')` 返回 undefined,`saveMetaItem` 走它自己 文档写明的「未注册 schema 的类型不经校验直接落库」分支 —— `PUT /meta/api/:name` 接受任意 JSON。这是 `declared ≠ enforced` 反着读:enforced but undeclared。 - `MetadataTypeSchema` + `DEFAULT_METADATA_TYPE_REGISTRY` 补 `api` 条目; - `BUILTIN_METADATA_TYPE_SCHEMAS` 补 `api: ApiEndpointSchema`; - `ApiEndpointSchema` 补 ADR-0010 保护信封(每个注册类型的不变量); - `api` 的最小 create seed(教 carve-out 形状与 object_operation 的两个半边); - showcase `KIND_COVERAGE` 接手 `apis` 的覆盖(它不再是「非注册表 kind」)。 旗标按证据定,不是新授权:无静态条目时 `isRuntimeCreateAllowed` 与 `assertAllowed` 都走「无注册表条目 ⇒ 可运行时创建」的兜底(两处注释都点名 `api`),所以运行时直写 本来就被接受、只是不校验。`allowRuntimeCreate: true` 把这个既有判决写下来, `allowOrgOverride: false` 同样是今天的实际取值。code-only 方案被证据否掉:它会把 今天的 200 变成 403,且 #5086 在落库前对 draft 一视同仁地拒绝,#5206 第 2 步 (PR #5279)将无 draft 可门。 `ApiEndpointSchema` 的收紧被实测否掉:同一个 schema 也解析存量行,而存量行带 `packageId` / `state`,`strictObject` 让 packages/metadata 10 条测试转红。`api` 因此 与 `view` 同列 STILL_STRIP,实测写进该列表注释,真正的修法(信封/正文分离)另立 #5309。 Fixture 逐条裁定而非批量改写:protocol-meta 与 sys-metadata-repository 里的 `api` 标本被**替换**(留着会让断言经另一条分支变绿、却仍宣称在证明「无静态条目」那条); endpoint-matcher 那条「strips storage annotations」整条重写(它钉的正是信封被丢弃 这个缺陷本身)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- .changeset/api-metadata-type-registry.md | 80 +++++++ examples/app-showcase/src/coverage.ts | 21 +- .../metadata/src/endpoint-matcher.test.ts | 47 +++- .../src/metadata-validation-sweep.test.ts | 44 +++- packages/objectql/src/protocol-meta.test.ts | 76 ++++++- .../src/sys-metadata-repository.test.ts | 32 ++- packages/spec/authorable-surface.json | 7 + packages/spec/src/api/endpoint.zod.ts | 50 ++++- .../spec/src/kernel/metadata-create-seeds.ts | 29 +++ .../spec/src/kernel/metadata-plugin.zod.ts | 73 +++++++ .../metadata-type-api-registration.test.ts | 200 ++++++++++++++++++ .../src/kernel/metadata-type-schemas.test.ts | 38 +++- .../spec/src/kernel/metadata-type-schemas.ts | 18 ++ 13 files changed, 678 insertions(+), 37 deletions(-) create mode 100644 .changeset/api-metadata-type-registry.md create mode 100644 packages/spec/src/kernel/metadata-type-api-registration.test.ts diff --git a/.changeset/api-metadata-type-registry.md b/.changeset/api-metadata-type-registry.md new file mode 100644 index 0000000000..e75621e581 --- /dev/null +++ b/.changeset/api-metadata-type-registry.md @@ -0,0 +1,80 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `api` is a declared metadata kind — `DEFAULT_METADATA_TYPE_REGISTRY` + `BUILTIN_METADATA_TYPE_SCHEMAS` (#5271, part of #5206) + +`api` items were produced, indexed and executed while the spec declared the kind +nowhere. Artifact ingest maps `defineStack({ apis })` to `api` metadata +(`ARTIFACT_FIELD_TO_TYPE`), the endpoint matcher indexes them +(`buildEndpointIndex`), and #5040's executor serves them — but +`DEFAULT_METADATA_TYPE_REGISTRY` had no `{ type: 'api', … }` entry and +`BUILTIN_METADATA_TYPE_SCHEMAS` had no `api` binding. So +`getMetadataTypeSchema('api')` returned `undefined` and `saveMetaItem` took its +documented "unregistered type is stored without validation" branch: +`PUT /api/v1/meta/api/:name` accepted **any JSON** and answered 200. That is +`declared ≠ enforced` read backwards — enforced but **undeclared**. + +Both halves are now declared, which is one fix with two faces: + +- **A body is validated.** The existing 422 `invalid_metadata` path applies to + `api` like every other kind, with structured Zod issues naming the offending + key. An endpoint with no `target`, or no `type`, is refused instead of stored. +- **The type is describable.** `/meta/types` emits a real JSON Schema and a + create seed for `api`, so the metadata-admin engine renders a form rather than + a raw-JSON textarea, and the entry carries a real label, domain and file + patterns instead of the synthesised `label: 'api'`, `filePatterns: []` + placeholder a type with no registry row gets. + +**The write door is unchanged.** `allowRuntimeCreate: true` records what the +runtime already did: with no static registry entry, both write gates +(`isRuntimeCreateAllowed`, `assertAllowed`) fall through to "runtime-creatable", +and both name `api` in that comment. `allowOrgOverride` stays `false`, also its +effective value today — an endpoint is the publishing package's outward URL +contract, and a per-org fork could move `path`, flip `authRequired` or drop +`rateLimit` on a URL third parties integrate against. Marking the type code-only +instead (`allowRuntimeCreate: false` + `allowOrgOverride: false`) was considered +and rejected: it would turn today's 200 into a 403 rather than validate it, and +#5086's refusal runs before persistence for drafts too, which would leave +#5206 step 2's `publishPackageDrafts` endpoint gate with no draft to gate. + +**`ApiEndpointSchema` gains the ADR-0010 protection envelope, and stays open to +unknown keys.** Every registered kind must declare the envelope its loader +stamps (`_packageId` / `_provenance`), or it is dropped on every parse; that +spread is added. Closing the shape against unknown keys was attempted and +**measured to be unsafe**: the same schema parses stored rows as well as +authored declarations (`buildEndpointIndex`, `gateApiItemsForPublish`), and a +stored row carries the metadata layer's own bookkeeping (`packageId`, `state`), +so `strictObject` turned 10 tests in `packages/metadata` red — the load-time +backstop excluded endpoints and the publish gate reported a schema error in +place of its ADR-0121 D6 verdict. `api` therefore joins `view` on the #4001 +campaign's `STILL_STRIP` list, with that measurement written into the list's own +note, and the real fix (separating the stored envelope from the body at the +metadata layer) is filed as #5309 rather than bought by teaching the authoring +vocabulary two storage keys. + +**This is a shape check, not a second servability judge.** ADR-0121's rules — +the `apps/` carve-out (D1/D2), anonymous-requires-an-armed-`rateLimit` +(D6), the supported target subset, mapping and policy — stay with +`validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure`, which run +at publish and again at load. A pin test asserts an anonymous unmetered endpoint +parses green here and is still refused by the gate, so the two never grow +competing opinions. + +**Upgrade note (not purely additive).** A stored `api` row that does not satisfy +`ApiEndpointSchema` is refused with 422 on its **next write**; reads and the +existing load-time behaviour are unchanged (the matcher already excluded +unparseable rows loudly, #5189). Every `api` declaration reachable in this repo +— the two E8-migrated showcase endpoints and the two dogfood policy-fixture +endpoints — was parsed against `ApiEndpointSchema` before landing this: all four +clean. A live deployment's `sys_metadata` cannot be scanned from CI; an operator +holding hand-written `api` rows should run `GET /api/v1/meta/diagnostics?type=api` +(which now covers the type) before upgrading. + +ADR-0088's admission test is satisfied on all three clauses: independent +lifecycle (the matcher indexes and invalidates one item at a time), declarative +governability (`allowRuntimeCreate` plus file patterns), and a real consumer +(#5040's executor, boot-proven by #5040 E8). This does not reverse the `router` +kind's retirement — `router`'s delivered forms are code contributions, whereas a +single `ApiEndpoint` is a declarative artifact, exactly the "third, real +delivered form" ADR-0088's own `router` row anticipated. diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 41cffcb890..2ac9382b7d 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -115,6 +115,17 @@ export const KIND_COVERAGE: Record = { 'PERMANENT by design (ADR-0088): a runtime-created snapshot produced by Setup → Datasources → Sync (ADR-0062). A package shipping one would be stale on arrival; the showcase demos the federation flow that produces it.', issue: ISSUE.noAuthoringSurface, }, + // [#5271] `api` graduated from STACK_COLLECTION_COVERAGE into the registry: + // it is now a real metadata kind (`DEFAULT_METADATA_TYPE_REGISTRY` + + // `BUILTIN_METADATA_TYPE_SCHEMAS`), so its coverage is owned here. The notes + // below moved verbatim from the old `STACK_COLLECTION_COVERAGE.apis` entry — + // the proof did not change, only which manifest is responsible for it. + api: { + status: 'demonstrated', + files: ['src/system/apis/index.ts'], + notes: + 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtl: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', + }, translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] }, email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] }, doc: { @@ -179,12 +190,10 @@ export const STACK_COLLECTION_COVERAGE: Record = { files: ['src/data/extensions/account.extension.ts'], notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).', }, - apis: { - status: 'demonstrated', - files: ['src/system/apis/index.ts'], - notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtl: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays waived: code-only. src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', - }, + // `apis` is NOT listed here any more: as of #5271 it is a registry kind, so + // its coverage lives in `KIND_COVERAGE.api` above. Leaving a duplicate row in + // this manifest — whose contract is "stack collections that are NOT registry + // kinds" — would mean two places to update and one of them silently wrong. connectors: { status: 'demonstrated', files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'], diff --git a/packages/metadata/src/endpoint-matcher.test.ts b/packages/metadata/src/endpoint-matcher.test.ts index 2104fee71d..2ad68e57bc 100644 --- a/packages/metadata/src/endpoint-matcher.test.ts +++ b/packages/metadata/src/endpoint-matcher.test.ts @@ -131,14 +131,51 @@ describe('buildEndpointIndex', () => { expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false); }); - it('strips storage annotations (_lock / packageId) rather than choking on them', () => { + // [#5271] REPLACED, not re-spelled. This case used to read "strips storage + // annotations (_lock / packageId) rather than choking on them" and asserted + // `not.toHaveProperty('_lock')` over a fixture spelling `_lock: { managed: + // true }` — a shape ADR-0010 never defined (`_lock` is the 4-state enum + // none / no-overlay / no-delete / full). Both halves stopped being right when + // `api` became a registered kind (#5271, part of #5206): + // + // • `ApiEndpointSchema` now declares `...MetadataProtectionFields`, because + // every registered kind must — the loader stamps the envelope and an + // undeclared one is DROPPED on every parse, losing protection metadata on + // round-trip. So the old assertion pinned exactly the defect that spread + // fixes, and it would have kept passing for the wrong reason. + // • The invented `_lock` object is now a VALUE error rather than an unknown + // key, so the endpoint would be excluded from the index entirely — the + // fixture's own premise ("does not choke") silently inverted. + // + // What remains true, and is what this case now pins: a stored row carries + // BOTH the ADR-0010 envelope (declared ⇒ survives) and the metadata layer's + // own bookkeeping (`packageId` / `state`, written by `MetadataManager` and + // NOT endpoint vocabulary ⇒ stripped). That split is the measured reason + // `api` sits on the #4001 campaign's STILL_STRIP list: closing this shape + // would make every stored row unparseable here. + it('keeps the ADR-0010 envelope and strips the metadata layer’s bookkeeping', () => { const index = buildEndpointIndex( - [{ ...endpoint(), _lock: { managed: true }, _packageId: 'pkg_showcase' }], + [{ + ...endpoint(), + // ADR-0010 envelope — declared by the schema, so it round-trips. + _lock: 'no-overlay', + _packageId: 'pkg_showcase', + _provenance: 'package', + // Metadata-layer bookkeeping — not endpoint vocabulary, so stripped. + packageId: 'com.objectstack.showcase', + state: 'active', + }], makeLogger(), ); - const hit = index.get('GET /api/v1/apps/showcase/tasks')!; - expect(hit).toBeDefined(); - expect(hit as Record).not.toHaveProperty('_lock'); + + const hit = index.get('GET /api/v1/apps/showcase/tasks'); + expect(hit, 'a stored row with storage annotations must still index').toBeDefined(); + + const body = hit as unknown as Record; + expect(body._packageId).toBe('pkg_showcase'); + expect(body._lock).toBe('no-overlay'); + expect(body).not.toHaveProperty('packageId'); + expect(body).not.toHaveProperty('state'); }); }); diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 428fd006b1..4c49b34b46 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -13,11 +13,15 @@ * 2. A deliberately broken payload (missing required field) → * expect `invalid_metadata` + status 422 + structured `issues[]`. * - * Types without a Zod schema in the central registry (`function`, - * `service`, `router`, plugin-only types like `theme`/`api`/`webhook`) are - * still expected to pass through unvalidated — that is the documented - * fall-through, not a regression. We pin it explicitly so any future - * coverage gap is visible in the report. + * Types without a Zod schema in the central registry (plugin-only types like + * `theme`/`webhook`) are still expected to pass through unvalidated — that is + * the documented fall-through, not a regression. We pin it explicitly so any + * future coverage gap is visible in the report. + * + * [#5271] `api` LEFT that bucket. It was the specimen this paragraph named + * while `PUT /meta/api/:name` stored arbitrary JSON (#5206); it is now a + * registered kind with `ApiEndpointSchema` bound, so it is swept like any + * other runtime-creatable type and has a fixture below. */ import { describe, it, expect, vi } from 'vitest'; @@ -193,6 +197,36 @@ const FIXTURES: Record = { invalid: { apps: { sweep_app: { label: 'Sweep' } } }, invalidatedField: 'locale', }, + // [#5271, part of #5206] `api` used to sit in this file's "no schema → + // fall-through" bucket (see the module doc). It now has one, so it gets a + // real fixture: the valid body is the E8-migrated showcase shape (an + // `object_operation` endpoint under its stack's ADR-0121 D1 carve-out), and + // the invalid body drops `target`, which `ApiEndpointSchema` requires. + // + // The invalid body is deliberately a SCHEMA violation, not a publish-gate + // violation: an off-carve-out path or an anonymous-without-armed-budget + // endpoint parses green here and is refused one door later, by + // `validateApiEndpointDeclarations` (publish) / `buildEndpointIndex` + // (load). This sweep must pin the door it actually is, or it would claim + // coverage for a judgement it never makes. + api: { + valid: { + name: 'sweep_task_feed', + path: '/api/v1/apps/sweep/tasks', + method: 'GET', + type: 'object_operation', + target: 'sweep_task', + objectParams: { object: 'sweep_task', operation: 'find' }, + authRequired: true, + }, + invalid: { + name: 'sweep_task_feed', + path: '/api/v1/apps/sweep/tasks', + method: 'GET', + type: 'object_operation', + }, + invalidatedField: 'target', + }, email_template: { valid: { name: 'sweep.welcome', diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 3535fb2c02..075fbadbac 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1372,16 +1372,23 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // ─────────────────────────────────────────────────────────────── // Regression: plugin-registered types (no static registry entry) // - // `theme`, `api`, `connector`, `data`, `mapping`, `policy`, - // `sharing_rule`, `webhook`, `analytics_cube`, `package` are - // registered by plugins at runtime — not in - // DEFAULT_METADATA_TYPE_REGISTRY. `getMetaTypes()` synthesises + // `theme`, `connector`, `data`, `policy`, `sharing_rule`, `webhook`, + // `analytics_cube`, `package` are registered by plugins at runtime — + // not in DEFAULT_METADATA_TYPE_REGISTRY. `getMetaTypes()` synthesises // descriptors with `allowRuntimeCreate: true` for them so the // admin UI advertises them as writable. The write gate must // agree, otherwise users see "writable" types 403 on save. // // Before fix: gate keyed off the static registry only, rejecting // these 10+ types with not_creatable / 403. + // + // [#5271] `api` LEFT this list — it now has a static registry entry. + // Its specimen was REPLACED rather than re-spelled: leaving it here + // would have kept the assertion green through the *other* branch of + // `isRuntimeCreateAllowed` (statically registered with + // `allowRuntimeCreate: true`) while the test claimed to prove the + // "no static entry" fall-through. `theme` and `webhook` still exercise + // that branch; `api`'s own behaviour is pinned in the test below. // ─────────────────────────────────────────────────────────────── it('accepts brand-new plugin-registered type (no static registry entry)', async () => { @@ -1393,12 +1400,6 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'my_theme', label: 'Test', tokens: {} }, organizationId: 'org_alpha', }); - const apiResult = await scoped.saveMetaItem({ - type: 'api', - name: 'my_api', - item: { name: 'my_api', path: '/x', method: 'GET' }, - organizationId: 'org_alpha', - }); const webhookResult = await scoped.saveMetaItem({ type: 'webhook', name: 'my_webhook', @@ -1407,10 +1408,63 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); expect(themeResult.success).toBe(true); - expect(apiResult.success).toBe(true); expect(webhookResult.success).toBe(true); }); + // ─────────────────────────────────────────────────────────────── + // [#5271, part of #5206] `api` — the write door is UNCHANGED, the + // shape check is new. + // + // Before this change the type had no registry entry, so both write + // gates took their "no static entry ⇒ synthesised allowRuntimeCreate" + // fall-through and `resolveOverlaySchema('api', …)` returned + // `undefined` — `PUT /api/v1/meta/api/:name` stored ANY JSON and + // answered 200. The registry entry keeps the AUTHORIZATION verdict + // byte-identical (`allowRuntimeCreate: true`) and adds the 422 the + // rest of the kinds already had. Both halves are asserted, because + // a change that quietly closed the door would also make the first + // assertion below fail — which is the point of pinning it. + // ─────────────────────────────────────────────────────────────── + + it('accepts a spec-valid `api` item (write door unchanged by the registry entry)', async () => { + mockEngine.findOne.mockResolvedValue(null); + + const result = await scoped.saveMetaItem({ + type: 'api', + name: 'my_api', + item: { + name: 'my_api', + path: '/api/v1/apps/alpha/tasks', + method: 'GET', + type: 'object_operation', + target: 'alpha_task', + objectParams: { object: 'alpha_task', operation: 'find' }, + }, + organizationId: 'org_alpha', + }); + + expect(result.success).toBe(true); + }); + + it('refuses a spec-INVALID `api` item with 422 instead of storing it unvalidated', async () => { + mockEngine.findOne.mockResolvedValue(null); + + // The exact body the old "plugin-registered types" case used to + // save with `success: true`: no `type`, no `target`, so it could + // never be executed by anything. It is now named and refused. + await expect( + scoped.saveMetaItem({ + type: 'api', + name: 'my_api', + item: { name: 'my_api', path: '/x', method: 'GET' }, + organizationId: 'org_alpha', + }), + ).rejects.toMatchObject({ + code: 'INVALID_METADATA', + status: 422, + }); + }); + it('artifact-backed view (allowOrgOverride:true) still overlays cleanly', async () => { // Regression: types that DO allow overlays must keep working // even when the item is artifact-backed. diff --git a/packages/objectql/src/sys-metadata-repository.test.ts b/packages/objectql/src/sys-metadata-repository.test.ts index 87f11f39d8..fa9dbd63dc 100644 --- a/packages/objectql/src/sys-metadata-repository.test.ts +++ b/packages/objectql/src/sys-metadata-repository.test.ts @@ -282,13 +282,19 @@ describe('SysMetadataRepository', () => { // ── runtime-create gate: plugin-registered types must be accepted ─── // - // Regression: types like `theme`, `api`, `connector`, `webhook` are + // Regression: types like `theme`, `connector`, `webhook` are // not in DEFAULT_METADATA_TYPE_REGISTRY — they are registered at // runtime by plugins. The listing endpoint (protocol.getMetaTypes()) // synthesises descriptors with `allowRuntimeCreate: true` for them, // so the admin UI advertises them as writable. The repo gate must // agree, otherwise the UI 403s on save. Previously the gate keyed // off the static registry only and rejected all 9+ such types. + // + // [#5271] `api` was one of the two specimens here and has been REPLACED + // by `webhook`, not re-spelled: `api` now HAS a static registry entry + // (`allowRuntimeCreate: true`), so it satisfies `assertAllowed` through + // the first branch and would have kept this test green while proving + // nothing about the "no static entry" fall-through it is named for. it('put accepts plugin-registered type with intent=runtime-only (theme)', async () => { const result = await repo.put( @@ -299,10 +305,30 @@ describe('SysMetadataRepository', () => { expect(result.version).toMatch(/^sha256:/); }); - it('put accepts plugin-registered type with intent=runtime-only (api)', async () => { + it('put accepts plugin-registered type with intent=runtime-only (webhook)', async () => { + const result = await repo.put( + { org: 'org_alpha', type: 'webhook', name: 'my_webhook' }, + { name: 'my_webhook', url: 'https://e.example/x', events: ['x.created'] }, + { parentVersion: null, actor: 'studio', intent: 'runtime-only' }, + ); + expect(result.version).toMatch(/^sha256:/); + }); + + it('put accepts statically-registered `api` with intent=runtime-only (#5271)', async () => { + // The other half of the same gate: `api` graduated INTO the registry + // with `allowRuntimeCreate: true`, so the repository door it already + // had must stay open. `assertAllowed` is a TYPE gate — the body shape + // is judged by `saveMetaItem`'s 422, one layer up. const result = await repo.put( { org: 'org_alpha', type: 'api', name: 'my_api' }, - { name: 'my_api', path: '/x', method: 'GET' }, + { + name: 'my_api', + path: '/api/v1/apps/alpha/tasks', + method: 'GET', + type: 'object_operation', + target: 'alpha_task', + objectParams: { object: 'alpha_task', operation: 'find' }, + }, { parentVersion: null, actor: 'studio', intent: 'runtime-only' }, ); expect(result.version).toMatch(/^sha256:/); diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 5703c2b88a..f036933561 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -491,6 +491,13 @@ "api/ApiDocumentationConfig:title", "api/ApiDocumentationConfig:ui", "api/ApiDocumentationConfig:version", + "api/ApiEndpoint:_lock", + "api/ApiEndpoint:_lockDocsUrl", + "api/ApiEndpoint:_lockReason", + "api/ApiEndpoint:_lockSource", + "api/ApiEndpoint:_packageId", + "api/ApiEndpoint:_packageVersion", + "api/ApiEndpoint:_provenance", "api/ApiEndpoint:authRequired", "api/ApiEndpoint:cacheTtl", "api/ApiEndpoint:description", diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index a3a5087336..92efd7e09e 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { HttpMethod, RateLimitConfigSchema } from '../shared/http.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** * API Mapping Schema @@ -17,35 +18,74 @@ export const ApiMappingSchema = lazySchema(() => z.object({ /** * API Endpoint Schema * Defines an external facing API contract. + * + * ## Registered kind: envelope DECLARED, unknown keys still STRIPPED (#5271) + * + * `api` became a REGISTERED metadata kind in #5271 (part of #5206), which puts + * this schema under the two invariants every registered kind is held to + * (`kernel/metadata-type-schemas.test.ts`). It satisfies one and is a measured + * exception to the other — and the reason is worth stating, because "close it + * like the other 23" is the obvious next edit and it does not work: + * + * - **The ADR-0010 protection envelope IS declared** (the spread at the bottom + * of the shape). The artifact loader stamps `_packageId` / `_provenance` on + * every registered item (`applyProtection`), and undeclared they were + * dropped on every parse — protection metadata lost on round-trip. + * + * - **Unknown keys are still stripped**, so `api` joins `view` on the #4001 + * campaign's `STILL_STRIP` list. This schema is not only an authoring + * surface: it is also what STORED rows are parsed with, by + * `buildEndpointIndex` (`packages/metadata/src/endpoint-matcher.ts`) and by + * `gateApiItemsForPublish` (`MetadataManager.publishPackage`). A stored row + * carries the metadata layer's own bookkeeping — `packageId` and `state`, + * written by `MetadataManager.register` / `publishPackage` and read back by + * `publishPackage`'s own package filter — which are NOT endpoint vocabulary. + * Closing this shape was tried and measured: every stored row fails with + * `unrecognized_keys: ['packageId', 'state']`, so the load-time backstop + * excludes it (its route answers 404) and the publish gate reports a schema + * error instead of the D6 verdict it exists to give. Exactly `view`'s shape + * of exception — one type name worn by both an authored document and a wire + * row — and the fix is to separate the stored envelope from the body at the + * metadata layer, not to teach this vocabulary two bookkeeping keys. + * + * The cost of leaving it open is real and is filed rather than hidden: a + * `cacheTTL` / `outputMappings` / `objectParam` typo parses green, publishes + * green, and the endpoint then serves without the policy or projection its + * author wrote. */ export const ApiEndpointSchema = z.object({ /** Identity */ name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique endpoint ID'), path: z.string().regex(/^\//).describe('URL Path (e.g. /api/v1/customers)'), method: HttpMethod.describe('HTTP Method'), - + /** Documentation */ summary: z.string().optional(), description: z.string().optional(), - + /** Execution Logic */ type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe('Implementation type'), target: z.string().describe('Target Flow ID, Script Name, or Proxy URL'), - + /** Logic Config */ objectParams: z.object({ object: z.string().optional(), operation: z.enum(['find', 'get', 'create', 'update', 'delete']).optional(), }).optional().describe('For object_operation type'), - + /** Data Transformation */ inputMapping: z.array(ApiMappingSchema).optional().describe('Map Request Body to Internal Params'), outputMapping: z.array(ApiMappingSchema).optional().describe('Map Internal Result to Response Body'), - + /** Policies */ authRequired: z.boolean().default(true).describe('Require authentication'), rateLimit: RateLimitConfigSchema.optional().describe('Rate limiting policy'), cacheTtl: z.number().optional().describe('Response cache TTL in seconds'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `api` is a registered metadata kind as of #5271, so the artifact loader + // stamps these on every item; undeclared they were dropped on every parse. + ...MetadataProtectionFields, }); /** diff --git a/packages/spec/src/kernel/metadata-create-seeds.ts b/packages/spec/src/kernel/metadata-create-seeds.ts index 1ebbdfbe22..28235dbd73 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.ts @@ -123,6 +123,35 @@ const BUILTIN_METADATA_CREATE_SEEDS: Partial> = { // a skill bundles tools; an empty list is a valid starting point. tools: [], }, + // [#5271] Declarative HTTP endpoint (ADR-0121). Seeded rather than left to + // the create form because the two mistakes an author (very often an AI one — + // ADR-0033) makes here are both structural, and both are fixed by starting + // from a complete shape: + // + // 1. THE PATH CARVE-OUT. `ApiEndpointSchema.path` only requires a leading + // slash, but ADR-0121 D1 confines a declared path to + // `/api/v1/apps//` and publish rejects + // anything else. The namespace segment is DERIVED from stack identity + // (D2), so no static literal can be right — `example_namespace` is a + // deliberate placeholder the author replaces, and its shape is the part + // that teaches. A blank form invites `/api/v1/customers`, which parses + // and is then refused. + // 2. THE TARGET HALVES. `type: 'object_operation'` needs BOTH + // `objectParams.object` and `objectParams.operation` or the publish gate + // refuses it as unservable; seeding the pair means a create round-trips. + // + // `authRequired` is deliberately omitted: it DEFAULTS to `true`, and letting + // the default do the work is what makes the safe shape the effortless one + // (the seed must never be the thing that teaches `authRequired: false`, + // which ADR-0121 D6 pairs with a mandatory armed `rateLimit`). + api: { + name: 'new_api_endpoint', + path: '/api/v1/apps/example_namespace/new-endpoint', + method: 'GET', + type: 'object_operation', + target: PLACEHOLDER_OBJECT, + objectParams: { object: PLACEHOLDER_OBJECT, operation: 'find' }, + }, email_template: { name: 'new_email_template', label: 'New Email Template', diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index bf71ce21b8..ea64c9d713 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -113,6 +113,30 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // code contributions: plugin `contributes.routes` + declarative `apis:` // (router), `defineStack({ functions })` + `contributes.functions` // (function), and the plugin/service registry itself (service). + // + // [#5271, part of #5206] `api` is the ONE declarative endpoint ITEM kind, and + // it is NOT a reversal of the `router` retirement above: `router` was retired + // as a KIND because its delivered forms (`contributes.routes`, imperative + // `http.server` mounts) are code contributions. A single `ApiEndpoint` — a + // stable URL plus a policy layer over an existing pipeline — is a declarative + // artifact, and it passes all three clauses of ADR-0088's admission test: + // 1. INDEPENDENT LIFECYCLE — the endpoint matcher indexes, invalidates and + // re-judges one stored `api` item at a time (`buildEndpointIndex`, + // `MetadataManager.ENDPOINT_METADATA_TYPE`). + // 2. DECLARATIVE GOVERNABILITY — `allowRuntimeCreate: true` plus file + // patterns (see the registry entry below). + // 3. A REAL CONSUMER — #5040's E-series executor serves them and + // `/openapi.json` describes them; #5040 E8 proves it on a real boot. + // ADR-0088's own `router` row already anticipated this: "the endpoint + // executor is being built under #5040, after which declarative `apis:` + // becomes a third, real delivered form". + // + // The kind was live long before it was declared — artifact ingest has mapped + // `defineStack({ apis })` → `api` items (`ARTIFACT_FIELD_TO_TYPE`) all along, + // so this entry closes the MIRROR of `declared ≠ enforced`: enforced but + // undeclared. Until #5271 the type resolved no schema, so `saveMetaItem` + // stored ANY JSON under it unvalidated (#5206). + 'api', // Declarative HTTP endpoints (ApiEndpointSchema, ADR-0121) // #4616: the canonical schema is `EmailTemplateDefinitionSchema` // (`system/email-template.zod.ts`), which is what `BUILTIN_METADATA_TYPE_SCHEMAS` // resolves this kind to. This comment used to name `EmailTemplateSchema` — the @@ -685,6 +709,55 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // RUNTIME-CREATED (ADR-0062/0088): produced by the datasource Sync wizard — // a derived snapshot; packages never ship one (it would be stale on arrival). { type: 'external_catalog', label: 'External Catalog', filePatterns: ['**/*.external-catalog.ts', '**/*.external-catalog.yml', '**/*.external-catalog.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 6, domain: 'system' }, + // [#5271, part of #5206] `api` — declarative HTTP endpoints (ADR-0121). + // + // WHY THE FLAGS ARE THESE VALUES (the decision this entry records): + // + // `allowRuntimeCreate: true` is NOT a new grant — it WRITES DOWN what the + // runtime already did. Until this entry existed, `api` had no static registry + // row, and both write gates treat a type with no row as runtime-creatable on + // purpose: `isRuntimeCreateAllowed` (metadata-protocol `protocol.ts`) and + // `assertAllowed` (`sys-metadata-repository.ts`) each fall through with + // "types with NO static registry entry are synthesised by `getMetaTypes()` + // with allowRuntimeCreate: true, so the write gate must agree" — and both + // name `api` in that comment. So `PUT /api/v1/meta/api/:name` accepted + // writes; it just accepted them UNVALIDATED. Declaring `true` here keeps the + // authorization verdict byte-identical and changes exactly one thing: the + // body must now satisfy `ApiEndpointSchema` (422 `invalid_metadata`). + // + // The alternative — CODE-ONLY (`allowRuntimeCreate: false` + + // `allowOrgOverride: false`, the `job` / `agent` shape) — was considered and + // rejected on the evidence: + // • it would REMOVE a door rather than validate one, turning today's 200 + // into a 403 for every runtime author, which is a contract change no + // issue in this chain asked for; + // • #5086 (PR #5263) refuses code-only types BEFORE persistence, draft and + // active alike — so `api` DRAFTS would become impossible, and #5206's + // step 2 (the `publishPackageDrafts` endpoint gate, PR #5279) would have + // nothing left to gate; + // • ADR-0121's ruling is "publish REJECTS" with a named-key prescription + // (D1/D2/D6), which presupposes an author who could write the draft. + // "Rejected at publish" is not "refused at authoring". + // + // `allowOrgOverride: false` (also unchanged from today's effective value): an + // endpoint is an OUTWARD URL contract owned by the declaring package. A + // per-org fork could move `path`, flip `authRequired` or drop `rateLimit` on + // the publisher's own URL, and ADR-0005 defaults this flag to false precisely + // so that opt-in is deliberate. Same posture as `datasource`. + // + // `supportsOverlay: false` — there is no merge semantic for an endpoint; + // `executionPinned: false` — an endpoint DELEGATES (ADR-0121 D5), and the + // pinned artifact is the target `flow`, which carries `executionPinned: true` + // itself. `loadOrder: 92` is after `flow` (80), so a flow-typed endpoint's + // target already exists when the endpoint registers. + // + // ⚠️ The registry is the authority on WHO MAY WRITE; it is not the endpoint + // publish gate. `validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` + // (`api/endpoint-publish-gate.ts`) remain the ONE judge of what is servable — + // run at publish (stack schema, `publishPackage`, `publishPackageDrafts`) and + // again at load (`buildEndpointIndex`). This entry adds a SHAPE check in + // front of them, never a second opinion about servability. + { type: 'api', label: 'API Endpoint', description: 'Declarative HTTP endpoint — a stable URL and policy layer over an existing pipeline (ADR-0121)', filePatterns: ['**/*.api.ts', '**/*.api.yml', '**/*.api.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 92, domain: 'system' }, { type: 'translation', label: 'Translation', filePatterns: ['**/*.translation.ts', '**/*.translation.yml', '**/*.translation.json'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 90, domain: 'system' }, { type: 'email_template', label: 'Email Template', filePatterns: ['**/*.email-template.ts', '**/*.email-template.yml', '**/*.email-template.json'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 85, domain: 'system' }, // ADR-0046: package documentation. Inert data — no runtime behavior, no diff --git a/packages/spec/src/kernel/metadata-type-api-registration.test.ts b/packages/spec/src/kernel/metadata-type-api-registration.test.ts new file mode 100644 index 0000000000..998411b298 --- /dev/null +++ b/packages/spec/src/kernel/metadata-type-api-registration.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `api` IS a declared metadata kind (#5271, part of #5206). + * + * ## What was wrong, in one sentence + * + * `api` items were produced (artifact ingest maps `defineStack({ apis })` → + * `api`), indexed (`buildEndpointIndex`) and executed (#5040 E5/E8) while the + * spec declared the kind NOWHERE — so `getMetadataTypeSchema('api')` returned + * `undefined` and `saveMetaItem` took its documented "unregistered type ⇒ store + * without validation" branch. `PUT /api/v1/meta/api/:name` accepted any JSON at + * all. That is `declared ≠ enforced` read backwards: ENFORCED BUT UNDECLARED. + * + * ## The two doors this file keeps apart + * + * These tests pin the registration and the SHAPE door only. Whether a + * well-shaped endpoint is SERVABLE — the ADR-0121 D1/D2 carve-out, D6's + * anonymous-needs-an-armed-budget rule, the supported target subset, the + * mapping and policy rules — belongs to + * `validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` + * (`../api/endpoint-publish-gate`), which run at publish and again at load. + * The last test here pins that separation directly, because a registry entry + * that grew a second opinion about servability is the one way this change + * could go wrong later. + */ + +import { describe, it, expect } from 'vitest'; + +import { ApiEndpointSchema } from '../api/endpoint.zod'; +import { identityFreeEndpointGateFailure } from '../api/endpoint-publish-gate'; +import { DEFAULT_METADATA_TYPE_REGISTRY, MetadataTypeSchema } from './metadata-plugin.zod'; +import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from './metadata-type-schemas'; + +/** The E8-migrated showcase shape, transcribed from + * `examples/app-showcase/src/system/apis/index.ts`. The originals are typed + * `ApiEndpoint` at their own site and boot-proven by + * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`; + * this copy exists so the spec package can assert the binding without + * depending on an example. */ +const SHOWCASE_TASK_FEED = { + name: 'showcase_task_feed', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + summary: 'Task feed', + description: 'Returns tasks via a declarative object_operation endpoint — no handler code.', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: true, + cacheTtl: 30, +} as const; + +/** The flow-typed half of the same stack. */ +const SHOWCASE_INQUIRY_PURGE = { + name: 'showcase_inquiry_purge_api', + path: '/api/v1/apps/showcase/inquiries/purge', + method: 'POST', + summary: 'Purge closed inquiries', + type: 'flow', + target: 'showcase_inquiry_purge', + authRequired: true, +} as const; + +const apiEntry = () => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'api'); + +describe('`api` is a declared metadata kind', () => { + it('is a member of MetadataTypeSchema', () => { + expect(MetadataTypeSchema.safeParse('api').success).toBe(true); + }); + + it('has an entry in DEFAULT_METADATA_TYPE_REGISTRY', () => { + expect(apiEntry(), '`api` missing from DEFAULT_METADATA_TYPE_REGISTRY').toBeDefined(); + expect(apiEntry()!.domain).toBe('system'); + expect(apiEntry()!.label).toBe('API Endpoint'); + }); + + it('is enumerable by getMetaTypes(): the registry entry carries file patterns and a schema', () => { + // `getMetaTypes()` (metadata-protocol) decorates each registry entry with + // `getMetadataTypeSchema(type)`. Before #5271 `api` could only appear as a + // SYNTHESISED descriptor — `label: 'api'`, `filePatterns: []`, + // `domain: 'system'`, `schema: undefined` — so the metadata-admin engine + // had no JSON Schema to build a form from and fell back to a raw-JSON + // textarea. Both halves of that fix are asserted together because either + // one alone leaves the form unrenderable. + expect(apiEntry()!.filePatterns.length).toBeGreaterThan(0); + expect(getMetadataTypeSchema('api')).toBeDefined(); + }); + + it('resolves to ApiEndpointSchema — the one endpoint shape (#4939 convergence)', () => { + // Identity, not "some schema": `packages/spec/src/api` deliberately retired + // its rival endpoint shapes down to this one, and a binding to a lookalike + // would silently reintroduce the second dialect that retirement removed. + expect(getMetadataTypeSchema('api')).toBe(ApiEndpointSchema); + expect(listMetadataTypeSchemaTypes()).toContain('api'); + }); +}); + +describe('`api` registry flags — the authorization verdict, written down', () => { + it('declares `allowRuntimeCreate: true`', () => { + // NOT a new grant. With no static entry, `isRuntimeCreateAllowed` + // (metadata-protocol) and `assertAllowed` (sys-metadata-repository) both + // fall through to "no registry entry ⇒ runtime-creatable", and both name + // `api` in that comment — so runtime writes were already accepted, just + // unvalidated. Flipping this to `false` (with allowOrgOverride also false) + // would make the type CODE-ONLY under #5086, turning today's 200 into a + // 403 and leaving #5206 step 2's `publishPackageDrafts` endpoint gate + // (PR #5279) with no draft it could ever gate. + expect(apiEntry()!.allowRuntimeCreate).toBe(true); + }); + + it('is NOT code-only: at least one runtime write channel stays declared', () => { + // The exact predicate #5086 (PR #5263) refuses on, spelled as the gate + // spells it, so a future flag edit fails here rather than in a 403 nobody + // expected. + const entry = apiEntry()!; + const codeOnly = entry.allowRuntimeCreate === false && entry.allowOrgOverride === false; + expect(codeOnly).toBe(false); + }); + + it('declares `allowOrgOverride: false` — an endpoint is the publisher’s outward URL', () => { + // A per-org fork could move `path`, flip `authRequired` or drop + // `rateLimit` on a URL third parties have already integrated against. + // ADR-0005 defaults the flag to false so opting in is deliberate. + expect(apiEntry()!.allowOrgOverride).toBe(false); + }); + + it('declares `executionPinned: false` — the pinned artifact is the target flow', () => { + // ADR-0009 pins metadata whose runtime invocations can pause across + // redeploys. An endpoint DELEGATES (ADR-0121 D5); `flow` carries the pin. + expect(apiEntry()!.executionPinned).toBe(false); + const flow = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow'); + expect(flow!.executionPinned).toBe(true); + }); + + it('loads after `flow`, so a flow-typed endpoint’s target already exists', () => { + const flow = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow'); + expect(apiEntry()!.loadOrder).toBeGreaterThan(flow!.loadOrder); + }); +}); + +describe('the save-time shape door `api` just gained', () => { + it('accepts the E8-migrated showcase endpoints (object_operation + flow)', () => { + // A VALUE verdict — the rule judges the body, not whether a key is an + // authoring surface — so the criterion is a fully green `safeParse`, not + // merely "no unrecognized keys". + for (const endpoint of [SHOWCASE_TASK_FEED, SHOWCASE_INQUIRY_PURGE]) { + const parsed = ApiEndpointSchema.safeParse(endpoint); + expect( + parsed.success, + parsed.success ? '' : `${endpoint.name}: ${JSON.stringify(parsed.error?.issues)}`, + ).toBe(true); + } + }); + + it('refuses a body missing `target` — loudly, naming the key', () => { + // The shape `PUT /meta/api/:name` used to store with a 200: no execution + // target at all, so nothing could ever run it. + const parsed = ApiEndpointSchema.safeParse({ + name: 'headless_endpoint', + path: '/api/v1/apps/showcase/x', + method: 'GET', + type: 'object_operation', + }); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues.map((i) => i.path.join('.'))).toContain('target'); + }); + + it('refuses a body that is not an endpoint at all', () => { + const parsed = ApiEndpointSchema.safeParse({ hello: 'world' }); + expect(parsed.success).toBe(false); + }); + + it('refuses a `path` with no leading slash', () => { + const parsed = ApiEndpointSchema.safeParse({ ...SHOWCASE_TASK_FEED, path: 'apps/showcase/tasks' }); + expect(parsed.success).toBe(false); + }); +}); + +describe('the shape door does NOT duplicate the endpoint publish gate', () => { + it('a schema-valid but UNSERVABLE endpoint passes here and is refused by the gate', () => { + // ADR-0121 D6: anonymous access requires an ARMED rate limit. The schema + // has no opinion about that pairing — and must not grow one, or there + // would be two judges of what is servable and they would drift. This is + // the assertion that fails if somebody later "helpfully" folds a gate rule + // into `ApiEndpointSchema`. + const anonymousUnmetered = { + ...SHOWCASE_TASK_FEED, + name: 'anon_unmetered', + authRequired: false, + }; + + const parsed = ApiEndpointSchema.safeParse(anonymousUnmetered); + expect(parsed.success, 'the SHAPE is fine — only servability is not').toBe(true); + + const failure = identityFreeEndpointGateFailure(parsed.data!); + expect(failure, 'ADR-0121 D6 must still refuse it at the gate').toBeDefined(); + expect(failure!.path).toEqual(['rateLimit']); + }); +}); diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 73092d3b1a..7651c28ab2 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -239,8 +239,34 @@ describe('registered metadata types', () => { * open member is a wire shape wearing the same type name — which is exactly the * distinction the ledger's classification rule exists to draw, arriving here as * the campaign's final answer rather than as an exception to it. + * + * ## `api` arrives (2026-08-04, #5271) with the SAME distinction, not a new one + * + * `api` joined the registry after the campaign ended, and it lands on this list + * for `view`'s reason wearing different clothes: `ApiEndpointSchema` is not only + * an authoring surface — it is what STORED rows are parsed with, by + * `buildEndpointIndex` (`packages/metadata/src/endpoint-matcher.ts`) and by + * `MetadataManager.publishPackage`'s `gateApiItemsForPublish`. A stored row + * carries the metadata layer's own bookkeeping (`packageId`, `state` — written + * by `register` / `publishPackage`, and read back by `publishPackage`'s package + * filter), which is not endpoint vocabulary. + * + * This was MEASURED, not assumed. Closing the shape with `strictObject` turns + * every stored row into `unrecognized_keys: ['packageId', 'state']`: the + * load-time backstop then excludes the endpoint (its route answers 404) and the + * publish gate reports a schema error in place of the ADR-0121 D6 verdict it + * exists to give — 10 tests in `packages/metadata` go red, which is what + * surfaced it. So the debt is real and it is NOT in this vocabulary: the fix is + * to separate the stored envelope from the body at the metadata layer, filed + * separately, after which `api` comes off this list. Teaching `ApiEndpointSchema` + * two bookkeeping keys to buy strictness would make the authoring contract + * describe the storage layer, which is the trade this campaign refuses. + * + * So: `view` is the end state; `api` is tracked debt with a named owner. Both + * are here for the same underlying reason — one type name serving both an + * authored document and a wire row. */ -const STILL_STRIP = new Set(['view']); +const STILL_STRIP = new Set(['view', 'api']); /** The registered schema's own top-level posture: `.strict()` sets a `never` catchall. */ function topLevelPosture(schema: unknown, depth = 0): 'strict' | 'strip' | null { @@ -311,7 +337,15 @@ describe('#4001 — registered-type closure is derived, not tallied', () => { // ADR-0088 retirement), taking an already-closed schema with it. The // campaign's closure ratio is unchanged — one fewer registered type, not // one fewer closed one. + // + // 24 → 25 on 2026-08-04: `api` JOINED the registry (#5271, part of #5206), + // the first new registered kind since the campaign ended. It declares the + // protection envelope (so `UNDECLARED_ENVELOPE` stays empty) but goes onto + // STILL_STRIP — measured, see that list's note: the same schema parses + // stored rows carrying `packageId` / `state`, so closing it breaks the + // load-time backstop and the publish gate. Closed count is therefore + // unchanged at 23 while the registered total moves to 25. expect(closed.length).toBe(23); - expect(types.length).toBe(24); + expect(types.length).toBe(25); }); }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 57c65a15c2..04abd6e6eb 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -46,6 +46,8 @@ import { DatasetSchema } from '../ui/dataset.zod'; import { FlowSchema } from '../automation/flow.zod'; +import { ApiEndpointSchema } from '../api/endpoint.zod'; + import { JobSchema } from '../system/job.zod'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { TranslationItemSchema } from '../system/translation.zod'; @@ -109,6 +111,22 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // System Protocol datasource: DatasourceSchema, + // [#5271, part of #5206] Declarative HTTP endpoints (ADR-0121). The kind was + // produced and consumed long before it resolved a schema: artifact ingest + // maps `defineStack({ apis })` → `api` items and the endpoint matcher indexes + // them. Without this entry `resolveOverlaySchema('api', …)` returned + // `undefined`, so `saveMetaItem` took its documented "unregistered type → + // store without validation" branch and `PUT /meta/api/:name` accepted ANY + // JSON. With it, the existing 422 `invalid_metadata` path applies to `api` + // like every other kind, and `/meta/types` emits a real JSON Schema so the + // metadata-admin engine renders a form instead of a raw-JSON textarea. + // + // This is a SHAPE check only. Whether a well-shaped endpoint is SERVABLE + // (ADR-0121 D1/D2 namespace carve-out, D6 anonymous-needs-armed-budget, + // supported target subset, mapping/policy rules) is judged by + // `validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` at + // publish and again at load — one judge, not two. + api: ApiEndpointSchema, translation: TranslationItemSchema, email_template: EmailTemplateDefinitionSchema, doc: DocSchema, // ADR-0046: flat Markdown package documentation From 3eff14f8701f3ad33bc510d38ee5d6f3b11d26e4 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 19:05:35 +0000 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20sync=20origin/main=20+=20=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E9=87=8D=E7=94=9F=E6=88=90=20spec=20=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF;=E8=A1=A5=20#5279=20fixture=20=E7=9A=84=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git merge origin/main`(至 5aae790,无冲突),生成物按 os-regen 四步互保: generated 文件整体 checkout 回合并基线,再 wholesale 重生成,最后断言兄弟 PR 的条目仍在。 两处过程中发现并纠正的坑,记下来免得下一个人重踩: 1. `gen:api-surface` 读的是**构建产物**,不是源码。合并后没重建就重生成,会把 #5021(PR #5289)刚退役的 `AnimationSchema` / `ZIndexSchema` 四行**重新加 回去** —— 正是 AGENTS.md §9 的陈旧产物陷阱。重建 spec 后重生成才对 (4422 → 4418 exports)。 2. 第 2 步的 `git checkout origin/main -- ` 必须用**你实际合并的那个 tip**,不是 `origin/main` 的当前值。main 在我合并与 checkout 之间又前进了, 于是把 #4938 的 http-server 生成文档拉了进来 —— 而我的源码里没有那个改动, 等于提交了一份源码产不出的生成物。改用合并基线 5aae790 后归零。 最终生成物 delta 相对合并基线只有 7 行,纯增量(ApiEndpoint 的保护信封键); #5021 的退役完好(Animation/ZIndex 确认缺席)。 `protocol-publish-drafts-endpoint-gate.test.ts`(#5279,已合入 main)的 ENDPOINT_SCHEMA 用例改了**落地路径**而非断言:该 draft 现在过不了 saveMetaItem 的 422(这正是 #5206「一处修,两面得」要的结果),所以 fixture 改为先按真实写 路径存一条合法 draft、再只污染其 body —— 让那条 backstop 分支仍然被真实覆盖, 而不是删掉用例留一条无测试的活分支。未改该 PR 的任何生产代码。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- ...tocol-publish-drafts-endpoint-gate.test.ts | 29 +++++++++++++++---- packages/spec/authorable-surface.json | 24 +++++---------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts index 06151dac45..563e086b10 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts @@ -318,11 +318,30 @@ describe('publishPackageDrafts — the ADR-0121 endpoint publish gate (#5206 ste const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); - // `api` has no entry in BUILTIN_METADATA_TYPE_SCHEMAS (that half is - // #5271, the spec lane), so the direct-write path stores arbitrary JSON - // verbatim. Parsing is the gate's precondition: an unparseable - // declaration cannot be judged and could never be served either. - await saveApiDraft(protocol, 'garbage', { name: 'garbage', totally: 'not an endpoint' }); + // [#5271] This comment used to read "`api` has no entry in + // BUILTIN_METADATA_TYPE_SCHEMAS (that half is #5271, the spec lane), so + // the direct-write path stores arbitrary JSON verbatim" — and it minted + // the garbage draft through `saveMetaItem`. That half has now landed: + // `api` resolves `ApiEndpointSchema`, so this body is refused with a + // 422 at the EARLIEST door and the draft can no longer be created at + // all. That is the "一处修,两面得" outcome #5206 asked for, and it is + // asserted on the spec lane's side (packages/objectql + // /src/protocol-meta.test.ts, "refuses a spec-INVALID `api` item"). + // + // The `ENDPOINT_SCHEMA` branch this case pins is therefore no longer + // reachable from the Studio write path — it is exactly what the module + // header calls it, a BACKSTOP, for a row that reached the store some + // other way: a direct `metadata.register()`, a migration, or a row + // written before #5271. Deleting the case would leave a live branch + // with no test; re-spelling the body would only re-test the 422. So the + // fixture PLANTS such a row instead of minting one — it saves a valid + // draft through the real write path (so every bookkeeping column is + // byte-for-byte what production writes) and then corrupts only the + // stored body, which is the one thing the earlier door cannot police. + await saveApiDraft(protocol, 'garbage', validEndpoint({ name: 'garbage' })); + const planted = Array.from(rows.values()).find((r) => r.name === 'garbage' && r.state === 'draft'); + expect(planted, 'the valid draft must exist before it is corrupted').toBeDefined(); + planted!.metadata = JSON.stringify({ name: 'garbage', totally: 'not an endpoint' }); const res = await protocol.publishPackageDrafts({ packageId: PKG }); expect(res).toMatchObject({ success: false, publishedCount: 0 }); diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index f036933561..45ac5e6ac3 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -6930,8 +6930,6 @@ "ui/AddRecordConfig:formView", "ui/AddRecordConfig:mode", "ui/AddRecordConfig:position", - "ui/Animation:duration", - "ui/Animation:timing", "ui/App:_lock", "ui/App:_lockDocsUrl", "ui/App:_lockReason", @@ -7901,7 +7899,7 @@ "ui/SyncConfig:maxRetries", "ui/SyncConfig:retryInterval", "ui/SyncConfig:strategy", - "ui/Theme:animation", + "ui/Theme:animation [RETIRED]", "ui/Theme:borderRadius", "ui/Theme:colors", "ui/Theme:customVars", @@ -7912,7 +7910,7 @@ "ui/Theme:name", "ui/Theme:shadows", "ui/Theme:typography", - "ui/Theme:zIndex", + "ui/Theme:zIndex [RETIRED]", "ui/TimelineConfig:colorField", "ui/TimelineConfig:endDateField", "ui/TimelineConfig:groupByField", @@ -7940,10 +7938,10 @@ "ui/TreeConfig:labelField", "ui/TreeConfig:parentField", "ui/Typography:fontFamily", - "ui/Typography:fontSize", - "ui/Typography:fontWeight", - "ui/Typography:letterSpacing", - "ui/Typography:lineHeight", + "ui/Typography:fontSize [RETIRED]", + "ui/Typography:fontWeight [RETIRED]", + "ui/Typography:letterSpacing [RETIRED]", + "ui/Typography:lineHeight [RETIRED]", "ui/UrlNavItem:badge", "ui/UrlNavItem:badgeVariant", "ui/UrlNavItem:icon", @@ -8044,14 +8042,6 @@ "ui/WidgetProperty:name", "ui/WidgetProperty:required", "ui/WidgetProperty:type", - "ui/WidgetProperty:validation", - "ui/ZIndex:base", - "ui/ZIndex:dropdown", - "ui/ZIndex:fixed", - "ui/ZIndex:modal", - "ui/ZIndex:modalBackdrop", - "ui/ZIndex:popover", - "ui/ZIndex:sticky", - "ui/ZIndex:tooltip" + "ui/WidgetProperty:validation" ] } From a4f4a8bca4a6b6e513c9d16a0f92595172e3a56c Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 19:22:13 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(spec):=20=E6=8A=8A=20`api`=20?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20liveness=20=E6=B2=BB=E7=90=86=E5=B9=B6?= =?UTF-8?q?=E6=92=AD=E7=A7=8D=E5=8F=B0=E8=B4=A6;=E9=87=8D=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=B8=89=E4=BB=BD=E5=8F=82=E8=80=83=E6=96=87=E6=A1=A3?= =?UTF-8?q?=20(#5271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM 返工第 1 轮。把 `api` 注册成 metadata type 就落入「每个 REGISTERED 类型 必须被治理」不变量,而 `check:liveness` 不在我上一轮跑过的门清单里: ✗ 1 REGISTERED metadata type(s) governed by nothing: api 走的是第一条路线(纳入 GOVERNED + 播种台账),没有用 PENDING_GOVERNANCE —— 这是治理成本最低的时点:#5040 的 E 系列执行器全部已合 main,每个键**今天** 都有真实证据路径,而不是一句承诺。datasource 的教训(#4487:无治理期积了六个 惰性键,只能靠人手找出来)就是这条路线存在的理由。 27 条属性分类:live 25、planned 2、dead 0。 逐键证据按层给到 file:line —— 匹配器(endpoint-matcher)吃 name/path/method; 执行器(endpoint-executor)吃 type/target/objectParams;策略链 (endpoint-policy + security/inbound-rate-limit)吃 authRequired/rateLimit/ cacheTtl;映射层(api-mapping)吃 inputMapping/outputMapping;OpenAPI 增强 (rest/openapi-endpoints)吃 summary/description。7 个保护信封键由门自动判 live (ADR-0010),故不写进台账。 两个 `transform` 判 **planned 而非 dead**,这个区分是有承重的:台账里的 dead 指「解析了、没有消费方」即静默 no-op;而 transform 是反过来 —— 它被解析后在 publish 与 runtime 两处**响亮拒绝**(api-mapping.ts:259),作者会被告知怎么改。 它留在词表里而不是被删,是因为接纳它需要函数注册表 + sandbox 裁决 (#5040 §3.4),那是一个待做的设计决定,不是一个可以顺手删掉的键。 无任何 `api` 键属于 proof-registry 的 bound high-risk class,因此没有一条带 `proof` —— 不为显得周全而编造。 另:`check:docs` 实测**仍红**(上一轮的同步提交并没有修好它,我从未跑过 `gen:docs`)。三份生成文档已重生成,delta 纯属本单:endpoint.mdx 的 7 个信封键, metadata.mdx / metadata-plugin.mdx 的枚举里多了 `api`。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- content/docs/references/api/endpoint.mdx | 7 + content/docs/references/api/metadata.mdx | 4 +- .../references/kernel/metadata-plugin.mdx | 5 +- packages/spec/liveness/api.json | 142 ++++++++++++++++++ .../spec/scripts/liveness/check-liveness.mts | 2 +- 5 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 packages/spec/liveness/api.json diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index de56608202..8b9d78ccee 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -44,6 +44,13 @@ const result = ApiEndpointSchema.parse(data); | **authRequired** | `boolean` | ✅ | Require authentication | | **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Rate limiting policy | | **cacheTtl** | `number` | optional | Response cache TTL in seconds | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index c5e6016325..b6ddcd1a41 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -341,7 +341,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -376,7 +376,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 1852b6d866..5b279c3ed5 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -152,7 +152,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -202,6 +202,7 @@ const result = MetadataBulkResultSchema.parse(data); * `datasource` * `external_catalog` * `translation` +* `api` * `email_template` * `doc` * `book` @@ -220,7 +221,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | diff --git a/packages/spec/liveness/api.json b/packages/spec/liveness/api.json new file mode 100644 index 0000000000..2fd1984617 --- /dev/null +++ b/packages/spec/liveness/api.json @@ -0,0 +1,142 @@ +{ + "type": "api", + "_note": "ApiEndpointSchema (packages/spec/src/api/endpoint.zod.ts). Seeded 2026-08-04 (#5271, part of #5206) in the same change that made `api` a REGISTERED metadata type — governance and registration land together, which is the whole lesson of `datasource` (#4487): it went ungoverned long enough to accumulate six inert keys that had to be found by hand. Consumers, by layer: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target` / `objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired` / `rateLimit` / `cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping` / `outputMapping`; and the OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary` / `description`. This is the cheapest possible moment to govern the type: #5040's E-series built every one of those consumers and all of it is on main, so each key has a real evidence path today rather than a promise. Nothing here is `dead`. The two `transform` keys are `planned`, not `dead`, and the distinction is load-bearing — see their notes. No `api` property is a bound HIGH_RISK class in proof-registry.mts, so no entry carries a `proof`; none is invented to look thorough. The protection-envelope keys (`_lock*`, `_provenance`, `_packageId/Version`) are auto-classified live by the gate (ADR-0010) and are deliberately absent from this file.", + "props": { + "name": { + "status": "live", + "evidence": "packages/metadata/src/endpoint-matcher.ts:210", + "verifiedAt": "2026-08-04", + "note": "The metadata item key, and the tiebreaker with real consequences: when two stored declarations claim the same METHOD + normalized path, the lexicographically-first `name` keeps the route and the loser is named at `error` level. Also the subject of every publish-gate and matcher message, so a rename changes what an author is told." + }, + "path": { + "status": "live", + "evidence": "packages/metadata/src/endpoint-matcher.ts:199 (endpointIndexKey → normalizeEndpointPath)", + "verifiedAt": "2026-08-04", + "note": "Half the index key. Compared as a WHOLE STRING with exactly one trailing slash trimmed — no percent-decoding, no Unicode normalization, no case folding in 17.x. ADR-0121 D1 additionally confines it to the stack's `apps//` carve-out at publish; that is a gate, not a second consumer." + }, + "method": { + "status": "live", + "evidence": "packages/metadata/src/endpoint-matcher.ts:115 (normalizeEndpointMethod), :199", + "verifiedAt": "2026-08-04", + "note": "The other half of the index key, upper-cased before comparison. Also read by the policy layer: `cacheTtl` is GET-only, so `method` decides whether a Cache-Control header can be emitted at all." + }, + "summary": { + "status": "live", + "evidence": "packages/rest/src/openapi-endpoints.ts:214", + "verifiedAt": "2026-08-04", + "note": "Emitted as the OpenAPI operation's `summary` (#5040 E6). Documentation-shaped rather than behavioural, but it genuinely reaches a served artifact — /openapi.json — so it is live, not decorative." + }, + "description": { + "status": "live", + "evidence": "packages/rest/src/openapi-endpoints.ts:215", + "verifiedAt": "2026-08-04", + "note": "Emitted as the OpenAPI operation's `description` (#5040 E6). Same reasoning as `summary`." + }, + "type": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-executor.ts:216 (object_operation), :232 (flow)", + "verifiedAt": "2026-08-04", + "note": "The executor's dispatch key. NOTE the enum is wider than the runtime: only 'object_operation' and 'flow' execute; 'script' and 'proxy' are REFUSED at publish (endpoint-publish-gate.ts targetGate) and again by planEndpointTarget at :246. The KEY is live — it is the two rejected VALUES that are not, which is why this is a `live` row and not a qualified one." + }, + "target": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-executor.ts:233", + "verifiedAt": "2026-08-04", + "note": "The flow name a `type: 'flow'` endpoint triggers, delegated through the same automation pipeline as POST /automation//trigger (ADR-0121 D5 — no second execution dialect). For `object_operation` the routing comes from `objectParams` instead, so an empty `target` is only refused on the flow branch." + }, + "objectParams": { + "children": { + "object": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-executor.ts:217, :394", + "verifiedAt": "2026-08-04", + "note": "The object an `object_operation` endpoint reads or writes. Line 394 is the one that matters for security: `object` comes from the DECLARATION, never from the request, so a caller cannot redirect a declared endpoint at another object." + }, + "operation": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-executor.ts:218, :368", + "verifiedAt": "2026-08-04", + "note": "find / get / create / update / delete, delegated to the same callData pipeline the built-in /data route uses. Also decides whether a request body is read at all, which is what makes `inputMapping` inert on the bodyless operations (publish refuses that combination — #5111)." + } + } + }, + "inputMapping": { + "children": { + "source": { + "status": "live", + "evidence": "packages/runtime/src/api-mapping.ts:318 (readPath)", + "verifiedAt": "2026-08-04", + "note": "Dot path read out of the request body, before delegation — so a mapping can never buy a caller past `authRequired` or the rate limiter." + }, + "target": { + "status": "live", + "evidence": "packages/runtime/src/api-mapping.ts:322 (writePath)", + "verifiedAt": "2026-08-04", + "note": "Dot path written into the params the executor sees. Prototype keys and colliding targets are refused rather than silently discarded (:291)." + }, + "transform": { + "status": "planned", + "evidence": "packages/runtime/src/api-mapping.ts:259", + "verifiedAt": "2026-08-04", + "note": "PLANNED, deliberately not `dead`, and the difference is the point. `dead` in this ledger means 'parsed, no consumer' — a silent no-op. `transform` is the opposite: it is parsed and then LOUDLY REFUSED, at publish (endpoint-publish-gate.ts mappingGate) and again at runtime (:259), because there is no transformation-function registry anywhere in the platform. An author who writes it is told so and told what to do instead. It stays in the vocabulary rather than being removed because admitting it needs a function registry AND a sandbox ruling of its own (#5040 §3.4) — that is a design decision to make, not a key to quietly delete. Nothing enforce-or-remove has to chase: it is already refused." + } + } + }, + "outputMapping": { + "children": { + "source": { + "status": "live", + "evidence": "packages/runtime/src/api-mapping.ts:318 (readPath, via applyOutputMapping at :358)", + "verifiedAt": "2026-08-04", + "note": "Dot path read out of the SUCCESS body only. An error answer is never remapped — a projection able to reshape a 401/429/500 into data could disguise a failure as a result." + }, + "target": { + "status": "live", + "evidence": "packages/runtime/src/api-mapping.ts:322 (writePath, via applyOutputMapping at :358)", + "verifiedAt": "2026-08-04", + "note": "Dot path written into the response body. Validated BEFORE delegation, so a broken projection cannot let a `create` insert a record and then fail to answer." + }, + "transform": { + "status": "planned", + "evidence": "packages/runtime/src/api-mapping.ts:259", + "verifiedAt": "2026-08-04", + "note": "Same key, same gate, same reasoning as `inputMapping.transform` — the mapping validator walks both arrays through one code path. See that entry." + } + } + }, + "authRequired": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-policy.ts:354", + "verifiedAt": "2026-08-04", + "note": "The default-deny gate, and the key whose history is the reason this whole surface was refused for a release: #4936 measured it parsing green and gating NOTHING (no route was mounted, no matcher existed) — declared security that enforced nothing, i.e. false compliance. It is enforced now. It DEFAULTS to true, so `false` is the only way to open an anonymous entry point, and ADR-0121 D6 pairs that with a mandatory armed `rateLimit`. Note the deliberate omission at the call site: no anonymous-exemption argument is passed, so a declared path can never exempt itself from its own default deny." + }, + "rateLimit": { + "children": { + "enabled": { + "status": "live", + "evidence": "packages/runtime/src/security/inbound-rate-limit.ts:88 (deriveBucketConfig), consumed via packages/runtime/src/endpoint-policy.ts:148", + "verifiedAt": "2026-08-04", + "note": "ARMS the budget, and it defaults to FALSE — so `rateLimit: { windowMs, maxRequests }` written without it parses into a budget that meters nothing. That is exactly why ADR-0121 D6's gate tests `enabled === true` rather than the presence of `rateLimit`: a presence check would be vacuous." + }, + "windowMs": { + "status": "live", + "evidence": "packages/runtime/src/security/inbound-rate-limit.ts:91", + "verifiedAt": "2026-08-04", + "note": "Budget window in milliseconds; with `maxRequests` it sizes the token bucket (refillPerSec = maxRequests / (windowMs / 1000)). A zero-or-negative window is an armed-but-unusable budget and fails CLOSED (:102) rather than silently disabling metering." + }, + "maxRequests": { + "status": "live", + "evidence": "packages/runtime/src/security/inbound-rate-limit.ts:90", + "verifiedAt": "2026-08-04", + "note": "Bucket capacity. Same fail-closed rule as `windowMs` (:95): 'the author asked for metering and got none' is the worse outcome, so an impossible budget errors rather than passing traffic." + } + } + }, + "cacheTtl": { + "status": "live", + "evidence": "packages/runtime/src/endpoint-policy.ts:252 (cacheControlHeader)", + "verifiedAt": "2026-08-04", + "note": "Seconds, emitted as a Cache-Control response header and nothing more (#5091 narrowed the original design to header semantics only — there is no response store). Applied to SUCCESSFUL answers only: telling a client to reuse a 401/429/5xx for half a minute is worse than saying nothing. GET-only — on any other method publish refuses it (#5040 §3.3) and the runtime warns instead of emitting." + } + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 1ade846a13..2a0c8c33a1 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -97,7 +97,7 @@ const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api']; // Registered metadata types that are NOT yet governed — the coverage ratchet. // From 6aba08e9a6dcd95e72247568d0217dbf23561ca0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 20:02:19 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(i18n):=20=E9=87=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=20platform-objects=20=E7=9A=84=E5=9B=9B=E4=B8=AA=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=20bundle=20(#5271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM 返工第 2 轮。`api` 进 metadata type registry 后,platform-objects 的 metadata-forms bundle 键集多出该类型的 label/description,四个 locale 漂移。 `node scripts/check-i18n-bundles.mjs --write`(merge 模式:不覆盖任何既有翻译, 新键以源文填充)。delta 纯属本单 —— 四个 bundle 各 +4 行,只有 `api` 一个键: api: { label: "API Endpoint", description: "Declarative HTTP endpoint — …" } 非英文 bundle 里这两条暂为英文源文,这是 merge 模式的既定产物("they still need translating");`check:i18n-coverage` 的冻结基线未被突破,故两门皆绿。 手改 `.generated.ts` 不是这条链的修法。 ⚠️ 这是我第三次漏门(liveness → docs → i18n),根因是我一直按"改了哪个包就跑 哪个包的门"收工,而这三道都是**仓根**的、由被改内容触发而非由被改目录触发。 已改为按 `.github/workflows` 的 job 清单逐条过:本次 push 前跑完 44 道 CI 必跑 门(lint.yml 25 道 + spec-liveness-check.yml 4 道 + spec 生成物 13 道 + validate-deps.yml 2 道),全绿。 注:i18n 两门必须在 **full build 之后**跑 —— 抽取器经 extract config 读 `@objectstack/spec` 的 dist、并跑构建后的 CLI。未构建时它报的是九个包 "extract failed — no output",很容易被误读成配置坏了而不是缺构建。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a907eb829d..085d94f197 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1351,6 +1351,10 @@ export const enMetadataForms: NonNullable = { external_catalog: { label: "External Catalog" }, + api: { + label: "API Endpoint", + description: "Declarative HTTP endpoint — a stable URL and policy layer over an existing pipeline (ADR-0121)" + }, translation: { label: "Translation" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 36d649cae8..c68cf55488 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1351,6 +1351,10 @@ export const esESMetadataForms: NonNullable = external_catalog: { label: "Catálogo externo" }, + api: { + label: "API Endpoint", + description: "Declarative HTTP endpoint — a stable URL and policy layer over an existing pipeline (ADR-0121)" + }, translation: { label: "Traducción" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 3b57001a82..9c0b5dd136 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1351,6 +1351,10 @@ export const jaJPMetadataForms: NonNullable = external_catalog: { label: "外部カタログ" }, + api: { + label: "API Endpoint", + description: "Declarative HTTP endpoint — a stable URL and policy layer over an existing pipeline (ADR-0121)" + }, translation: { label: "翻訳" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 405fd721b8..8ede8c5ca7 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1351,6 +1351,10 @@ export const zhCNMetadataForms: NonNullable = external_catalog: { label: "外部目录" }, + api: { + label: "API Endpoint", + description: "Declarative HTTP endpoint — a stable URL and policy layer over an existing pipeline (ADR-0121)" + }, translation: { label: "翻译" },