Skip to content

Commit 845542a

Browse files
committed
feat(spec): api 补进 DEFAULT_METADATA_TYPE_REGISTRY 与 BUILTIN_METADATA_TYPE_SCHEMAS (#5271)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
1 parent 88b9b2d commit 845542a

13 files changed

Lines changed: 678 additions & 37 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `api` is a declared metadata kind — `DEFAULT_METADATA_TYPE_REGISTRY` + `BUILTIN_METADATA_TYPE_SCHEMAS` (#5271, part of #5206)
6+
7+
`api` items were produced, indexed and executed while the spec declared the kind
8+
nowhere. Artifact ingest maps `defineStack({ apis })` to `api` metadata
9+
(`ARTIFACT_FIELD_TO_TYPE`), the endpoint matcher indexes them
10+
(`buildEndpointIndex`), and #5040's executor serves them — but
11+
`DEFAULT_METADATA_TYPE_REGISTRY` had no `{ type: 'api', … }` entry and
12+
`BUILTIN_METADATA_TYPE_SCHEMAS` had no `api` binding. So
13+
`getMetadataTypeSchema('api')` returned `undefined` and `saveMetaItem` took its
14+
documented "unregistered type is stored without validation" branch:
15+
`PUT /api/v1/meta/api/:name` accepted **any JSON** and answered 200. That is
16+
`declared ≠ enforced` read backwards — enforced but **undeclared**.
17+
18+
Both halves are now declared, which is one fix with two faces:
19+
20+
- **A body is validated.** The existing 422 `invalid_metadata` path applies to
21+
`api` like every other kind, with structured Zod issues naming the offending
22+
key. An endpoint with no `target`, or no `type`, is refused instead of stored.
23+
- **The type is describable.** `/meta/types` emits a real JSON Schema and a
24+
create seed for `api`, so the metadata-admin engine renders a form rather than
25+
a raw-JSON textarea, and the entry carries a real label, domain and file
26+
patterns instead of the synthesised `label: 'api'`, `filePatterns: []`
27+
placeholder a type with no registry row gets.
28+
29+
**The write door is unchanged.** `allowRuntimeCreate: true` records what the
30+
runtime already did: with no static registry entry, both write gates
31+
(`isRuntimeCreateAllowed`, `assertAllowed`) fall through to "runtime-creatable",
32+
and both name `api` in that comment. `allowOrgOverride` stays `false`, also its
33+
effective value today — an endpoint is the publishing package's outward URL
34+
contract, and a per-org fork could move `path`, flip `authRequired` or drop
35+
`rateLimit` on a URL third parties integrate against. Marking the type code-only
36+
instead (`allowRuntimeCreate: false` + `allowOrgOverride: false`) was considered
37+
and rejected: it would turn today's 200 into a 403 rather than validate it, and
38+
#5086's refusal runs before persistence for drafts too, which would leave
39+
#5206 step 2's `publishPackageDrafts` endpoint gate with no draft to gate.
40+
41+
**`ApiEndpointSchema` gains the ADR-0010 protection envelope, and stays open to
42+
unknown keys.** Every registered kind must declare the envelope its loader
43+
stamps (`_packageId` / `_provenance`), or it is dropped on every parse; that
44+
spread is added. Closing the shape against unknown keys was attempted and
45+
**measured to be unsafe**: the same schema parses stored rows as well as
46+
authored declarations (`buildEndpointIndex`, `gateApiItemsForPublish`), and a
47+
stored row carries the metadata layer's own bookkeeping (`packageId`, `state`),
48+
so `strictObject` turned 10 tests in `packages/metadata` red — the load-time
49+
backstop excluded endpoints and the publish gate reported a schema error in
50+
place of its ADR-0121 D6 verdict. `api` therefore joins `view` on the #4001
51+
campaign's `STILL_STRIP` list, with that measurement written into the list's own
52+
note, and the real fix (separating the stored envelope from the body at the
53+
metadata layer) is filed as #5309 rather than bought by teaching the authoring
54+
vocabulary two storage keys.
55+
56+
**This is a shape check, not a second servability judge.** ADR-0121's rules —
57+
the `apps/<namespace>` carve-out (D1/D2), anonymous-requires-an-armed-`rateLimit`
58+
(D6), the supported target subset, mapping and policy — stay with
59+
`validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure`, which run
60+
at publish and again at load. A pin test asserts an anonymous unmetered endpoint
61+
parses green here and is still refused by the gate, so the two never grow
62+
competing opinions.
63+
64+
**Upgrade note (not purely additive).** A stored `api` row that does not satisfy
65+
`ApiEndpointSchema` is refused with 422 on its **next write**; reads and the
66+
existing load-time behaviour are unchanged (the matcher already excluded
67+
unparseable rows loudly, #5189). Every `api` declaration reachable in this repo
68+
— the two E8-migrated showcase endpoints and the two dogfood policy-fixture
69+
endpoints — was parsed against `ApiEndpointSchema` before landing this: all four
70+
clean. A live deployment's `sys_metadata` cannot be scanned from CI; an operator
71+
holding hand-written `api` rows should run `GET /api/v1/meta/diagnostics?type=api`
72+
(which now covers the type) before upgrading.
73+
74+
ADR-0088's admission test is satisfied on all three clauses: independent
75+
lifecycle (the matcher indexes and invalidates one item at a time), declarative
76+
governability (`allowRuntimeCreate` plus file patterns), and a real consumer
77+
(#5040's executor, boot-proven by #5040 E8). This does not reverse the `router`
78+
kind's retirement — `router`'s delivered forms are code contributions, whereas a
79+
single `ApiEndpoint` is a declarative artifact, exactly the "third, real
80+
delivered form" ADR-0088's own `router` row anticipated.

examples/app-showcase/src/coverage.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
115115
'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.',
116116
issue: ISSUE.noAuthoringSurface,
117117
},
118+
// [#5271] `api` graduated from STACK_COLLECTION_COVERAGE into the registry:
119+
// it is now a real metadata kind (`DEFAULT_METADATA_TYPE_REGISTRY` +
120+
// `BUILTIN_METADATA_TYPE_SCHEMAS`), so its coverage is owned here. The notes
121+
// below moved verbatim from the old `STACK_COLLECTION_COVERAGE.apis` entry —
122+
// the proof did not change, only which manifest is responsible for it.
123+
api: {
124+
status: 'demonstrated',
125+
files: ['src/system/apis/index.ts'],
126+
notes:
127+
'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.',
128+
},
118129
translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] },
119130
email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] },
120131
doc: {
@@ -179,12 +190,10 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
179190
files: ['src/data/extensions/account.extension.ts'],
180191
notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).',
181192
},
182-
apis: {
183-
status: 'demonstrated',
184-
files: ['src/system/apis/index.ts'],
185-
notes:
186-
'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.',
187-
},
193+
// `apis` is NOT listed here any more: as of #5271 it is a registry kind, so
194+
// its coverage lives in `KIND_COVERAGE.api` above. Leaving a duplicate row in
195+
// this manifest — whose contract is "stack collections that are NOT registry
196+
// kinds" — would mean two places to update and one of them silently wrong.
188197
connectors: {
189198
status: 'demonstrated',
190199
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],

packages/metadata/src/endpoint-matcher.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,51 @@ describe('buildEndpointIndex', () => {
131131
expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false);
132132
});
133133

134-
it('strips storage annotations (_lock / packageId) rather than choking on them', () => {
134+
// [#5271] REPLACED, not re-spelled. This case used to read "strips storage
135+
// annotations (_lock / packageId) rather than choking on them" and asserted
136+
// `not.toHaveProperty('_lock')` over a fixture spelling `_lock: { managed:
137+
// true }` — a shape ADR-0010 never defined (`_lock` is the 4-state enum
138+
// none / no-overlay / no-delete / full). Both halves stopped being right when
139+
// `api` became a registered kind (#5271, part of #5206):
140+
//
141+
// • `ApiEndpointSchema` now declares `...MetadataProtectionFields`, because
142+
// every registered kind must — the loader stamps the envelope and an
143+
// undeclared one is DROPPED on every parse, losing protection metadata on
144+
// round-trip. So the old assertion pinned exactly the defect that spread
145+
// fixes, and it would have kept passing for the wrong reason.
146+
// • The invented `_lock` object is now a VALUE error rather than an unknown
147+
// key, so the endpoint would be excluded from the index entirely — the
148+
// fixture's own premise ("does not choke") silently inverted.
149+
//
150+
// What remains true, and is what this case now pins: a stored row carries
151+
// BOTH the ADR-0010 envelope (declared ⇒ survives) and the metadata layer's
152+
// own bookkeeping (`packageId` / `state`, written by `MetadataManager` and
153+
// NOT endpoint vocabulary ⇒ stripped). That split is the measured reason
154+
// `api` sits on the #4001 campaign's STILL_STRIP list: closing this shape
155+
// would make every stored row unparseable here.
156+
it('keeps the ADR-0010 envelope and strips the metadata layer’s bookkeeping', () => {
135157
const index = buildEndpointIndex(
136-
[{ ...endpoint(), _lock: { managed: true }, _packageId: 'pkg_showcase' }],
158+
[{
159+
...endpoint(),
160+
// ADR-0010 envelope — declared by the schema, so it round-trips.
161+
_lock: 'no-overlay',
162+
_packageId: 'pkg_showcase',
163+
_provenance: 'package',
164+
// Metadata-layer bookkeeping — not endpoint vocabulary, so stripped.
165+
packageId: 'com.objectstack.showcase',
166+
state: 'active',
167+
}],
137168
makeLogger(),
138169
);
139-
const hit = index.get('GET /api/v1/apps/showcase/tasks')!;
140-
expect(hit).toBeDefined();
141-
expect(hit as Record<string, unknown>).not.toHaveProperty('_lock');
170+
171+
const hit = index.get('GET /api/v1/apps/showcase/tasks');
172+
expect(hit, 'a stored row with storage annotations must still index').toBeDefined();
173+
174+
const body = hit as unknown as Record<string, unknown>;
175+
expect(body._packageId).toBe('pkg_showcase');
176+
expect(body._lock).toBe('no-overlay');
177+
expect(body).not.toHaveProperty('packageId');
178+
expect(body).not.toHaveProperty('state');
142179
});
143180
});
144181

packages/objectql/src/metadata-validation-sweep.test.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313
* 2. A deliberately broken payload (missing required field) →
1414
* expect `invalid_metadata` + status 422 + structured `issues[]`.
1515
*
16-
* Types without a Zod schema in the central registry (`function`,
17-
* `service`, `router`, plugin-only types like `theme`/`api`/`webhook`) are
18-
* still expected to pass through unvalidated — that is the documented
19-
* fall-through, not a regression. We pin it explicitly so any future
20-
* coverage gap is visible in the report.
16+
* Types without a Zod schema in the central registry (plugin-only types like
17+
* `theme`/`webhook`) are still expected to pass through unvalidated — that is
18+
* the documented fall-through, not a regression. We pin it explicitly so any
19+
* future coverage gap is visible in the report.
20+
*
21+
* [#5271] `api` LEFT that bucket. It was the specimen this paragraph named
22+
* while `PUT /meta/api/:name` stored arbitrary JSON (#5206); it is now a
23+
* registered kind with `ApiEndpointSchema` bound, so it is swept like any
24+
* other runtime-creatable type and has a fixture below.
2125
*/
2226

2327
import { describe, it, expect, vi } from 'vitest';
@@ -193,6 +197,36 @@ const FIXTURES: Record<string, Fixture> = {
193197
invalid: { apps: { sweep_app: { label: 'Sweep' } } },
194198
invalidatedField: 'locale',
195199
},
200+
// [#5271, part of #5206] `api` used to sit in this file's "no schema →
201+
// fall-through" bucket (see the module doc). It now has one, so it gets a
202+
// real fixture: the valid body is the E8-migrated showcase shape (an
203+
// `object_operation` endpoint under its stack's ADR-0121 D1 carve-out), and
204+
// the invalid body drops `target`, which `ApiEndpointSchema` requires.
205+
//
206+
// The invalid body is deliberately a SCHEMA violation, not a publish-gate
207+
// violation: an off-carve-out path or an anonymous-without-armed-budget
208+
// endpoint parses green here and is refused one door later, by
209+
// `validateApiEndpointDeclarations` (publish) / `buildEndpointIndex`
210+
// (load). This sweep must pin the door it actually is, or it would claim
211+
// coverage for a judgement it never makes.
212+
api: {
213+
valid: {
214+
name: 'sweep_task_feed',
215+
path: '/api/v1/apps/sweep/tasks',
216+
method: 'GET',
217+
type: 'object_operation',
218+
target: 'sweep_task',
219+
objectParams: { object: 'sweep_task', operation: 'find' },
220+
authRequired: true,
221+
},
222+
invalid: {
223+
name: 'sweep_task_feed',
224+
path: '/api/v1/apps/sweep/tasks',
225+
method: 'GET',
226+
type: 'object_operation',
227+
},
228+
invalidatedField: 'target',
229+
},
196230
email_template: {
197231
valid: {
198232
name: 'sweep.welcome',

0 commit comments

Comments
 (0)