Skip to content

Commit e307d87

Browse files
committed
wip: ledger entry, changeset, skills line, console link
Claude-Session: https://claude.ai/code/session_01FEkQ5ni5yjiTswP6crgAkX Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8948d3c commit e307d87

6 files changed

Lines changed: 317 additions & 64 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/skills': patch
5+
'@object-ui/app-shell': patch
6+
---
7+
8+
fix(metadata-protocol)!: a write naming no package onto an installed package's view, dashboard, report, translation or email template is refused, naming the base's `.custom` pair and `os migrate packageless-overlays`
9+
10+
Clause-②: yes (narrowing) — the metadata save, publish, delete and rollback doors refuse a write that names no package onto an item of the five overlay types an installed read-only package ships, which they accepted before as a package-unbound Setup row. Nothing is widened.
11+
12+
Metadata-protocol: narrowed (the `allowEnvironmentOverlay` flag of `view`, `dashboard`, `report`, `translation` and `email_template` no longer opens a package-less write onto a packaged item; it stays the predicate the migration ceremony and the read of an unmigrated row use)
13+
14+
Skills-docs: updated here — `skills/objectstack-platform/SKILL.md`'s ADR-0146 paragraph said "a package-unbound Setup row still wins over the base's until D11"; it now says such a row from before D11 wins until migrated and that D11 refuses new ones. The same paragraph drops "the base's is unchanged" after the export sentence, which "replaces the base's document whole" already implies, to stay under the bundle's token ceiling.
15+
16+
<!-- adr-0087: registered packageless-overlay-write-closed -->
17+
18+
**BREAKING** write narrowing at the metadata doors (`@objectstack/metadata-protocol`), shipped as `minor` during the launch window.
19+
20+
**Run the ceremony before you upgrade.** On each deployment with Setup customizations of a package's items, run `os migrate packageless-overlays` (preview), then `os migrate packageless-overlays --apply`, on the release before this one (#28413 shipped the command). If you upgrade first, nothing breaks at the upgrade: every customization is still served as before. But each of them becomes read-only in Setup until the command moves it, because its package-less save, reset, publish and rollback are refused.
21+
22+
### Migration: FROM → TO
23+
24+
FROM, a customization of the installed package `com.acme.crm`'s view written with no package:
25+
26+
```http
27+
PUT /api/v1/meta/view/account_list
28+
{ "name": "account_list", "label": "My accounts" }
29+
```
30+
31+
TO, the same customization written into the base's paired `.custom`, declaring what it overrides:
32+
33+
```http
34+
PUT /api/v1/meta/view/account_list?package=com.acme.crm.custom
35+
{ "name": "account_list", "label": "My accounts", "overrides": { "package": "com.acme.crm", "basedOn": "1.4.0" } }
36+
```
37+
38+
To restore the package original, FROM `DELETE /api/v1/meta/view/account_list` TO `DELETE /api/v1/meta/view/account_list?package=com.acme.crm.custom`. `basedOn` is the base's installed `manifest.version`. The pair is created by `os migrate packageless-overlays --apply`, or by Studio's Customize.
39+
40+
- **Now refused.** A save (draft or publish mode), a publish, a delete of the active item, or a rollback whose restored row is package-less, with no `?package=` (or an empty one), of a `view`, `dashboard`, `report`, `translation` or `email_template` that an installed read-only package ships. Each answers `403 NOT_OVERRIDABLE`, writes nothing, and its message names the item, the shipping package, the pair (`?package=<base>.custom`), the `overrides` declaration and `os migrate packageless-overlays`. Setup's form save and reset show that message as sent. The refusal comes before every gate that reads a stored row, so it is the same with or without `overrides` in the body.
41+
- **Still open.** Every write naming `?package=<base>.custom` (the pair's save, publish, delete and rollback, judged by the override write gate as before); a rollback of the pair's own declared row, which is judged as the pair's override; a write of a name no installed package ships (environment metadata); a write of a name whose only shipping packages have ids no `.custom` pair can be formed from (the ceremony lists those rows `no_pair` and cannot move them, so their write is not closed); and discarding a draft.
42+
- **Unchanged.** A package-less row stored before this release is still served exactly as before, by every read. The ceremony still moves it: it writes into the pair, never package-less.
43+
- **Studio.** The read-only item notice no longer links to the package-less Setup overlay (`@object-ui/app-shell`).
44+
- **Removed with it.** #28413's interim allowance, by which a package-less save that landed on a moved row kept that row's `overrides` block, is gone: such a save is refused like any other.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// ADR-0146 D11 — a runtime narrowing at the metadata write doors with no
6+
// authorable key: a write naming no package onto an item an installed package
7+
// ships is refused. Registered because an upgrading deployment can carry the
8+
// package-less Setup rows those writes made, and the operator step that moves
9+
// them into the base's `.custom` pair is to run BEFORE the upgrade (the
10+
// migration prescription the dispatch order puts on this change's note).
11+
export const entry: SemanticMigration = {
12+
id: 'packageless-overlay-write-closed',
13+
surface:
14+
'a metadata write that names no package (PUT, DELETE, publish or rollback under '
15+
+ '/api/v1/meta/<type>/<name> with no ?package=, and Setup\'s form save and reset, which send '
16+
+ 'none) onto a view, dashboard, report, translation or email template that an installed '
17+
+ 'read-only package ships, and the package-less sys_metadata rows such writes stored',
18+
replacement:
19+
'before upgrading, run `os migrate packageless-overlays` (preview) and then '
20+
+ '`os migrate packageless-overlays --apply` on each deployment with Setup customizations: it '
21+
+ 'moves each package-less row of a name one installed package ships into that package\'s '
22+
+ 'paired `<base>.custom`, declaring `overrides: { package: <base>, basedOn: <installed '
23+
+ 'version> }`. From then on write the customization into the pair: '
24+
+ '`PUT /api/v1/meta/<type>/<name>?package=<base>.custom` with the `overrides` declaration in '
25+
+ 'the body, and `DELETE /api/v1/meta/<type>/<name>?package=<base>.custom` to restore the '
26+
+ 'package original',
27+
reason:
28+
'A customization of an installed package lives in its paired `.custom` package, where it is '
29+
+ 'a declared override that is exported with the pair and judged against the base\'s version '
30+
+ '(ADR-0146 D1, D2, D11). The package-less write made a row of no package instead, which no '
31+
+ 'export carries. Rows already stored that way are still served unchanged, so nothing breaks '
32+
+ 'at the upgrade itself, but each package-less save, publish, delete or rollback of them is '
33+
+ 'refused 403 NOT_OVERRIDABLE naming the pair and this command, so a deployment that skipped '
34+
+ 'the step finds its Setup customizations read-only. No conversion applies: the rows are '
35+
+ 'stored data, not authored source, and the move goes through the save door. A name several '
36+
+ 'packages ship, or one whose package id forms no pair, is listed by the command and left '
37+
+ 'for the operator. ADR-0146, ADR-0087.',
38+
acceptanceCriteria:
39+
'On the deployment\'s database and configuration, `os migrate packageless-overlays` reports '
40+
+ 'nothing movable (exit 0). Each customization is then served exactly as before, and a save '
41+
+ 'of it with `?package=<base>.custom` and its `overrides` declaration succeeds where the same '
42+
+ 'save naming no package answers 403 NOT_OVERRIDABLE.',
43+
};

‎skills/objectstack-platform/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ to a new protocol major — never to silence a mismatch.
131131

132132
**`manifest.namespace` (ADR-0048):** Optional, but **enforced once set**. When a package declares `manifest.namespace: 'crm'`, every `object.name` must start with `crm_` or `defineStack` errors (`validateNamespacePrefix` in `@objectstack/spec`); the legacy `<ns>__<short>` double-underscore form is rejected, and `sys_`-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim `crm` fails with `NamespaceConflictError` (downgrade to a warning with `OS_METADATA_COLLISION=warn`). `os lint` additionally emits a non-fatal `naming/namespace-prefix` warning for bare-named UI/automation items (app, page, dashboard, flow, action, report, dataset) when a namespace is set.
133133

134-
**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template or translation (page, app, dataset, doc, book, mapping, flow and action wait for their doors), never judges `basedOn` and ignores any other package's declaration. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a package-unbound Setup row still wins over the base's until D11. An undeclared pair row or a dependent's row is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`; the base's is unchanged.
134+
**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template or translation (page, app, dataset, doc, book, mapping, flow and action wait for their doors), never judges `basedOn` and ignores any other package's declaration. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a pre-D11 package-unbound Setup row wins until migrated; D11 refuses new ones. An undeclared pair row or a dependent's row is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`.
135135

136136
---
137137

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28576 (ADR-0146 D11) — Setup's item page over a PACKAGED item
5+
* shows the server's refusal of its package-less write as sent.
6+
*
7+
* The server closed the package-less write onto an item a code package ships:
8+
* a save or a reset naming no package is refused `403 NOT_OVERRIDABLE`, and
9+
* the sentence names the base's paired `.custom` package and the
10+
* `os migrate packageless-overlays` ceremony — the two things the
11+
* administrator can do. Setup's overlay page stays until its route retires
12+
* (Studio is the editor), so what it owes is to relay that sentence whole, in
13+
* the server's words: its error line leads with the first sentence and keeps
14+
* the full text behind "More" (objectui#13332).
15+
*
16+
* Pinned over the REAL page and a REAL `MetadataClient`, whose transport is an
17+
* in-memory server answering the `/meta` doors as the framework does after
18+
* the close: the packaged item's layers, and a `403` for each write that names
19+
* no package. Both the Save (a `PUT` with no `package`) and the Reset (a
20+
* `DELETE` with no `package`) are asserted to have reached the server with no
21+
* `package` key, so the refusal read back is the one that write was given.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import * as React from 'react';
26+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
27+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
28+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
29+
import { z } from 'zod';
30+
import { ComponentRegistry } from '@object-ui/core';
31+
import { DocSchema } from '@objectstack/spec/system';
32+
import { MetadataClient } from '@object-ui/data-objectstack';
33+
34+
const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
35+
vi.mock('sonner', () => ({ toast: toasts }));
36+
37+
const NAME = 'shipped_guide';
38+
const BASE = 'com.acme.handbook';
39+
const PAIR = `${BASE}.custom`;
40+
41+
const doc = (content: string) => ({ name: NAME, label: NAME, content, group: 'reference' });
42+
43+
/** The server's sentences, worded as the framework words them; read back verbatim below. */
44+
const SAVE_CLOSED =
45+
`'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` +
46+
`of an installed package lives in its paired .custom package (ADR-0146 D11). Save it into '${PAIR}' ` +
47+
`(?package=${PAIR}), declaring overrides: { package: '${BASE}', basedOn }. A package-less customization stored ` +
48+
`before this release is still served; run \`os migrate packageless-overlays\` first to move it into '${PAIR}'.`;
49+
const RESET_CLOSED =
50+
`'doc/${NAME}' ships in '${BASE}', and a write naming no package onto a packaged item is closed: a customization ` +
51+
`of an installed package lives in its paired .custom package (ADR-0146 D11). Remove it from '${PAIR}' ` +
52+
`(?package=${PAIR}). A package-less customization stored before this release is still served; run ` +
53+
`\`os migrate packageless-overlays\` first to move it into '${PAIR}'.`;
54+
55+
const server = {
56+
/** The packaged item's stored package-less row, as an unmigrated deployment holds it. */
57+
overlay: doc('# Customized') as Record<string, unknown>,
58+
writes: [] as Array<{ method: string; query: URLSearchParams }>,
59+
};
60+
61+
const json = (status: number, body: unknown) =>
62+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
63+
64+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
65+
const method = (init?.method ?? 'GET').toUpperCase();
66+
const url = new URL(String(input), 'http://console.test');
67+
const q = url.searchParams;
68+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
69+
if (!meta) return json(200, []);
70+
const seg = meta[1].split('/').map(decodeURIComponent);
71+
if (seg[0] === '_drafts') return json(200, { drafts: [] });
72+
if (seg.length === 1) return json(200, { items: [] });
73+
if (seg.length === 3 && seg[2] === 'layers') {
74+
const code = { ...doc('# Shipped'), _packageId: BASE, _provenance: 'package' };
75+
return json(200, {
76+
code, overlay: server.overlay, overlayScope: 'env', effective: server.overlay,
77+
provenance: 'package', packageId: BASE, editable: true, deletable: true, resettable: true, lock: 'none',
78+
});
79+
}
80+
if (seg.length === 3 && seg[2] === 'references') return json(200, []);
81+
if (seg.length === 2 && (method === 'PUT' || method === 'DELETE')) {
82+
server.writes.push({ method, query: new URLSearchParams(q) });
83+
// After the close: a write naming no package onto this packaged item is refused.
84+
if (!q.get('package')) {
85+
return json(403, { error: method === 'PUT' ? SAVE_CLOSED : RESET_CLOSED, code: 'NOT_OVERRIDABLE' });
86+
}
87+
return json(200, { success: true });
88+
}
89+
if (seg.length === 2) {
90+
if (q.get('state') === 'draft') return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
91+
return json(200, { type: seg[0], name: seg[1], item: server.overlay });
92+
}
93+
return json(200, {});
94+
}
95+
96+
const client = vi.hoisted(() => ({ current: null as unknown }));
97+
98+
vi.mock('./useMetadata', async (importOriginal) => {
99+
const mod = await importOriginal<typeof import('./useMetadata')>();
100+
return {
101+
...mod,
102+
useMetadataClient: () => client.current,
103+
useMetadataTypes: () => ({
104+
loading: false,
105+
entries: [
106+
// An overlay-able type as the registry serves the five: the page opens
107+
// the packaged item for editing on this flag (objectui#12141).
108+
{ type: 'doc', label: 'Documentation', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true, schema: DOC_JSON_SCHEMA },
109+
],
110+
}),
111+
};
112+
});
113+
114+
import { MetadataResourceEditPage } from './ResourceEditPage';
115+
import { registerDefaultMetadataSchemas } from './default-schemas';
116+
import { registerBuiltinPreviews } from './previews';
117+
118+
const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record<string, unknown>;
119+
client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
120+
121+
registerDefaultMetadataSchemas();
122+
registerBuiltinPreviews();
123+
124+
const STUB_NS = 'packageless-write-closed-28576';
125+
function StubMarkdown({ schema }: { schema: { content?: string } }) {
126+
return <div data-testid="markdown-render">{schema.content}</div>;
127+
}
128+
beforeAll(() => {
129+
ComponentRegistry.register('markdown', StubMarkdown as never, { namespace: STUB_NS });
130+
});
131+
afterAll(() => {
132+
ComponentRegistry.unregister('markdown', STUB_NS);
133+
});
134+
135+
beforeEach(() => {
136+
server.overlay = doc('# Customized');
137+
server.writes = [];
138+
for (const fn of Object.values(toasts)) fn.mockClear();
139+
window.localStorage.setItem('metadata-admin:autosave', '0');
140+
});
141+
afterEach(() => {
142+
cleanup();
143+
window.localStorage.removeItem('metadata-admin:autosave');
144+
window.history.replaceState(null, '', '/');
145+
});
146+
147+
async function settle() {
148+
await act(async () => {
149+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
150+
});
151+
}
152+
153+
async function open() {
154+
const path = `/metadata/doc/${NAME}`;
155+
window.history.replaceState(null, '', path);
156+
render(
157+
<MemoryRouter initialEntries={[path]}>
158+
<Routes>
159+
<Route path="/metadata/doc/:name" element={<MetadataResourceEditPage type="doc" name={NAME} />} />
160+
<Route path="/metadata/doc" element={<span data-testid="list-view">list</span>} />
161+
</Routes>
162+
</MemoryRouter>,
163+
);
164+
const source = await screen.findByRole('textbox', { name: 'Markdown source' }, { timeout: 8000 });
165+
await settle();
166+
return source;
167+
}
168+
169+
/** The page's error line, opened whole: the server's sentence as sent. */
170+
async function refusalShown(): Promise<string> {
171+
fireEvent.click(await screen.findByTestId('refusal-more'));
172+
return screen.getByTestId('refusal-detail').textContent ?? '';
173+
}
174+
175+
describe('Setup shows the server\'s refusal of a package-less write onto a packaged item as sent (objectstack#28576)', () => {
176+
it('Save: the PUT names no package, and the refusal is the page\'s error line, word for word', async () => {
177+
const source = await open();
178+
fireEvent.change(source, { target: { value: '# Customized again' } });
179+
fireEvent.click(screen.getByRole('button', { name: /Save \(⌘S\)/ }));
180+
181+
await waitFor(() => expect(server.writes.filter((w) => w.method === 'PUT')).toHaveLength(1));
182+
await settle();
183+
expect(server.writes[0]!.query.has('package')).toBe(false);
184+
expect(await refusalShown()).toBe(SAVE_CLOSED);
185+
expect(toasts.success).not.toHaveBeenCalled();
186+
});
187+
188+
it('Reset: the DELETE names no package, and the refusal is the page\'s error line, word for word; the page stays', async () => {
189+
await open();
190+
fireEvent.click(screen.getByTestId('reset-or-delete-button'));
191+
const dialog = await screen.findByRole('alertdialog');
192+
fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action'));
193+
194+
await waitFor(() => expect(server.writes.filter((w) => w.method === 'DELETE')).toHaveLength(1));
195+
await settle();
196+
expect([...server.writes[0]!.query.keys()]).toEqual([]);
197+
expect(await refusalShown()).toBe(RESET_CLOSED);
198+
expect(screen.queryByTestId('list-view')).toBeNull();
199+
});
200+
});

0 commit comments

Comments
 (0)