Skip to content

Commit a27c4db

Browse files
committed
metadata-protocol: drop the position regime row — a packaged position's refusal keeps the managed seal's edit remedy; the switch is documented beside the table
Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 112937e commit a27c4db

4 files changed

Lines changed: 9 additions & 99 deletions

File tree

‎.changeset/15204-s2c-position-regime-row.md‎

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,15 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
132132

133133
### Packaged flows, actions and permission sets (ADR-0126)
134134

135-
`flow`, `action` and `permission` are behavioral types, in Regime C of [ADR-0126](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0126-packaged-metadata-customization-model.md), and `position` is pre-charted there ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6). A packaged item of one of these types is locked against in-place edits (and, for a flow or an action, against removal). The refusal names the type's sanctioned path, not a redeploy or `OS_METADATA_WRITABLE`, and each type names only the routes it has:
135+
`flow`, `action` and `permission` are behavioral types, in Regime C of [ADR-0126](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0126-packaged-metadata-customization-model.md). A packaged item of one of these types is locked against in-place edits (and, for a flow or an action, against removal). The refusal names the type's sanctioned path, not a redeploy or `OS_METADATA_WRITABLE`, and each type names only the routes it has:
136136

137137
| Type | Sanctioned path the refusal names |
138138
| :--- | :--- |
139139
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
140140
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
141141
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |
142-
| `position` | Switch it off (`POST /api/v1/security/_activation/position/:name`, body `{enabled: false}`). No clone is offered. The audience anchors `everyone` and `guest` are not switchable, so their refusal names no switch. |
143142

144-
A permission set also has a switch, outside the refusal: `POST /api/v1/security/_activation/permission/:name`, body `{enabled: false}`, switches it off for the deployment. The refusal does not name it.
143+
A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.
145144

146145
The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged.
147146

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,11 @@
5757
* (`packaged-permission-set-lock.ts`), so one condition reads one way at
5858
* every door.
5959
*
60-
* - `position` — the switch only (pre-charted on the regime, ADR-0131 D6):
61-
* `POST /security/_activation/position/:name`, body `{ enabled }`, the
62-
* security catalog's activation door. ⛔ No clone: a position has no clone
63-
* door. Without this row a packaged position's refusal read the generic
64-
* managed seal ("edit the source artifact and redeploy") and never named the
65-
* switch that exists. The two audience anchors (`everyone`, `guest`) are
66-
* the row's `notFor`: the door refuses them, so they keep the generic seal.
67-
*
68-
* Every switch route is gated by the ONE §5 write-authority gate
69-
* (`packages/runtime/src/domains/activation-gate.ts`), so each carries the same
60+
* Both switch routes are gated by the ONE §5 write-authority gate
61+
* (`packages/runtime/src/domains/activation-gate.ts`), so both carry the same
7062
* operator-only clause. Still absent: ADR-0126 §3's pre-charted `tool` /
71-
* `skill` — no sanctioned path is built for them, and a row with no route does
72-
* not compile ({@link PackagedBaseRegimeCRoutes}).
63+
* `skill` / `position` — no sanctioned path is built for them, and a row with no
64+
* route does not compile ({@link PackagedBaseRegimeCRoutes}).
7365
*
7466
* ## The one origin-gated row
7567
*
@@ -139,7 +131,6 @@
139131
*/
140132

141133
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
142-
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity';
143134

144135
/**
145136
* An ADR-0126 customization regime a packaged-base refusal speaks for — only the
@@ -164,17 +155,7 @@ export type PackagedBaseRegimeCRoutes =
164155
* type's sanctioned routes, or an origin-gated type's owning source.
165156
*/
166157
export type PackagedBaseRegimeRow =
167-
| {
168-
readonly regime: 'C';
169-
readonly routes: PackagedBaseRegimeCRoutes;
170-
/**
171-
* Names the row does NOT speak for, each with why. A name listed here
172-
* reads the generic managed seal ({@link managedItemSealedSentence}'s
173-
* own sentence) instead of the row's routes, because those routes refuse
174-
* it — a refusal must never name a path that refuses the caller too.
175-
*/
176-
readonly notFor?: Readonly<Record<string, string>>;
177-
}
158+
| { readonly regime: 'C'; readonly routes: PackagedBaseRegimeCRoutes }
178159
| {
179160
readonly regime: 'origin-gated';
180161
/** The type's noun, opening the sentence the way the type's own admin door opens it. */
@@ -217,20 +198,6 @@ export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow
217198
+ 'or POST /api/v1/data/sys_permission_set with a new name',
218199
},
219200
},
220-
position: {
221-
regime: 'C',
222-
routes: {
223-
switchOff: 'POST /api/v1/security/_activation/position/:name, body {enabled: false}; '
224-
+ 'operator-only where one install serves several organizations',
225-
},
226-
// The activation door refuses the two audience anchors (ADR-0090 D5/D9),
227-
// so their refusal must not name it: switching one off would remove the
228-
// authenticated or anonymous baseline of every principal in one write.
229-
notFor: Object.fromEntries(AUDIENCE_ANCHOR_POSITIONS.map((anchor) => [
230-
anchor,
231-
'an audience anchor: the activation door refuses it, so the switch is not its remedy',
232-
])),
233-
},
234201
datasource: {
235202
regime: 'origin-gated',
236203
noun: 'Datasource',
@@ -340,7 +307,7 @@ export function isOriginGatedType(type: string): boolean {
340307
* Kept under the REST door's 500-character client-message bound
341308
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
342309
* the tail is truncated. Characters outside the item's name, save / removal:
343-
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294, `position` 307 / 300,
310+
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294,
344311
* `datasource` 192 / 193 — so a name of up to 88 characters arrives whole for
345312
* every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
346313
* with its own remedy (`default`: under 300 characters whole).
@@ -351,9 +318,6 @@ export function packagedBaseRegimeSentence(
351318
const singular = PLURAL_TO_SINGULAR[type] ?? type;
352319
const row = packagedBaseRegimeRow(singular);
353320
if (row === undefined) return undefined;
354-
if (row.regime === 'C' && row.notFor !== undefined && Object.prototype.hasOwnProperty.call(row.notFor, name)) {
355-
return undefined;
356-
}
357321
const lock = row.regime === 'origin-gated'
358322
? `${row.noun} '${name}' is code-defined and cannot be `
359323
+ (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. '

‎packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts‎

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
4545
['page', new Map([['pkg_page', shipped('pkg_page')]])],
4646
['action', new Map([['pkg_action', shipped('pkg_action')]])],
4747
['permission', new Map([['pkg_perm', shipped('pkg_perm')]])],
48-
['position', new Map([
49-
['pkg_position', shipped('pkg_position')],
50-
['everyone', shipped('everyone')],
51-
['guest', shipped('guest')],
52-
])],
5348
]);
5449

5550
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
@@ -384,49 +379,13 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa
384379
});
385380
}
386381

387-
for (const environmentId of [undefined, 'env_1']) {
388-
it(`\`position\` — save names the security catalog's activation switch and no clone `
389-
+ `(${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
390-
// [ADR-0131 D6] Without its row a packaged position read the generic
391-
// managed seal ("edit the source artifact and redeploy"), naming no switch.
392-
const message = expectRegimeC(refusalOn(environmentId, 'position', 'pkg_position', 'save'), 'position', 'pkg_position', 'save');
393-
expect(message).toBe(
394-
`${LOCKED('position', 'pkg_position', 'save')} `
395-
+ 'Switch it off (POST /api/v1/security/_activation/position/:name, body {enabled: false}; '
396-
+ `${OPERATOR_ONLY}). See ${ADR_0126}.`,
397-
);
398-
expect(message.toLowerCase()).not.toContain('clone');
399-
});
400-
}
401-
402382
it('`permission` removal stays the #6960 carve-out — the row adds no refusal where there was none', () => {
403383
// `permission` merges its overlay at read (`supportsOverlay: true`), so
404384
// removing its overlay row is repair, never refused on this ground.
405385
expect(refusal('permission', 'pkg_perm', 'delete')).toBeNull();
406386
expect(refusalOnHostConfig('permission', 'pkg_perm', 'delete')).toBeNull();
407387
});
408388

409-
it('`position` — an audience anchor keeps the generic managed seal: the switch refuses it, so it is not named', () => {
410-
// The activation door refuses `everyone` and `guest` (the row's `notFor`).
411-
for (const anchor of ['everyone', 'guest']) {
412-
const r = refusal('position', anchor, 'save');
413-
expect(shape(r), anchor).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
414-
expect(r.message, anchor).toBe(
415-
`Metadata item 'position/${anchor}' is provided by a managed package and is sealed against in-place edits: `
416-
+ 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open '
417-
+ 'a managed item. Edit the source artifact and redeploy. '
418-
+ 'See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.',
419-
);
420-
expect(r.message, anchor).not.toContain('_activation');
421-
}
422-
});
423-
424-
it('`position` removal is the same #6960 carve-out — its row adds no refusal where there was none', () => {
425-
// `position` merges its overlay at read (`supportsOverlay: true`), like `permission`.
426-
expect(refusal('position', 'pkg_position', 'delete')).toBeNull();
427-
expect(refusalOnHostConfig('position', 'pkg_position', 'delete')).toBeNull();
428-
});
429-
430389
it('the /meta doors throw the row\'s sentence for an action, on save and on removal (one emitter)', async () => {
431390
const p = protocolOn('env_1');
432391
const thrownBy = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);
@@ -477,12 +436,11 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa
477436
expect(longName).toHaveLength(88);
478437
const registry = {
479438
getArtifactItem: (type: string, name: string) =>
480-
(['flow', 'action', 'permission', 'position'].includes(type) && name === longName ? shipped(name) : undefined),
439+
(['flow', 'action', 'permission'].includes(type) && name === longName ? shipped(name) : undefined),
481440
};
482441
const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), 'env_1');
483442
for (const [type, operation] of [
484443
['flow', 'save'], ['flow', 'delete'], ['action', 'save'], ['action', 'delete'], ['permission', 'save'],
485-
['position', 'save'],
486444
] as const) {
487445
const message = String((p.packagedBaseRefusal({ type, name: longName, operation }) as any).message);
488446
expect(message.length, `${type} ${operation}`).toBeLessThan(500);

0 commit comments

Comments
 (0)