diff --git a/.changeset/api-runtime-create-withdrawn.md b/.changeset/api-runtime-create-withdrawn.md new file mode 100644 index 0000000000..00ede0b339 --- /dev/null +++ b/.changeset/api-runtime-create-withdrawn.md @@ -0,0 +1,77 @@ +--- +'@objectstack/spec': major +'@objectstack/metadata-protocol': major +'@objectstack/objectql': major +--- + +refactor(spec)!: `api` is code-only — withdraw a runtime create door the endpoint matcher could never read (#5488, ADR-0049 remove side) + + + +**FROM → TO:** `PUT /api/v1/meta/api/{name}` (200 "Saved") → declare the endpoint as a +stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through +`publishPackage`. The runtime write now answers **403 `NOT_CREATABLE`**, in `?mode=draft` +as well as direct-active. The artifact route is **unchanged** — a `**/*.api.ts` file valid +before this release is valid after it, byte for byte. + +`DEFAULT_METADATA_TYPE_REGISTRY`'s `api` entry declared `allowRuntimeCreate: true` and the +runtime never honoured it. Measured on a real showcase boot (`objectstack dev --fresh`, 47 +plugins): + +``` +PUT /api/v1/meta/api/e8_backdoor → 200 {"success":true,…,"message":"Saved …"} +GET /api/v1/apps/showcase/backdoor → 404 (anonymous AND authenticated) +``` + +…and **no** `[EndpointMatcher] … EXCLUDED` line anywhere in the boot log: the endpoint was +not gated out, it was never in the index at all. The serving criterion belongs to +`IMetadataService.matchEndpoint` → `EndpointMatcher` → `MetadataManager.listForIndex('api')`, +which reads the manager's own registry plus its registered loaders +(`["filesystem","memory"]` on dev/serve). A runtime write lands in `sys_metadata`, which is +in neither. So the declaration promised a capability that could not exist. + +A declared-but-unhonoured capability is ADR-0049 false compliance, and "answers Saved, then +404s forever" is its most dangerous shape for the AI authors ADR-0033 targets. The +maintainer ruled REMOVE on 2026-08-07 rather than converge the read path: making the matcher +read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 +miss-vs-outage distinction on a new read path, and there is no business pull for +Studio-authored endpoints today — 17.x serves declarative endpoints through stack artifacts, +which is what showcase uses (#5040 E8, LIVE). + +## The retirement kit + +- **`allowRuntimeCreate: false`** on the `api` registry entry. With `allowOrgOverride` + already `false`, the type is now **code-only** — the `job` / `agent` / `capability` shape — + so the existing #5086 inlet refuses before persistence, on every kernel, with + `code: 'NOT_CREATABLE'`, `status: 403` and a prescription derived from the entry's own + `filePatterns[0]`. No new refusal mechanism was written for this. +- **`gateApiDraftsForPublish` is retired** (`metadata-protocol`), together with its nine + tests and the `PUBLISH_DRAFTS_NAMESPACE_REMEDY` string only it appended. It landed two + days earlier in PR #5279 and is removed **deliberately and on the record**, not lost in a + refactor: it gated a draft→active promotion into a state the matcher can never read, and + with the inlet closed no `api` draft can exist for it to judge. The in-place comment at + its old call site carries the reasoning. +- **The `metadata-plugin.zod.ts` decision block is rewritten as a recorded overturn.** It + used to record CODE-ONLY as "considered and rejected"; its three bullets are kept verbatim + with what became of each, so the reversal is auditable rather than silently contradicted. +- **The `api` create seed is removed** and `api` joins `KNOWN_UNSEEDED`. A pre-filled "New + API Endpoint" form whose save can only 403 is the UI half of the same false compliance. +- **Pins, not deletions.** The two #5271 tripwire pins that asserted + `allowRuntimeCreate: true` are **replaced** by retirement pins asserting the new verdict — + their comments predicted this exact consequence, and both predictions were correct. Every + rejection case asserts `code` **and** `status` (ADR-0112 envelope), never `toThrow()` + alone (#6142). + +## What did NOT change + +`validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` remain the one judge +of what is servable, on the route that serves: the stack schema, `publishPackage` (#5189), +and again at load in `buildEndpointIndex` (PR #5203). ADR-0121's "publish REJECTS" ruling is +intact. `deleteMetaItem` stays ungated so pre-existing rows can be cleaned up, and +`OS_METADATA_WRITABLE=api` remains the single operator escape hatch — note it unlocks the +**write** only; the endpoint still will not be served, which is why it is a diagnostic +rather than a workaround. + +**Re-entry path**, recorded by the ruling: if #2657 Part B promotes `apis` to a registered +type **with a real consumption path**, the flag and the publish gate come back together — +implementation first, declaration second. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 319dbc27b8..48c15e828e 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -411,6 +411,9 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 - **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361. - Done when: No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading `response.cursor` no longer type-checks either, and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `unreadCount` is untouched (#6363) and still reports the total across the whole matching inbox rather than the window. A caller that omitted `limit` receives the same 50 rows it always received. +- **`api-runtime-create-withdrawn`** — `PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)` → Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage` + - Why not automatic: The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311). + - Done when: No caller creates or updates an `api` item through the runtime metadata API. `PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` as well as direct-active, because the gate runs before the draft/publish branch and does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring `apis:` still compiles, still passes `validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load (`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Any `api` rows already sitting in `sys_metadata` from before this change were never served either; they can be deleted (`deleteMetaItem` is deliberately not gated by this refusal, so repair stays possible). - **`import-run-automations-declared-default-corrected`** — `api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as "off by default for bulk"; it is now default(true), which is what the server has always done` → an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing - Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078. - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says (#6037). 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 c19f53b460..21f609af5d 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 @@ -13,36 +13,56 @@ import { identityFreeEndpointGateFailure, ApiEndpointSchema } from '@objectstack import { ObjectStackProtocolImplementation } from './protocol.js'; /** - * Regression for #5206 step 2 (engine half) — `publishPackageDrafts` runs the - * ADR-0121 / #5040 E7 endpoint publish gates on `api` drafts. + * RETIREMENT PINS (#5488) — this file used to pin `gateApiDraftsForPublish`, + * the ADR-0121 / #5040 E7 endpoint publish gate that #5206 step 2 (PR #5279) + * added to `publishPackageDrafts`. That gate is GONE, deliberately, and these + * tests now pin the reason it can never be needed again. * - * ## What was open + * ## What the retired gate did, and why it went * - * `protocol.publishPackageDrafts` is the REAL entry point behind Studio's - * "publish everything" (ADR-0033 / ADR-0067 D2). Its only type-aware pre-flight - * was the object namespace-prefix rule (`validateObjectNamespacePrefix`, gated - * on `d.type === 'object'`), so an `api` draft was promoted draft→active having - * met no gate at all — the same shape #5189 closed on - * `MetadataManager.publishPackage`, one path over. + * It judged whether an `api` draft was fit to be promoted draft→active in + * `sys_metadata`. The measurement that ended it (#5488, real showcase boot) is + * that **no such row is ever served**: the serving criterion belongs to + * `IMetadataService.matchEndpoint` → `EndpointMatcher` → + * `MetadataManager.listForIndex('api')`, which reads the manager's registry + * plus its filesystem/memory loaders. A runtime write lands in `sys_metadata`, + * which is in neither — so `PUT /api/v1/meta/api/:name` answered 200 "Saved" + * and the declared route then 404'd forever, with no `[EndpointMatcher] … + * EXCLUDED` line, because the endpoint was never in the index to be excluded + * from. The gate was a correct verdict about a state with no consumer. * - * The SECURITY consequence was already contained by PR #5203: the endpoint - * matcher re-judges every stored item at index-build time with the same - * `firstFailure`, so an anonymous zero-quota endpoint that skipped publish is - * EXCLUDED from the index and named at `error` level. What was still broken is - * WHEN the author is told: ADR-0121 says publish refuses, and the author is - * owed a prescription naming the offending key at publish time — not a line in - * a boot log. These tests pin the earlier door; the last one is asserted to - * still be there (`identityFreeEndpointGateFailure`, bottom of the file). + * The maintainer ruled on 2026-08-07T16:59Z (Option B): flip the `api` registry + * entry to `allowRuntimeCreate: false` and let the existing #5086 inlet refuse + * loudly — ADR-0049's remove side, since a capability that is declared and not + * honoured is false compliance. The flip makes `api` code-only, and the #5086 + * inlet runs BEFORE persistence and BEFORE the draft/publish branch (it does + * not read `mode`), so no `api` draft can be authored at all. A gate for a row + * that cannot exist is a phantom check, so it was removed with the door. * - * ## Failure granularity — mirrored, not invented + * ## What these tests pin instead * - * The pre-existing posture for a namespace-prefix violation is a WHOLE-BATCH - * refusal found before anything is promoted: `success: false`, - * `publishedCount: 0`, `published: []`, every violation in `failed[]`. The - * endpoint gate joins that same pre-flight and behaves identically (ADR-0067 - * D2's "a commit cannot half-land", and what #5189 does on the sibling path - * with `itemsPublished: 0`). Tests below assert the healthy siblings of a bad - * `api` draft stay drafts. + * 1. The refusal, in the ADR-0112 envelope — `code` AND `status`, not merely + * "it threw" (#6142): a bare throw would stay green on a driver that throws + * the wrong error, which is the whole defect class here. + * 2. That the refusal covers the DRAFT door too, not just direct-active — that + * is the specific prediction the #5271 tripwire comments made, and it is now + * the intended behaviour rather than a warning. + * 3. That nothing is persisted by the refused write. + * 4. That `publishPackageDrafts` is otherwise untouched: non-`api` drafts + * publish exactly as before. + * + * ## What still judges endpoints (unchanged, and asserted at the bottom) + * + * `validateApiEndpointDeclarations` / `identityFreeEndpointGateFailure` + * (`spec/api/endpoint-publish-gate`) on the route that actually serves — the + * stack schema, `publishPackage` (#5189), and again at load in + * `buildEndpointIndex` (PR #5203). ADR-0121's "publish REJECTS" ruling is + * intact on that route; only the runtime-authored-draft door it also used to + * cover is gone, because that door opened onto nothing. + * + * Re-entry, as the ruling recorded it: if #2657 Part B promotes `apis` to a + * registered type WITH A REAL CONSUMPTION PATH, the flag and this gate come + * back together — implementation first, declaration second. */ interface Row { @@ -201,193 +221,127 @@ const objectBody = (name: string) => ({ fields: { title: { type: 'text', label: 'Title' } }, }); -/** Save one `api` draft the way the ungated Studio direct-write path does. */ -async function saveApiDraft( +/** + * Attempt one `api` write the way Studio's direct-write path does, and return + * the thrown error. `mode` is the point of the parameter: the #5086 inlet runs + * before the draft/publish branch and never reads it, so BOTH doors must refuse. + */ +async function attemptApiWrite( protocol: ObjectStackProtocolImplementation, name: string, item: unknown, -): Promise { - await protocol.saveMetaItem({ type: 'api', name, item: item as any, packageId: PKG, mode: 'draft' }); + opts: { mode?: 'draft' | 'publish'; organizationId?: string } = {}, +): Promise { + try { + await protocol.saveMetaItem({ + type: 'api', + name, + item: item as any, + packageId: PKG, + ...(opts.mode ? { mode: opts.mode } : {}), + ...(opts.organizationId ? { organizationId: opts.organizationId } : {}), + } as any); + } catch (err) { + return err; + } + throw new Error(`expected the #5086 inlet to refuse the api write '${name}', but it resolved`); +} + +/** + * The ADR-0112 envelope a code-only refusal must carry. Asserted as `code` AND + * `status` — never `rejects.toThrow()` alone (#6142): the unfixed shape here + * ALSO throws (or, on the pre-#5488 build, resolves with a 200 receipt), so a + * throw-only assertion carries one bit where the defect has two. + */ +function expectCodeOnlyRefusal(err: any, expectedCode: 'NOT_CREATABLE' | 'NOT_OVERRIDABLE') { + expect(err.code).toBe(expectedCode); + expect(err.status).toBe(403); + // The prescription must point at the route that DOES serve endpoints — the + // stack artifact compiled and shipped through `publishPackage`. It is + // derived from the registry entry's own `filePatterns[0]`, so it cannot + // drift from the declaration that produced the refusal. + expect(err.message).toContain('**/*.api.ts'); + expect(err.message).toContain('allowRuntimeCreate=false'); } const draftRows = (rows: Map) => Array.from(rows.values()).filter((r) => r.state === 'draft'); const activeRows = (rows: Map) => Array.from(rows.values()).filter((r) => r.state === 'active'); -describe('publishPackageDrafts — the ADR-0121 endpoint publish gate (#5206 step 2)', () => { - it('refuses an anonymous, unmetered `api` draft (D6) and NAMES the key to fix', async () => { +describe('`api` runtime writes are refused at the inlet — the retired gate’s replacement (#5488)', () => { + it('refuses a DIRECT-ACTIVE `api` write with the ADR-0112 envelope, and persists nothing', async () => { const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); - // The exact shape ADR-0121 D6 exists to prevent, and the one the - // runtime honours faithfully: anonymous + no armed budget. - await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', authRequired: false })); - - // Before this fix: { success: true, publishedCount: 1 } — promoted - // straight to active, ungated. - const res = await protocol.publishPackageDrafts({ packageId: PKG }); + // A body that is perfectly schema-valid and perfectly servable-looking. + // That matters: only a body nothing else would reject proves the + // refusal came from the registry consult (#5086), not from a 422. + const err = await attemptApiWrite(protocol, 'list_things', validEndpoint()); - expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); - expect(res.published).toEqual([]); - expect(res.failed).toHaveLength(1); - expect(res.failed[0]).toMatchObject({ type: 'api', name: 'open_things', code: 'ENDPOINT_GATE' }); - // The gate's OWN message — named endpoint, named key, prescription. - expect(res.failed[0].error).toContain("Endpoint 'open_things'"); - expect(res.failed[0].error).toContain('`authRequired: false` without an ARMED rate limit'); - expect(res.failed[0].error).toContain('rateLimit: { enabled: true'); - - // Nothing was promoted: the draft is still a draft. + expectCodeOnlyRefusal(err, 'NOT_CREATABLE'); + // #5264 — a refused write asks the engine for nothing at all. + expect(rows.size).toBe(0); expect(activeRows(rows)).toHaveLength(0); - expect(draftRows(rows).map((r) => r.name)).toEqual(['open_things']); }); - it('a `rateLimit` that is present but NOT armed is still refused (D6 is not a presence check)', async () => { - const { engine } = makeStubEngine('showcase'); - const protocol = new ObjectStackProtocolImplementation(engine); - - await saveApiDraft( - protocol, - 'open_things', - validEndpoint({ - name: 'open_things', - authRequired: false, - // `enabled` defaults to false → a budget that meters nothing. - rateLimit: { windowMs: 60000, maxRequests: 100 }, - }), - ); - - const res = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); - expect(res.failed[0].error).toContain('`enabled` is not `true`'); - }); - - it('publishes a valid `api` draft (the gate refuses shapes, not the type)', async () => { + it('refuses a DRAFT `api` write too — the inlet runs before the draft/publish branch', async () => { + // THE pin of this file. The #5271 tripwire comments predicted exactly + // this consequence of flipping the flag ("`api` DRAFTS would become + // impossible, and #5206 step 2's endpoint gate would have nothing left + // to gate"), and it is now intended: `saveMetaItem` consults + // `isRuntimeCreateAllowed` before it ever looks at `request.mode`. const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); - await saveApiDraft(protocol, 'list_things', validEndpoint()); - - const res = await protocol.publishPackageDrafts({ packageId: PKG }); + const err = await attemptApiWrite(protocol, 'list_things', validEndpoint(), { mode: 'draft' }); - expect(res.failed).toEqual([]); - expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); - expect(res.published.map((p) => p.name)).toEqual(['list_things']); + expectCodeOnlyRefusal(err, 'NOT_CREATABLE'); expect(draftRows(rows)).toHaveLength(0); - expect(activeRows(rows).map((r) => r.name)).toEqual(['list_things']); + expect(rows.size).toBe(0); }); - it('an anonymous endpoint WITH an armed budget publishes (D6 is satisfiable, not a ban)', async () => { + it('refuses on an ORG-SCOPED kernel as well — #5086 is not a topology carve-out', async () => { const { engine, rows } = makeStubEngine('showcase'); - const protocol = new ObjectStackProtocolImplementation(engine); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test'); - await saveApiDraft( - protocol, - 'open_things', - validEndpoint({ - name: 'open_things', - authRequired: false, - rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, - }), - ); + const err = await attemptApiWrite(protocol, 'list_things', validEndpoint(), { + mode: 'draft', + organizationId: 'org_1', + }); - const res = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); - expect(activeRows(rows).map((r) => r.name)).toEqual(['open_things']); + expectCodeOnlyRefusal(err, 'NOT_CREATABLE'); + expect(rows.size).toBe(0); }); - it('refuses a path outside the stack carve-out, and a duplicate METHOD+path claim', async () => { - const { engine } = makeStubEngine('showcase'); + it('refuses the D6 shape the retired gate used to catch — now one door earlier', async () => { + // The anonymous, unmetered endpoint ADR-0121 D6 exists to prevent. The + // retired gate refused it at PUBLISH with `code: 'ENDPOINT_GATE'`; + // it is now refused at AUTHORING with `code: 'NOT_CREATABLE'`, because + // the row it would have been promoted into is unreachable either way. + // The D6 criterion itself is untouched and still asserted at the bottom + // of this file on the route that serves. + const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); - await saveApiDraft( + const err = await attemptApiWrite( protocol, - 'stray', - validEndpoint({ name: 'stray', path: '/api/v1/things' }), - ); - const strayRes = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(strayRes).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); - expect(strayRes.failed[0].error).toContain("not inside this stack's endpoint carve-out"); - expect(strayRes.failed[0].error).toContain('/api/v1/apps/showcase/'); - - // Two drafts in ONE batch claiming the same METHOD + normalized path. - const dup = makeStubEngine('showcase'); - const dupProtocol = new ObjectStackProtocolImplementation(dup.engine); - await saveApiDraft(dupProtocol, 'a_things', validEndpoint({ name: 'a_things' })); - await saveApiDraft( - dupProtocol, - 'b_things', - validEndpoint({ name: 'b_things', path: '/api/v1/apps/showcase/things/' }), + 'open_things', + validEndpoint({ name: 'open_things', authRequired: false }), + { mode: 'draft' }, ); - const dupRes = await dupProtocol.publishPackageDrafts({ packageId: PKG }); - expect(dupRes).toMatchObject({ success: false, publishedCount: 0 }); - expect(dupRes.failed.some((f) => /already claimed by endpoint/.test(f.error))).toBe(true); - expect(dup.rows.size).toBe(2); - expect(activeRows(dup.rows)).toHaveLength(0); - }); - - it('refuses an `api` draft whose body does not even satisfy ApiEndpointSchema', async () => { - const { engine, rows } = makeStubEngine('showcase'); - const protocol = new ObjectStackProtocolImplementation(engine); - - // [#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 }); - expect(res.failed[0]).toMatchObject({ type: 'api', name: 'garbage', code: 'ENDPOINT_SCHEMA' }); - expect(res.failed[0].error).toContain('does not satisfy ApiEndpointSchema'); - expect(activeRows(rows)).toHaveLength(0); - }); - - it('refuses when the package declares NO namespace — the D2 precondition, reported once', async () => { - // `getPackage` → undefined: the object namespace-prefix rule - // grandfathers this (a bare object name is a naming smell), but an - // endpoint with no namespace is an UNOWNABLE URL, so the endpoint gate - // runs unconditionally and its own precondition fires. - const { engine, rows } = makeStubEngine(undefined); - const protocol = new ObjectStackProtocolImplementation(engine); - - await saveApiDraft(protocol, 'list_things', validEndpoint()); - await saveApiDraft(protocol, 'other_things', validEndpoint({ name: 'other_things', path: '/api/v1/apps/showcase/others' })); - - const res = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(res).toMatchObject({ success: false, publishedCount: 0 }); - // ONE report, not one per endpoint — the missing namespace is a - // property of the package, so it is unattributed (`name: ''`). - expect(res.failed).toHaveLength(1); - expect(res.failed[0]).toMatchObject({ type: 'api', name: '', code: 'ENDPOINT_GATE' }); - expect(res.failed[0].error).toContain('MUST declare an explicit `manifest.namespace`'); - // …plus THIS path's remedy (where to set it from here). - expect(res.failed[0].error).toContain('From `publishPackageDrafts` specifically'); - expect(activeRows(rows)).toHaveLength(0); + expectCodeOnlyRefusal(err, 'NOT_CREATABLE'); + expect(rows.size).toBe(0); }); - it('fails the WHOLE batch — healthy siblings of a bad `api` draft stay drafts', async () => { + it('publishPackageDrafts can no longer receive an `api` draft to gate', async () => { + // The structural claim behind retiring `gateApiDraftsForPublish`: with + // the inlet closed there is no way to reach a state the gate judged. + // A batch publish therefore sees an empty `api` set by construction, + // and reports no ENDPOINT_GATE / ENDPOINT_SCHEMA code ever again. const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); + await attemptApiWrite(protocol, 'open_things', validEndpoint({ name: 'open_things', authRequired: false }), { mode: 'draft' }); await protocol.saveMetaItem({ type: 'object', name: 'showcase_thing', @@ -395,40 +349,19 @@ describe('publishPackageDrafts — the ADR-0121 endpoint publish gate (#5206 ste packageId: PKG, mode: 'draft', }); - await saveApiDraft(protocol, 'list_things', validEndpoint()); - await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', path: '/api/v1/apps/showcase/open', authRequired: false })); const res = await protocol.publishPackageDrafts({ packageId: PKG }); - // Mirrors the namespace-prefix posture exactly: pre-flight refusal, - // nothing promoted — NOT "publish the two good ones". - expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 1 }); - expect(res.published).toEqual([]); - expect(res.failed.map((f) => f.name)).toEqual(['open_things']); - expect(draftRows(rows).map((r) => r.name).sort()).toEqual(['list_things', 'open_things', 'showcase_thing']); - expect(activeRows(rows)).toHaveLength(0); - }); - - it('reports object namespace-prefix AND endpoint violations in ONE report', async () => { - const { engine } = makeStubEngine('showcase'); - const protocol = new ObjectStackProtocolImplementation(engine); - - await protocol.saveMetaItem({ - type: 'object', - name: 'thing', // missing the `showcase_` prefix - item: objectBody('thing'), - packageId: PKG, - mode: 'draft', - }); - await saveApiDraft(protocol, 'open_things', validEndpoint({ name: 'open_things', authRequired: false })); - - const res = await protocol.publishPackageDrafts({ packageId: PKG }); - - expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 2 }); - expect(res.failed.map((f) => f.code).sort()).toEqual(['ENDPOINT_GATE', 'NAMESPACE_PREFIX']); + expect(res).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 }); + expect(res.failed.map((f: { code?: string }) => f.code)).toEqual([]); + expect(activeRows(rows).map((r) => r.name)).toEqual(['showcase_thing']); + expect(draftRows(rows)).toHaveLength(0); }); it('leaves non-`api` drafts alone — no endpoint gate, no behaviour change', async () => { + // Survives the retirement unchanged: it pinned that the gate was + // type-scoped, and it now pins that removing it changed nothing for + // every other type. const { engine, rows } = makeStubEngine('showcase'); const protocol = new ObjectStackProtocolImplementation(engine); diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 38e735b5b3..8acee616e0 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -102,6 +102,30 @@ const PROBES: Record }> = packageId: 'com.example.probe', }, }, + // [#5488] The fourth flagged type. `api` declared `allowRuntimeCreate: + // true` and the runtime never honoured it: `matchEndpoint` reads + // `MetadataManager.listForIndex('api')` (the manager's registry plus its + // filesystem/memory loaders), while a runtime write lands in + // `sys_metadata` — so `PUT /api/v1/meta/api/:name` answered 200 "Saved" + // and the declared route 404'd forever. ADR-0049 enforce-or-remove, ruled + // REMOVE on 2026-08-07. Endpoints are authored as stack artifacts + // (`**/*.api.ts`) and shipped through `publishPackage`, which is untouched. + // + // Schema-valid on purpose, like the three above — and here it carries + // extra weight: `api` gained `ApiEndpointSchema` in #5271, so a minimal + // body would 422 before the registry consult and the probe would prove + // nothing about the code-only gate. + api: { + name: 'rc3_api_probe', + item: { + name: 'rc3_api_probe', + path: '/api/v1/apps/example_namespace/probe', + method: 'GET', + type: 'object_operation', + target: 'example_object', + objectParams: { object: 'example_object', operation: 'find' }, + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -203,14 +227,18 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // ── the flags are data: keep the suite honest about new ones ────────── it('covers every code-only type the registry declares', () => { - // Today: job (#4509), agent (ADR-0063 §2) and capability (#5961, - // ADR-0066 D1). When a fourth type is flagged, this fails until it has - // a schema-valid probe above — which is the whole cost of covering it, - // and is exactly what happened when `capability` joined: this - // assertion and eleven generated cases went red on the spec-side - // registry edit alone, before a line of this file was touched. + // Today: job (#4509), agent (ADR-0063 §2), capability (#5961, + // ADR-0066 D1) and api (#5488, ADR-0049 remove side — the maintainer + // ruling of 2026-08-07 withdrew a runtime create door the endpoint + // matcher could never read). When a fifth type is flagged, this fails + // until it has a schema-valid probe above — which is the whole cost of + // covering it, and is exactly what happened when `capability` joined + // and again when `api` did: this assertion and the generated cases + // went red on the spec-side registry edit alone, before a line of this + // file was touched. That auto-enrolment is the point of deriving the + // set instead of listing it (Prime Directive #8). expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'capability', 'job']); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'job']); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d649120aea..d4876b1a64 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -65,15 +65,15 @@ import { } from '@objectstack/spec/kernel'; import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; import { stripReadDecorations } from '@objectstack/spec/kernel'; -// [#5206 step 2, #5040 E7 / ADR-0121] The endpoint publish gates, reused -// verbatim — see `gateApiDraftsForPublish`. `validateApiEndpointDeclarations` -// is the ONE judge of what is servable; this module calls it, it never restates -// a criterion. -import { - ApiEndpointSchema, - validateApiEndpointDeclarations, - type ApiEndpoint, -} from '@objectstack/spec/api'; +// [#5488] The `@objectstack/spec/api` import that stood here — `ApiEndpointSchema`, +// `validateApiEndpointDeclarations`, `type ApiEndpoint` — went with +// `gateApiDraftsForPublish` (see its retirement note in `publishPackageDrafts`). +// This module no longer judges endpoints at all: `api` is code-only since #5488, +// so no `api` draft can be authored here, and the ONE judge of what is servable +// stays `validateApiEndpointDeclarations` on the artifact route (stack schema, +// `publishPackage` #5189, and `buildEndpointIndex` at load, PR #5203). An +// endpoint-shaped import left behind with no caller reads as a capability this +// module still has (#3950), so it is removed rather than parked. import { z } from 'zod'; import { computeMetadataDiagnostics, @@ -221,22 +221,12 @@ function stripServedSystemColumns(type: string, item: T): T { return canonicalMetaType(type) === 'object' ? stripInjectedSystemColumns(item) : item; } -/** - * [#5206 step 2] Where an author of THIS path sets the namespace the ADR-0121 - * D2 gate demands. - * - * The criterion itself is the endpoint gate's — this sentence only answers the - * follow-up question "so where do I put it, from here?", which differs per - * publish path (`MetadataManager.publishPackage` has its own, #5189). It is - * appended to the gate's own message, never in place of it. - */ -const PUBLISH_DRAFTS_NAMESPACE_REMEDY = - "From `publishPackageDrafts` specifically: the namespace is read from the package's registered " - + '`manifest.namespace`, so declare it on the manifest and re-install/update the package ' - + '(`installPackage` derives a default from the package id for Studio-authored packages, so an ' - + 'absent one means the package is not in the runtime registry or its manifest predates that). ' - + 'Alternatively publish the endpoints as part of a stack artifact (`defineStack` → compile → ' - + 'artifact ingest), which carries the manifest and runs these same gates at parse time.'; +// [#5488] `PUBLISH_DRAFTS_NAMESPACE_REMEDY` stood here (#5206 step 2): the +// "so where do I declare the namespace, from here?" sentence appended to the +// ADR-0121 D2 gate's own message on this path. It is retired with +// `gateApiDraftsForPublish`, the only thing that ever appended it — this path +// no longer reports endpoint violations, because it can no longer receive an +// `api` draft to violate anything. /** * [#3770] One-shot flag for the "engine has no schema registry" warning emitted @@ -9664,140 +9654,6 @@ export class ObjectStackProtocolImplementation implements return { drafts }; } - /** - * [#5206 step 2, #5189, #5040 E7 / ADR-0121] Run the endpoint publish gates - * over a batch's `api` drafts and report every failure as a publish-blocking - * violation. - * - * ## Why it exists on THIS path - * - * E7 (#5111) hung the per-endpoint gates on `ObjectStackDefinitionSchema`, - * so every path that parses a STACK is covered. `publishPackageDrafts` - * parses no stack: it reads `sys_metadata` draft rows and promotes them. - * #5189 closed the same hole on `MetadataManager.publishPackage`; this is - * the other door, and the one Studio's "publish everything" button actually - * goes through (ADR-0033 / ADR-0067 D2). Until now the only type-aware - * pre-flight here was the object namespace-prefix check, so an `api` draft - * — anonymous, unmetered, whatever — became `active` unjudged. - * - * ## One judge, never a second criteria set - * - * Everything this method decides comes from - * {@link validateApiEndpointDeclarations}: the same function the stack - * schema runs, the same function `publishPackage` runs, and (minus the two - * identity-bearing gates) the same `firstFailure` the endpoint matcher's - * load-time backstop runs. The messages are the gate's own — they already - * name the endpoint, the offending key and the fix, which is the whole - * point of refusing HERE instead of in a boot log. Nothing in this file - * restates a rule about what is servable. - * - * ## Why it can run the FULL gate, namespace included - * - * Unlike `publishPackage` — which indexes by `packageId`, holds no manifest - * and must be handed a `namespace` — this path already resolves the - * package's declared `manifest.namespace` for the object-prefix rule. So - * the namespace gate (ADR-0121 D1/D2) is judgeable here and is judged. - * Deliberately NOT conditional on the namespace being present: the gate's - * own precondition ("a stack that declares `apis:` MUST declare an explicit - * `manifest.namespace`") is a criterion, and skipping it when the answer is - * "there is none" would reopen the hole for exactly the packages least - * likely to have been through a stack compile. The object-prefix rule above - * grandfathers namespace-less packages because a bare object name is a - * naming smell; a namespace-less endpoint is an unownable URL. - * - * ## Boundaries, stated rather than silently assumed - * - * - Judged over the drafts BEING PROMOTED, mirroring the object-prefix rule - * directly above it. A draft that collides with an already-`active` - * endpoint of the same package is therefore not caught here; the matcher - * resolves store-wide duplicate claims deterministically and names the - * loser at `error` level (`buildEndpointIndex`). Widening this to the - * package's whole active set would mean refusing a publish over something - * the author is not publishing — a different contract, not a bug fix. - * - A draft row that vanished between `listDrafts` and here is skipped, not - * invented into a gate failure: the promote loop reports the real - * `no_draft` for it. - * - A store read that FAILS propagates. "Could not read the draft" must - * never be answered with "the gate passed" (ADR-0110 D3's distinction, - * same reason the matcher refuses to turn an outage into a 404). - * - * @param drafts the batch's draft headers, as `listDrafts` returned them. - * @param namespace the package's declared `manifest.namespace`, or - * `undefined` when it declares none (the gate reports that itself). - * @returns one entry per violation, `[]` when the batch has no `api` - * drafts (a package without endpoints is untouched by this pass). - */ - private async gateApiDraftsForPublish( - drafts: ReadonlyArray<{ type: string; name: string; organizationId: string | null }>, - namespace: string | undefined, - ): Promise> { - const apiDrafts = drafts.filter((d) => canonicalMetaType(d.type) === 'api'); - if (apiDrafts.length === 0) return []; - - const violations: Array<{ type: string; name: string; error: string; code: string }> = []; - /** Parsed endpoints, index-aligned with {@link gatedNames}. */ - const endpoints: ApiEndpoint[] = []; - const gatedNames: string[] = []; - - for (const d of apiDrafts) { - const draftOrgId = d.organizationId ?? null; - const draftRepo = this.getOverlayRepo(draftOrgId); - const ref = { type: 'api', name: d.name, org: draftOrgId ?? 'env' } as unknown as Parameters[0]; - const draft = await draftRepo.get(ref, { state: 'draft' }); - if (!draft) continue; // raced away — the promote loop reports `no_draft` - - // Parsing is the gate's PRECONDITION, not a sixth gate: an - // `ApiEndpoint` is what `validateApiEndpointDeclarations` judges. - // Refusing an unparseable draft here is the same ruling #5189 made - // on the sibling path — a shape that cannot be gated could not be - // served either (the matcher's own loud skip refuses it at load), - // so publishing it would mint a route that answers 404 forever. - const parsed = ApiEndpointSchema.safeParse(draft.body); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - violations.push({ - type: 'api', - name: d.name, - error: - `api draft '${d.name}' does not satisfy ApiEndpointSchema and cannot be published: ` - + `${issue.message} (at ${issue.path.join('.') || ''}). An endpoint that does ` - + `not parse cannot be gated (ADR-0121) and would be EXCLUDED from endpoint ` - + `matching at load anyway, so its declared route would answer 404.`, - code: 'ENDPOINT_SCHEMA', - }); - } - continue; - } - endpoints.push(parsed.data); - gatedNames.push(d.name); - } - - for (const issue of validateApiEndpointDeclarations(endpoints, { namespace })) { - // The gate reports per-endpoint issues at `['apis', , …]` and - // the namespace PRECONDITION once at `['apis']` — the latter is a - // property of the package, not of any one endpoint, so it is - // reported once, unattributed, with this path's own remedy appended. - const index = typeof issue.path[1] === 'number' ? issue.path[1] : undefined; - if (index === undefined) { - violations.push({ - type: 'api', - name: '', - error: `${issue.message} ${PUBLISH_DRAFTS_NAMESPACE_REMEDY}`, - code: 'ENDPOINT_GATE', - }); - continue; - } - violations.push({ - type: 'api', - name: gatedNames[index] ?? '', - error: issue.message, - code: 'ENDPOINT_GATE', - }); - } - - return violations; - } - /** * Publish every pending DRAFT bound to a package in one shot (ADR-0033) — * the "publish whole app" action. Promotes each draft→active by reusing the @@ -9893,19 +9749,43 @@ export class ObjectStackProtocolImplementation implements } } - // [#5206 step 2, #5040 E7 / ADR-0121] The endpoint publish gates on the - // OTHER publish path. `MetadataManager.publishPackage` gained them in - // #5189; this function — the real entry point behind Studio's "publish - // everything" (ADR-0033 / ADR-0067 D2) — promoted `api` drafts to - // active without meeting a single gate. The security consequence is - // already caught one layer down (PR #5203 re-judges every stored item - // at index-build time and EXCLUDES the ones that never passed), so what - // this closes is the LATENESS: ADR-0121 says publish refuses, and an - // author is owed a prescription naming the offending key here, not an - // `error` line in a boot log they never read. The load-time backstop - // stays exactly where it is — this is the earlier door, not a - // replacement for the last one. - preflightViolations.push(...(await this.gateApiDraftsForPublish(drafts, pkgNamespace))); + // [#5488 — RETIRED GATE, recorded overturn of PR #5279] + // + // `gateApiDraftsForPublish` stood here (#5206 step 2, #5040 E7 / + // ADR-0121) and ran the endpoint publish gates over this batch's `api` + // drafts. It is deliberately REMOVED, two days after it landed, by the + // maintainer ruling of 2026-08-07T16:59Z implemented in #5488 — not + // deleted as dead weight and not lost in a refactor. + // + // Why it can go: the gate judged whether an `api` draft was fit to be + // PROMOTED TO ACTIVE in `sys_metadata`. No such row is ever served. The + // serving criterion belongs to `IMetadataService.matchEndpoint` → + // `EndpointMatcher` → `MetadataManager.listForIndex('api')`, which + // reads the manager's registry plus its filesystem/memory loaders; + // `sys_metadata` is in neither, so a promoted endpoint 404s forever + // (real boot, #5488 — no `EXCLUDED` line, because it was never in the + // index to be excluded from). The gate was a correct verdict about a + // state with no consumer. + // + // Why it MUST go rather than sit unreached: `api` is now code-only + // (`allowRuntimeCreate: false` + `allowOrgOverride: false`), and the + // #5086 inlet refuses the write BEFORE persistence and BEFORE the + // draft/publish branch — it does not look at `mode`. So no `api` draft + // row can be created any more, and this gate could never again see one. + // Leaving it would leave unreachable code asserting a rule about a row + // that cannot exist, which is the shape of a phantom check. + // + // What still judges endpoints, unchanged: `validateApiEndpointDeclarations` + // / `identityFreeEndpointGateFailure` (`api/endpoint-publish-gate.ts`) + // on the route that actually serves — the stack schema, `publishPackage` + // (#5189), and again at load in `buildEndpointIndex` (PR #5203). ADR-0121 + // keeps its "publish REJECTS" ruling in full on that route; only the + // runtime-authored-draft door it used to also cover is gone, because + // that door opened onto nothing. + // + // Re-entry, as the ruling recorded it: if #2657 Part B promotes `apis` + // to a registered type WITH A REAL CONSUMPTION PATH, this gate comes + // back with it — implementation first, declaration second. if (preflightViolations.length > 0) { return { diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 4c49b34b46..e5cad32324 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -19,9 +19,17 @@ * 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. + * while `PUT /meta/api/:name` stored arbitrary JSON (#5206); it became a + * registered kind with `ApiEndpointSchema` bound, and was swept like any other + * runtime-creatable type. + * + * [#5488] `api` has now left this SUITE altogether, and by a different door: + * the maintainer ruling of 2026-08-07 flipped its registry entry to + * `allowRuntimeCreate: false` (a runtime-created endpoint was never served — + * the matcher reads `listForIndex('api')`, a runtime write lands in + * `sys_metadata`). Since `creatable` below is DERIVED from that flag, the type + * drops out on its own and its fixture was removed with it. Nothing about the + * fall-through rule changed; the set it applies to did. */ import { describe, it, expect, vi } from 'vitest'; @@ -197,36 +205,17 @@ 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', - }, + // [#5488] The `api` fixture (#5271) was REMOVED here, deliberately, rather + // than left in place. This suite sweeps `DEFAULT_METADATA_TYPE_REGISTRY + // .filter((e) => e.allowRuntimeCreate)`, so flipping `api` to + // `allowRuntimeCreate: false` (maintainer ruling 2026-08-07) drops the type + // out of `creatable` by itself — and a fixture for a type the sweep no + // longer visits is never executed. It would have gone on sitting here + // looking like coverage while asserting nothing, which is the failure mode + // this file exists to detect in others. `api`'s write door now has explicit + // pins of its own: the 403 `NOT_CREATABLE` refusal in + // `protocol-meta.test.ts` and `sys-metadata-repository.test.ts` (this + // package), and the retirement pins in `metadata-protocol`. 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 d16e76bc83..cb02519404 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1629,46 +1629,53 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); // ─────────────────────────────────────────────────────────────── - // [#5271, part of #5206] `api` — the write door is UNCHANGED, the - // shape check is new. + // [#5488, overturning #5271's pins] `api` — the write door is now + // CLOSED, and it closes in front of the shape check. // - // 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. + // #5271 pinned this pair as "the write door is UNCHANGED, the shape + // check is new": a spec-valid `api` saved with 200, a spec-invalid one + // 422'd. Both assertions are inverted here, deliberately — maintainer + // ruling 2026-08-07T16:59Z, implemented in #5488. A runtime-created + // endpoint was never served (`matchEndpoint` reads the manager's + // registry + filesystem/memory loaders; a runtime write lands in + // `sys_metadata`, in neither), so ADR-0049 required enforce-or-remove + // and the ruling chose remove: `allowRuntimeCreate: false`. + // + // ⚠️ ORDER MATTERS, and it is why the 422 case inverts too: the #5086 + // inlet consults the registry BEFORE any body validation, so a + // spec-invalid `api` body no longer reaches `INVALID_METADATA` — it is + // refused as `NOT_CREATABLE` first. Pinning both bodies at the same + // verdict is what proves the gate is the TYPE gate and not the shape + // gate wearing a new code. // ─────────────────────────────────────────────────────────────── - it('accepts a spec-valid `api` item (write door unchanged by the registry entry)', async () => { + it('refuses a spec-VALID `api` item — the type is code-only since #5488', async () => { mockEngine.findOne.mockResolvedValue(null); - const result = await scoped.saveMetaItem({ - type: 'api', - name: 'my_api', - item: { + await expect( + scoped.saveMetaItem({ + type: 'api', 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', + 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', + }), + ).rejects.toMatchObject({ + code: 'NOT_CREATABLE', + status: 403, }); - - expect(result.success).toBe(true); }); - it('refuses a spec-INVALID `api` item with 422 instead of storing it unvalidated', async () => { + it('refuses a spec-INVALID `api` item with the SAME 403 — the type gate runs first', 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. + // The body #5271 pinned at 422. It never reaches the schema now. await expect( scoped.saveMetaItem({ type: 'api', @@ -1677,8 +1684,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { organizationId: 'org_alpha', }), ).rejects.toMatchObject({ - code: 'INVALID_METADATA', - status: 422, + code: 'NOT_CREATABLE', + status: 403, }); }); diff --git a/packages/objectql/src/sys-metadata-repository.test.ts b/packages/objectql/src/sys-metadata-repository.test.ts index 8f47829194..6206453d3c 100644 --- a/packages/objectql/src/sys-metadata-repository.test.ts +++ b/packages/objectql/src/sys-metadata-repository.test.ts @@ -321,24 +321,35 @@ describe('SysMetadataRepository', () => { 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: '/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:/); + it('put refuses statically-registered `api` with allowRuntimeCreate:false (#5488)', async () => { + // RETIREMENT PIN. This case asserted the OPPOSITE until 2026-08-09: + // `api` graduated into the registry with `allowRuntimeCreate: true` + // (#5271), so "the repository door it already had must stay open". + // That door is now deliberately shut — maintainer ruling + // 2026-08-07T16:59Z, implemented in #5488 — because a runtime-created + // endpoint was never served: `matchEndpoint` reads + // `MetadataManager.listForIndex('api')` (registry + filesystem/memory + // loaders), and a runtime write lands in `sys_metadata`, in neither. + // + // `assertAllowed` derives its allow-list from the same registry + // constant the protocol inlet uses, so the repository refuses in the + // same breath — the second of the two doors #5086 named. The body + // below is deliberately spec-VALID: only a body nothing else would + // reject proves the refusal came from the TYPE gate. + await expect( + repo.put( + { org: 'org_alpha', type: 'api', name: 'my_api' }, + { + 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' }, + ), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); }); it('put still refuses plugin-registered type without runtime-only intent', async () => { diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a9552a2c84..580b3bbec4 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -745,6 +745,13 @@ "toMajor": 17, "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." }, + { + "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)", + "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`", + "migrationId": "api-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)." + }, { "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", @@ -1555,6 +1562,13 @@ "toMajor": 17, "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." }, + { + "surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)", + "replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`", + "migrationId": "api-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)." + }, { "surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done", "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index 183888f2d4..8b42e6a6b0 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -61,6 +61,13 @@ describe('metadata create seeds validate against their spec schemas', () => { // there is no runtime create surface for a create seed to seed. It is on // this list for `job`/`agent`'s reason, not as deferred work. 'capability', + // [#5488] `api` joined them on 2026-08-09 (maintainer ruling + // 2026-08-07T16:59Z). It HAD a seed (#5271) and lost it with the runtime + // create door: `PUT /api/v1/meta/api/:name` now 403s `NOT_CREATABLE` + // before validation, so a minimal create literal has no create to serve. + // Endpoints are authored as stack artifacts and shipped via + // `publishPackage`. Same category as `capability`, not deferred work. + 'api', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-create-seeds.ts b/packages/spec/src/kernel/metadata-create-seeds.ts index 28235dbd73..f0135d70b0 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.ts @@ -123,35 +123,18 @@ 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' }, - }, + // [#5488] `api` HAD a create seed here (#5271). It was REMOVED when `api` + // became code-only (`allowRuntimeCreate: false` + `allowOrgOverride: false`, + // maintainer ruling 2026-08-07T16:59Z): a create seed exists to make a + // runtime CREATE round-trip, and there is no longer a runtime create surface + // for it to seed — the #5086 inlet refuses `PUT /api/v1/meta/api/:name` with + // 403 `NOT_CREATABLE` before any body is validated. Keeping it would have + // handed the Studio designer a pre-filled "New API Endpoint" form whose save + // can only 403, which is the UI half of the same false compliance ADR-0049 + // made this change to remove. `api` is on `KNOWN_UNSEEDED` in the test for + // `capability`'s reason (ADR-0066 D1 / #5961), not as deferred work. + // Endpoints are authored in the stack artifact (`**/*.api.ts`, or + // `defineStack({ apis })`) and shipped through `publishPackage`. 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 f6e1dc2bdd..b2cd20564c 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -123,8 +123,14 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // 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). + // 2. DECLARATIVE GOVERNABILITY — file patterns plus a resolved declaration + // schema (`ApiEndpointSchema`), see the registry entry below. ⚠️ This + // clause originally read "`allowRuntimeCreate: true` plus file + // patterns"; #5488 flipped that flag to `false` (maintainer ruling + // 2026-08-07), so governability now rests on the artifact route alone — + // which is the route that was ever governed. The admission test is + // unaffected: ADR-0088 asks whether the kind is DECLARATIVELY governed, + // not whether it is runtime-writable. // 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 @@ -783,31 +789,71 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // // 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`). + // ⚠️ RECORDED OVERTURN — 2026-08-09 (#5488). The block below used to record a + // decision for `allowRuntimeCreate: true`, and the three bullets it rested on + // are reproduced verbatim further down because they were not wrong about the + // mechanism — they were wrong about the PREMISE they all shared. That premise + // ("there is a runtime create door here worth validating") was disproven by a + // real boot: `PUT /api/v1/meta/api/:name` answered 200 "Saved", and the + // endpoint was then NEVER SERVED — `GET` on its declared path 404s forever, + // with no `[EndpointMatcher] … EXCLUDED` line, because it was not gated out, + // it was never in the index at all. The serving criterion is owned by + // `IMetadataService.matchEndpoint` → `EndpointMatcher` → + // `MetadataManager.listForIndex('api')`, which reads the manager's `registry` + // plus its registered loaders (`["filesystem","memory"]`); a runtime write + // lands in `sys_metadata`, which is in neither. So `allowRuntimeCreate: true` + // declared a capability the runtime never had. // - // 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 + // The maintainer ruled on it 2026-08-07T16:59Z, verbatim: + // + // "Decision: Option B — flip the `api` registry entry to + // `allowRuntimeCreate: false` and make the write inlet reject loudly + // (the existing #5086 mechanism). ADR-0049 remove side, with the + // corresponding retirement bookkeeping. […] Re-entry path recorded: if + // #2657 Part B ever promotes `apis` to a registered type with a real + // consumption path, re-enable then — implementation first, declaration + // second." + // + // Rationale as ruled: zero business pull for Studio-authored runtime + // endpoints today (17.x declarative endpoints are served via stack artifacts + // / `publishPackage`, which is untouched); making the matcher read + // `sys_metadata` instead would re-open cache, invalidation, tenancy and the + // ADR-0110 D3 miss-vs-outage semantics on a new read path — not a cost to pay + // without pull; and a write that answers "Saved" and then 404s forever is the + // most dangerous silent-lie shape for AI authors (ADR-0049 false compliance). + // + // WHAT THE THREE ORIGINAL BULLETS SAID, and what became of each — kept + // verbatim so the overturn is auditable rather than silently rewritten: + // • "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 + // issue in this chain asked for" — TRUE, and now deliberate: #5488 is the + // issue that asked for it, and the door being removed opened onto nothing. + // A 403 that names the artifact route is strictly better than a 200 whose + // route 404s. + // • "#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 + // nothing left to gate" — MECHANICALLY CORRECT, and it is why the flip + // could not be split spec-first. `gateApiDraftsForPublish` is therefore + // retired in the SAME change (#5488), deliberately and on the record: it + // gated a promotion into a state the matcher can never read. + // • "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". + // 'Rejected at publish' is not 'refused at authoring'." — STILL TRUE of + // ADR-0121, and unaffected: the publish gates + // (`validateApiEndpointDeclarations`) remain the one judge of servability + // on the route that actually serves — the stack artifact / `publishPackage` + // path. What is withdrawn is only the runtime-authored draft, which had no + // servable destination to be judged toward. + // + // `allowRuntimeCreate: false` + `allowOrgOverride: false` therefore makes + // `api` CODE-ONLY (the `job` / `agent` / `capability` shape): the #5086 inlet + // refuses `PUT /api/v1/meta/api/:name` before persistence, on every kernel, + // in draft mode as well as active, with `code: 'NOT_CREATABLE'`, `status: 403` + // and a prescription naming this entry's own `filePatterns[0]` + // (`**/*.api.ts`) — i.e. declare the endpoint in the stack artifact and ship + // it through `publishPackage`. `OS_METADATA_WRITABLE` remains the one + // operator escape hatch, unchanged. // // `allowOrgOverride: false` (also unchanged from today's effective value): an // endpoint is an OUTWARD URL contract owned by the declaring package. A @@ -827,7 +873,7 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // 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: '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: false, 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 index 998411b298..0833977087 100644 --- a/packages/spec/src/kernel/metadata-type-api-registration.test.ts +++ b/packages/spec/src/kernel/metadata-type-api-registration.test.ts @@ -23,6 +23,17 @@ * 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. + * + * ## …and WHICH channel the shape door stands in (updated by #5488) + * + * `api` is now CODE-ONLY (`allowRuntimeCreate: false` + `allowOrgOverride: + * false`, maintainer ruling 2026-08-07T16:59Z). The declaration schema is + * therefore consulted on the ARTIFACT route — stack compile, `publishPackage`, + * loader ingest — and no longer on `PUT /api/v1/meta/api/:name`, which the + * #5086 inlet refuses with 403 `NOT_CREATABLE` before any body validation + * runs. The schema cases below are unchanged and still correct: they assert + * `ApiEndpointSchema` itself, which is the same object either route reaches. + * Only the door it stands in moved. See the retirement pins below for why. */ import { describe, it, expect } from 'vitest'; @@ -97,25 +108,50 @@ describe('`api` is a declared metadata kind', () => { }); 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); + // ── RETIREMENT PINS (#5488) ─────────────────────────────────────────────── + // + // These two cases REPLACE the #5271 tripwire pins that asserted the opposite + // (`allowRuntimeCreate: true`, "is NOT code-only"). Those pins were doing + // their job: their comments predicted, verbatim, that flipping this flag + // would make the type code-only under #5086 and leave PR #5279's + // `publishPackageDrafts` endpoint gate with no draft it could ever gate. + // Both predictions were correct, and both outcomes are now INTENDED — + // maintainer ruling 2026-08-07T16:59Z (Option B), implemented by #5488. + // + // What the tripwires could not see is why: the runtime create door they were + // protecting opened onto nothing. `matchEndpoint` → `EndpointMatcher` → + // `MetadataManager.listForIndex('api')` reads the manager's registry plus the + // filesystem/memory loaders; a runtime write lands in `sys_metadata`, which + // is in neither, so a runtime-created endpoint 404s forever (real boot, + // #5488). ADR-0049 calls a declared-but-unhonoured capability false + // compliance and requires enforce-or-remove; this is the remove side. + // + // Re-entry path, as the ruling recorded it: if #2657 Part B promotes `apis` + // to a registered type WITH A REAL CONSUMPTION PATH, flip back then — + // implementation first, declaration second. Until then these pins are what + // makes a silent re-flip loud. + + it('declares `allowRuntimeCreate: false` — the runtime create door is retired (#5488)', () => { + expect(apiEntry()!.allowRuntimeCreate).toBe(false); }); - it('is NOT code-only: at least one runtime write channel stays declared', () => { + it('IS code-only: no runtime write channel is declared (#5086 refuses the inlet)', () => { // 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. + // spells it. `true` here is what makes `PUT /api/v1/meta/api/:name` answer + // 403 `NOT_CREATABLE` instead of 200 "Saved" — in draft mode too, since the + // inlet runs before the draft/publish branch and does not look at `mode`. const entry = apiEntry()!; const codeOnly = entry.allowRuntimeCreate === false && entry.allowOrgOverride === false; - expect(codeOnly).toBe(false); + expect(codeOnly).toBe(true); + }); + + it('keeps `filePatterns` non-empty, so the 403 can prescribe the artifact route', () => { + // `codeOnlySourceHint` reads `filePatterns[0]` straight back into the + // refusal body ("Declare it in source (…) and redeploy"). An empty array + // would refuse the write and then say nothing about the route that DOES + // serve endpoints — the stack artifact compiled through `publishPackage`. + // Same reason `capability` pins this (ADR-0066 D1 / #5961). + expect(apiEntry()!.filePatterns[0]).toMatch(/\.api\./); }); it('declares `allowOrgOverride: false` — an endpoint is the publisher’s outward URL', () => { @@ -139,7 +175,7 @@ describe('`api` registry flags — the authorization verdict, written down', () }); }); -describe('the save-time shape door `api` just gained', () => { +describe('the declaration shape door (artifact / publish route — see the header note)', () => { 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 diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 2aba9f3408..5e75726b24 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -76,11 +76,11 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from './metadata-plugin.zod'; * * The converse does NOT hold: presence here is about schema RESOLUTION * (validation, diagnostics, generated docs), not about the runtime-create - * door. `agent` (ADR-0063 §2) and `job` (#4509) are both listed and both carry - * `allowRuntimeCreate: false` in `DEFAULT_METADATA_TYPE_REGISTRY` — they are - * authored in code and still need their schema resolvable. That registry is - * the authority on who may write at runtime; this map only says what shape a - * given type has. + * door. `agent` (ADR-0063 §2), `job` (#4509), `capability` (#5961) and `api` + * (#5488) are all listed and all carry `allowRuntimeCreate: false` in + * `DEFAULT_METADATA_TYPE_REGISTRY` — they are authored in code and still need + * their schema resolvable. That registry is the authority on who may write at + * runtime; this map only says what shape a given type has. */ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = { // Data Protocol @@ -123,9 +123,23 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // 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. + // JSON. + // + // ⚠️ [#5488] WHICH DOOR THIS SCHEMA NOW STANDS IN. The paragraph above used + // to continue "…and `/meta/types` emits a real JSON Schema so the + // metadata-admin engine renders a form instead of a raw-JSON textarea". + // That is no longer the point of the binding: `api` is CODE-ONLY as of #5488 + // (`allowRuntimeCreate: false` + `allowOrgOverride: false`, maintainer ruling + // 2026-08-07), because a runtime-created endpoint was never served — the + // matcher reads `listForIndex('api')`, and a runtime write lands in + // `sys_metadata`, which it does not read. So `PUT /meta/api/:name` is refused + // by the #5086 inlet with 403 `NOT_CREATABLE` BEFORE any body validation, and + // the 422 `invalid_metadata` path is no longer reachable for this kind from + // the runtime write door. The entry stays, and is still load-bearing, for the + // reason the header docblock gives: schema RESOLUTION is not the write door. + // `api` items are validated on the ARTIFACT route — stack compile, loader + // ingest, `publishPackage` — exactly like `job` and `agent`, which are listed + // here and code-only for their own reasons. // // 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, diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9ade85db41..5b9f5b72e2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2997,6 +2997,59 @@ const step17: MigrationStep = { + 'reports the total across the whole matching inbox rather than the window. A caller ' + 'that omitted `limit` receives the same 50 rows it always received.', }, + { + id: 'api-runtime-create-withdrawn', + surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)', + replacement: + 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) ' + + 'and ship it through `publishPackage`', + reason: + 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never ' + + 'honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/' + + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the ' + + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` ' + + 'line, because the endpoint was never in the index to be excluded from. The serving ' + + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> ' + + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its " + + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands ' + + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not ' + + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s ' + + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The ' + + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because ' + + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and ' + + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no " + + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author ' + + 'them at runtime; showcase uses the artifact route, #5040 E8 LIVE). ' + + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' + + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' + + 'authorable one, and the artifact route it points authors toward is untouched — a ' + + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What ' + + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and ' + + 'Studio callers rather than a stack conversion — the same disposition ' + + '`BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` ' + + '(PR #5279) is retired with it: it gated a promotion into a state the matcher can ' + + 'never read, and with the inlet closed no `api` draft can exist for it to judge. ' + + 'Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered ' + + 'type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, ' + + 'declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311).', + acceptanceCriteria: + 'No caller creates or updates an `api` item through the runtime metadata API. ' + + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body ' + + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' + + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` ' + + 'as well as direct-active, because the gate runs before the draft/publish branch and ' + + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole ' + + 'point of the change: a stack declaring `apis:` still compiles, still passes ' + + '`validateApiEndpointDeclarations` at publish (`publishPackage`, #5189) and at load ' + + '(`buildEndpointIndex`, PR #5203), and its endpoints still SERVE — that route was ' + + 'always the only one that served. An operator who genuinely needs the runtime door ' + + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape ' + + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and ' + + 'the endpoint still will not be served, which is why it is a diagnostic and not a ' + + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change ' + + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' + + 'gated by this refusal, so repair stays possible).', + }, { id: 'import-run-automations-declared-default-corrected', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a