From b500c091541e8d4c45185eb9121f4521c0dea359 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:28:26 +0000 Subject: [PATCH 1/2] feat(metadata-protocol)!: cross-tenant uninstall must be declared explicitly (#7780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protocol.deletePackage` selected rows with `{ package_id }` and added an organization predicate only when the caller supplied one, so a call with no `organizationId` matched EVERY organization's rows — measured during #7705 at 5 of 5 deleted, including a foreign org's. The two doors of `DELETE /api/v1/packages/:id` disagreed about which semantic they invoked: the direct-mount REST registrar passes no org (cross-tenant), the dispatcher twin resolves one (org-scoped). Neither chose it; it fell out of a missing argument. `resolveActiveOrganizationId` (#4127) is entirely catch-wrapped, so an accidental org-less call and a deliberate env-wide one are byte-identical at the call site. Maintainer ruling (2026-08-12): 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」. `deletePackage` gains `allTenants?: boolean`, the explicit carrier. The tenant scope must be readable off the request, and there are two ways to violate that, both answering `TENANT_SCOPE_REQUIRED` (400) and deleting nothing: organizationId | allTenants | result ---------------|----------------|-------------------------------- set | absent / false | org-scoped delete (unchanged) absent | true | cross-tenant delete absent | absent / false | 400 set | true | 400 An explicit `allTenants: false` is treated as undeclared — it is not an affirmative request for cross-tenant semantics, so it cannot authorise them. Both-supplied is refused rather than resolved: the two are contradictory, and resolving narrow-first makes the flag silently inert while resolving explicit-first ignores a named org and deletes every tenant's rows — the original defect wearing a flag. Refusing is also the only reading that stays correct when a request is composed from two places, which is exactly what the catch-wrapped resolver makes real. The REST door declares `allTenants: true`: `packages/rest` has no org to resolve, so of the two remedies the ruling allows only declaring is available. The no-org branch is still NOT narrowed to `organization_id IS NULL` — #7705 proved that orphans every org-scoped row. The remedy is explicitness. #7705 case 4 pinned the old no-org-clears-everything behaviour deliberately. It is rewritten, not deleted, into the halves of the ruled contract, and the reason it existed is carried by the case that goes red if anyone narrows the branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS --- .changeset/uninstall-explicit-all-tenants.md | 69 +++++++++++ .../src/durable-package.test.ts | 8 +- packages/metadata-protocol/src/protocol.ts | 83 ++++++++++++- .../src/protocol-package-lifecycle.test.ts | 6 +- .../package-first-authoring.dogfood.test.ts | 2 +- packages/rest/src/package-routes.ts | 20 +++- ...ge-uninstall-org-scope.integration.test.ts | 110 +++++++++++++++++- .../spec/src/api/error-code-ledger.zod.ts | 1 + 8 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 .changeset/uninstall-explicit-all-tenants.md diff --git a/.changeset/uninstall-explicit-all-tenants.md b/.changeset/uninstall-explicit-all-tenants.md new file mode 100644 index 0000000000..62384494dd --- /dev/null +++ b/.changeset/uninstall-explicit-all-tenants.md @@ -0,0 +1,69 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/rest": minor +"@objectstack/spec": minor +--- + +feat(metadata-protocol)!: cross-tenant uninstall must be declared — `deletePackage` refuses a call that names neither an organization nor `allTenants` (#7780) + +**This changes the contract of a destructive operation, and a caller that omits +the organization today starts getting a 400. That is the point of the change, +not a side effect of it.** + +`protocol.deletePackage` selected its rows with `{ package_id }` and added an +organization predicate only when the caller supplied one. With no +`organizationId` the predicate matched **every organization's rows** — measured +during #7705 at 5 of 5 deleted, including a foreign organization's. + +Nobody chose that. It fell out of a missing argument, and the two doors of +`DELETE /api/v1/packages/:id` disagreed about which semantic they were invoking: + +- the direct-mount REST registrar (`packages/rest/src/package-routes.ts`) passes + no organization and got the cross-tenant reading; +- the dispatcher twin (`packages/runtime/src/domains/packages.ts`) resolves one + and got the org-scoped reading. + +Worse, the two are indistinguishable at the call site. `resolveActiveOrganizationId` +(#4127) is entirely `catch`-wrapped, so any throw on the auth seam returns +`undefined` — an accidental org-less call and a deliberate environment-wide one +are byte-identical, and the accident silently selected the widest possible +reading of a destructive operation. + +Maintainer ruling (2026-08-12), quoted unchanged: + +> 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」. + +**What changes** + +- `deletePackage` gains `allTenants?: boolean`, the explicit carrier for + cross-tenant semantics. +- A call with neither `organizationId` nor `allTenants: true` is refused with + `TENANT_SCOPE_REQUIRED` (HTTP 400) and deletes nothing. An explicit + `allTenants: false` is treated as undeclared: it is not an affirmative request + for cross-tenant semantics, so it cannot authorise them. +- A call supplying **both** `organizationId` and `allTenants: true` is refused + with the same code and status. The two are contradictory, not redundant — one + scopes to a tenant, the other clears every tenant — and both silent + resolutions are worse than a refusal: resolving narrow-first makes + `allTenants: true` silently inert, and resolving explicit-first ignores a named + organization and deletes every tenant's rows, which is the original defect + wearing a flag. Rejecting is also the only reading that stays correct when a + request is composed from two places (a resolver supplying the org, config + supplying the flag). The message names both offending parameters. +- The REST direct-mount door now declares `allTenants: true`. It has no + organization to resolve (`packages/rest` carries no org plumbing at all), so of + the two remedies the ruling allows, only declaring the intent is available + there. Its observable behaviour is unchanged; what changed is that the width is + now stated at the call site instead of inferred from an absent argument. + +**What deliberately does NOT change** + +The no-organization branch is still **not** narrowed to `organization_id IS +NULL`. #7705 proved that narrowing orphans every org-scoped row — the same +defect pointed the other way. The remedy here is explicitness, not narrowing. + +**Callers that must be updated** + +Any caller of `deletePackage` that omits `organizationId` and intends an +environment-wide uninstall must now pass `allTenants: true`. The refusal message +names both remedies. diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index f4be7aac12..b94f197b1c 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -73,7 +73,7 @@ describe('installPackage — durable persistence (#2532)', () => { describe('deletePackage — durable un-registration (#2532 counterpart)', () => { it('drops the sys_packages record so the package cannot resurrect at boot', async () => { const { impl, del } = makeImpl(); - await (impl as any).deletePackage({ packageId: 'com.example.orders' }); + await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true }); expect(del).toHaveBeenCalledWith('com.example.orders'); }); }); @@ -84,7 +84,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { const cleanup = vi.fn(async () => ({ success: true, removed: 3 })); (impl as any).registerUninstallCleanup('security.package-permissions', cleanup); - const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', actor: 'usr_1' }); + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true, actor: 'usr_1' }); expect(cleanup).toHaveBeenCalledWith(expect.objectContaining({ packageId: 'com.example.orders', actor: 'usr_1' })); expect(res.cleanups).toEqual([ @@ -97,7 +97,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { try { const { impl } = makeImpl(); (impl as any).registerUninstallCleanup('boom', async () => { throw new Error('db down'); }); - const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders' }); + const res: any = await (impl as any).deletePackage({ packageId: 'com.example.orders', allTenants: true }); expect(res.cleanups).toEqual([ { name: 'boom', success: false, removed: 0, error: 'db down' }, ]); @@ -112,7 +112,7 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { const second = vi.fn(async () => ({ success: true, removed: 2 })); (impl as any).registerUninstallCleanup('x', first); (impl as any).registerUninstallCleanup('x', second); - const res: any = await (impl as any).deletePackage({ packageId: 'p' }); + const res: any = await (impl as any).deletePackage({ packageId: 'p', allTenants: true }); expect(first).not.toHaveBeenCalled(); expect(res.cleanups[0].removed).toBe(2); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c84c059af2..d96fe5c859 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11702,6 +11702,7 @@ export class ObjectStackProtocolImplementation implements async deletePackage(request: { packageId: string; organizationId?: string; + allTenants?: boolean; actor?: string; keepData?: boolean; }): Promise<{ @@ -11712,6 +11713,78 @@ export class ObjectStackProtocolImplementation implements failed: Array<{ type: string; name: string; error: string; code?: string }>; cleanups: UninstallCleanupOutcome[]; }> { + // [#7780] A cross-tenant uninstall must be DECLARED, never inferred from + // an absent parameter. Maintainer ruling (2026-08-12): + // 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」. + // + // Before this gate, `{ packageId }` with no org matched EVERY + // organization's rows — measured during #7705 at 5 of 5 deleted, + // including a foreign org's. The two doors disagreed on which semantic + // that was: the direct-mount REST registrar + // (`packages/rest/src/package-routes.ts`) passes no org and got the + // cross-tenant read, while the dispatcher twin + // (`packages/runtime/src/domains/packages.ts`) resolves one and got the + // org-scoped read. Nobody chose that split; it fell out of a missing + // argument. + // + // Why a flag and not a convention: `resolveActiveOrganizationId` + // (#4127) is entirely `catch`-wrapped, so ANY throw on the auth seam + // returns `undefined`. An accidental org-less call and a deliberate + // env-wide one are byte-identical at the call site, and the widest + // possible reading of a destructive operation is the one that must + // never be reachable by accident. `allTenants: true` is the carrier + // that makes the two distinguishable. + // + // ⛔ NOT narrowed to `organization_id IS NULL` — #7705 proved that + // revives the orphaned-row defect on the other door. The remedy here is + // explicitness, not narrowing: with the flag, the no-org branch stays + // package-wide exactly as it was. + // + // Mirrors the `force: true` / `DESTRUCTIVE_CHANGE` opt-in this same + // class already uses for `saveMetaItem` — refuse, name the remedy in the + // message, and carry a ledger-declared code plus an explicit status. + // Two ways to violate ONE contract — "the uninstall's tenant scope must be + // readable off the request" — so both answer in the same family, with the + // same code and status, and each names the parameters that produced it. + // + // (a) CONTRADICTORY. `organizationId` says "this tenant", `allTenants` + // says "every tenant". Rejecting beats picking a winner, because both + // silent resolutions are worse than a refusal: resolving narrow-first + // makes `allTenants: true` silently INERT (the caller believes they + // asked for a cross-tenant uninstall and quietly gets a scoped one, + // discovering it only when the rows they expected gone are still + // there); resolving explicit-first silently IGNORES a named + // organization and deletes every tenant's rows — the original defect + // wearing a flag. Rejecting is also the only reading that stays correct + // when a request is COMPOSED from two places (a resolver supplying the + // org, config supplying the flag), which is exactly the accidental + // composition `resolveActiveOrganizationId` makes real. + if (request.organizationId && request.allTenants === true) { + const err = new Error( + `[tenant_scope_required] Refusing to uninstall '${request.packageId}':` + + ` organizationId ('${request.organizationId}') and allTenants: true are mutually exclusive —` + + ` one scopes the uninstall to a single tenant, the other clears every tenant's rows.` + + ` — pass organizationId alone to scope it, or allTenants: true alone to confirm the cross-tenant uninstall.` + ); + (err as any).code = 'TENANT_SCOPE_REQUIRED'; + (err as any).status = 400; + throw err; + } + // (b) UNDECLARED. Note `!== true`: an explicit `allTenants: false` lands + // here with absent, deliberately. `false` is not a request for + // cross-tenant semantics, so it cannot authorise them — only the + // affirmative `true` does. + if (!request.organizationId && request.allTenants !== true) { + const err = new Error( + `[tenant_scope_required] Refusing to uninstall '${request.packageId}' with no organization scope:` + + ` an uninstall that names neither an organization nor an explicit cross-tenant intent would delete` + + ` EVERY organization's rows for this package.` + + ` — pass organizationId to scope it, or allTenants: true to confirm the cross-tenant uninstall.` + ); + (err as any).code = 'TENANT_SCOPE_REQUIRED'; + (err as any).status = 400; + throw err; + } const where: Record = { package_id: request.packageId }; // [#7705] Surface BOTH org-scoped rows and env-wide (`organization_id // IS NULL`) rows to an org-scoped uninstall. A strict @@ -11738,7 +11811,15 @@ export class ObjectStackProtocolImplementation implements // registrar, `packages/rest/src/package-routes.ts`) passes no // `organizationId` at all, and restricting it to env-wide rows would // orphan every org-scoped row — the same bug, re-created on the other - // door. Absent an org, a full uninstall stays package-wide. + // door. Absent an org, a full uninstall stays package-wide — and since + // #7780 that branch is reachable only with `allTenants: true`, so the + // width is now something a caller ASKED for rather than something a + // missing argument selected. + // + // There is no tie-break for "both supplied" because that combination + // never reaches here — it is refused above. A destructive operation + // whose scope is stated twice, contradictorily, has no reading that is + // safe to guess. if (request.organizationId) { where.$or = [ { organization_id: request.organizationId }, diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts index 0c682bba65..83b198b1da 100644 --- a/packages/objectql/src/protocol-package-lifecycle.test.ts +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -77,7 +77,7 @@ describe('protocol.deletePackage', () => { { type: 'object', name: 'course', state: 'draft', organization_id: null }, { type: 'view', name: 'course_list', state: 'active', organization_id: null }, ]); - const res = await protocol.deletePackage({ packageId: 'app.edu' }); + const res = await protocol.deletePackage({ packageId: 'app.edu', allTenants: true }); expect(res).toMatchObject({ success: true, deletedCount: 3, failedCount: 0 }); const calls = deleteMetaItem.mock.calls.map((c) => c[0] as any); @@ -92,13 +92,13 @@ describe('protocol.deletePackage', () => { const { protocol, deleteMetaItem } = makeProtocol([ { type: 'object', name: 'course', state: 'active', organization_id: null }, ]); - await protocol.deletePackage({ packageId: 'app.edu', keepData: true }); + await protocol.deletePackage({ packageId: 'app.edu', allTenants: true, keepData: true }); expect((deleteMetaItem.mock.calls[0][0] as any)).not.toHaveProperty('dropStorage'); }); it('empty package → deletedCount 0, success false', async () => { const { protocol, deleteMetaItem } = makeProtocol([]); - const res = await protocol.deletePackage({ packageId: 'app.empty' }); + const res = await protocol.deletePackage({ packageId: 'app.empty', allTenants: true }); expect(deleteMetaItem).not.toHaveBeenCalled(); expect(res).toMatchObject({ success: false, deletedCount: 0 }); }); diff --git a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts index f74491e6ae..6460bf526a 100644 --- a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts @@ -143,7 +143,7 @@ describe('dogfood: the package is the authoring & delete unit (ADR-0070 D3/D4)', // 6. DELETE-CASCADE — deleting the base removes every item it owns; the base // becomes empty. This is the answer to "a pile of loose metadata, how do I // delete it?" — operate on the whole base. - const del = await protocol.deletePackage({ packageId: BASE }); + const del = await protocol.deletePackage({ packageId: BASE, allTenants: true }); expect(del.deletedCount).toBeGreaterThan(0); expect(del.failedCount).toBe(0); expect(await ownedNames(ql, BASE)).not.toContain(OBJ); diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index b46d7b79d2..7d5bf3dcfc 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -119,7 +119,10 @@ export interface PackageRoutesOptions { */ protocol?: { getMetaItems?(req: { type: string }): Promise<{ items: any[] }>; - deletePackage?(req: { packageId: string; actor?: string }): Promise<{ + // [#7780] `allTenants` is the explicit carrier for cross-tenant uninstall + // semantics; the protocol refuses a call that names neither it nor an + // `organizationId` (`TENANT_SCOPE_REQUIRED`, 400). + deletePackage?(req: { packageId: string; actor?: string; allTenants?: boolean }): Promise<{ success: boolean; deletedCount: number; failedCount: number; @@ -437,7 +440,20 @@ export function registerPackageRoutes( // no ghost grants). A version-scoped delete keeps the narrow durable // registry semantics, as does a deployment without the protocol. if (!version && typeof options.protocol?.deletePackage === 'function') { - const result = await options.protocol.deletePackage({ packageId }); + // [#7780] `allTenants: true` is stated, not implied. This registrar has + // no organization to resolve — `packages/rest` carries no + // `resolveActiveOrganizationId` and no org plumbing at all (the + // dispatcher twin owns that seam), so of the two doors the ruling + // allows — resolve an org, or declare the cross-tenant intent — only + // the second is available here. + // + // This preserves the behaviour this door has always had (a full + // uninstall through it is package-wide, which #7705 case 4 pinned on + // purpose); what changes is that the width is now DECLARED at the call + // site instead of being inferred from an argument nobody passed. The + // protocol now refuses the undeclared form outright, so the two doors + // can no longer disagree by accident. + const result = await options.protocol.deletePackage({ packageId, allTenants: true }); // Zero metadata rows is still a successful uninstall (e.g. a // runtime-registered package that never published metadata) — // only per-item failures make it a failure. diff --git a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts index f3e01bda3e..d818719958 100644 --- a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts +++ b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts @@ -242,19 +242,117 @@ describe('#7705 — org-scoped uninstall must not orphan env-wide sys_metadata r expect(await namesFor(engine, OTHER_PKG)).toEqual(['other_a']); }); - it('an uninstall with NO org still clears the whole package (the other door)', async () => { + // [#7780] REWRITTEN, not deleted — and the reason the original existed is + // still pinned, in the second half below. + // + // The old single case pinned that an uninstall with NO org "still clears the + // whole package", deliberately, so nobody would narrow the no-org branch to + // `organization_id IS NULL` and re-create the #7705 orphan bug on the other + // door. That branch is still NOT narrowed. What the maintainer ruled + // (2026-08-12, 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」) is + // that REACHING it now requires saying so. So the one case becomes the two + // halves of the ruled contract: the refusal, and the declared cross-tenant + // uninstall that still clears exactly what it cleared before. + it('an uninstall with NO org and NO explicit flag is REFUSED, and deletes nothing', async () => { + const { engine, protocol } = await boot(); + await seed(protocol); + + await expect( + (protocol as any).deletePackage({ packageId: PKG }), + ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); + + // A refusal, not a partial delete: the complete post-state is the seed, + // untouched — including the foreign org's row, which is the row the old + // no-org call took with it. + expect(await survivors(engine)).toEqual([ + `other_a[${OTHER_PKG},ENV]`, + `reprob_a[${PKG},ENV]`, + `reprob_b[${PKG},ENV]`, + `reprob_foreign[${PKG},${OTHER_ORG}]`, + `reprob_own[${PKG},${ACTIVE_ORG}]`, + `reprob_v[${PKG},ENV]`, + ]); + }); + + it('an uninstall with an explicit allTenants clears the whole package (the other door)', async () => { const { engine, protocol } = await boot(); await seed(protocol); // The direct-mount REST registrar (`packages/rest/src/package-routes.ts`) - // calls `deletePackage({ packageId })` with no org at all. Narrowing THAT - // branch to `organization_id IS NULL` — the other half of the #3115 shape - // — would orphan every org-scoped row instead, i.e. re-create this bug on - // the other door. This case pins that the no-org branch stays package-wide. - const res: any = await (protocol as any).deletePackage({ packageId: PKG }); + // has no organization to resolve — `packages/rest` carries no org plumbing + // at all — so of the two doors the ruling allows it declares + // `allTenants: true`. Narrowing THIS branch to `organization_id IS NULL` + // would orphan every org-scoped row instead, i.e. re-create #7705 on the + // other door. This case pins that the declared branch stays package-wide, + // at exactly the count the pre-#7780 no-org call produced. + const res: any = await (protocol as any).deletePackage({ packageId: PKG, allTenants: true }); expect(await namesFor(engine, PKG)).toEqual([]); expect(res.deletedCount).toBe(5); expect(await namesFor(engine, OTHER_PKG)).toEqual(['other_a']); }); + + // [#7780] `organizationId` + `allTenants: true` is CONTRADICTORY, not + // redundant: one says "this tenant", the other says "every tenant". Both + // silent resolutions are worse than a refusal — resolving narrow-first makes + // `allTenants: true` silently inert, resolving explicit-first ignores a named + // organization and deletes every tenant's rows (the original defect wearing a + // flag). It is also the reading that stays correct when a request is COMPOSED + // from two places, which is the accidental composition + // `resolveActiveOrganizationId` makes real. Same code and status as the + // undeclared case: one contract, two ways to violate it. + it('REFUSES when BOTH organizationId and allTenants are supplied, deleting nothing', async () => { + const { engine, protocol } = await boot(); + await seed(protocol); + + await expect( + (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG, allTenants: true }), + ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); + + // Names BOTH offending parameters, so the caller is not left guessing which + // pair conflicted. + await expect( + (protocol as any).deletePackage({ packageId: PKG, organizationId: ACTIVE_ORG, allTenants: true }), + ).rejects.toThrow(/organizationId.*mutually exclusive|mutually exclusive.*organizationId/s); + + expect(await namesFor(engine, PKG)).toEqual( + ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], + ); + }); + + // [#7780] An EXPLICIT `false` is not the same gesture as an absent flag, but + // it must land on the same refusal: `false` is not an affirmative request for + // cross-tenant semantics, so it cannot authorise them. Pinned so that a future + // `!request.allTenants`-style rewrite (which would treat them identically by + // accident rather than by decision) still has to face this case. + it('treats an explicit allTenants:false as undeclared — same 400 as absent', async () => { + const { engine, protocol } = await boot(); + await seed(protocol); + + await expect( + (protocol as any).deletePackage({ packageId: PKG, allTenants: false }), + ).rejects.toMatchObject({ code: 'TENANT_SCOPE_REQUIRED', status: 400 }); + + expect(await namesFor(engine, PKG)).toEqual( + ['reprob_a', 'reprob_b', 'reprob_foreign', 'reprob_own', 'reprob_v'], + ); + }); + + // …while an org-scoped call carrying the same explicit `false` is NOT a + // violation — the org states the scope, and `false` agrees with it. This is + // the row that keeps the refusal from over-firing on a legitimate caller that + // spells its flags out. + it('allows an org-scoped uninstall that spells allTenants:false explicitly', async () => { + const { engine, protocol } = await boot(); + await seed(protocol); + + const res: any = await (protocol as any).deletePackage({ + packageId: PKG, + organizationId: ACTIVE_ORG, + allTenants: false, + }); + + expect(res.deletedCount).toBe(4); + expect(await namesFor(engine, PKG)).toEqual(['reprob_foreign']); + }); }); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index f67cf920c4..2a18fed02e 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -284,6 +284,7 @@ export const ERROR_CODE_LEDGER = { 'OBJECT_OVERLAY_PACKAGE_MISMATCH', // [ADR-0029 D9.9] object overlay row bound to a package that does not own the object 'OBJECT_PACKAGE_DISABLED', // [#7557] object is registered but its owning package is disabled — data plane refuses rather than serving rows 'ROLLED_BACK', // atomic data-batch row was written, then undone by the batch rollback (#4793) + 'TENANT_SCOPE_REQUIRED', // [#7780] destructive call named neither an organization nor an explicit cross-tenant intent; needs an explicit opt-in 'UNSUPPORTED_QUERY_PARAM', 'VALIDATION_FAILED', 'VERSION_NOT_FOUND', From a54b870d0fc2aa3f86c7a7822fb7ebe39dfcc877 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:54:58 +0000 Subject: [PATCH 2/2] chore(spec): register the uninstall tenant-scope break on the ADR-0087 chain and regenerate docs (#7780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mechanical CI fixes on top of the accepted change. 1. `Check Changeset` — the changeset declares a breaking change and carried no ADR-0087 disposition. Registered rather than exempted, and the exemption was not available anyway: `no-migration-prescription` is refused when the changeset body carries a consumer-rewrite prescription, and this one does ("Callers that must be updated"). `unpublished` is false (all three bumped packages publish) and `already-registered` is false (the id is new here). The substantive reason is the same one the gate asks for: a consumer really does have to act — an uninstall that succeeded yesterday now answers 400 until it states its tenant scope — and which scope a given caller meant is an intent no transform can recover, because the catch-wrapped resolver makes an accidental org-less call byte-identical to a deliberate environment-wide one. That is the disposition `rest-requireauth-default-flip` (step 12) took for its own default flip, and `semantic/` already carries runtime-contract entries of this shape (`api-runtime-create-withdrawn`), so this is not a metadata-shaped entry forced into a metadata chain. New entry `semantic/17.package-uninstall-explicit-all-tenants.ts`, folded in with `gen:migration-registry`; `spec-changes.json` and `docs/protocol-upgrade-guide.md` regenerated as its projections. 2. `check:docs` — `TENANT_SCOPE_REQUIRED` was added to the error-code ledger and the generated reference docs were never regenerated. Ran `gen:schema` then `gen:docs`. Eleven pages move rather than the two CI named: the shared error envelope renders the code-enum arity, so `… +261 more` becomes `… +262 more` wherever it appears. All generated output, hand-edited nowhere. No `content/docs/releases/**` page is touched. Merged `origin/main` (clean) before regenerating, so the artifacts are generated against current main rather than the `59ac0c6` base. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS --- .changeset/uninstall-explicit-all-tenants.md | 9 +++++ content/docs/references/api/analytics.mdx | 6 +-- content/docs/references/api/auth.mdx | 4 +- .../docs/references/api/automation-api.mdx | 18 ++++----- content/docs/references/api/batch.mdx | 4 +- content/docs/references/api/contract.mdx | 15 ++++---- .../docs/references/api/error-code-ledger.mdx | 1 + content/docs/references/api/export.mdx | 12 +++--- content/docs/references/api/metadata.mdx | 38 +++++++++---------- content/docs/references/api/package-api.mdx | 16 ++++---- content/docs/references/api/protocol.mdx | 6 +-- content/docs/references/api/storage.mdx | 16 ++++---- docs/protocol-upgrade-guide.md | 3 ++ packages/spec/spec-changes.json | 14 +++++++ ....package-uninstall-explicit-all-tenants.ts | 35 +++++++++++++++++ packages/spec/src/migrations/registry.ts | 31 +++++++++++++++ 16 files changed, 161 insertions(+), 67 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts diff --git a/.changeset/uninstall-explicit-all-tenants.md b/.changeset/uninstall-explicit-all-tenants.md index 62384494dd..c50e4b3709 100644 --- a/.changeset/uninstall-explicit-all-tenants.md +++ b/.changeset/uninstall-explicit-all-tenants.md @@ -67,3 +67,12 @@ defect pointed the other way. The remedy here is explicitness, not narrowing. Any caller of `deletePackage` that omits `organizationId` and intends an environment-wide uninstall must now pass `allTenants: true`. The refusal message names both remedies. + +Registered on the ADR-0087 migration chain (step 17, +`package-uninstall-explicit-all-tenants`) rather than exempted: a consumer really +does have to act — an uninstall that succeeded yesterday now answers 400 until it +states its tenant scope — and which scope it meant is an intent no transform can +recover, which is the same disposition `rest-requireauth-default-flip` took for +its own default flip. + + diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index ead97a0d6f..879f553c57 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. | @@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ rows: Record[]; fields: object[]; sql?: string }` | ✅ | | @@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sql: string; params: any[] }` | ✅ | | diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index 642db72530..1c2eb781ff 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -117,7 +117,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ session: object; user: object; token?: string }` | ✅ | | @@ -153,7 +153,7 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2aa81457f2..146ff68fb8 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | @@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; deleted: boolean }` | ✅ | | @@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition | @@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details | @@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; enabled: boolean }` | ✅ | | @@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | | @@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 6a82367034..62f2f0e713 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -55,7 +55,7 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | @@ -122,7 +122,7 @@ const result = BatchConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index f42c07b053..591dd05f8c 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +257 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +258 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **message** | `string` | ✅ | Readable error message | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | @@ -285,6 +285,7 @@ const result = ApiErrorSchema.parse(data); * `SUGGESTION_NOT_FOUND` * `SUGGESTION_STATE` * `SUMMARY_RECOMPUTE_FAILED` +* `TENANT_SCOPE_REQUIRED` * `UNAUTHORIZED` * `UNIQUE_VIOLATION` * `UNKNOWN_KEY` @@ -311,7 +312,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | @@ -350,7 +351,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id?: string; success: boolean; errors?: object[]; index?: number; … }[]` | ✅ | Results for each item in the batch | @@ -392,7 +393,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **id** | `string` | ✅ | ID of the deleted record | @@ -444,7 +445,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of matching records | | **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info | @@ -460,7 +461,7 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if processed | | **success** | `boolean` | ✅ | | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }[]` | optional | | | **index** | `number` | optional | Index in original request | | **data** | `any` | optional | Result data (e.g. created record) | @@ -499,7 +500,7 @@ Key-value map of record data | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | ✅ | The requested or modified record | diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 7bdb404dec..aa06b53c5a 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -357,6 +357,7 @@ const result = ErrorCode.parse(data); * `SUGGESTION_NOT_FOUND` * `SUGGESTION_STATE` * `SUMMARY_RECOMPUTE_FAILED` +* `TENANT_SCOPE_REQUIRED` * `UNAUTHORIZED` * `UNIQUE_VIOLATION` * `UNKNOWN_KEY` diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 1523eb0ffa..f287cac90d 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -57,7 +57,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | | @@ -157,7 +157,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | | @@ -231,7 +231,7 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | | @@ -449,7 +449,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | | @@ -488,7 +488,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ jobs: object[]; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -546,7 +546,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 1f896ea7c8..d65d50934f 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -51,7 +51,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }` | ✅ | Full App Configuration | @@ -65,7 +65,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) | @@ -92,7 +92,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: object[] }` | ✅ | Bulk operation result | @@ -117,7 +117,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string }` | ✅ | | @@ -131,7 +131,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on | @@ -145,7 +145,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item | @@ -159,7 +159,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record` | optional | Effective metadata with all overlays applied | @@ -173,7 +173,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ exists: boolean }` | ✅ | | @@ -200,7 +200,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `any` | ✅ | Exported metadata bundle | @@ -228,7 +228,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result | @@ -242,7 +242,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item | @@ -256,7 +256,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `Record[]` | ✅ | Array of metadata definitions | @@ -270,7 +270,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Array of metadata item names | @@ -284,7 +284,7 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none | @@ -348,7 +348,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ items: object[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result | @@ -406,7 +406,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info | @@ -420,7 +420,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `string[]` | ✅ | Registered metadata type identifiers | @@ -446,7 +446,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ valid: boolean; errors?: object[]; warnings?: object[] }` | ✅ | Validation result | @@ -460,7 +460,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 691645aef3..bc51928b9c 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -57,7 +57,7 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details | @@ -89,7 +89,7 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | @@ -143,7 +143,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | @@ -185,7 +185,7 @@ Rollback package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | | @@ -220,7 +220,7 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | @@ -250,7 +250,7 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | @@ -277,7 +277,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | @@ -309,7 +309,7 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index d6dd9c4c74..0bc5fe9404 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -280,7 +280,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -428,7 +428,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | @@ -1528,7 +1528,7 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 4863e3bac3..bfec403889 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -46,7 +46,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | | @@ -72,7 +72,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ url: string }` | ✅ | | @@ -101,7 +101,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata | @@ -147,7 +147,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | | @@ -161,7 +161,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | | @@ -175,7 +175,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ key: string }` | ✅ | | @@ -202,7 +202,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | | @@ -216,7 +216,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | -| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | +| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 2d0c448d44..b1bb6bba5d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -428,6 +428,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`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. +- **`package-uninstall-explicit-all-tenants`** — `protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)` → explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it + - Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant "this tenant" or "every tenant" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip. + - Done when: Every caller of `deletePackage` states its tenant scope. A caller that intends an environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped one passes `organizationId`; no caller passes both. An explicit `allTenants: false` is treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 `TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means that script was relying on the cross-tenant reading and must now say so on purpose. The org-scoped path is unchanged — an uninstall carrying an `organizationId` still removes that org's rows AND the environment-wide (`organization_id IS NULL`) rows, exactly as #7705 left it. - **`plugin-activation-events-retired`** — `kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents` → (removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours) - Why not automatic: Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — promised lazy plugin activation ("plugins remain dormant until an activation event fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — `StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin` (a root schema, never part of a stack tree) and `DynamicLoadRequest` is a runtime request shape with no caller — so no `sys_metadata` row can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned via `retiredKey()` (its schema is not `.strict()`; a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliases `activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / `ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer "delete this key" but "this request shape does not exist" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse. - Done when: No `defineStudioPlugin` input authors `activationEvents` — authoring it is an unknown key on the strict studio manifest and a parse error carrying the prescription. On the kernel side the stronger #4834 criterion applies instead: there is no `DynamicLoadRequest` type or schema left to author it into at all. No code imports `ActivationEventSchema` / `ActivationEvent` from `@objectstack/spec/kernel` or `@objectstack/spec/studio` (TS2305 after upgrade). Runtime behaviour is byte-identical: plugins loaded eagerly before and after. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 5c591596dd..389bcbe54e 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -735,6 +735,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": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", + "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", + "migrationId": "package-uninstall-explicit-all-tenants", + "toMajor": 17, + "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip." + }, { "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)", @@ -1696,6 +1703,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": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", + "replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it", + "migrationId": "package-uninstall-explicit-all-tenants", + "toMajor": 17, + "rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip." + }, { "surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents", "replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)", diff --git a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts new file mode 100644 index 0000000000..f1c65d66bd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'package-uninstall-explicit-all-tenants', + surface: 'protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)', + replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', + reason: + 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' + + 'at 5 of 5 deleted, including a foreign org\'s (#7705, #7780). That width was never ' + + 'chosen; it fell out of a missing argument, and the two transports of the same route ' + + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' + + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' + + 'deletes nothing, as does supplying both (they are contradictory, not redundant). ' + + 'Whether a given caller meant "this tenant" or "every tenant" is an intent no ' + + 'transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an ' + + 'accidental org-less call and a deliberate environment-wide one are byte-identical ' + + 'at the call site — which is the whole reason the parameter had to become explicit ' + + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' + + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' + + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) ' + + 'takes for its own default flip.', + acceptanceCriteria: + 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' + + 'environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped ' + + 'one passes `organizationId`; no caller passes both. An explicit `allTenants: false` ' + + 'is treated as undeclared and refused, since it is not an affirmative request for ' + + 'cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 ' + + '`TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means ' + + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' + + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' + + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' + + 'exactly as #7705 left it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 974f861c26..47ee8610cf 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -3107,6 +3107,37 @@ 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: 'package-uninstall-explicit-all-tenants', + surface: 'protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)', + replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', + reason: + 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' + + 'at 5 of 5 deleted, including a foreign org\'s (#7705, #7780). That width was never ' + + 'chosen; it fell out of a missing argument, and the two transports of the same route ' + + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' + + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' + + 'deletes nothing, as does supplying both (they are contradictory, not redundant). ' + + 'Whether a given caller meant "this tenant" or "every tenant" is an intent no ' + + 'transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an ' + + 'accidental org-less call and a deliberate environment-wide one are byte-identical ' + + 'at the call site — which is the whole reason the parameter had to become explicit ' + + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' + + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' + + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) ' + + 'takes for its own default flip.', + acceptanceCriteria: + 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' + + 'environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped ' + + 'one passes `organizationId`; no caller passes both. An explicit `allTenants: false` ' + + 'is treated as undeclared and refused, since it is not an affirmative request for ' + + 'cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 ' + + '`TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means ' + + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' + + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' + + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' + + 'exactly as #7705 left it.', + }, { id: 'plugin-activation-events-retired', surface: