Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
'@objectstack/metadata-core': minor
---

fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing

Clause-②: yes (widening)

**What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest:

| Door | Before | Now |
| :--- | :--- | :--- |
| `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence |
| a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice |
| a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is |
| a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk |
| the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused |

**The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them):

- `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag.
- `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits.

**`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`.

**`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before.

Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site.

<!-- adr-0087: registered page-is-default-off-record-page-removed, page-template-full-width-to-default -->
24 changes: 24 additions & 0 deletions packages/metadata-core/src/artifact-forward-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,30 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', ()
expect(grant).not.toHaveProperty('allowRestore');
});

it('reads a FLAT package body\'s range off the body itself — the shape `registerApp` and the marketplace ledger hold', () => {
// The same bytes as `legacyPermissionDefinition`, flattened the way
// `normalizeBundle` and a package body inside `packages[]` spell them: the
// manifest half beside the collections, no `manifest` key.
const nested = legacyPermissionDefinition('^17.1.0');
const { manifest, ...collections } = nested;
const flat = { ...manifest, ...collections };
const fromFlat = applyArtifactForwardConversions(flat, { runtimeSpecVersion: '17.2.0' });
const fromNested = applyArtifactForwardConversions(nested, { runtimeSpecVersion: '17.2.0' });
expect(fromFlat.verdict).toBe('converted-forward');
expect(fromFlat.authoredFloor).toBe('17.1.0');
expect([fromFlat.verdict, fromFlat.authoredFloor]).toEqual([fromNested.verdict, fromNested.authoredFloor]);
expect((fromFlat.definition as typeof flat).permissions[0]!.objects.crm_ticket).not.toHaveProperty('allowPurge');
// A flat body authored at a current-or-newer floor is NOT the undeclared
// amnesty: the strict parse stays its authority.
const current = applyArtifactForwardConversions({ ...flat, engines: { protocol: '>=99.0.0' } }, { runtimeSpecVersion: '17.2.0' });
expect(current.verdict).toBe('authored-current');
expect(current.authoredFloor).toBe('99.0.0');
// A `manifest` object, when present, is still the only place the range is read.
const both = applyArtifactForwardConversions({ ...nested, engines: { protocol: '>=99.0.0' } }, { runtimeSpecVersion: '17.2.0' });
expect(both.verdict).toBe('converted-forward');
expect(both.authoredFloor).toBe('17.1.0');
});

it('closes the window when the runtime spec version cannot be resolved — amnesty needs positive version evidence', () => {
const def = legacyPermissionDefinition('^17.1.0');
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: null });
Expand Down
18 changes: 14 additions & 4 deletions packages/metadata-core/src/artifact-forward-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@
* the next release — so the change that re-legalizes a retired key decides
* that entry's fate at this door, where the default-flip refusal below
* ({@link DEFAULT_FLIPS_NOT_REPLAYED_HERE}) is the existing mechanism.
* - **A flat package body** — the manifest half beside the collections, with
* no `manifest` key (the shape `registerApp` and the marketplace ledger
* hold) — declares its range on itself, and the window is read there.
* - **No declared range** → replay the full chain. Same posture as the
* protocol handshake (which grandfathers range-less packages with a warning,
* ADR-0087 "never false-reject") and as the stored-row pass (whose rows
Expand Down Expand Up @@ -498,11 +501,18 @@ export function applyArtifactForwardConversions<T>(
return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [], replayedRetirements: [] };
}

// WHERE the declared range is read from. A stack definition carries it on
// its `manifest`; a package BODY carries it on itself — the manifest half of
// `AssembledPackageBodySchema` sits beside the collections, which is the
// shape a flat `manifest.register` / `registerApp` payload and a marketplace
// ledger entry have, and the shape `packages[i].manifest` has inside an
// option-B artifact (ADR-0130 D4). A definition that carries a `manifest`
// object is read there and nowhere else, as before; only a definition with
// no `manifest` is read on its own keys, so a flat body's window is the
// body's own, versioned, instead of the undeclared amnesty.
const manifest = (definition as { manifest?: unknown }).manifest;
const declared =
manifest && typeof manifest === 'object'
? resolveDeclaredRange(manifest as ProtocolHandshakeManifest)
: null;
const rangeCarrier: unknown = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : definition;
const declared = resolveDeclaredRange(rangeCarrier as ProtocolHandshakeManifest);
const floor = declared ? parseRangeFloor(declared.range) : null;
const authoredFloor = floor ? floor.join('.') : null;

Expand Down
Loading
Loading