Skip to content

Commit d44dbfa

Browse files
os-zhuangclaude
andauthored
feat(spec)!: shrink ApiMethod enum to the six primitives (#3543, P2 of #3391) (#3581)
* feat(spec)!: shrink ApiMethod enum to the six primitives (#3543, P2 of #3391) The authored enable.apiMethods enum is now exactly the six primitives (get/list/create/update/delete/bulk); the eight legacy values are DERIVED effective operations only. Stored metadata keeps parsing: a legacy value is stripped at parse by the new stripLegacyApiMethods z.preprocess layer (canonicalize-and-warn, permanent tolerance — real metadata does not upgrade in lockstep), with a loud dedicated warning on the deny-all cliff (a pure-legacy whitelist strips to [] = fully closed). Unknown values still hard-fail. LEGACY_API_METHOD_GUIDANCE carries the FROM→TO tombstones. Type split — authored vs effective vocabulary: new ApiOperation / ApiOperationSchema / API_OPERATION_ORDER (14 values, byte-stable pre-shrink wire order) carry the gate/wire vocabulary, so the 405 allowed array and /me/permissions apiOperations still serialize derived verbs and the frontend needs zero changes. EffectiveObjectPermissionSchema.apiOperations validates against ApiOperationSchema; API_METHOD_ORDER remains as a deprecated alias. EffectiveApiMethods.explicitLegacy and the P1 "explicit wins" honoring are removed — the resolver ignores legacy/unknown strings on un-parsed inputs so both paths converge. A present-but-non-array apiMethods now fails CLOSED (deny-all), landing the #3545 deferred tightening. objectql: warnDeprecatedExplicitApiMethods → warnStrippedLegacyApiMethods, a permanent per-object registration diagnostic (parse-time strip carries no object name); escalates on the deny-all case. The affordance-table "unification deferred to P2" comment is resolved: tables stay separate (ADR-0103). platform-objects: sys_business_unit(-member) reclaim P1's explicit import/export and, with sys_user_preference, drop apiMethods entirely (each named all six primitives = default-open that also tracks future primitives). The seven [] declarations are deliberately KEPT as defense-in-depth beside apiEnabled:false (deviation from the issue checklist, documented). Stale sys_secret fail-OPEN comment fixed. Migration: reporter codemod scripts/codemod/apimethods-legacy-to-primitives.mjs (reports exact replacements, flags widening — deliberately not an auto-rewriter). P1 changeset's "honored one release" sentence amended (both phases ship in one release train); the new major changeset carries the full FROM→TO table. Docs/skills/api-surface/json-schema/liveness regenerated. Closes #3543 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018V7nzG1x7nuqkwZQTowtBo * chore(changeset): declare the enum-shrink bump as minor per lockstep policy The repo's fixed (lockstep) Changesets group versions every publishable package together, and the launch-window CI gate (check-changeset-no-major) rejects majors — breaking changes ship as minor with the ! marker as the record, matching every pending breaking changeset on main (GraphQL-surface removal, ADR-0104 write cutover). The changeset body still carries the full BREAKING migration guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018V7nzG1x7nuqkwZQTowtBo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0164f40 commit d44dbfa

26 files changed

Lines changed: 768 additions & 230 deletions

.changeset/api-methods-derivation-contract.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ the REST data surface, the runtime HTTP/MCP dispatcher, and the
1717
`/me/permissions` annotation. The `apiMethods` whitelist is three-state —
1818
`undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and
1919
the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/
20-
export`) are DERIVED from the primitives, never declared standalone (a standalone
21-
declaration is honored one release with a registration-time deprecation warning).
20+
export`) are DERIVED from the primitives, never declared standalone. (This
21+
release also ships the enum shrink — see the `#3543` changeset: the authored
22+
enum IS the six primitives, and a stored legacy value is stripped at parse
23+
with a warning rather than honored.)
2224

2325
**Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create,
2426
update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
feat(spec)!: shrink the `ApiMethod` enum to the six primitives — legacy values are stripped at parse, never honored (#3543, P2 of #3391)
8+
9+
**BREAKING** (ships as `minor` per the lockstep launch-window policy — every
10+
package versions together, so a `major` here would promote the entire
11+
monorepo; the `!` marker and this changeset are the breaking-change record):
12+
the authored `enable.apiMethods` enum is now exactly the six
13+
primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`). The eight
14+
legacy values (`upsert`, `aggregate`, `history`, `search`, `restore`, `purge`,
15+
`import`, `export`) are no longer authorable — they are DERIVED effective
16+
operations, resolved by the server's single derivation table.
17+
18+
**Migration (FROM → TO).** Replace each legacy value with the primitives it
19+
derives from, then de-duplicate; if the result names all six primitives, delete
20+
the `apiMethods` key entirely (equivalent to default-open, and it tracks future
21+
primitives):
22+
23+
| FROM (legacy) | TO (primitives) | why |
24+
| ------------- | -------------------- | ------------------------------------------ |
25+
| `upsert` | `create`, `update` | upsert ⊆ create ∧ update |
26+
| `import` | `create`, `update` | import ⊆ create ∨ update (writeMode-precise) |
27+
| `export` | `list` | export ⊆ list |
28+
| `aggregate` | `list` | aggregate ⊆ list |
29+
| `search` | `list` | search ⊆ list ∧ `searchable` |
30+
| `history` | `get` | history ⊆ get ∧ `trackHistory` |
31+
| `restore` | *(delete the value)* | never derives — `enable.trash` retired (#2377) |
32+
| `purge` | *(delete the value)* | never derives — `enable.trash` retired (#2377) |
33+
34+
Reporter codemod: `node scripts/codemod/apimethods-legacy-to-primitives.mjs`
35+
(scans, reports the exact replacement per site, and flags whitelists the
36+
mapping would WIDEN so the edit stays reviewable).
37+
38+
**Stored metadata keeps parsing — permanent tolerance, narrowing only.** Real
39+
metadata does not upgrade in lockstep with the spec, so a stored legacy value
40+
is NOT a parse error: `stripLegacyApiMethods` (new export) strips it with a
41+
FROM→TO warning (canonicalize-and-warn). Stripping only ever NARROWS exposure —
42+
the derivation table still grants every legacy verb that derives from the
43+
primitives you declared. Two cliffs to know:
44+
45+
1. A whitelist of ONLY legacy values (e.g. `['upsert']`) strips to `[]` =
46+
**deny-all** — the object's API closes instead of widening. The strip
47+
warning and the objectql registration diagnostic both call this out.
48+
2. A legacy value NOT derivable from your declared primitives (e.g.
49+
`['get', 'export']` — export needs `list`) was honored by the P1
50+
"explicit wins" path and is now denied. Declare the underlying primitive.
51+
52+
**Type split — authored vs effective vocabulary.** `ApiMethod` (authored) is
53+
now six values; the NEW `ApiOperation` type / `ApiOperationSchema` /
54+
`API_OPERATION_ORDER` (fourteen values, byte-stable pre-shrink wire order)
55+
carry the EFFECTIVE vocabulary. The wire contract is unchanged: the 405
56+
`allowed` array and `/me/permissions` `apiOperations` still serialize derived
57+
verbs (`export`, `search`, …), and `EffectiveObjectPermissionSchema.apiOperations`
58+
now validates against `ApiOperationSchema`. `EffectiveApiMethods.explicitLegacy`
59+
is removed (nothing is honored verbatim anymore); `API_METHOD_ORDER` remains as
60+
a deprecated alias of `API_OPERATION_ORDER`.
61+
62+
**Fail-closed tightening (#3545):** a PRESENT but non-array `apiMethods` (only
63+
producible by a raw/out-of-band metadata write) now resolves to `deny-all`
64+
instead of unrestricted — a policy that exists but cannot be read fails CLOSED.
65+
66+
**Published JSON Schema diverges deliberately:** `data/ApiMethod.json` is the
67+
strict six-value enum (a `z.preprocess` is not representable in JSON Schema),
68+
so external JSON-Schema validators reject legacy values that the zod parse
69+
would strip-and-warn. Treat the JSON Schema as the authored contract; the zod
70+
tolerance exists for stored metadata.
71+
72+
**objectql:** the P1 "explicit wins" transition is reclaimed —
73+
`warnDeprecatedExplicitApiMethods` is replaced by `warnStrippedLegacyApiMethods`
74+
(a permanent per-object diagnostic for schemas that reach the registry without
75+
passing through Zod; the parse-time strip warning carries no object name).
76+
77+
**platform-objects:** whitelist audit — `sys_business_unit`,
78+
`sys_business_unit_member` (P1's explicit `import`/`export` reclaimed) and
79+
`sys_user_preference` dropped their `apiMethods` entirely (each named all six
80+
primitives = default-open). Read-only and deny-all whitelists are unchanged;
81+
the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside
82+
`apiEnabled: false`.

content/docs/data-modeling/objects.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ enable: {
9292
| `trackHistory` | `false` | Field history tracking for audit compliance |
9393
| `searchable` | `true` | Index records for global search |
9494
| `apiEnabled` | `true` | Expose object via automatic APIs |
95-
| `apiMethods` | all | Whitelist of allowed API operations |
95+
| `apiMethods` | all | Whitelist over the six primitives (`get/list/create/update/delete/bulk`); derived verbs (search/export/upsert/…) follow automatically. `undefined` = all, `[]` = none |
9696
| `files` | `false` | Enable file attachments |
9797
| `feeds` | `true` | Enable social feed and comments |
9898
| `activities` | `true` | Enable tasks and events tracking |

content/docs/data-modeling/schema-design.mdx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,12 @@ enable: {
6868
trackHistory: true, // Track field changes over time
6969
searchable: true, // Include in global search
7070
apiEnabled: true, // Expose via REST/GraphQL
71-
apiMethods: [ // Whitelist API operations
72-
'get',
73-
'list',
74-
'create',
71+
apiMethods: [ // Whitelist API operations — six primitives only;
72+
'get', // search/export/… DERIVE from them (list grants
73+
'list', // aggregate/search/export; create+update grants
74+
'create', // upsert/import). undefined = all, [] = none.
7575
'update',
76-
'delete',
77-
'search',
78-
'export'
76+
'delete'
7977
],
8078
files: true, // Allow file attachments
8179
feeds: true, // Enable activity feed (Chatter-like)

content/docs/references/data/object.mdx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ API Operations Enum
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { ApiMethod, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
18-
import type { ApiMethod, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
17+
import { ApiMethod, ApiOperation, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
18+
import type { ApiMethod, ApiOperation, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
1919

2020
// Validate data
2121
const result = ApiMethod.parse(data);
@@ -27,6 +27,20 @@ const result = ApiMethod.parse(data);
2727

2828
### Allowed Values
2929

30+
* `get`
31+
* `list`
32+
* `create`
33+
* `update`
34+
* `delete`
35+
* `bulk`
36+
37+
38+
---
39+
40+
## ApiOperation
41+
42+
### Allowed Values
43+
3044
* `get`
3145
* `list`
3246
* `create`
@@ -123,7 +137,7 @@ const result = ApiMethod.parse(data);
123137
| **stageField** | `string \| 'false'` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
124138
| **listViews** | `Record<string, { name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
125139
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. |
126-
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]; … }` | optional | Enabled system features modules |
140+
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
127141
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
128142
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
129143
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
@@ -160,7 +174,7 @@ const result = ApiMethod.parse(data);
160174
| **trackHistory** | `boolean` || Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance |
161175
| **searchable** | `boolean` || Index records for global search |
162176
| **apiEnabled** | `boolean` || Expose object via automatic APIs |
163-
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Whitelist of allowed API operations |
177+
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
164178
| **files** | `boolean` || Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent |
165179
| **feeds** | `boolean` || Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object |
166180
| **activities** | `boolean` || Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |

content/docs/references/security/permission.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const result = AdminScope.parse(data);
6767
| **modifyAllRecords** | `boolean` || Modify All Data (Bypass Sharing) |
6868
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
6969
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |
70-
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. |
70+
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum. |
7171

7272

7373
---

packages/objectql/src/registry.test.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnDeprecatedExplicitApiMethods, computeFQN, parseFQN } from './registry';
2+
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, computeFQN, parseFQN } from './registry';
33

44
describe('SchemaRegistry', () => {
55
let registry: SchemaRegistry;
@@ -848,27 +848,40 @@ describe('reconcileManagedApiMethods', () => {
848848
});
849849

850850
// ==========================================
851-
// warnDeprecatedExplicitApiMethods — #3391
852-
// One-shot deprecation warning for standalone LEGACY apiMethods values (the
851+
// warnStrippedLegacyApiMethods — #3543
852+
// One-shot per-object diagnostic for retired LEGACY apiMethods values (the
853853
// derived 8). Pure observation — never mutates the schema.
854854
// ==========================================
855-
describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
856-
it('warns when a whitelist declares a derived legacy value (import/export)', () => {
855+
describe('warnStrippedLegacyApiMethods (#3543)', () => {
856+
it('diagnoses a whitelist declaring a retired legacy value (import/export)', () => {
857857
const warn = vi.fn();
858-
warnDeprecatedExplicitApiMethods(
858+
warnStrippedLegacyApiMethods(
859859
{ name: 'crm_deal_a', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'] } } as any,
860860
{ warn },
861861
);
862862
expect(warn).toHaveBeenCalledTimes(1);
863863
expect(warn.mock.calls[0][0]).toContain('crm_deal_a');
864864
expect(warn.mock.calls[0][0]).toContain('import');
865865
expect(warn.mock.calls[0][0]).toContain('export');
866-
expect(warn.mock.calls[0][0]).toContain('#3391');
866+
expect(warn.mock.calls[0][0]).toContain('IGNORED');
867+
expect(warn.mock.calls[0][0]).toContain('#3543');
868+
// primitives remain, so no deny-all escalation
869+
expect(warn.mock.calls[0][0]).not.toContain('deny-all');
870+
});
871+
872+
it('escalates when ignoring legacy values leaves NO primitives (deny-all cliff)', () => {
873+
const warn = vi.fn();
874+
warnStrippedLegacyApiMethods(
875+
{ name: 'crm_deal_denyall', enable: { apiMethods: ['upsert'] } } as any,
876+
{ warn },
877+
);
878+
expect(warn).toHaveBeenCalledTimes(1);
879+
expect(warn.mock.calls[0][0]).toContain('deny-all');
867880
});
868881

869882
it('stays silent for a pure-primitive whitelist', () => {
870883
const warn = vi.fn();
871-
warnDeprecatedExplicitApiMethods(
884+
warnStrippedLegacyApiMethods(
872885
{ name: 'crm_deal_b', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } } as any,
873886
{ warn },
874887
);
@@ -877,16 +890,16 @@ describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
877890

878891
it('stays silent for an undefined / empty whitelist', () => {
879892
const warn = vi.fn();
880-
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
881-
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
893+
warnStrippedLegacyApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
894+
warnStrippedLegacyApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
882895
expect(warn).not.toHaveBeenCalled();
883896
});
884897

885898
it('warns only once per object name (hot path stays free)', () => {
886899
const warn = vi.fn();
887900
const schema: any = { name: 'crm_deal_once', enable: { apiMethods: ['list', 'aggregate'] } };
888-
warnDeprecatedExplicitApiMethods(schema, { warn });
889-
warnDeprecatedExplicitApiMethods(schema, { warn });
901+
warnStrippedLegacyApiMethods(schema, { warn });
902+
warnStrippedLegacyApiMethods(schema, { warn });
890903
expect(warn).toHaveBeenCalledTimes(1);
891904
});
892905
});

0 commit comments

Comments
 (0)